openapi: 3.1.0

info:
  title: NewsHub Public API
  version: 1.0.0
  summary: 匿名免费、零配置的 AI 资讯只读 API（精选 / 全部 / 日报，三轨一真值）。
  description: |
    NewsHub 公开只读 API —— 一批底层 items，三种投影/排序/字段丰富度（Skill / RSS / REST）。
    **匿名免费、无需 token、零配置。** 所有响应 `application/json; charset=utf-8`。

    本规范把所有**契约红线逐条写进各路径/参数/字段的 `description`**——读文档即可避坑，这是对二次开发者的承诺。

    ## 全局红线（务必通读）

    ### 排序键与「时间撕裂」
    - `GET /api/public/items` 按 **`publishedAt`（真实发布时间）倒序**，**永远不要假设它按入库时间排**。
    - `GET /api/public/feed`（自家前端 / RSS 后端用）按 **`timelineAt`（入库时间锚）倒序**——与 items 的排序键**不同**。
    - 慢推 RSS 源把老文章晚入库时，`publishedAt` 与 `timelineAt` 会**对不上**（「时间撕裂」）。
      **二次开发做去重、增量窗口查询，一律以 `publishedAt` 为准**，不要混用 feed 的顺序。

    ### cursor 黑盒（opaque）
    - `nextCursor` 是**不透明 token**：**禁止解析、禁止递增、禁止跨端点复用**（items 的 cursor 锚 `published_at`，feed 的 cursor 锚 `timeline_at`，编码不同，混用必错）。
    - 翻页只能「顺序跟随」上一页返回的 `nextCursor`。
    - `hasNext=false` **或** `nextCursor=null` → **停止翻页**。
    - cursor 失效 / 被篡改 / 时间越界（锚点 < 2020 或 > 明天）→ **静默回首屏（HTTP 200，不报错）**，而不是 4xx。

    ### since 7 天硬上限
    - `since` 默认 `now-7d`。
    - 早于 `now-7d` 的 `since` → **自动 clamp 到 7 天前（HTTP 200，不报错）**。
    - 晚于 `now+1min` 的 `since` → **HTTP 400**（拒绝未来时间）。
    - **items API 只回最近 7 天**；**更早的历史唯一入口 = `GET /api/public/dailies` + `GET /api/public/daily/{date}`**（日报存档）。

    ### 隐式过滤（红线，两 mode 通用，无法关闭）
    以下条目**任何 mode 都不返回**：
    - `duplicate_of_id` 非空（同文转载已合并）
    - 信源 `enabled = 0`（已停用信源）
    - `enrich_failed = 1`（富化失败）

    **`mode=selected` 额外规则**：
    - 所有分支均排除 `cluster_role='secondary'` 与 `retrospective=1`。
    - 满足以下任一分支才进入：编辑精选 `ai_selected=1 && ai_relevance>=60`；多源托入
      `duplicate_count>=3 && ai_relevance>=60 && quality_score>=85`；AI `paper`
      校准补选 `ai_relevance>=95 && quality_score>=80`；AI `tip` 校准补选
      `ai_relevance>=85 && quality_score>=75`。
    - 精选列表按底层信源限制每源最多 6 条，避免单一高产源占满结果。

    > 精选池是全量池的子集；`mode=selected` 返回的条目恒 `selected=true`。多源、paper/tip
    > 补选都是精度门槛，不是为了填满配额。

    **`mode=all` 额外不返回**：
    - `ai_selected IS NULL`（未评分，评分竞态保护）
    - `ai_relevance < 60`（相关度不足）
    - arXiv 论文源（量大刷屏；要论文请用 `category=paper`）

    ### 限流（红线）
    - 单 IP **600 req/min，burst 40**（`x/time/rate` per-IP token bucket，LRU 容量 10k IP）。
    - 超限 → **HTTP 429** `{"error":"rate_limited"}` + 响应头 `Retry-After: 2`。
    - 写接口（`POST /api/public/feedback`、`POST /api/public/submissions`）单独更严：**3 req/min/IP**。
    - 建议：**串行调用，翻页间隔 ~200ms**。

    ### 缓存
    - 进程内每个 query 组合缓存 60s。
    - 成功响应带 `Cache-Control: public, s-maxage=300, stale-while-revalidate=300` + weak ETag `W/"items-<sha1 短哈希>"`；
      `If-None-Match` 命中 → **304 空 body**。

    ### category slug ↔ 日报中文 label 对照表
    items API 用英文 slug；日报 `sections[].label` 随 `topic` 使用固定中文。一一对照：

    | items API `category` slug | 日报 `sections[].label` |
    |---|---|
    | `ai-models`   | 模型发布/更新 |
    | `ai-products` | 产品发布/更新 |
    | `industry`    | 行业动态 |
    | `paper`       | 论文研究 |
    | `tip`         | 技巧与观点 |
    | `invest-crypto`   | 加密货币 |
    | `invest-stock`    | 股市 |
    | `invest-macro`    | 宏观 |
    | `invest-arb`      | 套利与机会 |
    | `invest-research` | 研究与分析 |

    ### 错误约定
    - 设计原则：**能降级不报错**（cursor 回首屏、since clamp、`q` 过短忽略、`/dailies` 的 `take` 越界 clamp）；**该报错说人话**（400 信息直接告诉你怎么改）。
    - 错误 body 统一 `{"error": "<原因>"}`。
    - **`/api/public/items` 的 `take` 越界 → 400**；但 **`/api/public/dailies` 的 `take` 越界 → clamp（不报错）**——两者刻意不同，见各自参数说明。

    ### 其它端点（dogfooding / 自家前端，本规范不展开完整 schema）
    - `GET /api/public/feed`：**全字段** item（10 公开字段 + 内部信号 + legacy UTC 派生 dateKey/dateLabel/timeLabel；展示端应优先用 publishedAt 自行按显示时区格式化），**按 `timelineAt` 倒序**。供自家前端与 RSS 后端使用。
    - `GET /api/public/hot`：**72 小时热榜 top5**（热度底座 = `1 + duplicate_count`，「N 个信源」）。
    - `GET /api/public/sources`：**信源墙**（已收录信源列表）。
    - RSS 2.0：`/feed.xml`（精选 50）、`/feed/all.xml`（全部 50）、`/feed/daily.xml`（日报 30，每期一条）；`/rss`·`/rss.xml`·`/atom.xml` → 301 跳 `/feed.xml`。条目 feed 支持 `?topic=invest`（旧 `crypto` 为其别名）与 `?source=<信源显示名>`（穿透聚合源到账号，同 `/items?source=`；未知源→空 feed 不报错），故 `/feed/all.xml?source=<名>` 可订阅单一信源/账号。
    - Skill 轨：`/skill/SKILL.md`、`/skill/install.sh`、`/skill/README.md`（UA 黑名单豁免，`curl | bash` 一行装）。
  license:
    name: Reference Use
  contact:
    url: https://wqu.guru/agent

servers:
  - url: https://wqu.guru
    description: NewsHub 部署根地址（每部署恒定）。

tags:
  - name: items
    description: 条目列表（精选 / 全部、分类、时间窗、关键词、翻页）。
  - name: daily
    description: 日报（最新一期、指定日期、归档索引）—— 7 天之外历史的唯一入口。
  - name: hot
    description: 当前热点——多信源同时报道的大事件（多源印证 + 时间衰减热度排序）。
  - name: sources
    description: 信源墙——本站已收录的全部信源（覆盖面 / 出处可信度）。

paths:
  /api/public/items:
    get:
      tags: [items]
      operationId: listItems
      summary: 条目列表（精选 / 全部）
      description: |
        AI 资讯条目列表。**按 `publishedAt`（真实发布时间）倒序**，不按入库时间。

        - **`mode=selected`（默认）**：精选池——主菜单。编辑精选、多源高质量事件及
          校准后的 AI paper/tip 补选三类分支，统一排除 secondary/retrospective，并按底层信源
          限制每源最多 6 条；完整门槛见本规范“隐式过滤”。
        - **`mode=all`**：全量池——量大但杂。额外隐式过滤 `ai_selected IS NULL`、`ai_relevance<60`、arXiv 论文源（要论文走 `category=paper`）。
        - 两 mode 通用隐式过滤：`duplicate_of_id` 非空 / 信源 `enabled=0` / `enrich_failed=1` 一律不返回。
        - **只回最近 7 天**；更早历史走 `GET /api/public/dailies` + `GET /api/public/daily/{date}`。
        - 翻页：跟随响应里的 `nextCursor`（黑盒，详见参数）；`hasNext=false` 或 `nextCursor=null` 停止。
        - 关键词搜索是**服务端**搜索：传 `q`，不要拉一批回来客户端 grep（客户端只能看到前 100 条池子里的命中）。
      parameters:
        - name: topic
          in: query
          required: false
          description: |
            数据集分区：`ai`（默认）或 `invest`。两个数据集架构完全一样、数据完全隔离
            （AI 资讯 vs 金融资讯：加密 / 股市 / 宏观 / 套利 / 研究）。所有列表/日报/feed/
            stream 端点都支持本参数，默认 `ai`（向后兼容：不传 = 原 AI 行为，契约不变）。
            旧值 `crypto` 仍被接受，作为 `invest` 的别名（金融板块即由原 Crypto 升级而来）。
            其它值 → **400**。注意：`category` 的合法取值依 `topic` 而定（见下）。
          schema:
            type: string
            enum: [ai, invest]
            default: ai
        - name: mode
          in: query
          required: false
          description: |
            精选 `selected`（默认）或全量 `all`。其它值 → **400**。
          schema:
            type: string
            enum: [selected, all]
            default: selected
        - name: category
          in: query
          required: false
          description: |
            按分类过滤。必须是**当前 topic** 的 slug 之一；非法值 → **400**（错误信息会列出该 topic 的合法 slug）。
            - `topic=ai`：`ai-models`=模型发布/更新、`ai-products`=产品发布/更新、`industry`=行业动态、`paper`=论文研究、`tip`=技巧与观点。
            - `topic=invest`：`invest-crypto`=加密、`invest-stock`=股市、`invest-macro`=宏观、`invest-arb`=套利、`invest-research`=研究。
              （旧 `crypto-*` slug 会被自动映射到对应 `invest-*`，旧链接仍可用。）
          schema:
            type: string
            enum: [ai-models, ai-products, industry, paper, tip, invest-crypto, invest-stock, invest-macro, invest-arb, invest-research]
        - name: tag
          in: query
          required: false
          description: |
            按话题标签整词过滤（信源/厂商/主题标签，如 `Anthropic`、`模型发布`）。
            开放词表：未知标签返回空集（**200**，不报错）。可与 `category`/`q` 叠加。
          schema:
            type: string
        - name: source
          in: query
          required: false
          description: |
            按**信源显示名**精确过滤，只看该信源的条目（如 `吴说区块链（RSS）`、`The Defiant（RSS）`；
            名称取自每条结果的 `source` 字段）。未知名 → 空集（**200**，不报错）。
            注意：`mode=selected` 下该信源仍受「单源精选上限」约束；要看某信源全部，用 `mode=all`。
          schema:
            type: string
        - name: since
          in: query
          required: false
          description: |
            起始时间（ISO-8601 / RFC3339）。**默认 `now-7d`**。
            **7 天硬上限**：早于 `now-7d` → 自动 clamp 到 7 天前（**200，不报错**）；
            晚于 `now+1min` → **400**。更早历史唯一入口 = `/dailies` + `/daily/{date}`。
          schema:
            type: string
            format: date-time
          example: "2026-06-09T00:00:00Z"
        - name: take
          in: query
          required: false
          description: |
            返回条数。整数 **1..100**，默认 50。越界或非整数 → **400**
            （注意：与 `/dailies` 的 `take` 不同，items 的越界是硬错，不 clamp）。
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: cursor
          in: query
          required: false
          description: |
            翻页游标，**opaque 黑盒 token**。**禁止解析 / 递增 / 跨端点复用**——只能顺序跟随上一页的 `nextCursor`。
            失效 / 被篡改 / 时间越界（锚点 < 2020 或 > 明天）→ **静默回首屏（200，不报错）**。
          schema:
            type: string
        - name: q
          in: query
          required: false
          description: |
            关键词（**服务端**搜索，每个空白分隔的词 AND，跨 title / title_orig / summary / 话题标签(tags) /
            账号名(author) 命中——故公司、实体、X/公众号账号显示名都搜得到）。`minLength 2`（1 字符 →
            **忽略该参数**，不报错）；`maxLength 200`（超长 → **截断**，不报错）。公司/产品维度（如「OpenAI 最近发的」
            「Sora 相关」）走这里，不要把一批条目拉回来客户端 grep。仍受 **7 天窗**约束（更早历史走日报存档）。
          schema:
            type: string
            minLength: 2
            maxLength: 200
          example: "OpenAI"
      responses:
        "200":
          description: 条目列表 envelope。空集合也是 200（不报错）。
          headers:
            ETag:
              description: weak ETag `W/"items-<sha1 短哈希>"`；`If-None-Match` 命中 → 304 空 body。
              schema:
                type: string
            Cache-Control:
              description: "`public, s-maxage=300, stale-while-revalidate=300`（仅成功响应）。"
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ItemsEnvelope"
        "400":
          description: 参数非法（mode / category / take / since）。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "429":
          description: 触发限流（600 req/min，burst 40）。
          headers:
            Retry-After:
              description: 固定 `2`（秒）。
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: rate_limited
        "500":
          description: 内部错误（不泄栈）。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: internal

  /api/public/daily:
    get:
      tags: [daily]
      operationId: latestDaily
      summary: 最新一期日报
      description: |
        返回**最近可用一期**日报。日报是 **UTC 整日切片**的编辑成品，跟「过去 24 小时」滚动窗对不上——
        **没有「日报」二字不要走这里**（宽问题走 `/api/public/items`）。

        当日北京时间 08:00 前可能尚未生成 → 返回最近可用一期；一期都没有 → **404** `{"error":"daily not found"}`。

        注意 `generatedAt` 在本端点是 **integer（unix ms）**，与 `/dailies` 索引的 `generatedAt`（**string RFC3339**）类型不同，勿混用。
      parameters:
        - name: topic
          in: query
          required: false
          description: |
            数据集分区：`ai`（默认）或 `invest`（金融：加密/股市/宏观/套利/研究）。两套日报完全独立——
            「今天的金融/币圈日报」用 `topic=invest`。旧值 `crypto` 作 `invest` 别名；其它值 → **400**。
          schema:
            type: string
            enum: [ai, invest]
            default: ai
      responses:
        "200":
          description: 日报正文。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Daily"
        "404":
          description: 暂无任何日报。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: daily not found

  /api/public/daily/{date}:
    get:
      tags: [daily]
      operationId: dailyByDate
      summary: 指定日期日报
      description: |
        指定 UTC 日期的日报。`date` **严格零填充** `YYYY-MM-DD`（如 `2026-6-9` 非零填充 → **400**）。
        该日无日报 → **404** `{"error":"daily not found"}`。与 `/daily` 同 shape，
        `generatedAt` 同样是 **integer（unix ms）**。
      parameters:
        - name: date
          in: path
          required: true
          description: UTC 日期，严格零填充 `YYYY-MM-DD`。
          schema:
            type: string
            # [0-9] 而非 \d：部分校验器的 \d 会放行 Unicode 数字（如 ᱔），实现只接受 ASCII。
            pattern: '^[0-9]{4}-[0-9]{2}-[0-9]{2}$'
          example: "2026-05-15"
        - name: topic
          in: query
          required: false
          description: |
            数据集分区：`ai`（默认）或 `invest`（金融：加密/股市/宏观/套利/研究）。两套日报完全独立——
            「上个月15号的金融/币圈日报」用 `topic=invest`。旧值 `crypto` 作 `invest` 别名；其它值 → **400**。
          schema:
            type: string
            enum: [ai, invest]
            default: ai
      responses:
        "200":
          description: 该日日报正文。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Daily"
        "400":
          description: date 不是严格零填充 `YYYY-MM-DD`。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
        "404":
          description: 该日无日报。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"
              example:
                error: daily not found

  /api/public/dailies:
    get:
      tags: [daily]
      operationId: listDailies
      summary: 日报归档索引（无正文）
      description: |
        日报归档索引（只含日期 + 生成时间 + 导语，**无正文 sections**）。配合 `/daily/{date}` 取正文。
        这是 7 天之外历史的入口（「哪些日期有日报」走这里）。

        注意 `items[].generatedAt` 在本端点是 **string（RFC3339）**，
        与 `/daily` 正文里的 `generatedAt`（**integer unix ms**）类型不同。
      parameters:
        - name: take
          in: query
          required: false
          description: |
            返回条数。整数 **1..180**，默认 30。**越界 → clamp 到 [1,180]（不报错）**；
            非整数 → **400**。（与 `/items` 的 `take` 不同：items 越界是硬 400，这里是降级 clamp。）
            范围刻意只写在文字里、不写进 schema 数值约束——越界请求会被 200+clamp 接受，
            是「能降级不报错」的契约行为，不是 schema 违例。
          schema:
            type: integer
            default: 30
        - name: topic
          in: query
          required: false
          description: |
            数据集分区：`ai`（默认）或 `invest`（金融：加密/股市/宏观/套利/研究）。两套日报存档完全独立——
            金融/币圈日报存档用 `topic=invest`。旧值 `crypto` 作 `invest` 别名；其它值 → **400**。
          schema:
            type: string
            enum: [ai, invest]
            default: ai
      responses:
        "200":
          description: 日报索引 envelope。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DailiesIndex"
        "400":
          description: take 非整数。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/public/hot:
    get:
      tags: [hot]
      operationId: hotTopics
      summary: 当前热点（多信源大事件）
      description: |
        返回**此刻最热的多信源事件**——按热度排序的 top 榜（热度 = 多源印证 × 时间衰减 × 一手加成，
        72h 窗、热度阈值之上、约 5 条）。这是 NewsHub 相对单源爬虫的**差异化**：同一事件被
        N 家不同信源同时报道，`duplicateCount` / `duplicateSources` 直接量化了「多源印证强度」。

        **何时走这里**：用户问「今天 AI 圈**最大 / 最重要 / 最热**的事是什么」「现在最值得看的」
        这类**要事件而非全量**的问题 → `/hot`（而非 `items?mode=selected` 的「全部精选」）。
        据此可输出「**X 事件——N 家信源同时报道**」式的简报，凸显重要性与可信度。

        空榜（无够热的事件）→ `{"count":0,"items":[]}`（200，非错误）。`duplicateSources`
        是除 primary 外的其他**具名信源**（聚合源已穿透到账号，如「宝玉」而非「X List：AI 一手」）。
      parameters:
        - name: topic
          in: query
          required: false
          description: 数据集分区 `ai`（默认）/ `invest`（旧 `crypto` 为其别名）。其它值 → **400**。
          schema:
            type: string
            enum: [ai, invest]
            default: ai
        - name: category
          in: query
          required: false
          description: |
            可选子分类过滤——只看该分类的「专属当前热点」（按 item 自身分类，slug 取值同 `/items` 的
            `category`，依 `topic` 而定）。缺省 = 全站（全分类）热榜。**与 `/items` 不同，不做 400 校验**：
            未知或跨 topic 的 slug 匹配 0 行 → 空榜 `{"count":0,"items":[]}`（200，降级不报错）。
          schema:
            type: string
      responses:
        "200":
          description: 热点榜 envelope（按 heat 降序）。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HotResponse"
        "400":
          description: topic 非 ai/invest。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/Error"

  /api/public/sources:
    get:
      tags: [sources]
      operationId: sourceWall
      summary: 信源墙（已收录信源列表）
      description: |
        返回 NewsHub **已收录的全部信源**（信源墙 / source wall），按 `wallNumber` 升序——
        即这个聚合站「覆盖了哪些来源」。Agent 据此判断**信源覆盖面与出处可信度**（决定是否消费本站
        feed、或向用户说明「内容来自哪些源」时有用）。`submitter` 为空串 = 系统自带的策展源（前端
        渲染「匿名贡献者」）；非空为用户投稿贡献者。**匿名只读**、无分页（全量返回，约百余条）。
      responses:
        "200":
          description: 信源墙 envelope（按 wallNumber 升序）。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SourcesResponse"

components:
  schemas:
    Item:
      type: object
      description: |
        条目的**公开投影**——刻意裁剪：内部信号
        （`ai_relevance` / `selected_reason` / `tags` / `duplicate_*` / `cluster_*`）**一律不出现**。
        公开契约稳定，内部机制可演进。
      additionalProperties: false
      required: [id, title, title_orig, url, source, publishedAt, summary, category, score, selected]
      properties:
        id:
          type: string
          description: 条目 ID（ULID，字典序≈时间序）。
          example: "01J9ABCDXYZ0000000000000000"
        title:
          type: string
          description: 展示标题（中文为主）。
        title_orig:
          type: [string, "null"]
          description: 原文标题；等于展示标题时为 `null`。
        url:
          type: string
          format: uri
          description: 原文链接。
        source:
          type: string
          description: 信源**人话名**（如「IT之家（RSS）」）——是**字符串**，不是对象。
          example: "IT之家（RSS）"
        publishedAt:
          type: string
          format: date-time
          description: 真实发布时间，RFC3339 UTC 毫秒精度（排序键）。
          example: "2026-06-10T01:41:12.000Z"
        summary:
          type: string
          description: LLM 中文摘要（缺失时为空串 `""`）。
        category:
          type: string
          description: 分类 slug（5 选 1，对照表见全局红线；未分类时为空串 `""`）。
          example: "industry"
        score:
          type: integer
          description: 对外分数（= `final_score`）；展示用，**非排序键**；未评分为 0。
          example: 75
        selected:
          type: boolean
          description: |
            是否属于统一精选池：编辑精选、多源高质量事件或校准后的 AI paper/tip 补选；
            secondary、retrospective 和不可见行始终为 false。`mode=selected` 的条目恒 `true`。
    ItemsEnvelope:
      type: object
      description: "`/api/public/items` 列表 envelope。"
      required: [count, hasNext, nextCursor, items]
      properties:
        count:
          type: integer
          description: 本页条数。
        hasNext:
          type: boolean
          description: 是否还有下一页。`false` → 停止翻页。
        nextCursor:
          type: [string, "null"]
          description: 下一页游标（**opaque 黑盒**，禁止解析/递增/跨端点复用）。`null` → 停止翻页。
        items:
          type: array
          items:
            $ref: "#/components/schemas/Item"
    Daily:
      type: object
      description: |
        日报正文（`/daily` 与 `/daily/{date}` 同 shape）。
        `generatedAt` / `windowStart` / `windowEnd` 均为 **integer（unix ms）**。
      required: [date, generatedAt, windowStart, windowEnd, lead, sections]
      properties:
        date:
          type: string
          description: 日报 UTC 日期 `YYYY-MM-DD`。
          example: "2026-05-15"
        generatedAt:
          type: integer
          description: 生成时间，**unix 毫秒（integer）**。注意：`/dailies` 索引里同名字段是 string(RFC3339)。
          example: 1747300872000
        windowStart:
          type: integer
          description: 切片窗口起点，unix 毫秒（半开区间 `[windowStart, windowEnd)` 的 UTC 整日）。
        windowEnd:
          type: integer
          description: 切片窗口终点（不含），unix 毫秒。
        lead:
          description: 导语对象；无导语时为 `null`。
          oneOf:
            - type: "null"
            - type: object
              required: [title, paragraph]
              properties:
                title:
                  type: string
                  description: 导语标题（LLM 失败时回退为最高分条目标题）。
                paragraph:
                  type: string
                  description: 导语正文段落（回退时为空串）。
        stats:
          type: object
          description: |
            报头透明度统计（2026-06 增；向后兼容追加，旧日报可能缺此键，按 optional 处理）。
            `events`=窗口内全部精选事件数（未按版块封顶），`firstParty`=其中一手/官方信源
            （`channel='firstParty'`）条数，`sources`=去重信源数。
          required: [events, firstParty, sources]
          properties:
            events:
              type: integer
              description: 窗口内精选事件总数（与生成口径一致，未按版块上限裁剪）。
            firstParty:
              type: integer
              description: 其中来自一手/官方信源的条数。
            sources:
              type: integer
              description: 贡献条目的去重信源数。
        sections:
          type: array
          description: |
            分节，固定顺序，**空节省略**。`label` 取当前 `topic` 的固定 5 个中文值之一：
            AI = 模型发布/更新、产品发布/更新、行业动态、论文研究、技巧与观点；
            Invest = 加密货币、股市、宏观、套利与机会、研究与分析
            （与 items API 的 `category` slug 一一对照）。
          items:
            $ref: "#/components/schemas/DailySection"
    DailySection:
      type: object
      required: [label, items]
      properties:
        label:
          type: string
          description: 分节中文标签（随 topic 固定 5 选 1；schema 枚举列出 AI+Invest 全集）。
          enum: [模型发布/更新, 产品发布/更新, 行业动态, 论文研究, 技巧与观点, 加密货币, 股市, 宏观, 套利与机会, 研究与分析]
        items:
          type: array
          items:
            $ref: "#/components/schemas/DailySectionItem"
    DailySectionItem:
      type: object
      description: >-
        日报分节内的条目。4 个内容字段 + 下钻键（id / duplicateCount / tags / publishedAt）。
        id 是该事件代表条目的 ULID（稳定标识，可用于去重/追踪）；duplicateCount 是该事件
        被其他几家不同信源报道（多源印证强度——要拿其他信源的**具名清单**走 GET /api/public/hot）；
        tags 是话题/实体标签（可用于 /all?tag= 下钻）；publishedAt 是原条目发布时间。
        两者为后向兼容的可选字段，旧日报 payload 可能缺失。
      required: [title, summary, sourceUrl, sourceName, id, duplicateCount]
      properties:
        title:
          type: string
          description: 标题。
        summary:
          type: string
          description: 中文摘要。
        sourceUrl:
          type: string
          format: uri
          description: 原文链接。
        sourceName:
          type: string
          description: 信源人话名（聚合源穿透到实际账号）。
        id:
          type: string
          description: 事件代表条目 ULID（稳定标识，可用于去重/追踪）。
        duplicateCount:
          type: integer
          format: int64
          description: 该事件被其他几家不同信源报道（0=单源；越高=多源印证越强）。
        tags:
          type: array
          description: 话题/实体标签；新生成日报可带，旧日报可能缺失。
          items:
            type: string
        publishedAt:
          type: integer
          format: int64
          description: 原条目发布时间（unix 毫秒）；新生成日报带此字段，旧日报可能缺失。
    DailiesIndex:
      type: object
      description: "`/api/public/dailies` 索引 envelope（无正文）。"
      required: [count, items]
      properties:
        count:
          type: integer
          description: 索引条数。
        items:
          type: array
          items:
            $ref: "#/components/schemas/DailyIndexItem"
    DailyIndexItem:
      type: object
      description: 日报索引项（仅日期 + 生成时间 + 导语）。
      required: [date, generatedAt, leadTitle, leadParagraph]
      properties:
        date:
          type: string
          description: 日报 UTC 日期 `YYYY-MM-DD`。
          example: "2026-06-10"
        generatedAt:
          type: string
          format: date-time
          description: |
            生成时间，**string（RFC3339 UTC 毫秒）**。注意：`/daily` 正文里同名字段是 integer(unix ms)——两处类型不同。
          example: "2026-06-10T08:00:00.000Z"
        leadTitle:
          type: [string, "null"]
          description: 导语标题；缺失为 `null`。
        leadParagraph:
          type: [string, "null"]
          description: 导语正文；缺失为 `null`。

    HotResponse:
      type: object
      description: 热点榜 envelope（按 heat 降序）。
      required: [count, items]
      properties:
        count:
          type: integer
          description: 本次返回的热点数（空榜为 0）。
        items:
          type: array
          items:
            $ref: "#/components/schemas/HotTopic"

    SourcesResponse:
      type: object
      description: 信源墙 envelope；items 按 wallNumber 升序。
      required: [count, items]
      properties:
        count:
          type: integer
          description: 已收录信源总数（items 长度）。
        items:
          type: array
          items:
            $ref: "#/components/schemas/WallSource"

    WallSource:
      type: object
      description: 信源墙一条已收录信源；字段恰好 4 个。
      required: [wallNumber, name, submitter, url]
      properties:
        wallNumber:
          type: integer
          description: 收录序号（按通过审核顺序自增、稳定）。
        name:
          type: string
          description: 信源显示名（同 `/items` 的 `source`、`/feed?source=` 的穿透名）。
        submitter:
          type: string
          description: 贡献者；空串 = 系统自带策展源（前端渲染「匿名贡献者」）。
        url:
          type: string
          format: uri
          description: 信源主页 / feed 链接。

    HotTopic:
      type: object
      description: 一个多信源热点事件（簇代表 + 多源印证聚合）。
      required:
        [
          id,
          title,
          url,
          source,
          publishedAt,
          earliestPublishedAt,
          latestPublishedAt,
          duplicateCount,
          duplicateSources,
          heat,
        ]
      properties:
        id:
          type: string
          description: 事件代表条目 ULID。
        title:
          type: string
          description: 中文展示标题（事件头条）。
        url:
          type: string
          format: uri
          description: 代表条目原文链接。
        source:
          type: string
          description: 代表条目（头条）自身信源人话名（聚合源已穿透到账号）——**与 `url` 指向的文章同源**，点 `url` 落到这家。
        publishedAt:
          type: string
          format: date-time
          description: 代表条目自身发布时间（RFC3339 UTC 毫秒）。
        earliestPublishedAt:
          type: string
          format: date-time
          description: 同事件最早一条信源的时间（事件起点）。
        latestPublishedAt:
          type: string
          format: date-time
          description: 同事件最近一条信源的时间（最新进展 / 热度衰减基准）。
        duplicateCount:
          type: integer
          format: int64
          description: 该事件被其他几家不同信源报道（**多源印证强度**；0 = 仅单源）。
        duplicateSources:
          type: array
          items:
            type: string
          description: 除头条 `source` 外、同事件其他信源的**具名清单**（聚合源穿透到账号，如「宝玉」）；`source` + 本清单 = 全部参与信源；无则 `[]`。
        heat:
          type: number
          format: double
          description: 热度分（多源印证 × 时间衰减 × 一手加成），用于排序。

    Error:
      type: object
      description: 统一错误 body（400 / 404 / 429 / 500）。
      required: [error]
      properties:
        error:
          type: string
          description: |
            人话原因。常见值：`rate_limited`（429）、`daily not found`（404）、`internal`（500）；
            400 为「列出合法取值」的具体提示。
          example: "daily not found"
