# API Push 短新闻 > /api/v1/ingest — 让外部系统(微信机器人、RSS-digest 脚本、自家脚本) > 直接 POST 中文短新闻入库,跳过抓取和翻译,只跑分类 + 双 provider 点评。 跟 RSS 抓取的对比: | 维度 | RSS | API Push(短新闻)| |---|---|---| | 触发 | worker 周期拉 | 外部主动 POST | | 内容 | 长文 + 全文抽取 | 标题 + 短文(≤5000 字)| | 语言 | 外文 → 翻译 | 中文原生,无翻译 | | LLM 任务 | 排版 + 分类 + 插图 + 双点评 | 分类 + 双点评(无排版、无插图)| | 入库 kind | `rss` / `html_list` / `tg_channel` | `api_push` | | 去重 key | `url_hash` | `content_hash`(= SHA1 文本指纹) | --- ## 调用方协议 ### 鉴权 每个 source 一个独立 token(绑定 source_id),通过 `X-Ingest-Token` 头传。 ```http POST /api/v1/ingest Content-Type: application/json X-Ingest-Token: { "external_id": "wx-2026-06-14-001", "title": "美联储宣布维持利率不变", "body": "美联储在最新议息会议后宣布...", "url": "https://example.com/article/123", "source_ref": "wechat", "author": "张三", "published_at": "2026-06-14T10:00:00Z", "tags": ["财经", "美联储"] } ``` 字段: | 字段 | 必填 | 长度 | 说明 | |---|---|---|---| | `title` | ✅ | 1-200 字 | 文章标题 | | `body` | ✅ | 1-5000 字 | 正文纯文本(保留 `\n` 换行)| | `external_id` | 推荐 | ≤128 字 | 调用方业务 ID,幂等 key | | `url` | ❌ | ≤2048 字 | 原始链接(可选,纯展示用)| | `source_ref` | ❌ | ≤64 字 | 短新闻里的二级来源标识,如 `"wechat"`/`"rss-digest"` | | `author` | ❌ | ≤255 字 | 作者 | | `published_at` | ❌ | ISO8601 | 发布时间,缺省 = now | | `tags` | ❌ | ≤10 个,每个 ≤32 字 | 直接写入 category,跳过 LLM 分类 | ### 响应 成功新建(201): ```json { "article_id": 12345, "content_hash": "a1b2c3...", "status": "created" } ``` 命中三层去重之一(200): ```json { "article_id": 12340, "content_hash": "a1b2c3...", "status": "duplicate", "reason": "external_id_match", "matched_external_id": "wx-2026-06-14-001" } ``` `reason` 取值: - `external_id_match` —— L1,同 source 下已有相同 `external_id` - `content_hash_match` —— L2,任意 source 下已有相同内容指纹 - `db_unique` —— L3,并发场景下 DB UNIQUE 约束兜底 错误响应: | 状态码 | 含义 | 触发 | |---|---|---| | 400 | 字段缺失/超长 | pydantic 自动校验 | | 401 | token 无效/吊销/过期 | `X-Ingest-Token` 缺失或不匹配 | | 403 | 源 disabled 或 kind 不匹配 | source.enabled=false 或 kind≠api_push | | 404 | source 不存在 | token 绑定的 source_id 不在 | | 429 | 触发限速 | 同 token 1 秒内推送 >2 篇(`INGEST_RATE_PER_SEC`)| --- ## 三层去重 ``` 请求进来 │ ├─ L1:SELECT WHERE external_id=? AND source_id=? │ → 命中 → 200 duplicate, reason=external_id_match │ ├─ L2:SELECT WHERE content_hash=? │ → 命中 → 200 duplicate, reason=content_hash_match │ ├─ INSERT (UNIQUE 约束兜底) │ → IntegrityError → 200 duplicate, reason=db_unique │ └─ 写入成功 → 201 created ``` `content_hash` 算法: ```python if external_id: raw = f"ext:{external_id}" else: raw = f"{title.strip()}|{body[:500]}" # 仅取前 500 字符,防尾部噪声 sha1(raw) → 40 字符 ``` - `external_id` 存在时 → 强幂等(调用方业务 ID 不变就一定去重) - `external_id` 缺失时 → 标题 + 前 500 字符作为兜底指纹 --- ## 限速 每个 token 独立计数(Redis),默认 2 篇/秒。改 `.env` 的 `INGEST_RATE_PER_SEC` 后重启 api 容器生效。 429 响应带 `Retry-After: 1` 头。 --- ## 入库后会发生什么 短新闻入库时,`translation_status / format_status / image_ai_status` 均为 `'n/a'`, 跳过翻译、排版、插图。 `enrichment_loop` 看到 `is_short_news=True` + 任意 `*_status='pending'` 时: 1. **classify**(分类):带 `tags` 入库 → 直接标 `'ok'`,跳过;无 `tags` → 调 LLM 分类 2. **commentary**(Angel 评论):调 LLM 生成 3. **commentary_meituan**(美团评论):调 LLM 生成 4. **format** / **image**:跳过 完整 lifecycle: ``` T+0s POST /api/v1/ingest → 201 created T+~15s enrichment_loop 扫到 is_short_news=True → classify + 双 commentary T+~30s 三项 LLM 任务完成,前端拉 /articles/{id} 即可看到分类 + 双 provider 点评 ``` --- ## owner 操作手册 ### 生成 ingest token 1. 登录 owner 账号 2. 进入 `/admin/sources` 3. 新建源(kind=API Push)或点击现有 api_push 源的 **🔑 Token** 按钮 4. 在弹窗里填名称(如 `wechat-bot`),点 "生成" 5. **raw_token 立即复制保存** —— 关闭弹窗后只显示 hash,不再显示 raw 6. 把 raw_token 配置到调用方,作为 `X-Ingest-Token` 头 ### 撤销 token 在 Token 弹窗里点 "撤销" → 该 token 立即失效,下次推送返 401。 ### 调限速 编辑 `.env` → `INGEST_RATE_PER_SEC=N` → `docker compose up -d --no-deps --force-recreate api` (**不是 `restart`** —— `restart` 不会重新读 env;容器重建才会) --- ## 调用方示例 ### curl ```bash curl -X POST https://your-domain/api/v1/ingest \ -H "Content-Type: application/json" \ -H "X-Ingest-Token: YOUR_RAW_TOKEN" \ -d '{ "external_id": "wx-2026-06-14-001", "title": "美联储宣布维持利率不变", "body": "美联储在最新议息会议后宣布,将联邦基金利率目标区间维持在 4.25%-4.50%。这是连续第二次按兵不动...", "source_ref": "wechat", "published_at": "2026-06-14T10:00:00Z", "tags": ["财经", "美联储"] }' ``` ### Python(httpx) ```python import httpx async def push_short_news(token: str, **payload): async with httpx.AsyncClient() as client: r = await client.post( "https://your-domain/api/v1/ingest", headers={"X-Ingest-Token": token}, json=payload, timeout=10.0, ) r.raise_for_status() return r.json() ``` ### 重试策略(应对 429) ```python import asyncio from httpx import HTTPStatusError async def push_with_retry(token, **payload, max_retries=3): for attempt in range(max_retries): try: return await push_short_news(token, **payload) except HTTPStatusError as e: if e.response.status_code == 429 and attempt < max_retries - 1: # 429:等 Retry-After 头指定的秒数(默认 1 秒) wait = int(e.response.headers.get("Retry-After", 1)) await asyncio.sleep(wait) continue raise ``` --- ## 故障排查 | 现象 | 排查 | 修复 | |---|---|---| | 401 invalid ingest token | token 是否被撤销/过期;DB 里 `api_tokens.purpose` 是否为 `ingest` | 重新生成 | | 413 body length invalid | body 超过 5000 字 | 截断或拆条 | | 429 rate limit exceeded | 同 token 1 秒内推 >2 篇 | 退避 1 秒重试,或调高 `INGEST_RATE_PER_SEC` | | 短新闻一直 `classify_status='pending'` | `enrichment_loop` 是否在跑;`/admin/health` 看 worker 状态 | 重启 worker;检查 `.env` 里 `AGNES_API_KEY` / `MEITUAN_API_KEY` | | 短新闻在 Feed 里没出现 | 看后端日志 `docker compose logs worker \| grep ingest`;DB `SELECT * FROM articles WHERE is_short_news=true` 看是否入库 | 入库成功但 enrich 没跑就等;入库失败看 ingest 日志 |