openapi: 3.1.0

info:
  title: AI HOT Legacy Public API
  version: 1.5.0
  summary: AI HOT 未版本化兼容 API（匿名只读；新接入使用 /api/v1）
  description: |
    AI HOT (热点) 是中文 AI 资讯精选服务。本文件描述保留兼容的 `/api/public/*` 旧接口。
    新接入统一使用 `/api/v1/*` 与 https://aihot.virxact.com/openapi-v1.json；旧接口在迁移观察期
    保持现有响应兼容，不再作为新客户端的默认合同。

    匿名访问，无需 token。

    浏览器、空 User-Agent、curl 与默认 Python／Node／Go 等 HTTP 客户端均是正式支持路径。
    能设置时可用 `mycompany-aihot-sync/1.0 (+https://example.com/contact)` 自报身份，方便诊断，
    但 User-Agent 不是访问许可证，也不得伪装其它客户端。已确认商业采集、攻击或镜像签名仍可能
    在边缘被拦；正常退避后最多重试一次，仍失败请携带 `requestId` 到接入页反馈。

    要维护「当前全部精选」的本地副本，首次请求 `/api/public/selected/snapshot`，之后只轮询
    `/api/public/selected/changes`；增量会同时返回新增、原地编辑和撤选，不需要按
    `publishedAt` 猜时间窗口。旧客户端若只关心最近头部有没有新条目，仍可继续使用
    `/api/public/fingerprint` + `/api/public/items`；新客户端直接对 v1 完整 URL 做 ETag 条件请求。

    如果你想给 LLM Agent 接入，参考 https://aihot.virxact.com/agent —— 那里同时
    提供 Skill / RSS / REST API 三种接入方式。
  contact:
    name: AI HOT
    url: https://aihot.virxact.com/agent
  license:
    name: AI HOT API Terms
    url: https://aihot.virxact.com/about#public-integration-terms

servers:
  - url: https://aihot.virxact.com
    description: Production

externalDocs:
  description: Agent 接入指南（Skill / RSS / REST API 三轨）
  url: https://aihot.virxact.com/agent

paths:
  /api/public/selected/snapshot:
    get:
      operationId: getSelectedSnapshot
      summary: Complete current selected-item snapshot
      description: |
        一次返回**当前全部精选**，不受 `/api/public/items` 最近 7 天窗口限制，并附带后续增量
        所需的不透明 `cursor`。这是完整同步的起点；正常客户端只在首次接入，或 changes 明确返回
        `snapshot_required` 时重取。

        快照与 cursor 来自同一个数据库一致性视图：随后请求 changes 不会漏掉快照生成期间的新变化。
        `publishedAt` 是原文发布时间；`discoveredAt` 是 AI HOT 首次收到时间。不要再用任一时间字段
        猜增量边界，续传只认 cursor。

        响应由边缘共享缓存 5 分钟，并支持 ETag / 304。默认 HTTP 客户端可直接调用。
      parameters:
        - name: fields
          in: query
          schema:
            type: string
            enum: ["default", "minimal"]
            default: "default"
          description: |
            `default` 返回完整公开条目；`minimal` 省略 summary、第三方 url、title_en 和 attribution，
            适合只维护索引／深链。字段模式会绑定进 cursor，后续 changes 自动保持相同 shape。
      responses:
        "200":
          description: 当前完整精选及增量起点。
          headers:
            ETag:
              schema:
                type: string
            Cache-Control:
              schema:
                type: string
              description: "`public, s-maxage=300, stale-while-revalidate=900`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SelectedSnapshot"
        "304":
          description: If-None-Match 命中；快照未变化，空 body。
        "400":
          description: fields 不合法。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicApiError"
        "567":
          $ref: "#/components/responses/EdgeBlocked"
        "429":
          description: 限流触发；退避 30–60 秒，不要并发重试。

  /api/public/selected/changes:
    get:
      operationId: listSelectedChanges
      summary: Incremental selected-item upserts and removals
      description: |
        从 snapshot 或上一页返回的 `cursor` 之后，依次返回精选的 `upsert` 和 `remove`。
        一页可能因保持数据库事务原子性而略超过 `take`；客户端必须先完整应用整页，再保存响应里的
        新 cursor。重试同一个 cursor 是安全的。

        cursor 缺失、损坏、失效，或遇到无法在页内原子交付的极端超大后台事务时，接口明确返回
        `409 snapshot_required`；客户端应重取 snapshot。接口**绝不静默回首屏**，因此不会把漏数
        伪装成同步成功。

        空增量同样返回 200，cursor 保持不变。响应由边缘共享缓存 60 秒并支持 ETag / 304；
        推荐轮询间隔不低于 60 秒。
      parameters:
        - name: cursor
          in: query
          required: true
          schema:
            type: string
            maxLength: 200
          description: snapshot 或上一页 changes 返回的不透明 cursor；原样回传，不要解析或修改。
        - name: take
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 100
          description: 目标原始变化条数；为保持一次后台事务完整，实际 count 偶尔可能超过该值。
      responses:
        "200":
          description: 增量页。`hasMore=true` 时立即用新 cursor 拉下一页。
          headers:
            ETag:
              schema:
                type: string
            Cache-Control:
              schema:
                type: string
              description: "`public, s-maxage=60, stale-while-revalidate=60`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/SelectedChanges"
        "304":
          description: If-None-Match 命中；该 cursor 暂无新变化，空 body。
        "400":
          description: cursor 缺失／格式错误，或 take 不合法。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicApiError"
        "409":
          description: cursor 无法继续安全续传；必须重新获取 snapshot。
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicApiError"
        "567":
          $ref: "#/components/responses/EdgeBlocked"
        "429":
          description: 限流触发；退避 30–60 秒，不要并发重试。

  /api/public/items:
    get:
      operationId: listItems
      summary: AI HOT items (selected / all / by category)
      description: |
        AI HOT 公开动态。可按精选 / 最近 7 天公开池、分类、时间筛选。匿名只读，nginx proxy_cache 60 秒
        （2026-07-06 配合 /api/public/fingerprint 轮询探针从 5 分钟调短）。

        **旧客户端定时拉取姿势**：可以先轮询 `/api/public/fingerprint`，指纹变化后再调用本接口；
        新客户端应迁往 v1，并直接保存完整请求 URL 的 ETag。不要把本接口放进无条件定时循环。
        只需要索引/深链时用 `fields=minimal` 减少响应体积；
        需要摘要、原标题或第三方原文 URL 时保留默认 `fields=default`。

        **排序契约**：本接口按 `publishedAt`（真实发布时间）倒序，**不**按 timeline anchor。
        因此跟 `/api/public/feed`（按 timelineAt 倒序）在慢推 RSS 源上可能有时间撕裂；
        Agent / 二次开发者按 publishedAt 做去重和窗口查询是契约红线。

        **cursor 翻页**：响应里的 `nextCursor` 直接作为下次请求的 `cursor` 参数。
        cursor 失效 / 篡改 / 过期(早于 2020 / 晚于明天) → 静默回首屏，不报错。

        **限流**：公开 API 持续配额为单 IP 60 req/min；短突发由 nginx burst 缓冲，不应当作公开配额。
        Agent 串行调用，翻页/连续查询建议间隔 ≥1s；遇 429 后退避 30-60s 再恢复。

        **User-Agent 契约**：默认 SDK 与浏览器可直接调用；能设置时可自报身份
        （如 `mycompany-aihot-sync/1.0 (+https://example.com/contact)`）方便排障，但不是访问条件。
      parameters:
        - name: mode
          in: query
          schema:
            type: string
            enum: ["selected", "all"]
            default: "selected"
          description: |
            `selected` = AI HOT 精选(每日精编候选池,**默认值**);`all` = 最近 7 天公开池(含未精选次要条目，仍排除原公众号爆文榜来源、未审内容、低相关和已合并重复条目)。

            **路由优先级**:用户问的是"今天/最近 AI 圈"等宽问题 → 不传 mode 等同 `selected`(精选主菜单);用户明确说"全部/完整/所有/全量" → 显式 `mode=all` 拿全池。2026-05-08 默认从 `all` 切到 `selected`,跟 SKILL.md 路由规则对齐。

            **隐式过滤(两个 mode 都生效)**:
            - 已被合并的重复条目(`duplicateOfId` 非空) → 不返回
            - 原公众号爆文榜 `mp_hot` 信源 → 不返回；正常参与精选的官方／媒体公众号 `mp_account` 仍可能出现
            - 信源已禁用或 `present=false` → 不返回

            **selected 额外过滤**:
            - 同事件聚类的 secondary / related → 不返回(只保留 cluster primary,避免同事件重复露出)
            - `aiSelected != true` → 不返回

            **all 额外过滤**:
            - `aiSelected IS NULL`(尚未跑评分) → 不返回
            - `aiRelevance < 60`(评分判定相关性弱) → 不返回
            - arxiv 论文源(arxiv-anthropic/deepmind/deepseek/openai) → 不返回(论文流量大会刷屏"全部";要论文走 `category=paper`)
        - name: category
          in: query
          schema:
            type: string
            enum: ["ai-models", "ai-products", "industry", "paper", "tip"]
          description: |
            可选分类(英文 slug)。跟 `/api/public/daily` 返回的 `sections[].label`(中文) 对照:

            | items API `category` | daily API section `label` |
            |---|---|
            | `ai-models` | 模型发布/更新 |
            | `ai-products` | 产品发布/更新 |
            | `industry` | 行业动态 |
            | `paper` | 论文研究 |
            | `tip` | 技巧与观点 |
        - name: since
          in: query
          schema:
            type: string
            format: date-time
          description: |
            ISO datetime,限最近 7 天内。**不传等同于 since = now - 7d**(服务端硬上限,
            保护"土豆服务器"不被全表扫击穿)。早于 7 天前自动截到 7 天前;未来时间(超过 now+1min)
            返回 400。需要更深历史 → 走日报存档 `/api/public/dailies` + `/api/public/daily/{date}`。
        - name: take
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: cursor
          in: query
          schema:
            type: string
          description: |
            **opaque pagination token** — 上一次响应中的 `nextCursor` 原样回传。
            视作黑盒:不要尝试解析、不要 +1 递增、不要跨端点复用。
            内部编码格式不属于公开契约,可能在不通知的情况下变更。
            cursor 失效 / 损坏 / 过期 → 静默回首屏(不报错)。
        - name: q
          in: query
          schema:
            type: string
            minLength: 2
            maxLength: 200
          description: |
            关键词搜索:在 title / 中文 title / 中文 summary / 正文 四列上 ILIKE 匹配,
            走 PostgreSQL pg_trgm GIN 索引(2-6ms)。**1 个字符的 q 视作不搜索**
            (trgm 对单字符退化为全表扫,会影响其他用户)。q 超过 200 字会自动截断。
            跟其它 /api/public/* 请求共享单 IP 60 req/min 持续限流。
        - name: fields
          in: query
          schema:
            type: string
            enum: ["default", "minimal"]
            default: "default"
          description: |
            输出字段集。`default` 保持完整公开合同；`minimal` 只返回索引字段
            (`id/title/permalink/source/publishedAt/discoveredAt/category/score/selected`)，适合 fingerprint 变化后的
            定时同步和深链推送。需要 `summary`、`url`、`title_en` 时不要用 minimal。
      responses:
        "200":
          description: |
            OK. 响应包含 weak ETag(`W/"items-..."`),客户端可在下次请求带 `If-None-Match`
            实现 304 缓存(详见 304 response)。
          headers:
            ETag:
              schema:
                type: string
              description: |
                Weak ETag,基于**整页内容**(count + nextCursor + 每条 item 的全部字段)的 SHA1 短哈希。
                示例:`W/"items-d0112022d1961325"`。新条目入库、**已有条目被原地编辑(改摘要/分类/标题)**、
                或 query 参数变化(mode/category/since/q/take/cursor) 时 ETag 都会变化,客户端应
                **每个 query 组合各存一份 last-ETag**(典型用 query 的 hash 作 key)。
            Cache-Control:
              schema:
                type: string
              description: "`public, s-maxage=60, stale-while-revalidate=300`"
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/ItemList"
        "304":
          description: |
            **Not Modified** — 客户端带的 `If-None-Match` header 等于当前 ETag,
            服务器返回空 body,告诉你"自上次以来无变化"。

            **强烈推荐 cron / 定时拉取场景使用**:无新条目时返回 < 1KB 的 304 空 body,
            客户端 `return` 不用 parse JSON,服务器侧成本接近 0(99% 命中率)。

            **行为**:跟 200 同样的 ETag + Cache-Control header,只是无 body。
          headers:
            ETag:
              schema:
                type: string
        "400":
          description: |
            参数不合法（mode / category / since / take 越界、cursor 字符长度超限等）。
            响应体 `{ "error": "<原因>" }`。
        "567":
          $ref: "#/components/responses/EdgeBlocked"
        "429":
          description: |
            限流触发（单 IP 持续超过 60 req/min 且突发缓冲已耗尽）。
            429 由 nginx 返回；客户端按 HTTP 状态码处理，不要假设响应体一定是 JSON。建议退避 30-60 秒。
            （注：429 是限流码；部署期 upstream 不可达另返 502/503 维护页，与限流是两回事。）

  /api/public/daily:
    get:
      operationId: getLatestDaily
      summary: Latest AI HOT daily report
      description: 返回最新一天的精编日报。
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DailyReport"
        "404":
          description: 暂无可用日报
        "567":
          $ref: "#/components/responses/EdgeBlocked"
        "429":
          description: 限流触发（HTTP 429；按状态码退避，不保证响应体为 JSON）

  /api/public/daily/{date}:
    get:
      operationId: getDailyByDate
      summary: AI HOT daily report for a specific date
      parameters:
        - name: date
          in: path
          required: true
          schema:
            type: string
            pattern: '^\d{4}-\d{2}-\d{2}$'
          description: YYYY-MM-DD（UTC 0 点为基准）
          example: "2026-05-07"
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DailyReport"
        "400":
          description: 日期格式错误
        "404":
          description: 该日无日报
        "567":
          $ref: "#/components/responses/EdgeBlocked"
        "429":
          description: 限流触发（HTTP 429；按状态码退避，不保证响应体为 JSON）

  /api/public/dailies:
    get:
      operationId: listDailies
      summary: Index of recent AI HOT daily reports
      description: |
        返回最近 N 天日报的索引(不含正文,含 date / generatedAt / leadTitle / leadParagraph)。
      parameters:
        - name: take
          in: query
          schema:
            type: integer
            minimum: 1
            maximum: 180
            default: 30
          description: 严格整数 1-180,越界 / 非整数(1.5 / 1e2 / abc 等) → 400(跟 /api/public/items 行为一致)
      responses:
        "200":
          description: OK
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/DailyEntries"
        "400":
          description: 参数不合法(take 非整数 / 越界)
        "567":
          $ref: "#/components/responses/EdgeBlocked"
        "429":
          description: 限流触发（HTTP 429；按状态码退避，不保证响应体为 JSON）

  /api/public/hot-topics:
    get:
      operationId: listHotTopics
      summary: Current hot topics (multi-source heat ranking)
      description: |
        当前热点 —— 精选页置顶「当前热点」区的程序化访问。簇级多源热度排序（窗口内报道该事件的
        独立信源数 + 指数衰减），回答「现在 AI 圈最热的是什么」。

        **与 /api/public/items 排序不同**：items 按 publishedAt 倒序（最近发布），本端点按多源
        热度排序（最热）。对外剥内部 clusterId / heat 数值，只留浏览器可见字段 + permalink。

        匿名只读，weak ETag + 304（cron 轮询低成本），nginx proxy_cache。
      responses:
        "200":
          description: OK（含 weak ETag `W/"hot-..."`，可带 If-None-Match 走 304）
          headers:
            ETag:
              schema:
                type: string
            Cache-Control:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/HotTopicList"
        "304":
          description: Not Modified（If-None-Match 命中当前 ETag，空 body）
        "567":
          $ref: "#/components/responses/EdgeBlocked"
        "429":
          description: 限流触发（HTTP 429；按状态码退避，不保证响应体为 JSON）

  /api/public/fingerprint:
    get:
      operationId: getFingerprint
      summary: Lightweight change probe for polling clients
      deprecated: true
      description: |
        旧客户端的「有没有新内容」轻量信号（~100 字节）；新客户端不应接入本端点，而应直接对
        v1 完整 URL 做 ETag 条件请求。响应含 selected / all 两条流各自的
        不透明内容指纹：**存住上次的值，变了才去拉 /api/public/items**——把分钟级空轮询的成本
        从整页 JSON 降到一个指纹字符串。（注意与 items 端点的 `cursor` 翻页参数无关。）

        指纹契约：值变化 ⇔ 流头部有新条目（发布 / 下架 / 重排）；**原地编辑不触发**（编辑感知
        请依赖 items 端点的 ETag）。值不透明，勿解析，仅做字符串对比。

        60s 缓存 + weak ETag/304：恒速轮询近零成本。推荐轮询间隔 ≥ 60s（更快也只会命中同一份缓存）。
      responses:
        "200":
          description: OK（含 weak ETag `W/"fingerprint-..."`）
          headers:
            ETag:
              schema:
                type: string
            Cache-Control:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicFingerprint"
        "304":
          description: Not Modified（If-None-Match 命中当前 ETag，空 body）
        "567":
          $ref: "#/components/responses/EdgeBlocked"
        "429":
          description: 限流触发（HTTP 429；按状态码退避，不保证响应体为 JSON）

  /api/public/version:
    get:
      operationId: getVersion
      summary: API / Skill version info (machine-readable)
      description: |
        公开旧 API 与 Skill 的版本信息。该端点仅作为已安装 0.x Skill 发现稳定 1.0 的迁移桥；
        Skill 1.0 普通查询不再调用它。后端在稳定 `/api/v1` 合同内迭代不要求用户更新 Skill。

        版本极少变 → 长缓存（s-maxage=3600 + 1d stale）。weak ETag + 304 让轮询近零成本。
      responses:
        "200":
          description: OK（含 weak ETag `W/"version-..."`）
          headers:
            ETag:
              schema:
                type: string
            Cache-Control:
              schema:
                type: string
          content:
            application/json:
              schema:
                $ref: "#/components/schemas/PublicVersion"
        "304":
          description: Not Modified（If-None-Match 命中当前 ETag，空 body）
        "567":
          $ref: "#/components/responses/EdgeBlocked"

components:
  responses:
    EdgeBlocked:
      description: |
        请求在到达 AI HOT 应用前被 EdgeOne 安全规则拦截。HTTP 567 不等于 IP 被封，也不能靠
        轮换或伪装 User-Agent 绕过。正常退避后最多重试一次；仍失败请携带 requestId 到 help
        指向的接入页反馈。浏览器与默认 SDK 本身属于正式支持路径。
      content:
        application/json:
          schema:
            $ref: "#/components/schemas/EdgeBlockedError"
  schemas:
    EdgeBlockedError:
      type: object
      required:
        - error
        - code
        - requestId
        - help
      properties:
        error:
          type: string
          const: blocked
        code:
          type: integer
          const: 567
        requestId:
          type: string
          description: EdgeOne 请求 ID；误拦反馈时请原样附上
          example: "67041384400082682"
        help:
          type: string
          format: uri
          example: "https://aihot.virxact.com/agent?tab=api#agent-api-recovery"
    Attribution:
      type: object
      description: 来源署名与 canonical 反链（反镜像盗采；灰度开关关闭时可能省略）
      required:
        - source
        - canonical
      properties:
        source:
          type: string
          example: "AI HOT"
        canonical:
          type: string
          format: uri
          description: 站内规范 URL（items 为 /items/{id}，日报为 /daily/{date}）

    PublicFingerprint:
      type: object
      description: 轮询探针响应。指纹值不透明，只用于与上次取值做字符串对比。
      required:
        - selected
        - all
      properties:
        selected:
          type: string
          example: "f1-8f3a2b1c9d4e5f60"
          description: mode=selected 流的头部指纹，变化 = 精选流有新内容
        all:
          type: string
          example: "f1-1a2b3c4d5e6f7081"
          description: mode=all 流的头部指纹，变化 = 全量流有新内容
        docs:
          type: string
          format: uri
          description: 用法说明入口（/agent 接入页）

    PublicVersion:
      type: object
      properties:
        apiVersion:
          type: string
          example: "1.5.0"
          description: REST API 契约版本（加端点/字段=minor，破坏性变更=major）
        skillVersion:
          type: string
          example: "0.3.6"
          description: Skill（SKILL.md）版本。只供旧 0.x Skill 迁移发现；Skill 1.0 普通查询不轮询本端点
        updatedAt:
          type: string
          example: "2026-07-24"
        changelogUrl:
          type: string
          format: uri
          example: "https://aihot.virxact.com/changelog"
        recentChanges:
          type: array
          items:
            type: string
          description: 最近几条用户可感知变更摘要（精简；完整人读版在 changelogUrl）

    DailyReportSection:
      type: object
      properties:
        label:
          type: string
          example: "模型发布/更新"
        items:
          type: array
          items:
            type: object
            properties:
              title:
                type: string
              summary:
                type: string
              sourceUrl:
                type: string
                format: uri
              sourceName:
                type: string
              permalink:
                type: string
                format: uri
                nullable: true
                description: >
                  站内读者详情页绝对 URL（/items/{itemId}，站内中文翻译 + 富文本 + 无墙），
                  与 /api/public/items 的 permalink 同源。lead 占位 / 空 itemId 时为 null（回退用 sourceUrl）。
              attribution:
                $ref: "#/components/schemas/Attribution"
                description: permalink 对应内容的来源署名；permalink 为 null 时省略。

    DailyReportFlash:
      type: object
      properties:
        title:
          type: string
        sourceName:
          type: string
        sourceUrl:
          type: string
          format: uri
        publishedAt:
          type: string
          format: date-time
        permalink:
          type: string
          format: uri
          nullable: true
          description: 站内读者详情页绝对 URL（/items/{itemId}）。空 itemId 时为 null。
        attribution:
          $ref: "#/components/schemas/Attribution"
          description: permalink 对应内容的来源署名；permalink 为 null 时省略。

    DailyReport:
      type: object
      properties:
        date:
          type: string
          example: "2026-05-07"
        attribution:
          $ref: "#/components/schemas/Attribution"
          description: 该期日报的规范站内 URL（/daily/{date}）。
        generatedAt:
          type: string
          format: date-time
        windowStart:
          type: string
          format: date-time
        windowEnd:
          type: string
          format: date-time
        lead:
          type: [object, "null"]
          properties:
            title:
              type: string
            leadParagraph:
              type: string
        sections:
          type: array
          items:
            $ref: "#/components/schemas/DailyReportSection"
        flashes:
          type: array
          items:
            $ref: "#/components/schemas/DailyReportFlash"

    DailyEntries:
      type: object
      properties:
        count:
          type: integer
        items:
          type: array
          items:
            type: object
            properties:
              date:
                type: string
              attribution:
                $ref: "#/components/schemas/Attribution"
                description: 该期日报的规范站内 URL（/daily/{date}）。
              generatedAt:
                type: string
                format: date-time
              leadTitle:
                type: [string, "null"]
              leadParagraph:
                type: [string, "null"]
                description: LLM 主编当日导语段落(若该期生成了 lead);RSS daily.xml 也用

    Item:
      type: object
      description: |
        AI HOT 内容条目(公开 shape)。暴露用户在浏览器能看到的基础内容 +
        score(总分) + selected(精选标记)，以及同步必需的稳定 `discoveredAt`。五轴评分明细 / editorialJudgment /
        AI 标签 / 内部分类编号 / 模型 id 等机制仍不暴露。

        **注意**:本 API 的 `source` 是字符串(信源人话名)。**老接口 `/api/public/feed`
        给浏览器信息流翻页用,它的 `source` 是对象**(`{id, name, kind}`),字段名相同
        语义不同 — Agent / 二次开发者请优先用本接口(`/api/public/items`),不要混用。
      required:
        - id
        - title
        - url
        - permalink
        - source
        - publishedAt
        - discoveredAt
        - selected
      properties:
        id:
          type: string
          description: 条目稳定唯一 id（可用于追溯）
        title:
          type: string
          description: 显示标题（中文为主，无中译时回落原标题）
        title_en:
          type: [string, "null"]
          description: 原标题（仅当与 title 不同时存在）
        url:
          type: string
          format: uri
          description: 第三方原文 URL（信源原始链接）。
        permalink:
          type: string
          format: uri
          description: |
            站内读者详情页绝对 URL(`/items/{id}`)。该页是公开可见的最终呈现
            (中文翻译 + 富文本 + 无 X/付费墙),适合深链回 aihot 给用户阅读;`url` 仍是第三方原文。
            **始终非空**(对任意 id 都存在)。注:该页 `noindex`(避免被搜索引擎当原创正文收录),
            但对读者/分享完全可用。站内正文本身不经 API 直出(去 permalink 页读),避免批量扒全文。
        source:
          type: string
          description: |
            来源名(如 "OpenAI Blog" / "Anthropic 推特" / "X：Berry Xia (@berry_xia)")。
            **始终是字符串**,不会是对象——把它当 string 渲染就行。
        publishedAt:
          type: [string, "null"]
          format: date-time
          description: 第三方原文真实发布时间；可能早于 AI HOT 收录时间。
        discoveredAt:
          type: string
          format: date-time
          description: |
            AI HOT 第一次收到该条资讯的稳定时间，不随重新评分或编辑改变。它与 publishedAt 分开，
            可用于审计收录延迟；完整增量同步仍应使用 selected changes 的 cursor，而不是时间过滤。
        summary:
          type: [string, "null"]
          description: 中文摘要
        category:
          type: [string, "null"]
          enum: ["ai-models", "ai-products", "industry", "paper", "tip", null]
        score:
          type: [integer, "null"]
          minimum: 0
          maximum: 100
          description: |
            内容总分(0-100),等于网页卡片右上角显示的分数。分越高=越值得读。
            **不是排序字段**(items 按 publishedAt 倒序);可自行按 score 二次排序/过滤。
            理论上公开条目都已评分,极端竞态下可能为 null,请判空。
        selected:
          type: boolean
          description: |
            是否进入 AI HOT 精选(网页「精选」标记)。`mode=selected` 时恒为 true;
            `mode=all` 时区分精选主菜单(true)与全池次要条目(false)。
        attribution:
          $ref: "#/components/schemas/Attribution"
          description: 条目 canonical 来源署名；灰度开关关闭时可能省略。

    ItemList:
      type: object
      properties:
        count:
          type: integer
        hasNext:
          type: boolean
        nextCursor:
          type: [string, "null"]
          description: 翻页用，下一次请求的 `cursor` 参数。
        items:
          type: array
          items:
            oneOf:
              - $ref: "#/components/schemas/Item"
              - $ref: "#/components/schemas/ItemMinimal"

    ItemMinimal:
      type: object
      description: |
        `fields=minimal` 的轻量条目。用于索引 / 推送深链，不含 summary、第三方原文 URL、原标题。
      required:
        - id
        - title
        - permalink
        - source
        - publishedAt
        - discoveredAt
        - selected
      properties:
        id:
          type: string
        title:
          type: string
        permalink:
          type: string
          format: uri
          description: 站内读者详情页绝对 URL(/items/{id}),适合直接给用户打开。
        source:
          type: string
        publishedAt:
          type: [string, "null"]
          format: date-time
        discoveredAt:
          type: string
          format: date-time
        category:
          type: [string, "null"]
          enum: ["ai-models", "ai-products", "industry", "paper", "tip", null]
        score:
          type: [integer, "null"]
          minimum: 0
          maximum: 100
        selected:
          type: boolean

    PublicApiError:
      type: object
      required:
        - code
        - error
      properties:
        code:
          type: string
          enum: ["invalid_request", "snapshot_required"]
        error:
          type: string

    SelectedSnapshot:
      type: object
      required:
        - schemaVersion
        - asOf
        - fields
        - cursor
        - count
        - items
      properties:
        schemaVersion:
          type: integer
          const: 1
        asOf:
          type: string
          format: date-time
          description: 快照对应的最后一条精选变化时间；不是每条内容的发布时间。
        fields:
          type: string
          enum: ["default", "minimal"]
        cursor:
          type: string
          description: 后续调用 selected changes 的不透明起点。
        count:
          type: integer
          minimum: 0
        items:
          type: array
          items:
            oneOf:
              - $ref: "#/components/schemas/Item"
              - $ref: "#/components/schemas/ItemMinimal"

    SelectedChanges:
      type: object
      required:
        - schemaVersion
        - fields
        - cursor
        - count
        - hasMore
        - changes
      properties:
        schemaVersion:
          type: integer
          const: 1
        fields:
          type: string
          enum: ["default", "minimal"]
        cursor:
          type: string
          description: 整页成功应用后保存；下一次原样回传。
        count:
          type: integer
          minimum: 0
          description: 折叠同一事务内同一条目的中间态之后，本页实际变化数。
        hasMore:
          type: boolean
        changes:
          type: array
          items:
            oneOf:
              - $ref: "#/components/schemas/SelectedUpsertChange"
              - $ref: "#/components/schemas/SelectedRemoveChange"

    SelectedUpsertChange:
      type: object
      required:
        - op
        - changedAt
        - item
      properties:
        op:
          type: string
          const: upsert
        changedAt:
          type: string
          format: date-time
        item:
          oneOf:
            - $ref: "#/components/schemas/Item"
            - $ref: "#/components/schemas/ItemMinimal"

    SelectedRemoveChange:
      type: object
      required:
        - op
        - changedAt
        - id
      properties:
        op:
          type: string
          const: remove
        changedAt:
          type: string
          format: date-time
        id:
          type: string
          description: 从本地精选集合删除的条目 id。

    HotTopic:
      type: object
      description: |
        当前热点条目(公开 shape)。簇级多源热度的展示主条 + 报道该事件的信源数。
        刻意剥内部 clusterId 与 heat 数值(产品壁垒),只留浏览器可见字段 + permalink。
      required:
        - id
        - title
        - url
        - permalink
        - source
        - sourceCount
        - signalCount
        - sourceNames
        - latestAt
      properties:
        id:
          type: string
          description: 热点展示主条的条目 id(与 /api/public/items 的 id 同一空间,可深链 / 关联)。
        title:
          type: string
        url:
          type: string
          format: uri
          description: 第三方原文 URL。
        permalink:
          type: string
          format: uri
          description: 站内读者详情页绝对 URL(/items/{id}),中文翻译 + 富文本 + 无墙;始终非空。
        source:
          type: string
          description: 展示主条来源名。
        sourceCount:
          type: integer
          description: 窗口内报道该事件的独立精选信源数(= 网页「近48小时 N 个信源」)。
        signalCount:
          type: integer
          description: 窗口内独立纯热点信号组数。仅返回聚合数，不返回纯热点账号名称或内容。
        sourceNames:
          type: array
          items:
            type: string
          description: 窗口内信源名去重列表(按首报时间序)。
        latestAt:
          type: string
          format: date-time
          description: 窗口内最新一条报道时间(ISO UTC)。

    HotTopicList:
      type: object
      properties:
        count:
          type: integer
        items:
          type: array
          items:
            $ref: "#/components/schemas/HotTopic"
