# geo-news-python
**Repository Path**: Humoonruc/geo-news
## Basic Information
- **Project Name**: geo-news-python
- **Description**: 国际新闻简报自动生成系统
- **Primary Language**: Python
- **License**: GPL-3.0
- **Default Branch**: master
- **Homepage**: None
- **GVP Project**: No
## Statistics
- **Stars**: 0
- **Forks**: 0
- **Created**: 2026-06-24
- **Last Updated**: 2026-09-27
## Categories & Tags
**Categories**: Uncategorized
**Tags**: None
## README
# geo-news-python
国际新闻简报自动生成系统。从新闻爬取 → AI 标注分发 → 语义分组 → 去重整合 → 格式化排版 → 导出 Word,全流程自动化。
## 项目概述
**核心思路**:将多信源国际新闻自动采集、标注、分发到地区和领域文件,经 AI 语义分组形成话题组,再由 AI 逐话题去重整合生成结构化简报,最终合并输出 Markdown + Word 文档。
**一次完整周报的全流程**(约 10–15 分钟):
```
login → crawl (5路并行) → export → auto-tag (并行)
→ 人工初审(删除不重要条目)
→ distribute(规则分发 + 热点专题 + 兜底)→ group(AI 语义分组)
→ 人工调组(合并/拆分话题)
→ to-xml → dedup-news → final-format → abstract
→ merge → weekly-summary → pandoc .docx → archive
```
三个 `.bat` 文件对应三个阶段,双击即可运行:
| 文件 | 做什么 | 输入 → 输出 |
|------|--------|------------|
| `crawl.bat` | 登录 + 五路爬取 + 导出 + AI 标注 | 新闻网站 → `content/source/output_*.md` |
| `preprocess.bat` | 分发 + 热点分类 + AI 语义分组 | `output_*.md` → `content/geo_news/` + `content/eco_news/` |
| `consolidate.bat` | 去重整合 + 合并 + 周报摘要 + Word + 归档 | 分组文件 → `content/output/consolidate_total.{md,docx}` |
| `weekend.bat` | 收尾:归档(始终)→ 分发检查 → 清空工作目录 | 准备下一周 |
> **数据流设计原则**:标注信息嵌入数据本身(XML 标签),而非仅依赖 prompt 约束。AI 无法绕过数据结构中的硬约束。
---
## 快速开始
### 安装
```powershell
python -m pip install httpx beautifulsoup4 lxml playwright "openai<3" python-dotenv jieba
python -m playwright install chromium
```
> **openai 需锁定 `<3`**:3.x 依赖 httpx2/httpcore2 新版 HTTP 栈,在 Python 3.14 下
> 程序退出时会抛 `RuntimeError: generator didn't stop after athrow()`
> (httpcore2 的异步生成器清理 bug;任务本身已完成,但报错会污染输出、误判为失败)。
> 2.x 的调用方式完全一致,且走 httpx/httpcore 稳定栈。
> 转换 Word 需要安装 [pandoc](https://pandoc.org/)。
### 配置
1. 项目根目录创建 `.env`,写入 DeepSeek API Key:
```
DEEPSEEK_API_KEY=sk-xxx
```
如需华尔街见闻 VIP 内容,追加账密:
```
WALLSTREETCN_USER=你的手机号或邮箱
WALLSTREETCN_PASS=你的密码
```
2. 编辑 `settings.py`:
- `CRAWL_START_TIME` — 兜底爬取起点(首次使用设历史起点如 `2026-01-01 00:00`)。日常增量时每个信源实际从自己 DB 最新时间继续,此值仅在信源无数据时兜底
- `CRAWL_WINDOW_START` — 固定窗口起点(默认空=增量模式)。补爬历史时设此值(如 `2026-08-01 00:00`),配合 `CRAWL_END_TIME` 组成固定范围,库中已有条目自动跳过;用完改回空字符串
- `OPINION_START_TIME` / `OPINION_END_TIME` — 参考资料(观察者网)的导出时间窗口,每周末手动更新
3. 首次运行需要人工过点选验证码(华尔街见闻)。运行 `crawl.bat` 时 Phase 0 自动弹出有头浏览器,手动完成验证即可。登录态保存在 `data/wallstreetcn_profile/`,后续可复用。
### 运行
```powershell
crawl.bat # 爬取 → 导出 → 标注
# → 人工快速浏览 content/source/output_*.md,删掉不重要条目
preprocess.bat # 分发 + 话题分组
# → 人工检查话题组是否合理,必要时合并/拆分
consolidate.bat # 整合 → 去重 → 格式化 → Word → 归档
```
最终产物:`content/output/consolidate_total.md` + `.docx` + `weekly_summary.md`。
每周结束时运行 `weekend.bat` 收尾:归档当前整合稿(始终执行),并在 source 全部清空后清空工作目录、准备下一周(见 Phase 4)。
---
## 工作流详解
### Phase 1: `crawl.bat` — 新闻采集
```
Phase 0: login-wallstreetcn → 有头浏览器登录,保存 profile
Phase 1: 五路并行爬取(人民网 / 联合早报 / 观察者网 / 财联社 / 华尔街见闻)
写入双库 SQLite:geo_news.db(地缘) + eco_news.db(经济)
Phase 2: 从 SQLite 导出 Markdown → content/source/output_{source}.md
Phase 3: AI 自动标注元数据(并行处理 5 个信源)
```
**标注字段**(AI 自动补齐 7 个):领域 / 二级领域 / 三级领域 / 地区 / 摘要 / 标签 / 文本性质
标注结果直接写入 `output_*.md` 文件,每条新闻末尾追加 `>> 标签:xxx` 等元数据行。
### Phase 2: `preprocess.bat` — 分发与分组
**第一步:分发**(`distribute_news.py`,三阶段)
| 阶段 | 内容 | 目标 |
|------|------|------|
| Phase 1 规则分发 | 已标注的「政治和地缘」按地区 → `geo_{region}.md`;「经济和金融」先匹配「二级+三级」组合规则,未命中再按二级领域 → `eco_*.md` | 地区/领域文件 |
| Phase 2 热点专题 | 剩余有摘要条目 → LLM 一次调用判断是否属于 AI+/新能源/气候/商业航天等热点专题(默认 DeepSeek,可经 `AI_PROVIDER` 切换) | `eco_{topic}.md` |
| Phase 3 兜底 | 仍未分发的条目 → `eco_others.md` | 零遗漏 |
>`config/hot_topics.toml` 定义热点专题(目前 5 个:AI+、新能源产业链、气候异常(厄尔尼诺/极端天气)、商业航天、房地产),增删专题只需修改此文件 + 同步 prompt。
**第二步:AI 语义分组**(`group_news_ai.py`)
对上一步产出的所有 `eco_*/geo_*` 文件,调用 LLM(走 **flash 档**,由 `AI_PROVIDER_FLASH` 决定 Provider 与模型,可用 `--model` 覆盖)进行语义分组,将属于同一话题的新闻归为一组,形成「话题组」。每组以 `# 组名(N 条)` 为一级标题,后续整合时按组处理。
分组结果直接写回原文件:
- **组内**:条目按发布时间升序排列。
- **组间**:按话题涉及的「国家/地区」聚类排序——从组内条目的标题/标签/摘要识别主要国家,同一国家的组相邻(如印太文件里日本话题组相邻、印度话题组相邻)。该排序是确定性代码逻辑(`COUNTRY_KEYWORDS` 词典),不依赖 AI 返回顺序。
- **中东特殊**:`geo_MiddleEast.md` 中「美伊冲突」大话题的组优先排列,组间按组内最早发布时间升序(旧→新,呈现事件时间线);其余涉及黎巴嫩、巴勒斯坦、也门等国家的组再按国别聚类。
### Phase 3: `consolidate.bat` — 整合与输出
```
Step 1: quick(to-xml → dedup-news → final-format → abstract,6 个地区并行处理,并发上限 6;dedup 组内再并发 4 路,并复用增量缓存)
Step 2: merge — 合并各地区摘要稿 → consolidate_total.md(优先读 _abstract.md,降级 _fmt.md)
Step 3: weekly-summary — AI 生成 750–850 字周报摘要
Step 4: pandoc(--lua-filter=scripts/fost.lua)— Markdown 转 Word (.docx)
Step 5: archive — 归档到 SQLite(data/annotated_news.db)
```
**quick 子流程**(每个地区独立执行):
| 步骤 | 工具 | 作用 |
|------|------|------|
| to-xml | `metadata_to_xml.py` | 将人工标注(`>> 标签:xxx`)转为结构化 `` XML 标签 |
| dedup-news | `dedup_news.py` (v4-pro) | 同组新闻去重/精简,保持信息密度,展开 FOST 评论。**组内并发 4 路 + 增量缓存**(内容未变的组直接复用) |
| final-format | `final_format.py` (v4-flash) | 格式规范化:统一标题层级、控制段落长度、动态字数预算。批量运行时**文件间并发** |
| abstract | `abstract.py` (v4-flash) | 正文简化为 5-8 句摘要,FOST 评论压缩为 2-3 句。批量运行时**文件间并发** |
**Word 输出(Step 4 pandoc + `scripts/fost.lua`)**:
- 每个 `#### FOST:评论` 段渲染为自定义段落样式 `FOSTComment`:字体/字号/首行缩进与模板 Heading 4 一致(蓝色楷体),但不属于标题样式,**不会出现在 Word 导航窗格**
- FOST 段前自动空一行;FOST 段后追加一个"**返回目录**"超链接段:宋体 9 号、蓝色、右对齐,点击跳转到文档第一页(书签 `_Top`,埋于文档开头第一个标题)
- `FOSTComment` 样式预置在 `content/output/template.docx` 的 styles.xml 中(`basedOn` 指向 heading 4 的 styleId `41`,仅将大纲级别覆盖为正文级,因此不在导航窗格显示)
- 注意:`template.docx` 被 Word 打开时 pandoc/python 无法写入,转换前需先关闭 Word 中的相关文档
### Phase 4: `weekend.bat` — 收尾
```
Step 1: 归档标注新闻到 SQLite(始终执行,无论 source 是否清空)
Step 2: 检查 content/source/ 下 4 个源文件是否全部为空(确认已全部分发)
Step 3: 清空 opinion/、eco_news/、geo_news/ 中所有 .md 文件(仅 source 全部清空后执行)
Step 4: 提示修改 settings.py 中 OPINION_START_TIME / OPINION_END_TIME(同上)
```
> **归档优先**:Step 1 的归档始终执行,确保当前整合稿不会因 source 未清空而丢失(归档为增量去重、幂等)。若 source 仍有未分发内容,归档后即告警停止,跳过 Step 3/4,等完成分发后再跑一次本脚本。正常周(source 已清空)行为与旧版一致。
---
## 输出示例
各地区整合稿格式:
```markdown
## 特朗普 80 岁生日庆典与政治秀:白宫格斗赛与独立日集会预告
据华盛顿综合电,6 月 14 日,特朗普以举办 UFC 格斗赛"UFC Freedom 250"的
独特方式庆祝 80 岁生日。耗资 6000 万美元,14 名 UFC 明星在白宫草坪的
"The Claw"场馆中对决。
#### FOST:特朗普将 80 岁生日、UFC 格斗赛与独立 250 周年庆典深度绑定,
是一场精心计算的政治表演。这不仅向核心选民集中动员和效忠确认,更意在
打破传统政治礼仪,强化其"反建制斗士"形象。
```
每周摘要示例:
```markdown
在美洲,特朗普办 UFC 格斗赛庆 80 岁生日。民调显示乡村支持率降至 50%。
麦康奈尔再次住院。纽森指控特朗普政治调查其家人。美联储维持利率不变……
在欧洲,瑞士举行限制千万人口公投。英军首次主导拦截俄"影子舰队"油轮。
俄乌大规模空袭升级,基辅世界文化遗产遭重创……
在中东,美伊宣布达成历史性和平协议,特朗普严批以色列空袭搅局……
```
---
## 核心设计
本章按数据流顺序,从采集到输出,逐层说明架构决策和边界条件。
---
### 一、数据采集层
#### 1.1 双库架构
`geo_news.db`(地缘:人民网/联合早报/观察者网)和 `eco_news.db`(经济:财联社/华尔街见闻)独立存放:
- 两类新闻的下游处理逻辑不同——地缘按地区分发,经济按二级领域分发
- 隔离避免单文件膨胀,也方便按需迁移
#### 1.2 并发安全与两级去重
**WAL 模式**:所有 SQLite 连接启用 `PRAGMA journal_mode=WAL` + `busy_timeout=5000`,因五路爬虫通过 `asyncio.gather` 并行执行,多进程可能同时写入。`insert_news` 内部有 3 次指数退避重试兜底极端锁冲突。
**两级去重**——SQLite 约束 + 爬虫侧 break 逻辑各有不同粒度:
| 去重函数 | 去重键 | 适用场景 | 原因 |
|----------|--------|----------|------|
| `get_existing_keys` | 标题 + 链接 | 联合早报 | 时间仅在详情页存在,去重检查时不可用(待重构为先抓详情再判断) |
| `get_existing_keys_full` | 标题 + 发布时间 + 链接 | 其余 4 个爬虫 | 防"旧闻换时间重发"或"快讯改标题转完整报道"导致误判 |
```python
# SQLite UNIQUE 约束仍为 (标题, 链接)——写入去重粒度较粗
# break 逻辑用三元组,更严格——确保不跳过多条可能的新内容
```
**假阴性边界**:观察者网/财联社的去重用列表页时间,但入库可能用详情页更精确时间 → key 不匹配 → 文章被重新处理。这是**安全的"假阴性"**:最多浪费一次请求,不会丢内容。
#### 1.3 五源爬虫策略
| 信源 | 技术方案 | 关键约束 |
|------|----------|----------|
| **华尔街见闻** | Playwright 浏览器内 `fetch()` 调 API | 反爬最复杂:登录入口为首页 modal(旧 `/signin` 已 404);检测 headless 隐藏登录入口,首次必须用有头模式手动过验证码;VIP 文章需展开 `.mask` 遮罩层后提取正文;登录态通过 `launch_persistent_context` 保存在 `data/wallstreetcn_profile/` 跨次复用 |
| **财联社** | Playwright `page.on("response")` 拦截 API | API 响应结构不统一(翻页纯数组 vs assembled `{"data":{"depth_list":[...]}}`),代码通过候选 key 深度遍历自适配;无限滚动依赖点击"加载更多"按钮而非 `scrollTo` |
| **联合早报** | Playwright 无限滚动 | `#publish_time` 只在详情页 → 去重仅能用标题+链接;IP 自动跳转域名(`.cn`/`.com`/`.sg`),爬取时需关闭 VPN |
| **观察者网** | httpx 优先 + Playwright 回退 | body < 2000 字符判定为 JS 渲染页(风闻社区),自动切换 Playwright;多页文章自动探测(`_1.shtml→_2.shtml` 或 `.shtml→_2.shtml`);列表页/详情页时间不一致,优先详情页 |
| **人民网** | httpx + BeautifulSoup | 最简单,纯静态 HTML;正文含"《 人民日报 》"的转载条目在 auto_tag 阶段自动移除 |
#### 1.3.1 华尔街见闻爬虫:反爬对抗全流程
华尔街见闻是五个信源里反爬最复杂的。以下是本项目已踩过的坑与对应对策(按请求生命周期排序)。
**1. 登录态**
- 登录入口是首页弹窗 modal(旧版 `/signin` 已 404)。
- 网站检测 headless 会隐藏登录入口 → 首次必须用有头模式(`settings.WALLSTREETCN_HEADLESS=False`)手动过点选验证码。
- 登录态用 `launch_persistent_context(user_data_dir=data/wallstreetcn_profile/)` 持久化,跨次复用,无需每次登录。
- `main()` 检查 `_login()` 返回值;登录失败(尤其 headless 下找不到入口)会明确告警,不再静默继续白爬。
- 若运行中出现 `⚠️ 命中「下载华尔街见闻App」拦截页`,几乎可确定是登录态失效 → 有头模式 `cli.py login-wallstreetcn` 重登一次。
**2. User-Agent 伪装(致命点)**
- 必须用真实浏览器 UA 覆盖 Playwright 默认值:`launch_persistent_context(user_agent=REAL_UA)` 中的 `REAL_UA` 去掉了默认的 `HeadlessChrome` 字样。
- 若不覆盖,服务端直接返回「下载华尔街见闻App」兜底层:SSR `
` 有时还是文章标题(迷惑性极强),但 `` 只有 logo + "读懂全球市场" + 下载链接,正文 0 字。日志表现为 `body文本长度≈17`、`总数=0`。
- 这是最容易误判的根因——看起来像"React 没渲染"或"超时",实际是服务端根本没吐正文。
**3. 详情页导航:禁用 `networkidle`**
- `page.goto(wait_until="networkidle")` 在实时行情站永远超时(WebSocket/行情轮询不断,连续 500ms 无请求的条件满足不了)→ 每篇 30s 超时、全部 `正文获取失败`。
- 改为 `wait_until="domcontentloaded"`。
**4. 标题校验:轮询等待 SPA 注水**
- 华尔街见闻是 Next.js SPA,SSR 默认 `
` 是「华尔街见闻」,文章标题由前端异步注入,可能晚于 domcontentloaded 才出现。
- 用 `wait_for_function("(key)=>document.title.indexOf(key)!==-1", arg=预期标题前8字, timeout=15000)` 轮询,直到标题变成预期文章标题(等价于「不再是默认标题」)。
- 比「固定 sleep 3s」更精准:注水快则几乎不等待,慢则最多等 15s;超时仍未匹配视为错误页/登录墙,跳过该篇。
**5. 正文渲染等待:轮询内容就绪**
- `domcontentloaded` 后正文是 React 异步渲染,`page.content()` 可能取到空。
- 用 `wait_for_function` 轮询直到文章容器(`.article__content`/`.rich-text`/`article` 等)或任意 `` 含 >50 字文本才提取,超时 15s。
- 命中 VIP 遮罩层 `.mask` 的文章需先点击展开再提取。
**6. 诊断手段**
- `_fetch_detail` 提取后打印诊断:`content长度 / 容器命中 /
总数 / 有文本
/ body文本长度`。
- 正文为空时 dump 页面 HTML 到 `data/debug_wsc_{id}.html`,便于排查真实 DOM(确认是否命中拦截页)。
- 命中 `下载华尔街见闻App` 或 `读懂全球市场` 关键词时,直接打 `⚠️ 命中拦截页` 告警。
**排错速查表**
| 现象 | 根因 | 解法 |
|------|------|------|
| 详情页 `Page.goto Timeout 30000ms exceeded` | `networkidle` 在行情站永不等到 | 改 `domcontentloaded`(见 3) |
| `正文获取失败`,`body≈17` 字、`0` 个 `
` | UA 含 `HeadlessChrome` / 登录态失效 → 命中「下载App」拦截页 | 覆盖真实 UA;有头重登(见 2、1) |
| `页面标题不匹配` 反复出现 | SPA 注水慢,初始等待不足 | 已改轮询等标题就绪(见 4) |
| `提取结果 content长度=0` 但标题正常 | React 未渲染完 | 已改轮询等正文就绪(见 5) |
| 日志无 `登录成功`/`已有有效登录态` | 登录态失效或 headless 隐藏入口 | 有头 `cli.py login-wallstreetcn` 重登 |
#### 1.4 增量导出:保留人工标注
`export_news.py` 的核心价值是**增量导出时不覆盖已有标注**:
- 目标 MD 已存在且链接匹配 → 复用全部 15 行 `>> ` 元数据
- 仅新条目生成空白元数据行,auto_tag 只标注新增部分
- 元数据行数不匹配(旧版代码遗留)自动识别并重建
**正文清理**:`_sanitize_content()` 将 `~~~`(围栏代码块)→ `---`(水平线),防止财联社快讯中的连续波浪线触发 Markdown 解析器吞掉后续内容。`_clean_summary()` 去除摘要内部换行,防止破坏元数据行结构。
#### 1.5 失败条目重试清单(`src/failed_items.py`)
**要解决的问题**:各爬虫都是增量模式(起点 = 该信源 DB 最新发布时间)。若某条抓取失败
(详情页超时、正文为空、时间解析失败),它不会入库;而本轮其他条目成功入库后**起点被推进**,
下次运行扫到起点那条就 `break` → **早于新起点的失败条目再也扫不到,永久漏抓**。
实测(2026-09-14 华尔街见闻):单轮 8 条失败中只有 1 条能靠增量机制补回,其余 6 条永久丢失。
**机制**:失败条目落盘到 `data/failed_items.json`,下轮运行优先重抓、成功即移除。
| 时机 | 动作 |
|---|---|
| 运行开头 | `cleanup()` 清掉「其实已入库」的残留(人工补抓过);`load()` 取出待重抓条目,**放在待处理列表最前** |
| 抓取失败 | `mark_fail()` 累加 `attempts` 并记录原因 |
| 抓取成功 | `mark_ok()` 从清单移除 |
| 运行结束 | 打印清单状态;达到 `MAX_ATTEMPTS`(5 轮)的条目不再自动重抓,改为告警提示人工处理 |
**5 个爬虫均已接入**:华尔街见闻、人民网、观察者网、联合早报、财联社。
运行日志中可见 `🔁 失败重试清单:并入 N 条待重抓`(有重抓时)与 `📋 失败清单:N 条待下轮自动重抓`(收尾统计)。
> ⚠️ 配套修正:观察者网原先会把**空正文**也写进 DB,链接一旦入库,下轮 `cleanup()`
> 就会把它当成"已抓过"清掉,永远补不回来。现已改为**空正文不入库 + 记入清单**。
---
### 二、AI 语义理解层
#### 2.1 自动标注(auto_tag)
每条新闻在采集后自动调用 LLM(默认 DeepSeek v4-flash,可经 `.env` 的 `AI_PROVIDER` 切换智谱)填写 7 个字段,嵌入元数据行供后续所有 AI 环节参考:
| 标注字段 | 作用 | 规则 |
|----------|------|------|
| **领域 / 二级领域 / 三级领域** | 分发阶段路由依据 | 政治和地缘 → 地区;经济和金融 → 二级领域 |
| **地区** | 地缘新闻分发到 6 大区 | 非洲 / 美洲 / 中国 / 欧洲 / 印太 / 中东 六选一 |
| **摘要** | 告知 AI "这条的核心意义" | 仅**需标注的条目**(其他字段为空)会清空旧摘要后 AI 重写,长文覆盖 2-3 个具体判断 + 关键数据,短文 1-2 句 |
| **标签** | 描述性关键词,辅助语义分组 | 3-5 个关键词,分号分隔 |
| **文本性质** | 区分新闻与分析评论 | 新闻 / 分析评论 |
> 另有 **重要性 / 强制独立 / 信源倾向 / 主题 / 分析师定性** 5 个字段为人工标注(初审时按需填写),供下游语义分组、去重整合、FOST 评论展开使用,auto_tag 不会自动填写。
执行策略:BATCH_SIZE=4 条一批,MAX_CONCURRENT=2 并发(每信源子进程并发,5 信源并行总并发=10);`max_tokens=16384`(V4-Flash 思考模式会先消耗推理 token 再输出正文,过小易把输出挤占为截断);失败条目逐条重试 `AUTO_TAG_MAX_RETRIES=3` 轮兜底(也可用 `--retry N` 覆盖)。
#### 2.2 AI 语义分组 vs TF-IDF
新闻分组由 `group_news_ai.py`(v4-pro)完成,对每个地区/领域文件进行语义理解,输出 `# topic_label(N 条)` 组标题。早期使用 TF-IDF + 余弦相似度,已升级为 AI 语义分组:
| 维度 | TF-IDF | AI 语义分组 |
|------|--------|------------|
| 原理 | 词频统计 + 余弦相似度 | 理解事件语义、因果链、参与方关系 |
| 同事件识别 | ❌ "美国加征关税"与"中方回应"词重叠低,判为不相关 | ✅ 理解这是同一事件的两个视角 |
| 假关联过滤 | ❌ "美联储加息"与"美联储人事变动"词重叠高,误判为相关 | ✅ 区分同一参与方的不同事件 |
| 因果链 | ❌ 无法理解 A 是因 B 是果 | ✅ "OPEC 减产→油价涨→美国释储"归一组 |
| 参数调优 | 需手动调三个参数,不稳定 | 无需调参 |
新闻分组本质是**语义理解**任务,不是相似度计算。从 TF-IDF 到 AI,是从"看起来像"到"真的相关"的跨越。
此外,标注字段 `强制独立=是` 的条目在代码层(`group_news_ai.py`)被强制各自单独成组——既不送入 AI 语义分组,也不并入任何主题预分组或 AI 组,纯代码层面保证深度长文/评论文章不被短新闻稀释。
**组间排序**:分组完成后,代码按每组涉及的「国家/地区」对话题组做聚类排序(同国家相邻),保证同一国别的话题聚在一起,便于按国别通读。主国家从组内条目的标题/标签/摘要中识别(标题命中权重最高,其次标签、摘要),`COUNTRY_KEYWORDS` 词典维护在 `group_news_ai.py`,新增国家只需追加关键词。中东文件另有特殊规则:美伊冲突大话题组优先并按时间排序,其余组按国别聚类(详见 `group_news_ai.py` 的 `_sort_groups_by_country`)。
#### 2.3 热点专题热插拔
`config/hot_topics.toml` + `prompt/hot_topics.md` 联动,增删专题只需改 TOML + 同步 prompt,代码自动适配。`distribute_news.py` 动态读取 TOML 生成目标文件和 LLM 分类逻辑,Phase 2 支持 `--skip-hot-topic ` 跳过特定专题。
---
### 三、数据预处理层
#### 3.1 XML 硬约束:数据驱动 > Prompt 约束
整个 pipeline 最核心的设计决策:**不靠 prompt 传递规则,而是将标注嵌入数据本身**。
```
直接依赖 prompt:AI 可能忽略或误读(prompt 是软性建议)
↓ 升级为
结构化 XML 标签:AI 读到的不是"规则",而是按规则组织好的数据
```
```xml
<标签>美欧贸易; 汽车关税; 欧盟反制标签>
<分析师定性>对欧施压信号,实际执行面临法律挑战分析师定性>
<重要性>高重要性>
<强制独立>否强制独立>
```
数据标签对 AI 是**不可绕过的输入**——它无法"忽略"一条数据的 `<标签>`,就像无法忽略标题或正文。Prompt 是最后一道防线,不是第一道。
#### 3.2 数据分层
| 层级 | 内容 | 路径 | 时间控制 |
|------|------|------|----------|
| **新闻信源**(4 个) | 人民网、联合早报、财联社、华尔街见闻 | `content/source/output_*.md` | `CRAWL_*` / `MD_*`(自动更新) |
| **参考资料** | 观察者网 | `content/opinion/output_guancha.md` | `OPINION_*`(周末手动更新) |
| **话题简报** | 人工撰写的一周主要话题简报 | `content/opinion/*.md`(除 `output_guancha.md`) | 手动维护 |
观察者网的数据定位:
- 不做独立条目,仅在 dedup_news 中按地区分组作为额外上下文喂给 AI
- AI 可在 FOST 评论中引用其深度分析,但**禁止以其为唯一信源**
- 可通过 `--no-guancha` 禁用
本周话题简报的数据定位:
- 人工按话题撰写,格式:`# 话题标题` + 事实梳理 + `## 评论:`(观点)
- 篇幅小(每篇约 1–2KB)且天然跨地区/跨话题,故 dedup_news **整块注入**每个新闻组的
参考资料区,由 AI 判断在哪些组吸收其观点与论述角度——使各地的 FOST 评论具备
本期整体视野,而非各自就事论事
- 不做按话题筛选:简报名是概括性标题(如"习近平访美:元首外交落地与休战延长的分歧"),
与新闻组话题的关键词重合度低,按相似度硬筛反而会漏掉真正相关的内容
- 新增简报只需把 `.md` 放进 `content/opinion/`,无需改代码;`--no-briefs` 可禁用
---
### 四、两阶段整合
"不遗漏信息"和"控制篇幅"是内在矛盾的认知任务,拆为两个独立 AI 调用,各司其职:
| 维度 | dedup_news(信息完整性) | final_format(可读性) |
|------|------------------------|---------------------|
| 模型 | DeepSeek v4-pro | DeepSeek v4-flash |
| 核心约束 | 不遗漏时间 / 主体 / 事件 / 影响 / 信源 | 每条 300–2000 字 |
| 合并 | 按话题组同组合并 | 禁止合并(dedup 已完成) |
| FOST 评论 | 深度展开,严禁敷衍 | 不修改 |
| 信源日期 | 每条事实必标 | 不修改 |
| 字数 | 无上限 | 按重要性标签动态分配预算 |
处理策略:N≥2 组 AI 合并去重,N=1 单条也走 AI 精简压缩(砍背景铺垫、仪式性表态、合并冗余段落)。可通过 `-g` 参数只处理大组。
---
### 五、Prompt 架构
#### 5.1 三层分离
每次 API 请求按顺序拼接,各层独立维护,修改即生效:
```
[System Prompt] 角色定义,极简。例:"你是一位资深国际政治分析师。"
[地区专属 Prompt] 地缘背景知识(prompt/ 下 6 份独立 .md)
放在 System Prompt 之后,确保 AI 在"拥有背景知识"
的状态下理解后续任务。
[阶段任务模板] 当前阶段指令 + Few-shot + 输出模板 + 禁止清单
[新闻正文] 经 XML 预处理的新闻数据
```
#### 5.2 模型与参数
| 阶段 | 模型 | temperature | max_tokens |
|------|------|-------------|------------|
| auto_tag | v4-flash | 0.1 | 16384 |
| distribute_news | v4-flash | 0.1 | 8192 |
| group_news_ai | v4-flash(flash 档) | 0.3(可用 `-T` 覆盖) | 流式,不设上限 |
| dedup_news | v4-pro | — | 65536 |
| final_format | v4-flash | — | 65536 |
| abstract | v4-flash | — | 65536 |
| weekly_summary | v4-pro | — | 65536 |
> **max_tokens 说明**:V4-Flash/Pro 为思考模式,`max_tokens` 是「推理 token + 正文输出」共享的总预算。过小(如 8192)会被推理挤占导致输出截断(`finish_reason=length`),故批量长文本场景统一放大到 16384~65536。`group_news_ai` 用流式调用且不设上限,天然无截断问题。
#### 5.3 AI Provider 开关(deepseek / zhipu)
DeepSeek 偶发卡顿/限流时,可通过 `.env` 的 `AI_PROVIDER` 开关临时切换到智谱 GLM。统一由 `src/llm_provider.py` 的 `get_provider()` 处理——读取开关、选择模型 / Base URL / API Key / 思考模式参数。
##### 双模型策略
两个 Provider 采用**对齐的档位语义**,切换 `AI_PROVIDER` 时两档模型同步切换:
| 档位 | 适用任务 | DeepSeek | 智谱 GLM | 智谱单价(每百万 tokens) |
|------|---------|----------|----------|------------------------|
| `flash` | 简单/批量任务(自动标注、热点分类、格式化、摘要、语义分组) | `deepseek-flash` | `glm-5.3-flash` | 见下方价目 |
| `pro` | 复杂/重推理(去重合并、周报生成) | `deepseek-flash`(V4 Pro 已于 2026-09-14 12:00 全量路由到 V4.1 Flash) | `glm-5.3-flash` | 见下方价目 |
> 智谱价格取自官方定价页(2026-09-07)。`glm-4.7-flash` 为免费档,但**限流较严**(实测会返回 `429 该模型当前访问量过大`),因此 flash 档默认**关闭思考**以提速并降低触发概率。
```env
# .env
# ── Provider 开关:两个档位各自独立,可混搭 ──
AI_PROVIDER=deepseek # 全局兜底(未设分档开关时两档都用它)
# AI_PROVIDER_FLASH=zhipu # 简单任务单独指定(取消注释生效)
# AI_PROVIDER_PRO=deepseek # 复杂任务单独指定(取消注释生效)
ZHIPU_API_KEY=xxx # 用到 zhipu 时需要
ZHIPU_THINKING=enabled # 思考模式 enabled/disabled(两档统一覆盖,不填则按档位默认)
ZHIPU_REASONING_EFFORT=high # 只能 low/high/max(填 medium 会报 400 code 1210,整批全挂)
# 分档模型(留空则用上表默认值)
ZHIPU_MODEL_FLASH=glm-4.7-flash # 免费档
ZHIPU_MODEL_PRO=glm-5.3-flash
DEEPSEEK_MODEL_FLASH=deepseek-flash # = DeepSeek-V4.1-Flash(旧名 deepseek-v4-flash 已下线,仍可调用)
DEEPSEEK_MODEL_PRO=deepseek-flash # 注:V4 Pro 自 2026-09-14 12:00 起全量路由到 V4.1 Flash
# flash 档建议关闭思考(更快、更省、显著降低免费档 429)
ZHIPU_THINKING_FLASH=disabled
```
##### 内容审核回退(auto_tag / final_format / abstract 共用)
内容审核拦截是**平台级判定、与模型无关**,且**同一 Provider 重试多少次结果都一样**,只能换 Provider:
- DeepSeek:`400` + `Content Exists Risk`
- 智谱:`400` + `code: 1301`「系统检测到输入或生成内容可能包含不安全或敏感内容」
`auto_tag` / `final_format` / `abstract` 内置跨 Provider 回退;后两者还带**分片降级**,三级处理任何一级都不丢内容:
| 级别 | 动作 | 结果 |
|---|---|---|
| 1 | 整篇请求被拦 → 换备用 Provider | 通过则正常完成 |
| 2 | 备用也拦 → **按 `## 话题` 拆片**(每片 ≤ `CHUNK_TOPICS` 个话题)逐片重试 | 只剩含敏感表述的片被拦 |
| 3 | 某片仍被拦 → **继续二分细分**(6→3→1),直到单个话题 | 精确定位到真正敏感的那几个话题 |
| 4 | 单话题仍被拦 → 该话题**保留原文** | 内容完整,仅该话题未经 AI 清洗 |
审核判定针对**整篇输入**,所以拆小后命中率显著下降:25K 字符的 China 稿整篇必拦,
拆片后通常只剩 1~3 个话题被拦。最终状态为 `partial`,汇总会给出「N/M 话题已 AI 清洗」的统计。
**为什么先整片、被拦才细分**:单片请求也要带完整规则 prompt,话题越多则重复开销越小。
先整片试(快),被拦再二分定位(准),兼顾速度与粒度——实测 12 话题、仅 1 个敏感时
共 9 次请求,最终 11/12 话题得到清洗。细分阶段只试主 Provider
(备用方在顶层已证明拦同一批内容,每片都试会显著拖慢)。
**被拦话题会在终端逐条列出**(标题 + 文件行号),便于手动定位后单独处理:
```
[CHUNK] 分片结束:18/19 话题已 AI 清洗,1 个保留原文
⚠️ 以下 1 个话题被审核拦截,已保留原文(未经 AI 清洗):
1. [geo_China_dedup.md:L123] 某话题标题
→ 可手动改写这几处敏感表述后单独重跑,或直接接受原文
```
`auto_tag` 是逐条标注、本就按 `BATCH_SIZE` 分批,只需第 1 级。
失败处理:`final_format` / `abstract` 有失败(`blocked` / `err`)时返回**非 0** 中止,
**不会**拿上一次的旧产物静默顶替——避免 merge 读到过期文件,产出「看起来正常、实则缺内容」的 Word。
```env
LLM_FALLBACK_PROVIDER= # 留空 = 自动取「另一个 Provider」(主 DeepSeek 则回退智谱)
```
备用 Provider 未配置 API Key 时会自动禁用回退并给出提示,不影响主流程。
日志标记:`[BLOCKED] 改用 … 重试` → `[CHUNK] 分片降级` → `[OK [PARTIAL]]`。
> ⚠️ 旧变量 `AUTO_TAG_FALLBACK_PROVIDER` 已废弃:它的值恰等于主 Provider 时回退会被静默禁用(旧默认值 `deepseek` 在当前主配置下正是这种情况),统一改用 `LLM_FALLBACK_PROVIDER`。
##### 两个开关:按任务自由混搭
`AI_PROVIDER_FLASH` 与 `AI_PROVIDER_PRO` 可分别指定,实现「简单任务走便宜的、复杂任务走强的」:
| 组合 | 写法 | 适用场景 |
|------|------|---------|
| 简单=智谱、复杂=DeepSeek | `AI_PROVIDER_FLASH=zhipu` + `AI_PROVIDER_PRO=deepseek` | **最省钱**:标注类输出密集走智谱,dedup 留 DeepSeek 吃缓存与低峰半价 |
| 简单=DeepSeek、复杂=智谱 | `AI_PROVIDER_FLASH=deepseek` + `AI_PROVIDER_PRO=zhipu` | DeepSeek 卡顿时保住复杂任务 |
| 统一切换 | 只写 `AI_PROVIDER=zhipu` | 临时全量切换 |
解析顺序:`AI_PROVIDER_{TIER}` > `AI_PROVIDER`(全局兜底)> `deepseek`。只设其中一个分档开关时,另一档自动回退到全局值。
> ⚠️ 若把 `ZHIPU_MODEL_FLASH` 改成 `glm-5.3-flash`(它更快、也不限流),必须同时删掉 `ZHIPU_THINKING_FLASH=disabled` 或改为 `enabled`——该模型不支持 `thinking=disabled`,传了会报 `400`(code 1210)。
代码侧按档位取值:
```python
from src.llm_provider import get_provider
cfg = get_provider("flash") # 简单任务:DeepSeek v4-flash / 智谱 glm-4.7-flash
cfg = get_provider("pro") # 复杂任务:DeepSeek v4-pro / 智谱 glm-5.3-flash
```
优先级:`{PROVIDER}_MODEL_{TIER}` > `{PROVIDER}_MODEL`(旧变量,两档统一覆盖,向后兼容)> 内置默认值。思考模式同理:`ZHIPU_THINKING_{TIER}` > `ZHIPU_THINKING` > 档位默认(flash=disabled / pro=enabled)。
**全部 7 个 LLM 模块均已接入开关**(改 `.env` 即全链路切换):
| 模块 | 说明 | 档位 |
|------|------|------|
| `auto_tag` | 标注元数据 | `flash` |
| `distribute_news` | 热点专题分类 | `flash` |
| `group_news_ai` | AI 语义分组 | `flash` |
| `final_format` | 格式规范化 | `flash` |
| `abstract` | 摘要稿生成 | `flash` |
| `dedup_news` | 去重整合 | `pro` |
| `weekly_summary` | 周报摘要 | `pro` |
> **接入原则**:所有模块统一通过 `src/llm_provider.get_provider(tier)` 取配置(模型 / Base URL / API Key / 思考参数)。新增 LLM 模块时务必走这一入口,不要各自硬编码,否则该模块将无法响应 `AI_PROVIDER` 开关。
>
> **缓存说明**:`dedup_news` 的增量缓存键包含模型名,切换 Provider 后模型名变化会自动使缓存失效,不会读到另一个模型的旧产物。
**实测参考(2026-09-07,同一 5 条文件)**:
| Provider | 档位 | 模型 | 耗时 |
|----------|------|------|------|
| DeepSeek | flash | `deepseek-v4-flash` | 7s |
| 智谱 | flash | `glm-4.7-flash`(免费) | **47s** |
> 免费档可用但**慢 6~7 倍**,且限流较严(会返回 `429 该模型当前访问量过大`)。仅在 DeepSeek 卡/限流时临时切换,日常建议用 DeepSeek。
#### 5.4 六条设计原则
1. **语义分组写进数据**:group_news_ai 生成 `# topic_label(N 条)` → dedup_news 按组处理
2. **指令定义到原子维度**:列明必须包含的五个维度(时间、主体、事件、直接影响、信源)
3. **输出格式给模板**:信源日期格式、FOST 结构、标题层级全部模板化
4. **Few-shot 优于抽象描述**:嵌入理想输出 vs 不合格输出的对比示例
5. **数据分层**:观察者网仅做补充参考,禁止以其为唯一信源;人民网/早报为主体
6. **动态字数预算**:每类新闻字数 = 总预算 ×(该类条目数 / 总条目数)
所有 prompt 文件独立存储在 `prompt/` 目录。调优流程:运行 → 检查日志 → 修改 .md → 重跑,无需改代码。
> ⚠️ **prompt 日志目前只有 `weekly_summary` 会写**(`logs/prompts-{date}.log`)。auto_tag / dedup_news / final_format / abstract / distribute_news / group_news_ai 都**不写**——调这几个模块时只能看控制台输出与 `logs/info-*.log`。
---
### 六、编排与自动化
#### 6.1 时间窗口自推进
爬取与导出采用**按信源独立推进**的时间机制:
- **爬取**:每个爬虫起点 = 该信源在 DB 中的最新发布时间(`storage.get_latest_time(信源)`),互不拖累;`CRAWL_START_TIME` 仅在信源无数据时兜底
- **导出**:每个信源起点 = 其已导出 MD 文件中的最新时间(`export_news._md_latest_time()`);`MD_START_TIME` 仅在 MD 为空时兜底
- `run_news.py` 每次执行后仍会把 `CRAWL_START_TIME` / `MD_START_TIME` 更新为各信源最大值,但只作兜底,不影响各信源独立增量。同时清除 `__pycache__/settings.*.pyc`——NTFS mtime 精度约 2 秒,不清缓存会导致子进程读到旧值
**固定窗口补爬**:如需补爬某个时间点之后的历史数据,临时在 `settings.py` 设 `CRAWL_WINDOW_START="2026-08-01 00:00"`(可选 `CRAWL_END_TIME` 限制终点),跑 `crawl.bat` 后改回空字符串。窗口模式同时作用于爬取与导出,库中已有条目自动跳过;导出时会与 MD 已导出范围取并集,不丢失已有条目。
观察者网的 `OPINION_START_TIME` / `OPINION_END_TIME` 不自动更新,需每周末手动修改。
#### 6.2 并行与子进程隔离
- **爬取**(Phase 1):`asyncio.create_subprocess_exec` × 5,任一失败即 `sys.exit(1)`
- **标注**(Phase 3):同上,但失败仅 warn 不退出(标注可事后补)
- **quick 整合**:`ThreadPoolExecutor` 并行处理(geo 地区模式 / eco 组模式均如此),**并发上限 6**(`settings.QUICK_MAX_WORKERS`,实际取 `min(任务数, 6)`),每个地区/文件以**子进程**运行(`subprocess.run`)而非直接函数调用——DeepSeek SDK 的 `AsyncOpenAI` 在多线程共享时可能出现 event loop 冲突,子进程隔离避免此问题。上限取 6 是因为 geo 恰好 6 个地区,原值 5 会让第 6 个地区排队空等
- **dedup 组内并发**:地区子进程内部,一个文件的话题组用 `asyncio.Semaphore` + `gather` **并发处理**(上限 `settings.DEDUP_CONCURRENCY`,默认 4)。这是提速的关键——改造前组内完全串行,关键路径等于组数最多的地区(中东 31 组);并发后关键路径 ≈ `ceil(组数 / 4)`
- **final-format / abstract**:批量运行时文件级 `gather` 并发
- **to-xml**:全地区一次性处理(纯 IO 本地转换,不需并行)
#### 6.3 API 限流重试(429)
所有调用 DeepSeek 的模块统一封装 `chat_with_retry` 助手,命中 `RateLimitError`(HTTP 429)时自动**指数退避重试**:
- 覆盖 6 个模块共 8 个调用点:`dedup_news`(2)、`final_format`、`weekly_summary`、`distribute_news`、`auto_tag`(2)、`group_news_ai`(流式)
- 重试策略:最多额外 `MAX_API_RETRIES` 次(默认 5),第 n 次等待约 `BASE_BACKOFF × 2ⁿ` 秒(默认基数 2.0 → 2/4/8/16/32s)叠加 0~1s 随机抖动,避免多 worker 同时重试造成请求雪崩
- 参数集中在 `settings.py`(`MAX_API_RETRIES` / `BASE_BACKOFF`),改一处即全局生效
- 仅对 429 重试;其他异常原样抛出,交由各模块已有的降级/校验逻辑处理(如 dedup 失败降级、group 降级为手动分组)
#### 6.4 整合阶段耗时分布
整合阶段是整条流水线的耗时主体,且几乎全部消耗在 LLM 调用上:
| 阶段 | 模型 | 调用次数 | 并发方式 |
|------|------|---------|---------|
| to-xml | — | 0 | 本地转换,可忽略 |
| **dedup-news** | **v4-pro**(思考,max_tokens 65536) | **每组 1 次**(geo 全量约 100+ 组) | 地区间 6 路 × 组内 4 路 |
| final-format | v4-flash | 每文件 1 次(校验失败可 ×3) | 文件间并发 |
| abstract | v4-flash | 每文件 1 次(可 ×3) | 文件间并发 |
| merge / pandoc / archive | — | 0(仅 weekly-summary 调 1 次) | 本地,秒级 |
**阶段计时**:dedup / final-format / abstract 三个阶段的汇总都会打印总耗时(如 `=== dedup 阶段总耗时:X 分钟`)。dedup 还额外打印缓存命中数、每文件的组处理耗时与进度计数,便于事后定位瓶颈。
**并发调优**:在途请求数 ≈ 并行地区数 × `DEDUP_CONCURRENCY`。跑完后看控制台是否频繁出现 `[429]` 退避日志——几乎不出现 → 可调高 `DEDUP_CONCURRENCY` 到 6~8;频繁出现 → 下调到 2~3,用稳定性换速度。
#### 6.5 dedup 增量缓存
dedup 是唯一会反复处理相同内容的阶段,因此加了按内容指纹的增量缓存:
- **缓存键** = `sha256(CACHE_VERSION │ 模型 │ system prompt 全文 │ 话题 │ 观察者网参考 │ 组内全部条目文本)`。模型、提示词、话题、参考资料、正文**任一变化都会自动失效**,不会读到脏缓存
- **存储**:`content/work/.dedup_cache/<指纹>.md`,一组合并稿一个文件。该目录位于已被 `.gitignore` 覆盖的 `content/work/` 下,无需额外配置
- **跨周保留**:`weekend.bat` 只清 `opinion/`、`eco_news/`、`geo_news/`,不清 `content/work/`
- **产物顺序不受并发影响**:合并稿按组索引回填后重建文件,与串行执行结果一致
缓存开关:
| 场景 | 用法 |
|------|------|
| 单次跳过缓存 | `--no-cache`(本次不读也不写) |
| 强制全部重生成 | `--clear-cache`(先清空再继续) |
| 改了提示词或模型参数 | 递增 `dedup_news.py` 的 `CACHE_VERSION`,旧缓存整体失效 |
| 只想看能命中多少 | `--dry-run`(**只读不写**,不污染缓存也不产生费用) |
> **注意**:缓存无自动过期。若怀疑某组合并质量不佳且内容未变,用 `--clear-cache` 或删除 `content/work/.dedup_cache/` 强制重跑该内容。
---
## 项目结构
```
geo-news-python/
├── settings.py 全局配置
├── .env API Key + 华尔街见闻账密
├── cli.py 统一命令入口
│
├── crawl.bat 新闻采集(login → crawl → export → tag)
├── preprocess.bat 分发 + 语义分组
├── consolidate.bat 整合 → 合并 → 摘要 → Word → 归档
├── weekend.bat 收尾:归档(始终)→ 检查 → 清空 → 提示更新时间
│
├── src/
│ ├── storage.py SQLite 存储(双库:geo_news.db / eco_news.db)
│ ├── llm_provider.py LLM Provider 统一配置(AI_PROVIDER 开关:deepseek/zhipu)
│ ├── crawler/
│ │ ├── crawl_people.py 人民网爬虫
│ │ ├── crawl_zaobao.py 联合早报爬虫
│ │ ├── crawl_guancha.py 观察者网爬虫
│ │ ├── crawl_cls.py 财联社爬虫
│ │ ├── crawl_wallstreetcn.py 华尔街见闻爬虫
│ │ ├── login_wallstreetcn.py 华尔街见闻登录(有头浏览器)
│ │ ├── run_news.py 采集全流程编排器
│ │ ├── export_news.py SQLite → Markdown 导出
│ │ └── auto_tag.py AI 自动标注元数据
│ ├── preprocess/
│ │ ├── distribute_news.py 三阶段分发(规则 + 热点 + 兜底)
│ │ └── group_news_ai.py AI 语义分组
│ └── pipeline/
│ ├── metadata_to_xml.py 标注 → XML 预处理
│ ├── dedup_news.py 同组新闻 AI 去重精简(v4-pro)
│ ├── final_format.py 格式规范化(v4-flash)
│ ├── abstract.py fmt → 摘要稿(v4-flash)
│ ├── merge.py 合并各地区整合稿(优先读 _abstract.md)
│ ├── weekly_summary.py AI 周报摘要
│ └── archive_annotated_news.py 增量归档到 SQLite
│
├── scripts/
│ ├── clean_logs.py 清理过期日志
│ ├── weekly_cleanup.py 每周收尾清理
│ └── fost.lua pandoc filter:FOST 段落样式(FOSTComment)+ 返回目录超链接
│
├── prompt/ AI 提示词(独立 .md 文件,改后即生效)
│ ├── auto_tag.md 标注规则
│ ├── group_news_ai.md 语义分组规则
│ ├── hot_topics.md 热点专题分类规则
│ ├── dedup_geo.md geo 去重精简规则
│ ├── dedup_eco.md 经济类去重精简规则(量化数据零损耗)
│ ├── final_format.md 格式规范化规则
│ ├── 每周摘要模板.md 周报模板
│ └── prompt_{地区}.md 6 份地区地缘背景
│
├── config/
│ ├── hot_topics.toml 热点专题定义(增删即生效)
│ └── eco_routes.toml 二级+三级领域定向分发规则(增删即生效)
│
├── content/
│ ├── source/ output_{信源}.md(爬取输出,分发后清空)
│ ├── opinion/ 参考资料(观察者网)
│ ├── geo_news/ 地缘新闻(6 地区)
│ ├── eco_news/ 经济新闻(按领域 + 热点专题)
│ ├── work/ 中间产物(XML、dedup 过程文件)
│ └── output/ 最终简报 + Word
│
├── data/ SQLite(geo_news.db / eco_news.db / annotated_news.db)
└── logs/ 分级日志 + API 调用记录
```
---
## 配置参考
`settings.py` 关键配置项:
| 配置 | 说明 | 自动更新 |
|------|------|----------|
| `CRAWL_START_TIME` | 爬虫起点(兜底,仅信源无数据时用) | ✅ 每次 `run_news.py` 后自动更新 |
| `MD_START_TIME` | 新闻导出起点(兜底,仅 MD 为空时用) | ✅ 与 `CRAWL_START_TIME` 同步更新 |
| `CRAWL_WINDOW_START` | 固定爬取/导出窗口起点(空=增量,非空=补爬历史) | ❌ 手动设置,用完改回空 |
| `OPINION_START_TIME` | 参考资料导出起点 | ❌ 每周末手动改 |
| `OPINION_END_TIME` | 参考资料导出终点 | ❌ 每周末手动改 |
| `WALLSTREETCN_HEADLESS` | 华尔街见闻浏览器是否无头(False=有头可手动过验证码,True=后台静默;首次登录保持 False) | ❌ 默认 True |
| `WSC_DETAIL_CONCURRENCY` | 华尔街见闻详情页并发 tab 数(默认 1 串行;改 2 约 1.5-2 倍提速,但可能触发反爬,建议先小批量验证) | ❌ 默认 1 |
| `CLS_HEADLESS` | 财联社浏览器是否无头(True=后台静默、不遮挡桌面) | ❌ 默认 True |
| `GROUP_NEWS_AI_TEMPERATURE` | AI 语义分组温度(越低越精细保守,越高越粗放) | ❌ 默认 0.3,命令行 `-T` 可覆盖 |
| `DEDUP_CONCURRENCY` | dedup 单个文件内并发处理的话题组数上限 | ❌ 默认 4,命令行 `--concurrency` / `-c` 可覆盖 |
| `QUICK_MAX_WORKERS` | `quick` 并行处理的地区/文件数上限 | ❌ 默认 6(geo 恰好 6 个地区) |
| `MAX_API_RETRIES` / `BASE_BACKOFF` | 429/超时重试次数与退避基数 | ❌ 默认 5 / 2.0 |
| `DB_PATH` / `ECO_DB_PATH` | 双库路径 | — |
| `REGIONS` | 6 个地区标识 | — |
> 新闻信源和参考资料的时间窗口独立控制:新闻导出用 `MD_*`,观察者网导出用 `OPINION_*`。
---
## CLI 参考
所有命令通过 `python cli.py ` 调用。按业务流程排序:
```bash
# ── Phase 0: 登录 ──
python cli.py login-wallstreetcn # 有头浏览器登录(保存 profile)
# ── Phase 1-2: 爬取与导出 ──
python cli.py crawl people # 单信源爬取
python cli.py crawl zaobao
python cli.py crawl guancha
python cli.py crawl cls
python cli.py crawl wallstreetcn
python cli.py news # 全流程:爬取 → 导出 → 标注
python cli.py export-news # 仅导出
# ── Phase 3: 标注与分发 ──
python cli.py auto-tag --source cls # AI 标注
python cli.py distribute-news # 分发(规则 + 热点 + 兜底)
python cli.py distribute-news --dry-run # 预览
python cli.py distribute-news --source cls # 仅指定信源
# ── Phase 4: 语义分组 ──
python cli.py group-news-ai # 全部分组
python cli.py group-news-ai -f eco_ai_plus.md # 仅指定文件
python cli.py group-news-ai -T 0.5 # 调整分组粗细(默认 0.3,越低越精细)
# ── Phase 5: 整合 ──
python cli.py to-xml # 标注 → XML
python cli.py to-xml America,Europe # 指定地区
python cli.py dedup-news # 全量:6 地区 geo + 全部 eco
python cli.py dedup-news --prefix geo # 仅地缘(6 地区)
python cli.py dedup-news --prefix eco # 仅经济类(eco_*_xml)
python cli.py dedup-news MiddleEast # 仅指定地区
python cli.py dedup-news -g 5 # 仅处理 ≥5 条的大组
python cli.py dedup-news --dry-run # 预览(同时报告有多少组可命中缓存)
python cli.py dedup-news --no-guancha # 不用观察者网参考
python cli.py dedup-news -c 2 # 调低组内并发(429 频繁时)
python cli.py dedup-news --no-cache # 本次不用增量缓存(不读不写)
python cli.py dedup-news --clear-cache # 先清空缓存再跑(强制全部重新生成)
python cli.py final-format # 格式规范化
python cli.py final-format Africa # 指定地区
python cli.py abstract # fmt → 摘要稿(正文 5-8 句,FOST 2-3 句)
python cli.py abstract Africa # 指定地区
python cli.py merge # 合并各地区(优先 _abstract.md)
python cli.py weekly-summary # 周报摘要
python cli.py archive # 归档到 SQLite
# ── 快捷入口 ──
python cli.py quick # to-xml → dedup → format → abstract(全部地区)
python cli.py quick China,America # 指定地区快速整合
python cli.py full # 一键到底(含 abstract)
# ── 维护工具 ──
python cli.py clean-logs # 清理 30 天前日志
weekend.bat # 每周收尾(归档始终执行,清空/更新时间仅 source 清空后)
# 调试
$env:LOG_LEVEL="DEBUG"
python cli.py quick America
```
---
## 最佳实践
### 逐地区迭代工作流
6 个地区一次性全量整合难一次到位,推荐逐地区迭代:
1. **逐地区运行**:`python cli.py quick America` → 检查 → 满意锁定
2. **不满意则微调**:修改 `prompt_{地区}.md` 或源文件标注,重跑该地区
3. **全部满意后合并**:`python cli.py merge` + `python cli.py weekly-summary`
> 每个地区达到理想状态就锁定,不给后续改动破坏已合格结果的机会。
### 标注策略
| 症状 | 优先排查 | 解法 |
|------|----------|------|
| 话题被遗漏 | group_news_ai 分组结果 | 检查是否被误分入其它话题组,必要时人工调组 |
| 篇幅失控 | 重要性 + 摘要 | 降重要性 + 精炼摘要 |
| 合并混乱 | group_news_ai 分组 + dedup_news prompt | 先确认话题组划分是否合理,再收紧 dedup prompt |
| 评论敷衍 | 分析师定性 | 补充一句有方向的定性 |
| 深度长文消失 | 强制独立 | 设 `强制独立=是` |
> **原则**:能在数据层解决的(标注 → 预处理),不要写到 prompt 里。
### Prompt 调优
所有 prompt 作为独立 `.md` 文件外化在 `prompt/` 目录下,修改后直接生效。(`logs/prompts-{date}.log` 目前**仅 `weekly_summary` 会写**,其余模块不记录 prompt。)
调优流程:运行 → 检查日志定位问题 → 修改对应 `.md` → 重新运行。
---
## 日志与维护
- **分级日志**:`logs/{error,warn,info,debug}-YYYY-MM-DD.log`,爬虫默认 DEBUG,其余 INFO
- **API 记录**:`logs/prompts-{date}.log`(**仅 `weekly_summary` 写入**,其他模块暂无)
- **清理**:`python cli.py clean-logs` 删除 30 天前日志
- **归档**:`python cli.py archive` 增量归档到 `data/annotated_news.db`(去重键 = 发布时间 + 信源 + 链接)
- **收尾**:`weekend.bat` — 归档(始终)→ 检查分发完整性 → 清空工作目录 → 提示更新时间参数
---
## 迁移指南(Node.js → Python)
数据格式完全兼容。旧命令对照:
| Node.js | Python |
|---------|--------|
| `politics.bat` | `crawl.bat` |
| — | `preprocess.bat` |
| `consolidate.bat` | `consolidate.bat` |
| `npm run crawl:politics` | `python cli.py news` |
| `npm run consolidate` | `python cli.py quick` |
| `npm run preprocess` | `python cli.py distribute-news` + `python cli.py group-news-ai` |
| `npm run merge` | `python cli.py merge` |
| `npm run weekly-summary` | `python cli.py weekly-summary` |
| `npm run clean-logs` | `python cli.py clean-logs` |
| — | `python cli.py archive` |
| — | `python cli.py login-wallstreetcn` |
| — | `weekend.bat` |