Files
diary-news/README.md
mavis 85c05c19a7 refactor(search): 只展示 keyword 续接词,去掉 titles 段
产品决定:搜索建议只展示 ts_stat 高频词续接(如'美'→美国/美军/美国政府),
不要真实文章 id 提示(用户认为这种'文章#566871'是噪音,没连续性)。

改动:
- SearchSuggestionsResponse 去 title,只剩 query + keywords
- SearchService 只查 search_keywords,fallback 路径也只针对 keywords
- Feed.vue: 删掉 suggestTitles 状态 + SuggestTitleOption 类型联合,
  renderSuggestion 简化成 '词' 标签 + 词文本 + 右侧 weight 数字
- 0011 迁移: 删 search_title_suggestions 表 + 3 索引 + trigger + 函数
  (trigger 在每篇文章 INSERT/UPDATE 都会跑,删了能省掉无用性能损耗)
- 删除: app/models/search_title_suggestion.py + backfill_search_suggestions.py
  替换成: app/scripts/refresh_search_keywords.py(只跑一次词频刷新)
2026-06-15 19:37:40 +08:00

886 lines
41 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Diary News · 私人多源新闻翻译系统
> 抓境外权威源 → 自动中英对照 → 智能排版/分类/插图/点评。
> 跑在一台 2C/2G/30G 的香港 VPS 上,自用 + 家人/小圈子。
---
## 目录
1. [项目目标](#项目目标)
2. [关键特性](#关键特性)
3. [架构概览](#架构概览)
4. [技术栈](#技术栈)
5. [仓库结构](#仓库结构)
6. [数据模型](#数据模型)
7. [快速开始](#快速开始)
8. [功能详解](#功能详解)
9. [LLM 智能增强](#llm-智能增强)
10. [API 概览](#api-概览)
11. [开发-部署工作流](#开发-部署工作流)
12. [运维工具](#运维工具)
13. [故障排查](#故障排查)
14. [路线图](#路线图)
---
## 项目目标
**Why 存在**:
| 痛点 | 解决 |
|---|---|
| 信息茧房(算法推荐让你只看一类) | 多源并列,无个性化排序,纯时间序 |
| 翻译门槛(英文看不动) | 自动翻译(腾讯云 TMT + 本地 NLLB 降级) |
| 内容保存难(网页 404、推文删) | 抓取入库 + 全文 + 译文,永久可查 |
| 单台服务器成本敏感 | 30G 跑全栈,月费 ≤ 50 HKD |
**Who 给谁用**:
- **owner**: 唯一管理员,管理源 / 提示词 / 看健康看板
- **member**: 家庭成员 / 朋友,登录看文章 / 收藏 / 订阅关键词
---
## 关键特性
- 🌍 **多源 RSS 抓取**:Reuters / BBC / Al Jazeera / NHK / DW,带失败退避(连续 3 次失败把间隔 × 2,封顶 12 小时)
- 🌐 **智能翻译**:腾讯云 TMT(月 500 万字符配额)→ 本地 NLLB-200 降级,30 天 Redis 缓存避免重复
- 🤖 **LLM 智能增强** *(新)*:翻译完成后自动跑 4 项 LLM 任务 — 排版 / 分类 / 插图 / 点评
- 🎨 **AI 配图**:文生图模型自动为每篇文章生成插图(走 Agnes 平台,带限速)
- 📥 **API Push 短新闻** *(新)*:`POST /api/v1/ingest` 接收外部中文短新闻推送,
三层去重(L1 external_id / L2 content_hash / L3 DB UNIQUE)+ 每 token 2 篇/秒限速;
短新闻入库后跳过翻译/排版/插图,只跑分类 + 双 provider 点评
- 👤 **双角色鉴权**:JWT(access 60min + refresh 14d) + API Token(sha256,可撤销,给 Android / ingest 预留)
- 📌 **收藏 + 关键词订阅**:用户级书签,服务端定时按关键词命中推送(预留 Telegram 通道)
- 📊 **管理看板**:源健康度 / 翻译配额 / LLM 状态,全部可视化
- 🔍 **智能搜索建议** *(新)*:`GET /api/v1/search/suggestions?q=prefix` 实时返回高频词续接词(输入"美国"→ ["美国", "美国政府", "美国签证"]);zhparser 中文分词 + GIN 数组索引,前端 debounce 250ms 自动补全
后端用 zhparser 中文分词 + PG 全文搜索 + 候选词固化表,前端 debounce 250ms 自动补全
- 🔄 **热加载**:源/提示词改了不用重启,worker 每天 00:30 重建 job
- 🚀 **一键部署**:SSH 推公钥 + 一键 `git pull` 流程
- 🔒 **安全默认**:bcrypt 密码、API Token 加密、SQL 注入免疫(SQLAlchemy 2.0 参数化)
---
## 架构概览
```
┌─────────────────────────────────────────────────────────┐
│ VPS (HK-News · 2C/2G/30G · Ubuntu 24) │
│ │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ postgres │ │ redis │ │ caddy │ ← 唯一 │
│ └──────────┘ └──────────┘ │ :80/443 │ 对外 │
│ ▲ ▲ └────┬─────┘ │
│ │ │ │ │
│ ┌────┴──────┐ ┌────┴────┐ ┌────────┴───────┐ │
│ │ api │ │ worker │ │ frontend │ │
│ │ FastAPI │ │APSch+任务│ │ (nginx + SPA)│ │
│ └────┬──────┘ └────┬────┘ └────────────────┘ │
│ │ │ │
│ └──────┬───────┘ │
│ │ │
│ ┌───────────▼──────────┐ │
│ │ RSS 抓取(feedparser)│ │
│ │ 翻译(Tencent+NLLB) │ │
│ │ LLM 增强(Agnes) │ ← 排版/分类/插图/点评 │
│ │ url_hash 去重 │ │
│ └──────────────────────┘ │
└──────────────────────────────────────────────────────────┘
│ HTTPS / Push / Pull
┌──────────────────────┐
│ Gitea(代码托管) │
│ http://...:3000 │
└──────────────────────┘
```
**数据流**(单篇文章的一生):
下面这张图是当前生产实际链路。**抓**(RSS scheduler) → **翻**(translation_loop) → **增强**(enrichment_loop) → **展示**(/api/v1/articles/{id})。
```
┌───────────────────────────────────────────────────────────────────────┐
│ APScheduler 1 篇/秒(由 `backend/app/workers/__main__.py` 启动) │
├───────────────────────────────────────────────────────────────────────┤
│ │
│ ┌────────────────┐ ┌────────────────┐ ┌────────────────┐ │
│ │ translation_ │ │ translation_ │ │ enrichment_ │ │
│ │ loop │ │ loop │ │ loop │ │
│ │ 1 篇/秒 │ │ 独立(不并发抓) │ │ 2 秒/轮 + 8 并发│ │
│ │ │ │ │ │ SELECT 待增强 │ │
│ └────────────────┘ └────────────────┘ └────────────────┘ │
└───────────────────────────────────────────────────────────────────────┘
▲ ▲ ▲
│ │ │
│ fetch_one_source │ translate_article │ enrich_article
│ (RSS 抓取) │ (spark/zhipu/tencent) │ (Agnes 4 任务)
│ │ │
▼ ▼ ▼
┌───────────────────────────────────────────────────────────────────────┐
│ articles 表 │
│ 24 个核心字段 —— 见下方"字段生命周期"表 │
└───────────────────────────────────────────────────────────────────────┘
```
下面用一条时间线 + 字段状态表,把"一篇文章从 RSS 出现到用户在前端看到"的全部状态变化说清楚。
### 时间线(典型情况,2026-06 链路)
```
T+0s RSS 抓取(每 30~60 分钟一次,按 source 配置)
T+1s feedparser 解析 → 提取 url / title / body_text / lang_src
│ url_hash = SHA1(url)
│ INSERT ... ON CONFLICT (url_hash) DO NOTHING ← 去重
▼ 新行落地:
│ translation_status = 'pending'
│ format_status = 'n/a'
│ classify_status = 'n/a'
│ image_ai_status = 'n/a'
│ commentary_status = 'n/a'
│ category = NULL
│ title_zh / body_zh_* = NULL
│ fetched_at = now()
T+5~30s translation_loop 扫到这行
│ pipeline.translate_article(id)
├── 选引擎(service.py,优先级降序):
│ 1) spark ← Spark Lite (免费,默认)
│ 2) zhipu ← GLM-4-Flash (免费,2.3s/篇)
│ 3) tencent ← 腾讯云 TMT (按月配额;en-gb 会先 normalize)
│ 4) tencent_maas / agnes / local (备用,通常不用)
├── 翻译 title + body(按 4500 字符切 chunk)
│ 每个 chunk 走同一条引擎链
T+10~60s 翻译完成,严格 status 判定(`pipeline.py:197-212`):
│ 1) 检查 AuthFailure / TMT error marker
│ 2) 检查 body 是否等于原文(未翻译)
│ 3) 以上任一命中 → translation_status = 'failed'
│ 4) 否则 status = 'ok' 或 'partial'
T+15~30s enrichment_loop 扫到这行(translation_status='ok')
│ pipeline.enrich_article(id)
├── 4 任务(独立 try/except,任一失败不阻塞其他):
│ 1) classify ← LLM 返回 {"categories":[...]} → category 字段(逗号分隔多标签)
│ 2) format ← LLM 重排 → body_zh_formatted(含固定 CSS 容器)
│ 3) image ← 文生图,prompt 用正文第一段
│ 4) commentary ← LLM 点评
T+30~90s 4 任务全部跑完
│ status 分别落到: ok / failed / pending
T+任意 前端拉 /api/v1/articles/{id}
│ 展示:原文 + 译文(LLM 排版版) + 分类 + 插图 + 点评
用户看到完整文章
```
### 字段生命周期表(`articles` 表)
> 每一行是**一篇文章在某个时间点**的字段状态快照。**√** = 字段已填,NULL/`'n/a'`/`'pending'` 表示还没走到。
| 阶段 | translation_status | title_zh | body_zh_text | body_zh_formatted | image_ai_url | category | commentary | *_status (4 个 LLM) |
|---|---|---|---|---|---|---|---|---|
| **抓取后** | `pending` | NULL | NULL | NULL | NULL | NULL | NULL | `n/a` `n/a` `n/a` `n/a` |
| **翻译中** | `pending` | NULL | NULL | NULL | NULL | NULL | NULL | `n/a` `n/a` `n/a` `n/a` |
| **翻译完成** | `ok` | √ | √ | NULL | NULL | NULL | NULL | `n/a` `n/a` `n/a` `n/a` |
| **classify 完** | `ok` | √ | √ | NULL | NULL | √ | NULL | `n/a` `ok` `n/a` `n/a` |
| **format 完** | `ok` | √ | √ | √ | NULL | √ | NULL | `ok` `ok` `n/a` `n/a` |
| **image 完** | `ok` | √ | √ | √ | √ | √ | NULL | `ok` `ok` `ok` `n/a` |
| **commentary 完** | `ok` | √ | √ | √ | √ | √ | √ | `ok` `ok` `ok` `ok` |
> **状态值含义**:
> - `n/a` — 默认值,从未跑过 enrich
> - `pending` — 任务入队但还没处理
> - `ok` — 成功
> - `failed` — 该任务失败(其他任务继续)
### 翻译引擎优先级
> 降序排列,前者不可用时降级后者。完整逻辑见 `backend/app/services/translation/service.py`。
| 序位 | 引擎 | 配置 | 默认 model | 配额/限速 | 失败处理 |
|---|---|---|---|---|---|
| 1 | **spark** (Spark Lite) | `SPARK_API_PASSWORD` | `lite` | Lite 免费(企业级按 token) | 失败 → zhipu |
| 2 | **zhipu** (GLM-4-Flash) | `ZHIPU_API_KEY` | `glm-4-flash` | 免费(并发有限流) | 失败 → tencent |
| 3 | **tencent** (腾讯云 TMT) | `TENCENTCLOUD_SECRET_ID/SECRET` | n/a | 月 500 万字符(可调) | 配额满 → maas |
| 4 | tencent_maas (腾讯 MaaS u2) | `TENCENT_MAAS_API_KEY` | `u2` | 无配额 | 失败 → agnes |
| 5 | agnes (通用 LLM) | `AGNES_API_KEY` | n/a | 按 token | 失败 → local |
| 6 | local (本地 NLLB-200) | `LOCAL_TRANSLATE_ENABLED=true` | `nllb-200-distilled-600M` | 本地 CPU 推理 | 全部失败 → skip |
**关键细节**:
- **en-gb / zh-cn / ja-jp** 这类 BCP-47 区域码会被 `tencent.py:_normalize_lang` 切成主语言(`en` / `zh` / `ja`),否则 TMT 直接 400 报错。
- **缓存**: `translation:cache:{sha1(src|tgt|text)}` 在 Redis 存 30 天;同篇同 src/tgt 不重译。
- **配额**: `translation:month:YYYYMM` Redis 累加,腾讯 TMT 用满后自动切 maas。
### LLM 智能增强(4 任务并发限速)
> 入口 `backend/app/services/llm/enrichment.py`,常驻循环见 `enrichment_loop()`。
```
enrich_article(id):
┌─ classify → LlmClient.chat(llm 0.2) → 解析 {"categories":[…]} → category 字段
├─ format → LlmClient.chat(llm 0.3) → 排版后 HTML, 包到固定 CSS div
├─ image → LlmClient.generate_image(...) → URL 写到 image_ai_url
└─ commentary → LlmClient.chat(llm 0.6) → 200 字中文点评
```
**4 任务独立 try/except** — 任一失败只标该任务 `status=failed`,其他继续。最终落库结果示例:
```json
{"format": "ok", "classify": "ok", "image": "ok", "commentary": "ok"}
{"format": "ok", "classify": "failed:RuntimeError", "image": "ok", "commentary": "ok"}
```
**限速**:
- LlmClient 内部 `chat_sem + image_sem`(各 1 并发),`interval_sec=2.0` 每次调用后等 2s
- enrichment_loop 并发 8 篇(`ENRICHMENT_BATCH_SIZE=8`),`Semaphore(3)` 再降一次并发上限(防 Agnes 限流)
**状态健康度参考**:
- 174/174/174/174 = 100% OK(理想)
- 0 failed 是健康线
- 翻译后 30 秒内 enrich 入队是正常时延
### 故障模式速查
| 现象 | 排查 | 修复 |
|---|---|---|
| 文章停在 `pending` | `docker compose exec -T worker python -c "from app.config import settings; print(settings.tencentcloud_secret_id)"` 确认 TENCENT 配了;lang_src 是否带区域后缀 | en-gb 已被 normalize 兜底;真没 TENCENT key 走 spark/zhipu |
| `format_status=failed` 但其他 3 项 OK | `docker compose logs worker \| grep "enrich format failed"` 看异常 | 多数是 prompt 模板里 `{}` 没转义(`enrich_article` 内置 `_safe_format` 兜底) |
| 大量文章停在 `n/a n/a n/a n/a` | `curl /admin/health` 看 LLM 状态;`.env``AGNES_API_KEY` 是否过期 | 换新 key → `docker compose up -d worker` |
| 翻译报 `LLM 401` | `curl -X POST apihub.agnes-ai.com/v1/chat/completions -H "Authorization: Bearer $AGNES_API_KEY"` 单测 | Agnes key 失效,控制台重置 |
| worker 启动后 enrichment_loop 没起 | `docker compose logs worker \| grep "enrichment_loop started"` | 看不到 = enrichment_loop 协程没启动,查 __main__.py 调度 + 重启 |
| RSS 抓不到新文章 | `/admin/health``consecutive_failures`;`docker compose logs worker \| grep "fulltext fetch failed"` | 大概率反爬,暂停源 + 改 UA(已知 case:DW fulltext 需真实浏览器 UA) |
---
## 技术栈
### 后端
| 层 | 选型 |
|---|---|
| 语言 | Python 3.12 |
| Web 框架 | FastAPI 0.115 + Uvicorn |
| ORM | SQLAlchemy 2.0(asyncio)+ Alembic |
| 数据库 | PostgreSQL 16(asyncpg + psycopg2) |
| 缓存/限速 | Redis 7(256MB LRU) |
| HTTP 客户端 | httpx 0.28(异步) |
| RSS 解析 | feedparser 6 |
| HTML 抽取 | trafilatura 2 + BeautifulSoup4 + lxml |
| 翻译主 | 腾讯云 TMT(SDK: tencentcloud-sdk-python) |
| 翻译降级 | transformers + NLLB-200-distilled-600M |
| LLM | Agnes(OpenAI 兼容): agnes-2.0-flash / agnes-image-2.1-flash |
| 调度 | APScheduler 3.10(AsyncIO + Cron + Interval) |
| 鉴权 | passlib[bcrypt] 1.7 + bcrypt 4.0.1 + PyJWT 2.10 |
### 前端
| 层 | 选型 |
|---|---|
| 框架 | Vue 3.5 + Vite 5 |
| UI 库 | Naive UI 2.40 |
| 状态 | Pinia 2.2 |
| 路由 | vue-router 4.4 |
| HTTP | axios 1.7(自动 401 refresh) |
| 时间 | dayjs 1.11 |
### 部署
| 层 | 选型 |
|---|---|
| 容器化 | Docker Compose(7 服务) |
| 反代 | Caddy 2(alpine) |
| 静态 | nginx 1.27-alpine(SPA fallback) |
| 构建 | pyproject.toml(setuptools) + npm |
---
## 仓库结构
```
diary-news/
├── backend/ # FastAPI 后端
│ ├── app/
│ │ ├── api/ # HTTP 路由
│ │ │ ├── auth.py # /auth/login, /auth/refresh
│ │ │ ├── me.py # /me, /me/usage(翻译配额)
│ │ │ ├── articles.py # /articles 列表 + 详情 + 游标分页
│ │ │ ├── sources.py # /sources 只读列表
│ │ │ ├── bookmarks.py # /bookmarks 收藏
│ │ │ ├── subscriptions.py # /subscriptions 关键词订阅
│ │ │ ├── admin.py # /admin/sources, /admin/health, /admin/refresh
│ │ │ └── admin_llm.py # /admin/llm/settings, /admin/llm/enrich/{id}
│ │ ├── core/
│ │ │ ├── security.py # bcrypt + JWT + API Token
│ │ │ └── deps.py # get_current_user, require_owner
│ │ ├── models/ # SQLAlchemy 2.0 ORM
│ │ │ ├── user.py
│ │ │ ├── source.py
│ │ │ ├── article.py
│ │ │ ├── bookmark.py
│ │ │ ├── subscription.py
│ │ │ ├── api_token.py
│ │ │ └── llm_setting.py # LLM 设置(单行表)
│ │ ├── schemas/ # Pydantic v2 I/O 模型
│ │ │ ├── auth.py
│ │ │ ├── article.py
│ │ │ ├── source.py
│ │ │ ├── llm.py # LLM 设置 schemas + 默认提示词
│ │ │ └── misc.py
│ │ ├── services/
│ │ │ ├── fetchers/ # RSS / HTML / Telegram 采集器
│ │ │ ├── translation/ # 腾讯 TMT + 本地 NLLB + 配额门面
│ │ │ └── llm/ # Agnes client + 智能增强
│ │ │ ├── client.py
│ │ │ └── enrichment.py
│ │ ├── workers/
│ │ │ ├── __main__.py # APScheduler + translation_loop + enrichment_loop
│ │ │ └── pipeline.py # fetch_one_source / translate_article
│ │ ├── scripts/ # 初始化(create_user / seed_sources)
│ │ ├── config.py # Pydantic Settings(从 .env 读)
│ │ ├── database.py # 异步 SQLAlchemy 引擎
│ │ ├── redis_client.py # Redis 单例
│ │ └── main.py # FastAPI 入口
│ ├── alembic/
│ │ ├── env.py
│ │ ├── versions/
│ │ │ ├── 0001_initial.py # 6 张表 + 枚举
│ │ │ └── 0002_llm_settings_and_articles_ai.py
│ │ └── alembic.ini
│ ├── Dockerfile # python:3.12-slim
│ ├── pyproject.toml
│ └── .env.example
├── frontend/ # Vue 3 + Vite + Naive UI
│ ├── src/
│ │ ├── main.ts # createApp + Pinia + Router
│ │ ├── App.vue
│ │ ├── router.ts # 路由守卫(requiresAuth / ownerOnly)
│ │ ├── api/
│ │ │ ├── client.ts # axios + 401 refresh 拦截器
│ │ │ └── articles.ts # articles / sources / me / bookmarks / admin
│ │ ├── stores/auth.ts # Pinia 鉴权状态(localStorage 持久化)
│ │ ├── components/
│ │ │ └── AppLayout.vue # 顶栏 + 侧栏 + 配额条
│ │ └── views/
│ │ ├── Login.vue
│ │ ├── Feed.vue # 24h 列表 + 游标分页
│ │ ├── ArticleDetail.vue # 原文/译文/AI 排版/分类/插图/点评
│ │ ├── Bookmarks.vue
│ │ ├── Sources.vue
│ │ ├── AdminSources.vue # owner: 源管理 CRUD
│ │ └── AdminLlmSettings.vue # owner: LLM 提示词 + 测连接 + 触发
│ ├── Dockerfile
│ ├── nginx.conf
│ ├── vite.config.ts
│ ├── tsconfig.json
│ └── package.json
├── docs/
│ ├── architecture.md # 77 行实现版架构
│ └── acceptance.md # MVP 验收清单
├── scripts/ # 工具脚本
│ ├── deploy_pull.py # 免密部署:clone/pull + 失败回滚
│ ├── server_init.py # 远程服务器初始化(推公钥 + 7 项系统级运维)
│ ├── push_ssh_key.py # 单推公钥(已用 fingerprint 去重)
│ ├── _*.py # 临时调试脚本(gitignored)
│ └── deploy_remote.sh # 远程一键部署(老脚本)
├── .env.example # 配置示例(可 commit)
├── .gitignore # 忽略 .env / scripts/_*.py / node_modules 等
├── Caddyfile # 反代规则
├── docker-compose.yml # 7 服务
├── DEPLOY.md # 完整部署手册(165 行)
├── README.md # 本文件
└── news-aggregator-plan.md # 613 行方案设计 v0.1(决策背景)
```
---
## 数据模型
6 张表 + 1 张配置表(全部 PostgreSQL):
| 表 | 关键字段 | 说明 |
|---|---|---|
| **users** | role(enum: owner/member), password_hash | 用户 + 角色 |
| **sources** | slug(uniq), kind(rss/html_list/tg_channel/**api_push**), priority, fetch_interval_min, consecutive_failures, blocklist_tags | 采集源(`api_push` 是被动接收,不被 worker 调度) |
| **articles** | url_hash(uniq), translation_status(pending/ok/partial/failed/n/a), category, commentary, body_zh_formatted, image_ai_url, *_status, entities(JSONB), sentiment, topic_id, bias, **is_short_news**, **external_id**, **source_ref**, **content_hash(uniq)** | 文章 + 译文 + LLM 增强;短新闻 4 字段为 API Push 接入 |
| **bookmarks** | (user_id, article_id) UNIQUE | 收藏 |
| **subscriptions** | keyword, match_in(any/title/body), channel | 关键词订阅 |
| **api_tokens** | token_hash(sha256), purpose(mobile/ingest), source_id(ingest 专用), expires_at, revoked_at | Android + API Push ingest 双用途 |
| **llm_settings** | format_prompt, classify_prompt, commentary_prompt, image_prompt_template, image_size, chat_model, image_model, interval_sec, enabled | LLM 提示词(单行) |
ER 关系:
- `users` 1:N → `bookmarks` / `subscriptions` / `api_tokens`
- `sources` 1:N → `articles`(cascade delete), 1:N → `api_tokens`(purpose=ingest 时)
- `articles` 1:N → `bookmarks`, self-ref `duplicate_of`(去重链)
### 短新闻(API Push)特性
- `articles.is_short_news=true` 标记(其余文章默认 false)
- `articles.content_hash` UNIQUE 索引 — 内容指纹,API Push 三层去重的核心 key
- external_id 存在时:`sha1("ext:" + external_id)`
- external_id 缺失时:`sha1(title + "|" + body[:500])`
- `articles.url` 对短新闻合成占位 `api-push://{source_slug}/{content_hash[:16]}`
- 入库后 `translation/format/image_ai_status='n/a'`,enrichment_loop 只跑 classify + 双 provider commentary
- 中文源头的长新闻(RSS 抓的中文源)在前端详情页不显示译文板块,translation_loop 跳过省 TMT 配额
---
## 快速开始
### 本地开发(Linux/Mac/WSL2)
```bash
# 1. 克隆
git clone http://<gitea>/xiaji/diary-news.git
cd diary-news
# 2. 配置
cp .env.example .env
# 编辑 .env 填入:
# POSTGRES_PASSWORD / REDIS_PASSWORD / JWT_SECRET(用 openssl rand -hex)
# TENCENTCLOUD_SECRET_ID / TENCENTCLOUD_SECRET_KEY
# AGNES_API_KEY
# 3. 启动
docker compose up -d --build
# 4. 初始化
docker compose exec api alembic upgrade head
docker compose exec api python -m app.scripts.create_user --username owner --password YOUR_PASS
docker compose exec api python -m app.scripts.seed_sources
# 5. 触发一次抓取
docker compose exec api python -c "import asyncio; from app.workers.pipeline import run_once; asyncio.run(run_once())"
# 6. 打开
open http://localhost/ # macOS
xdg-open http://localhost/ # Linux
```
### Windows 本机(无 WSL)
直接装 Python 3.12 + Node 20:
```powershell
# 后端
cd backend
py -3.12 -m venv .venv
.venv\Scripts\activate
pip install -e .
# 起 postgres / redis(用 docker run -d 或者本机服务)
$env:DATABASE_URL = 'postgresql+asyncpg://...'
alembic upgrade head
uvicorn app.main:app --reload
# 前端
cd ../frontend
npm install
npm run dev
```
### 远程部署
完整步骤见 [`DEPLOY.md`](./DEPLOY.md)。**已部署过一次的机器**只需 2 步:
```bash
# 1. 在本机 push 代码 + 跑 deploy_pull.py
git push origin main
python scripts\deploy_pull.py # 自动 clone/pull + 失败回滚
# 2. 在服务器上重启应用(代码变了)
ssh hknews
cd /root/diary-news
docker compose restart api worker
docker compose exec api alembic upgrade head
```
---
## 功能详解
### RSS 抓取
- **5 个种子源**:Reuters / BBC / Al Jazeera / NHK / DW
- **抓取频率**:每源 60 分钟(可调),失败连续 3 次后间隔 × 2(封顶 12 小时)
- **去重**:`url_hash = SHA1(url)`,PG `ON CONFLICT DO NOTHING` 幂等
- **HTML 抽取**:trafilatura 抓全文,RSS 摘要短时自动补抓
- **可见性**:`/admin/sources` + `/admin/health` 看板
### 翻译(配额 + 缓存 + 降级)
```
[译文不存在]
↓ Semaphore(1) — 1 篇/秒
[缓存命中?]
├─ 是 → 直接返回(30 天有效)
└─ 否 ↓
[主引擎(腾讯 TMT)可配额?]
├─ 是 → 调 TMT → 写缓存 + 加计数
└─ 否 ↓
[本地 NLLB 启用?]
├─ 是 → 调 NLLB → 写缓存
└─ 否 → 原文 + [本条未翻译:配额耗尽] 标记
```
配额在 Redis `translation:month:YYYYMM` 计数器(月度自动滚动)。`TENCENT_TMT_QUOTA_BUFFER=0.05` 表示 95% 触发后切本地(避免爆配额)。
### 用户 / 鉴权
- **JWT**:access 60min + refresh 14d,HS256 签名
- **API Token**:Android 客户端用,sha256 存储(可撤销 + 过期)
- **角色**:`owner` 全部权限,`member` 看文章/收藏/订阅
- **密码**:bcrypt 4.0.1(锁版兼容 passlib)
### 收藏 / 关键词订阅
- 收藏:点星星,加到 `bookmarks`,有 note 字段
- 关键词订阅:扫 `articles.body_text`/`title`,命中后写入 `subscriptions.last_hit_at`,预留 Telegram 通道(MVP 不发)
---
## LLM 智能增强
> **新功能**(2026-06-08 加入)。翻译完成后,自动调 Agnes LLM 跑 4 项独立任务。
### 4 项任务
| 任务 | 输出字段 | LLM 类型 | 用途 |
|---|---|---|---|
| **排版** | `articles.body_zh_formatted` | chat | 重写译文为网页排版(分段/加粗/列表) |
| **分类** | `articles.category` | chat(返 JSON) | 给文章打 1-3 个分类标签 |
| **插图** | `articles.image_ai_url` | image | 文生图,英文 prompt 拼自 `title` |
| **点评** | `articles.commentary` | chat | 100-200 字评论,客观有深度 |
### 限速
- 单一 `LlmClient`,内部 `chat_sem` + `image_sem` 各 1 个并发
- 每次调用后 `await asyncio.sleep(LLM_INTERVAL_SEC)`(默认 2.0s)
- chat 和 image 互不阻塞
- 4 个任务在 `enrich_article` 内**串行**(已过 client 限速)
### 设置页(Owner only)
`/admin/llm` → 4 个 textarea + 几个 input:
- **总开关** `enabled`
- **4 个提示词**(可重置默认):`format_prompt` / `classify_prompt` / `commentary_prompt` / `image_prompt_template`
- **模型**:`chat_model` / `image_model`(默认 agnes-2.0-flash / agnes-image-2.1-flash)
- **插图尺寸**:`image_size`(默认 1024x768)
- **限速**:`interval_sec`(默认 2.0)
- **测连接**:发个 `ping` chat 请求,1-2 秒内返 OK
- **手动触发**:`POST /admin/llm/enrich/{article_id}` 跑一篇 4 任务
### 默认提示词
`backend/app/schemas/llm.py``DEFAULT_PROMPTS`,支持占位符:
- `{body}` — 译文正文
- `{title}` — 译后标题
- `{summary}` — 摘要
### 失败隔离
每个任务独立 try/except,失败标 `*_status='failed'`,**不影响**其他任务。
`enrichment_loop``*_status``pending/failed/n/a` 的文章,自动重试 failed。
### 智能搜索建议(autocomplete)
搜索框输入前缀(如"美"),下拉弹出**高频词续接词**:
- 输入"美" → ["美国", "美军", "美国政府", "美方", "美国队", ...]
- 输入"美国" → ["美国", "美国政府", "美国签证", "美国军事", ...]
- 输入"美国政" → ["美国政府"]
来源:`search_keywords` 表按 `prefix_keys @> ARRAY['前缀']` + `weight DESC` 查(ts_stat 从 articles.title_zh + body_zh_text + commentary 聚合的词频)。
**后端架构**:
| 组件 | 作用 | 更新时机 |
|------|------|---------|
| `search_keywords` | 存 ts_stat 词频(全文 + 评论) + prefix_keys 数组 | worker 每日 03:00 全量重建 + 启动时 10s 后跑一次 |
| `articles.title_zh_tsv` | `GENERATED``to_tsvector('chinese_zh', title_zh)` + GIN 索引 | 写入自动维护(commit 11 TODO 顺手填了) |
| `chinese_zh` text search config | zhparser 中文分词 + 简单词映射 | 0010 迁移一次建好 |
| `_fallback_keywords` 实时 ts_stat | search_keywords 表空时,fallback 到实时 ts_stat(慢但能用) | 冷启动友好 |
**中文分词**(`zhparser`):
PG `simple` parser 对中文按整段当一个 token,`ts_stat` 词频聚合不出有意义的结果(整句算 1 个词)。
`zhparser`(scws 字典)解决:Dockerfile 全源码编译(Alpine/Debian/PGDG 都没现成包),建 `chinese_zh` config。
**⚠️ 关键踩坑**: `ts_stat(query, 'a')` 第二参是 weights mask(只统计 A 权重位置),zhparser 不标 A 权重 → 静默 0 行。**用 `ts_stat(query text)` 单参**(等价 mask='abcd',聚合所有权重)。
**性能**:
- `prefix_keys text[]` + GIN 索引,`@> ARRAY['美']` 亚毫秒
- 1545 篇文章 → `search_keywords` 33639 词,`ts_stat` 全量 88s,凌晨一次用户无感
- 搜索建议 API 接口 P99 < 50ms
**冷启动**:
`search_keywords` 表为空时(刚建库 / worker 没刷新过),`_fallback_keywords` 实时跑 `ts_stat` 兜底。
无需手动回灌(不像之前用 articles trigger 维护的 `search_title_suggestions`)。
**API 契约**:
```http
GET /api/v1/search/suggestions?q=&limit=10
Authorization: Bearer <token>
200 {
"query": "",
"keywords": [
{"word": "", "weight": 4865, "source": "ts_stat"},
{"word": "", "weight": 203, "source": "ts_stat"},
{"word": "", "weight": 98, "source": "ts_stat"},
...
]
}
```
- `q` 1-20 字符
- `keywords``weight` 排(高→低),最多 limit 条
- 选词 → 自动填入 q + 触发搜索;回车仍然走原搜索路径
### 历史文章批量 enrich
新功能**只对**翻译完成后入库的文章生效。历史已翻译文章,手动 reset:
```sql
UPDATE articles
SET format_status='pending', classify_status='pending',
image_ai_status='pending', commentary_status='pending'
WHERE translation_status='ok';
```
---
## API 概览
所有 API 在 `/api/v1` 前缀下,完整定义见 `http://<host>/api/docs`(DEBUG 模式)。
### 公开
- `POST /auth/login` — 用户名 + 密码 → access + refresh token
- `POST /auth/refresh` — refresh token → 新 access
### 需要登录(任意角色)
- `GET /me` — 当前用户信息
- `GET /me/usage` — 翻译配额(已用 / 总额 / 百分比)
- `GET /articles?since=...&source=...&q=...&cursor=...` — 列表(游标分页)
- `GET /articles/{id}` — 详情
- `GET /sources` — 源列表(只读)
- `GET /bookmarks` / `POST /bookmarks` / `DELETE /bookmarks/{id}`
- `GET /subscriptions` / `POST /subscriptions` / `DELETE /subscriptions/{id}`
### API Push 短新闻(无鉴权,凭 X-Ingest-Token)
- `POST /api/v1/ingest` — 外部推送短新闻入库(中文原生,跳过翻译/排版/插图,跑分类 + 双 provider 点评)
- 鉴权:`X-Ingest-Token` 头对应 `api_tokens.purpose='ingest'` 的 sha256 token
- 限速:每 token 2 篇/秒(`INGEST_RATE_PER_SEC` 可调)
- 去重:三层(L1 external_id / L2 content_hash / L3 DB UNIQUE)
- 完整契约见 [`docs/api-push.md`](./docs/api-push.md)
### Owner only(`/admin/*`)
- `GET /admin/sources` / `POST` / `PATCH /{id}` / `DELETE /{id}` — 源 CRUD
- `POST /admin/sources/{source_id}/ingest-tokens` — 为 api_push 源生成 ingest token(raw_token 仅一次性返回)
- `GET /admin/sources/{source_id}/ingest-tokens` — 列出某个 source 的 ingest token
- `DELETE /admin/ingest-tokens/{token_id}` — 撤销 ingest token
- `POST /admin/refresh/{source_id}` — 立即触发抓取(对 api_push 源无效,返回 OK 但 worker 不调度)
- `POST /admin/translation/rerun/{article_id}` — 重译;**对短新闻/中文源文章返 400**(commit 4)
- `GET /admin/health` — 源健康看板
- `POST /admin/translation/quota/reset` — 重置本月配额
- `GET /admin/llm/settings` / `PUT` / `POST /reset` / `POST /test` — LLM 设置
- `POST /admin/llm/enrich/{article_id}` — 手动触发某篇 enrich
### 端到端测试
```bash
python scripts/smoke_ingest.py --token <RAW_TOKEN>
# 期望输出:ALL PASS (6/6)
```
详见 [`scripts/smoke_ingest.py`](./scripts/smoke_ingest.py) — 验证 /api/v1/ingest 的
"创建/重复 external_id/内容去重/错误 token/body 超长"6 步链路。
---
## 开发-部署工作流
```
┌─────────────────────────────────────┐
│ 本地 Windows (你) │
│ ─ 编辑代码 │
│ ─ git add / commit │
│ ─ git push origin main │
└─────────────────┬───────────────────┘
┌─────────────────────────────────────┐
│ Gitea(代码托管) │
└─────────────────┬───────────────────┘
┌─────────────────────────────────────┐
│ 跑 python scripts\deploy_pull.py │
│ ─ 免密登录(SSH key) │
│ ─ git clone/pull │
│ ─ 成功:保持 + 报告 │
│ ─ 失败:git reset --hard <前 sha> │
└─────────────────┬───────────────────┘
┌─────────────────────────────────────┐
│ 远程 HK-News │
│ ─ 代码最新 │
│ ─ (如果需要)docker compose restart│
│ ─ (如果需要)alembic upgrade head │
└─────────────────────────────────────┘
```
**完整循环命令**(本机跑):
```bash
git add -A
git commit -m "feat: ..."
git push origin main
python scripts\deploy_pull.py # 我帮你跑,你说"拉一下"即可
```
---
## 运维工具
| 脚本 | 用途 | 调用 |
|---|---|---|
| `scripts/deploy_pull.py` | 免密拉取 + 失败回滚 | `python scripts\deploy_pull.py` |
| `scripts/server_init.py` | 服务器系统级初始化(推公钥 + 7 项运维) | `REMOTE_PASS=xxx python scripts/server_init.py` |
| `scripts/push_ssh_key.py` | 单推 SSH 公钥(SSH key fingerprint 去重) | `REMOTE_PASS=xxx python scripts/push_ssh_key.py` |
| `docker compose logs -f worker` | 看 worker 日志 | 服务器上 |
| `docker compose exec api alembic upgrade head` | 跑 migration | 服务器上 |
### deploy_pull.py 完整参数
```bash
python scripts\deploy_pull.py \
--host 207.57.129.228 \
--port 19717 \
--user root \
--repo-dir /root/diary-news \
--repo-url http://124.223.26.33:3000/xiaji/diary-news.git \
--branch main
# 干跑
python scripts\deploy_pull.py --dry-run
# 手动回退
python scripts\deploy_pull.py --rollback <sha>
```
也支持 env var:`DEPLOY_HOST` / `DEPLOY_PORT` / `DEPLOY_USER` / `DEPLOY_REPO_DIR` / `DEPLOY_REPO_URL` / `DEPLOY_SSH_KEY`
---
## 故障排查
### Q: 翻译一直失败?
1. `docker compose logs worker | grep -E "translate|tencent"`
2.`translation:month:YYYYMM` Redis key 是不是满了
3.`.env``TENCENT_TMT_QUOTA_BUFFER=0.1` 给更多缓冲
### Q: 某个 RSS 源一直 fail?
1. `/admin/health``consecutive_failures` 字段
2. `docker compose logs worker | grep <source_slug>`
3. 大概率是 RSS URL 失效或被反爬,先 `enabled=false` 暂停,在 `/admin/sources` 编辑后重启 worker
### Q: LLM 增强不工作?
1. `/admin/llm` → 点 "测连接"
2. 看后端日志 `docker compose logs worker | grep -E "enrich|chat"`
3. 确认 `.env` 里有 `AGNES_API_KEY`
### Q: 服务器磁盘快满?
```sql
DELETE FROM articles WHERE published_at < now() - interval '90 day' AND duplicate_of IS NULL;
```
### Q: deploy 失败,想回退?
```bash
python scripts\deploy_pull.py --rollback <之前的好 sha>
# 或者手动
ssh hknews "cd /root/diary-news && git reset --hard <sha>"
```
### Q: 中文用户名乱码?
PowerShell 默认 GBK,运行前先 `chcp 65001` 切 UTF-8。
### Q: 短新闻 / API Push 相关?
- 短新闻入库后停在 enrich 阶段:看 worker 日志 `docker compose logs worker | grep enrichment`
- ingest 接口 401:`X-Ingest-Token` 无效/吊销/过期;回 `/admin/sources/{id}/ingest-tokens` 重新生成
- ingest 接口 429:1 秒内推超 2 篇(默认 `INGEST_RATE_PER_SEC=2`);退避 1 秒重试
- 短新闻 Feed 看不到:先 `SELECT * FROM articles WHERE is_short_news=true` 查是否入库
- 想清理 smoke 测试残留:`DELETE FROM articles WHERE source_ref='smoke';`
### Q: 中文 RSS 长新闻详情页仍显示"译文"板块?
- 确认 `articles.lang_src``'zh'` / `'zh-CN'` 等(前端判断标准)
- 详情页逻辑在 `ArticleDetail.vue` `isChineseSource` computed:`isShort || lang_src.startsWith('zh')`
- 详情页还会显示"原文"卡片代替"译文"卡(commit 6)
### Q: 加了 alembic 迁移但容器看不到新文件?
- 永远先 `docker compose up -d --no-deps --force-recreate api worker``alembic upgrade head`
- 容器在 bind mount 之前用镜像层启动,旧镜像层会被覆盖但只有 recreate 才生效
- 详细踩坑记录见 agent memory "FastAPI + alembic 部署 SOP"
---
## 路线图
- [x] **Phase 1 (MVP)**:5 RSS 源 + 翻译 + 网页 + admin CRUD
- [x] **Phase 1.5**:LLM 智能增强(排版/分类/插图/点评) ✅ 2026-06-08
- [x] **Phase 1.6**:API Push 短新闻(`POST /api/v1/ingest` + 三层去重 + per-token 限速) ✅ 2026-06-14
- [x] **Phase 1.7**:中文源头 RSS 优化(详情页隐藏译文 + 翻译循环跳过省配额) ✅ 2026-06-14
- [ ] **Phase 2**:PWA 离线缓存 / 关键词订阅推送(Telegram)
- [ ] **Phase 3**:Android 客户端(API Token 已预留)
- [ ] **Phase 4**:自动分类/点评/实体识别(目前是 LLM 一次性,无 ML pipeline)
- [ ] **Phase 5**:跨源立场对照 / 主题聚类
---
## 文档导航
- 📄 [DEPLOY.md](./DEPLOY.md) — 完整部署手册(新机器从 0 到能访问)
- 📐 [docs/architecture.md](./docs/architecture.md) — 实现版架构(77 行)
- ✅ [docs/acceptance.md](./docs/acceptance.md) — MVP 验收清单
- 📜 [news-aggregator-plan.md](./news-aggregator-plan.md) — 613 行方案设计 v0.1(选型决策背景)
- 🛠 [scripts/deploy_pull.py](./scripts/deploy_pull.py) — 部署工具源码
---
## 设计原则
- **轻量**:单机 30G 能跑,不堆重型服务
- **可控**:源管理 / 翻译配额 / 抓取调度 / LLM 提示词 全部可视化
- **可扩展**:ML 字段(category/commentary/entities/sentiment/bias)已建好,后续直接写值不动表
- **不反爬对抗**:愿意被 ban IP 就 ban,优先合规
- **透明失败**:不静默吞错,每个 stage 都有 `*_status` 字段记录
- **幂等可重跑**:所有运维脚本(server_init / deploy_pull)都幂等,跑多遍无副作用
---
**License**: Private use only.