- 后端 enrichment.ARTICLE_BODY_FONT_SIZE: 17 → 19(新写库的 LLM 排版版) - 前端 style.css .article-body / .diary-para: 17 → 19,line-height 1.7 → 1.75 - 768px 媒体查询: 16 → 18,line-height 1.8 → 1.85 - 480px 媒体查询: 15.5 → 17 老文章也立即变大(前端 .article-body 兜底);新排版文章用新 inline style。 后端要等下一次 enrich 才落新行(原 inline style 还是 17px), 如果想老排版版立即也变,在 /admin/llm 手动 trigger 几篇 enrich 即可。
Diary News · 私人多源新闻翻译系统
抓境外权威源 → 自动中英对照 → 智能排版/分类/插图/点评。 跑在一台 2C/2G/30G 的香港 VPS 上,自用 + 家人/小圈子。
目录
项目目标
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 平台,带限速)
- 👤 双角色鉴权:JWT(access 60min + refresh 14d) + API Token(sha256,可撤销,给 Android 预留)
- 📌 收藏 + 关键词订阅:用户级书签,服务端定时按关键词命中推送(预留 Telegram 通道)
- 📊 管理看板:源健康度 / 翻译配额 / LLM 状态,全部可视化
- 🔄 热加载:源/提示词改了不用重启,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— 默认值,从未跑过 enrichpending— 任务入队但还没处理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:YYYYMMRedis 累加,腾讯 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,其他继续。最终落库结果示例:
{"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), priority, fetch_interval_min, consecutive_failures | 采集源 |
| articles | url_hash(uniq), translation_status(pending/ok/partial/failed), category, commentary, body_zh_formatted, image_ai_url, *_status, category, entities(JSONB), sentiment, topic_id, bias | 文章 + 译文 + LLM 增强 |
| bookmarks | (user_id, article_id) UNIQUE | 收藏 |
| subscriptions | keyword, match_in(any/title/body), channel | 关键词订阅 |
| api_tokens | token_hash(sha256), expires_at, revoked_at | Android 预留 |
| llm_settings | format_prompt, classify_prompt, commentary_prompt, image_prompt_template, image_size, chat_model, image_model, interval_sec, enabled | LLM 提示词(单行) |
ER 关系:
users1:N →bookmarks/subscriptions/api_tokenssources1:N →articles(cascade delete)articles1:N →bookmarks, self-refduplicate_of(去重链)
快速开始
本地开发(Linux/Mac/WSL2)
# 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:
# 后端
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。已部署过一次的机器只需 2 步:
# 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),PGON 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) - 测连接:发个
pingchat 请求,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。
历史文章批量 enrich
新功能只对翻译完成后入库的文章生效。历史已翻译文章,手动 reset:
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 tokenPOST /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}
Owner only(/admin/*)
GET /admin/sources/POST/PATCH /{id}/DELETE /{id}— 源 CRUDPOST /admin/refresh/{source_id}— 立即触发抓取POST /admin/translation/rerun/{article_id}— 重译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
开发-部署工作流
┌─────────────────────────────────────┐
│ 本地 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 │
└─────────────────────────────────────┘
完整循环命令(本机跑):
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 完整参数
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: 翻译一直失败?
docker compose logs worker | grep -E "translate|tencent"- 看
translation:month:YYYYMMRedis key 是不是满了 - 调
.env的TENCENT_TMT_QUOTA_BUFFER=0.1给更多缓冲
Q: 某个 RSS 源一直 fail?
/admin/health看consecutive_failures字段docker compose logs worker | grep <source_slug>- 大概率是 RSS URL 失效或被反爬,先
enabled=false暂停,在/admin/sources编辑后重启 worker
Q: LLM 增强不工作?
/admin/llm→ 点 "测连接"- 看后端日志
docker compose logs worker | grep -E "enrich|chat" - 确认
.env里有AGNES_API_KEY
Q: 服务器磁盘快满?
DELETE FROM articles WHERE published_at < now() - interval '90 day' AND duplicate_of IS NULL;
Q: deploy 失败,想回退?
python scripts\deploy_pull.py --rollback <之前的好 sha>
# 或者手动
ssh hknews "cd /root/diary-news && git reset --hard <sha>"
Q: 中文用户名乱码?
PowerShell 默认 GBK,运行前先 chcp 65001 切 UTF-8。
路线图
- Phase 1 (MVP):5 RSS 源 + 翻译 + 网页 + admin CRUD
- Phase 1.5:LLM 智能增强(排版/分类/插图/点评) ✅ 2026-06-08
- Phase 2:PWA 离线缓存 / 关键词订阅推送(Telegram)
- Phase 3:Android 客户端(API Token 已预留)
- Phase 4:自动分类/点评/实体识别(目前是 LLM 一次性,无 ML pipeline)
- Phase 5:跨源立场对照 / 主题聚类
文档导航
- 📄 DEPLOY.md — 完整部署手册(新机器从 0 到能访问)
- 📐 docs/architecture.md — 实现版架构(77 行)
- ✅ docs/acceptance.md — MVP 验收清单
- 📜 news-aggregator-plan.md — 613 行方案设计 v0.1(选型决策背景)
- 🛠 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.