Files
xwlb/README.md
T
simon 60f8c263f2 《新闻联播》每日抓取入库:全链路 + 可移植化 + 定时任务
从 CCTV 主页抓取《新闻联播》,下载 → 转 MP3/WAV → 静音切分 → ASR 识别
→ LLM 校对 → 切分为单条新闻 → 入库 MySQL。

主要内容:
- 全链路:getVideo5 抓取下载、audioRead 转写、deepseek 校对与切分、newsProcess 入库
- 可移植化:配置分层,.env 只放密钥、config.yml 放模型/接入点/路由/参数
- 可换供应商:endpoints(kind/base_url/api_key_env/extra_body)+ routes 按环节选路
- 数据保真:数值事实守卫,校对改动数字/年份/届次则整片回退 ASR 原文;
  识别不完整不发布该日精编,避免半天内容被当成完整一天
- 定时任务:systemd 每天 21:00,失败 21:30 / 22:00 重试;
  只缺切分时只重跑切分(省掉全部 ASR),用 state/.asr_complete_* 标记判定阶段
- 隧道自愈:13306 不通时自动执行 autossh.sh(所有入口共用,systemd 托管时只等待)
- 中间产物每日清理;97 项离线自检(配置/清理/事实守卫/解析/隧道)
2026-09-25 11:17:46 +08:00

508 lines
29 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.
# xwlb —— 央视《新闻联播》自动转写与结构化入库流水线
把央视网《新闻联播》**完整版视频**自动抓取下来,转写成文字,再交给大模型切分成**一条条独立新闻**(含标题、正文)写进 MySQL/MariaDB。
全流程一条命令跑完,可幂等重跑、失败可定位、跑完自动清理中间产物。
> 内容版权归中央广播电视总台所有。本项目仅用于个人学习与研究用途,请勿用于商业分发。
---
## 目录
- [一、功能](#一功能)
- [二、技术架构](#二技术架构)
- [三、使用说明](#三使用说明)
- [四、相关文档](#四相关文档)
---
## 一、功能
### 1.1 主流程(`python main.py` 一条命令走完)
| 步骤 | 做什么 | 产出 |
|---|---|---|
| ① 链接发现 | 抓 `tv.cctv.com/lm/xwlb/day/YYYYMMDD.shtml`,匹配出当天「完整版《新闻联播》」的 VID 详情页 | 视频页 URL |
| ② 下载与抽音 | yt-dlp 下载 mp4,ffmpeg 抽出 mp3(已存在则跳过下载) | `xwlb_video/YYYYMMDD.mp4` / `.mp3` |
| ③ 转写落库 | mp3 → 16kHz 单声道 wav → 按静音切成 ~11 片 → 逐片语音识别 → LLM 校对 → 写入 `xwlb_daily` | 分片级原文 + 校对文本 |
| ④ 结构化精编 | 把当天全部分片文本拼起来交给 DeepSeek,切分成独立新闻并起标题 | `xwlb_daily_ext` 多行(标题 + 正文) |
| ⑤ 自动清理 | 当天全部成功后才清空 mp3/mp4/wav | 释放磁盘(实测单日约 121MB) |
### 1.2 工程能力
- **幂等重跑**:`xwlb_daily` 按 `(日期, 分片号)` 先删后插;`xwlb_daily_ext` 按日期整体替换。同一天跑多少遍结果都一致,不会产生重复行。
- **绝不丢已识别文本**:校对环节任何失败(超时、限流、返回空)都自动回退 ASR 原文,不会因为"润色"把整片文字丢掉。
- **数值事实守卫**:LLM 校对若改动了数字/年份/届次/规划期等事实(实测出现过 `2027→2024`、`十五五→十四五`、`第十一届→第九届`),该分片**整片回退 ASR 原文**。
- **不写脏数据**:识别为空的分片不写库;失败当天不删除已有数据,留给下次重跑。
- **可重跑的分步入口**:只重做转写、只重做切分、强制重切,都有独立命令。
- **配置分层**:口令/Key 在 `.env`,其余(含**三个模型名**与**各供应商 base url**)在 `config.yml`;换模型、换供应商都不用动代码。
- **失败说得清楚**:缺 Key、连不上库(含隧道提示)、模型返回非 JSON 等都有明确日志与修复指引。
- **97 项离线自检**:配置、清理、事实守卫、LLM 返回解析、隧道自愈五个测试套件,不依赖网络与真实 API。
### 1.3 当前数据规模(2026-09-25 实测)
| 表 | 行数 | 覆盖天数 | 日期范围 |
|---|---|---|---|
| `xwlb_daily`(分片级原文) | 7,973 | 725 | 2024-09-26 ~ 2026-09-23 |
| `xwlb_daily_ext`(单条新闻) | 11,494 | 523 | 2024-09-26 ~ 2026-09-23 |
单日耗时约 **25~35 分钟**(下载 + 抽音 + 11 片识别 + 校对 + 切分),全程顺序执行、无并发。
---
## 二、技术架构
### 2.1 数据流
```
main.py(当天)
│ start_date = end_date = %Y%m%d
▼
┌───────────────────────────────────────────────────────────────┐
│ ① 链接发现 getVideo5.get_all_video_links(start, end) │
│ xwlb_urls() → tv.cctv.com/lm/xwlb/day/YYYYMMDD.shtml
│ get_xwlb_video_link() → 匹配「完整版《新闻联播》」得到 VID 页 │
├───────────────────────────────────────────────────────────────┤
│ ② 下载与抽音 download_and_extract_audio(url, date, dir) │
│ yt-dlp best[ext=mp4]/best → xwlb_video/YYYYMMDD.mp4 │
│ ffmpeg -c:a libmp3lame -q:a 0 → xwlb_video/YYYYMMDD.mp3 │
├───────────────────────────────────────────────────────────────┤
│ ③ 转写落库 audioRead.process_long_audio(mp3, out_dir, date) │
│ convert_mp3_to_wav() 16kHz / 单声道 → input_YYYYMMDD.wav│
│ split_audio_by_smart_silence(700ms, -40dBFS, keep 400ms) │
│ → 贪心合并为 ≤3 分钟分片 chunk_i.wav(实测约 11 片/天) │
│ for each chunk: │
│ transcribe_audio() dashscope paraformer ASR │
│ analyze_and_correct_text() LLM 校对 + 数值事实守卫 │
│ _upsert_daily_chunk() → xwlb_daily(先删后插) │
├───────────────────────────────────────────────────────────────┤
│ ④ 切分入库 newsProcess.news_to_db(date) │
│ 读当天 news_improve 按分片号拼接 → DeepSeek(json_object) │
│ → 解析 [{news_id, news_title, news_content}] │
│ → 删除该日期旧记录 → 批量写入 xwlb_daily_ext │
├───────────────────────────────────────────────────────────────┤
│ ⑤ 清理 cleanup.maybe_cleanup_after_run(date, day_ok) │
│ 成功才清空 xwlb_video/*.mp3|*.mp4 与 audio_processing/*.wav │
└───────────────────────────────────────────────────────────────┘
```
### 2.2 技术栈
| 层 | 选型 | 用途 |
|---|---|---|
| 语言/运行 | Python 3.13 + `.venv` | 本机为 externally-managed 环境,必须用虚拟环境 |
| 网页抓取 | `requests` + `beautifulsoup4` | 解析央视按天页面,定位完整版视频 |
| 视频下载 | `yt-dlp` | 下载 mp4 |
| 音频处理 | `ffmpeg`(系统依赖)+ `pydub` + `audioop-lts` | 抽音、16k 单声道转换、静音切分(3.13 已移除 `audioop`,需补丁包) |
| 语音识别 | 阿里云 DashScope `paraformer-realtime-v2` | 中文长音频转写(接入点可配) |
| 文本校对 | 阿里云 DashScope `qwen3.7-max` | 修正 ASR 的错别字/断句(受事实守卫约束) |
| 新闻切分 | DeepSeek `deepseek-chat`(`json_object`) | 切分为独立新闻 + 起标题 |
| LLM 接入 | 可配置的 **endpoints + routes** | base url / 协议类型 / 密钥变量名都在 `config.yml`,可换供应商 |
| 数据库 | MySQL / MariaDB 10.11 + `mysql-connector-python` | 结果入库 |
| 配置 | `PyYAML`(config.yml)+ 自研 dotenv 解析(.env) | 敏感/非敏感分开 |
| 远程访问 | `autossh` | 本地 13306 → 远端 3306 隧道 |
### 2.3 目录结构
```
xwlb/
├── main.py # 每日入口(当天)
├── getVideo5.py # 链接发现 + 下载 + 主流程编排
├── audioRead.py # 转写 + 校对 + 分片写库
├── newsProcess.py # DeepSeek 切分 + 写 xwlb_daily_ext
├── deepseek.py # DeepSeek 客户端(重试 / 退避 / json_object)
├── cleanup.py # 中间产物清理(自动钩子 + 手动命令)
├── config.py / config.yml# 非敏感配置加载器 / 配置本体(含三个模型名)
├── env.py # 敏感配置(.env)加载与缺失检查
├── mysql_handler.py # 数据库层(项目内置,不再依赖父项目)
├── mysqlHandle.py # 转发 mysql_handler.MySQLDB
├── newsRedo.py # 手动重跑某天(跳过 / 只切分 / 全流程)
├── main_videos.py # 补缺失日期(区间写在文件内)
├── .env / .env.example # 敏感项 / 模板
├── requirements.txt
├── autossh.sh # SSH 隧道脚本
├── xwlb_video/ # 中间产物:YYYYMMDD.mp4 / .mp3(成功后清空)
├── audio_processing/ # 中间产物:input_*.wav / chunk_*.wav(成功后清空)
├── docs/ # ARCHITECTURE.md(技术说明)、BUGS.md(缺陷清单)
└── tests/ # 4 个离线测试套件
```
### 2.4 数据库结构
**`xwlb_daily` —— 音频分片级文本**(一天的完整版被切成约 11 片,每片一行)
| 字段 | 类型 | 说明 |
|---|---|---|
| `nid` | int, PK, auto_increment | 自增主键 |
| `news_days` | date | 日期(`YYYY-MM-DD`) |
| `daily_sub_id` | int | 分片序号,从 0 开始 |
| `news_raw` | text | ASR 原始识别文本 |
| `news_improve` | text | 校对后文本(关闭校对或守卫回退时等于 `news_raw`) |
| `news_title` | text | 保留字段(当前流程未使用) |
> 表中无 `(news_days, daily_sub_id)` 唯一索引,幂等性由代码「先删后插」保证。
**建表语句**(新环境可直接执行;库名默认 `myquant`)
```sql
CREATE TABLE IF NOT EXISTS xwlb_daily (
nid int(11) NOT NULL AUTO_INCREMENT,
news_days date NOT NULL,
daily_sub_id int(11) NOT NULL,
news_raw text NOT NULL,
news_improve text NOT NULL,
news_title text NOT NULL,
PRIMARY KEY (nid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
CREATE TABLE IF NOT EXISTS xwlb_daily_ext (
extid int(11) NOT NULL AUTO_INCREMENT,
news_date date NOT NULL,
sub_id tinyint(4) NOT NULL,
news_title varchar(256) NOT NULL,
news_content text NOT NULL,
PRIMARY KEY (extid)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_general_ci;
```
**`xwlb_daily_ext` —— 最终产物:单条新闻**
| 字段 | 类型 | 说明 |
|---|---|---|
| `extid` | int, PK, auto_increment | 自增主键 |
| `news_date` | date | 日期(`YYYY-MM-DD`) |
| `sub_id` | tinyint | 当天第几条新闻,从 1 开始 |
| `news_title` | varchar(256) | 新闻标题(模型生成) |
| `news_content` | text | 新闻正文 |
### 2.5 三个模型的职责
| 环节 | 模型配置项 | 走哪个接入点(`routes.*`) | 默认模型 | 输入 → 输出 |
|---|---|---|---|---|
| 语音识别 | `models.asr_model` | `asr` → `dashscope` | `paraformer-realtime-v2` | 单片 wav → 该片文本 |
| 文本校对 | `models.correct_model` | `correct` → `dashscope` | `qwen3.7-max` | 单片 ASR 文本 → 校对后文本(同一分片粒度) |
| 新闻切分 | `models.split_model` | `split` → `deepseek` | `deepseek-chat` | 当天全文 → JSON 数组(标题 + 正文) |
模型名与接入点是**分开配置**的:换供应商时改 `routes` + `endpoints`,再同步改模型名,代码不动(见 [3.4 更换 LLM 供应商](#34-更换-llm-供应商))。
**为什么要两道 LLM**:ASR 只保证"字"对上,标点、断句、同音错字仍需修;而切分属于理解任务——需要判断新闻边界、给每条起标题,所以交给擅长长文本理解的 DeepSeek。两道 LLM 的分工边界清晰,任一道失败都不会污染另一道的结果。
### 2.6 关键设计取舍
| 取舍 | 做法 | 原因 |
|---|---|---|
| 校对会改错事实 | 数值事实守卫:数值签名不一致就整片回退原文 | 宁保留可信的 ASR 原文,也不接受"通顺但错误"的文本 |
| 重跑会重复写库 | `xwlb_daily` 先删后插;`xwlb_daily_ext` 按日期替换 | 无需唯一索引即可幂等,重跑不产生脏数据 |
| 分片识别失败 | 记 ERROR、**不写空行**、其余分片照常 | 空行曾导致下游拼出残缺文本(历史库中有 101 行空白) |
| 切分返回非 JSON | 剥代码围栏 + `raw_decode` 兜底 + 强化提示词重试一次 | 历史上有 22 天因此得到 0 条精编 |
| 中间产物占用磁盘 | 当天全部成功才清理,失败保留 | 成功即无价值,可释放磁盘;失败则保留供重跑 |
| 顺序执行、不并发 | 逐片串行 | 单机负载可控、日志与失败定位简单;单日 25~35 分钟已满足离线需求 |
---
## 三、使用说明
### 3.1 环境要求
- Linux(本机 Raspberry Pi / Debian)
- Python 3.11 ~ 3.13(本项目在 3.13.5 上验证)
- 系统依赖:`ffmpeg`;`autossh`(远程数据库时)
- 一个可用的 MySQL/MariaDB 库(两张表需已存在,见 [2.4](#24-数据库结构))
- 阿里云 DashScope API Key(语音识别 + 校对)、DeepSeek API Key(切分)
### 3.2 安装
```bash
cd /home/pi/project/xwlb
# 1) 端口隧道:本地 13306 → 远端 3306(数据库不在本机时需要,通了可跳过)
bash autossh.sh
# 2) 虚拟环境(本机 Python 为 externally-managed,必须用 venv)
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
# 3) 配置
cp .env.example .env # 填 3 个敏感项
vim config.yml # 核对数据库地址、目录、模型
```
> Python 3.13 已移除标准库 `audioop`,`pydub` 需要 `requirements.txt` 中的 `audioop-lts`,已自动按版本条件安装。
### 3.3 配置
**(1)`.env` —— 只放敏感项**(已被 `.gitignore` 排除)
```ini
MYSQL_PASSWORD=...
DASHSCOPE_API_KEY=sk-...
DEEPSEEK_API_KEY=sk-...
```
**(2)`config.yml` —— 其余全部配置**
```yaml
mysql: { host: localhost, port: 13306, user: myquant, database: myquant }
paths: { video_dir: xwlb_video, audio_dir: audio_processing }
models:
asr_model: paraformer-realtime-v2 # 语音识别
correct_model: qwen3.7-max # ASR 文本校对
split_model: deepseek-chat # 新闻切分 + 标题
# 接入点(base url):换供应商 / 换区域 / 走代理只改这一段
endpoints:
dashscope:
kind: dashscope # dashscope=用 SDK,openai=OpenAI 兼容
http_base_url: https://dashscope.aliyuncs.com/api/v1 # 文本生成走 HTTP
websocket_base_url: wss://dashscope.aliyuncs.com/api-ws/v1/inference # 实时 ASR 走 WS
api_key_env: DASHSCOPE_API_KEY # 密钥在 .env 里的变量名
deepseek:
kind: openai
base_url: https://api.deepseek.com/v1
chat_completions_path: /chat/completions
api_key_env: DEEPSEEK_API_KEY
# 每个环节走哪个接入点(值是 endpoints 下的名字)
routes: { asr: dashscope, correct: dashscope, split: deepseek }
llm_correct:
enabled: 1 # 1=开启校对(默认);0=直接用 ASR 原文
cleanup:
after_daily_run: 1 # 当天成功结束后自动清理中间产物
only_on_success: 1 # 仅成功才清理;0=无论成败都清理
remove_video: 1 # 删除 mp3 / mp4
remove_audio: 1 # 删除 wav
```
**优先级**:`进程环境变量 > config.yml > 代码内置默认值`。
所以临时试验可以不改文件:
```bash
DASHSCOPE_LLM_MODEL=qwen-max .venv/bin/python audioRead.py 20260904
LLM_CORRECT_ENABLED=0 .venv/bin/python getVideo5.py 20260904 20260904
XWLB_ROUTE_CORRECT=deepseek .venv/bin/python newsProcess.py 2026-09-04 # 临时换校对供应商
```
启动时会打印生效配置(不含敏感值),含每个环节实际用的接入点:
```
配置来源: /home/pi/project/xwlb/config.yml | MySQL myquant@localhost:13306/myquant |
模型 asr=paraformer-realtime-v2 correct=qwen3.7-max split=deepseek-chat | 校对=开启 | 完成后清理=开启
接入点 asr -> dashscope kind=dashscope wss://dashscope.aliyuncs.com/api-ws/v1/inference(密钥变量 DASHSCOPE_API_KEY)
接入点 correct -> dashscope kind=dashscope https://dashscope.aliyuncs.com/api/v1(密钥变量 DASHSCOPE_API_KEY)
接入点 split -> deepseek kind=openai https://api.deepseek.com/v1/chat/completions(密钥变量 DEEPSEEK_API_KEY)
```
### 3.4 更换 LLM 供应商
供应商不写死在代码里:**地址在 `endpoints`,走哪条线在 `routes`,密钥变量名在 `api_key_env`**。
| 想做什么 | 改哪里 |
|---|---|
| 换区域(如 DashScope 国际站) | `endpoints.dashscope` 的两个 url |
| 走公司网关 / 代理 / 自建推理 | 改对应接入点的 `base_url`(OpenAI 兼容) |
| 换校对供应商(如月之暗面、智谱、Kimi) | ① `routes.correct` 指向新接入点 ② 在 `endpoints` 加一段 ③ 改 `models.correct_model` ④ `.env` 加对应 Key |
| 换切分供应商 | 同上,改 `routes.split` + `models.split_model` |
| 换 ASR 供应商 | ⚠️ 实时语音识别只有 `kind: dashscope` 一种适配器;换**厂商**需要新增适配器(换区域/网关仍可,改 url 即可) |
**示例:把校对从通义千问换成月之暗面**
```yaml
routes: { asr: dashscope, correct: moonshot, split: deepseek }
endpoints:
moonshot:
kind: openai
base_url: https://api.moonshot.cn/v1
chat_completions_path: /chat/completions
api_key_env: MOONSHOT_API_KEY
models:
correct_model: kimi-k2-0905-preview
```
```ini
# .env
MOONSHOT_API_KEY=sk-...
```
要点:
- `kind` 只有两种:`dashscope`(用 dashscope SDK)和 `openai`(OpenAI 兼容的 `/chat/completions`,覆盖 DeepSeek / Kimi / 智谱 / vLLM / 各类网关)。
- 请求地址 = `base_url` + `chat_completions_path`,所以不带版本号的网关地址(如 `https://my-gateway/llm`)也能直接填。
- 缺 `base_url`、`routes` 指向不存在的接入点、`kind` 拼错,都会在启动日志与调用时报出**可读错误**,不会静默走错地址。
- **换供应商不影响数值事实守卫**:无论哪家的校对结果,改动数字/年份/届次一样会被拦下并回退 ASR 原文(已用 DeepSeek 作为校对供应商实测)。
- 运行时必需哪些 Key 是**按路由推导**的:只有被 `routes` 用到的接入点才要求配 Key,未被使用的不会报缺失。
#### 3.4.1 推理模型(thinking)注意
现在的模型多是"推理模型":回答前先生成一大段思维链,**推理 token 按输出计费**。
实测(详见 [`docs/REPORT_raw_vs_improve.md`](./docs/REPORT_raw_vs_improve.md)):
| 环节 | 关掉思考 | 开着思考 | 结论 |
|---|---|---|---|
| 校对 | 312 token / 4~6 秒,输出几乎一致 | 5,932 token | **关**(省约 13 倍,机械任务不需要推理) |
| 切分 | 4,656 token,正文覆盖率 **95.34%**、20 条 | 19,077 token,覆盖率 **99.67%**、27 条 | **开**(关掉会丢 4.7% 正文、少切 7 条) |
关思考的参数名**因供应商而异**,写错会被**静默忽略**(不报错但也不生效),所以配在接入点上:
```yaml
endpoints:
qwen: { extra_body: {enable_thinking: false} } # 通义/百炼兼容模式
deepseek: { extra_body: {thinking: {type: disabled}} } # DeepSeek 推理模型
```
也可在 `llm_correct` / `llm_split` 段写 `extra_body` 覆盖(优先级更高)。
使用**推理模型**时 `llm_split.max_tokens` 要给足(现为 60000):实测推理 token 在
14k~24k 之间波动,上限 20000 会截断,甚至**整个回复为空**。
### 3.5 运行
| 场景 | 命令 | 说明 |
|---|---|---|
| 日常(当天) | `.venv/bin/python main.py` | 抓当天 → 下载 → 转写 → 切分 → 清理 |
| 指定某天 | `.venv/bin/python getVideo5.py 20260904 20260904` | 日期格式 `YYYYMMDD` |
| 指定区间 | `.venv/bin/python getVideo5.py 20260901 20260907` | 逐日全流程 |
| 补缺失日期 | `.venv/bin/python main_videos.py` | 反查库中缺失日期再补跑(**区间写在文件内**,需按需修改) |
| 重跑某天 | `.venv/bin/python newsRedo.py 2026-09-04` | 已有精编 → 跳过;有原文 → 只重做切分;都没有 → 全流程 |
| 强制重切 | `.venv/bin/python newsRedo.py 2026-09-04 --force` | 忽略已有记录,重新切分并**替换**该日 `xwlb_daily_ext` |
| 只重做切分 | `.venv/bin/python newsProcess.py 2026-09-04 [--force]` | 不碰音频,直接用库中文本重切 |
| 只重做转写 | `.venv/bin/python audioRead.py 20260904` | 用已有 `xwlb_video/20260904.mp3` 重跑分割+识别+落库 |
| 手动清理 | `.venv/bin/python cleanup.py [--dry-run]` | 清空两个中间产物目录,`--dry-run` 只列不删 |
> **注意**:默认 `cleanup.remove_video: 1`,当天成功后 mp3 会被删掉。因此**要重做某天的转写**(`audioRead.py`)时,需要先重新下载该天的 mp3——最简单是把 `cleanup.remove_video` 临时设为 `0`,或直接跑 `getVideo5.py` 重下再重跑。
### 3.6 自检与测试
五个测试套件共 **97 项断言**,全部离线运行(不消耗 API 额度、不触碰真实文件):
```bash
.venv/bin/python tests/test_config.py # 37 项:配置加载、优先级、接入点/路由、敏感项隔离
.venv/bin/python tests/test_cleanup.py # 10 项:清理开关五种组合(临时目录)
.venv/bin/python tests/test_fidelity_guard.py # 14 项:数值事实守卫(含真实篡改样例)
.venv/bin/python tests/test_news_parse.py # 16 项:LLM 返回解析(含真实坏返回)
.venv/bin/python tests/test_tunnel.py # 20 项:隧道自愈各分支(临时端口,不碰真实隧道)
```
其中隧道测试**不会碰真实的 13306 与 autossh.sh**:它用临时 config.yml + 临时端口的假脚本,
验证"端口已通时绝不执行脚本""systemd 托管时只等待不抢端口"等关键约定。
连通性自检:
```bash
# 打印生效配置并校验 Key 是否齐全
.venv/bin/python -c "import config; config.log_summary()"
# 数据库连通
.venv/bin/python -c "from mysqlHandle import MySQLDB; d=MySQLDB(); print(d.query_one('xwlb_daily','COUNT(*) c')); d.close()"
```
### 3.7 定时任务(systemd)
已配置为 systemd 定时器:**每天 21:00 触发全链路,失败后 21:30、22:00 各自动重试一次**。
```bash
bash scripts/install_systemd.sh # 安装/更新单元(幂等,可反复执行)
systemctl list-timers xwlb-daily.timer # 看下次触发时间
journalctl -u xwlb-daily -f # 实时看日志(也在 main.log)
sudo systemctl start xwlb-daily.service # 立即试跑一次
sudo systemctl disable --now xwlb-daily.timer # 临时停用
```
涉及三个单元(源码在 `systemd/`):
| 单元 | 作用 |
|---|---|
| `xwlb-daily.timer` | 每天 21:00 触发;`Persistent=true`,树莓派关机错过时刻会在开机后补跑 |
| `xwlb-daily.service` | `oneshot`,调用 `scripts/run_daily.sh`;`TimeoutStartSec=10800` 以容忍内部等待重试 |
| `xwlb-tunnel.service` | autossh 隧道托管(开机自启、断线自动重连),`xwlb-daily` 依赖它 |
重试不是简单地"再跑一遍":
- **只缺切分**(识别已全部完成)→ 只重跑切分环节,省掉全部 ASR 调用(约十几分钟与相应费用)
- **识别没跑完** → 重跑全链路
- 判断依据是识别**全部**完成后写的标记 `state/.asr_complete_<日期>`;
只看"库里有没有分片"是不够的——识别中途卡死也会留下部分分片,
那样会被误判成"只缺切分",**把半天内容当成完整一天入库**(`scripts/day_status.py` 专门防这个)
- 识别不完整的那一天**不会执行切分**(不发布半天的 ext),宁可让重试补全,
也不产生"看起来完整"的一天;分片原文仍在 `xwlb_daily` 里,不会丢
- 标记放在 `state/` 且不随清理删除:它是长期凭证,删了就分不清
"已完成"与"识别只跑了一半但切分照样写了 ext"
(`cleanup` 只清 `xwlb_video/*.mp3|*.mp4` 与 `audio_processing/*.wav`)
- 单次尝试有 40 分钟上限(`XWLB_ATTEMPT_TIMEOUT`),卡死的 ASR 不会拖垮整晚
- 有文件锁防重入,手动执行与定时触发叠在一起也不会重复烧 ASR
退出码:`0` 成功;`1` 重试用尽仍失败(会出现在 `systemctl --failed`);`2` 已有实例在运行。
> 用 cron 也行,但那样拿不到"超时上限、重试、防重入、开机补跑"这些保障:
> `40 20 * * * cd /home/pi/project/xwlb && .venv/bin/python main.py >> main.log 2>&1`
### 3.8 数据库隧道自愈
数据库在内网,靠 `autossh.sh` 把本地 `13306` 转发到远端 `3306`。隧道断掉时,
**任何入口都会自己把它拉起来**——不只是定时任务,手动跑 `main.py` / `getVideo5.py` /
`newsProcess.py` / `day_status.py` 都一样(实现挂在数据库连接层 `MySQLDB.connect()`)。
行为(`tunnel.py`,所有入口共用一套逻辑):
| 情况 | 动作 |
|---|---|
| 端口可连 | **什么都不做**(正常路径零开销,不会多起进程) |
| 端口不通,且 systemd 隧道单元 active | **只等待**它自动重连——不另起 autossh 抢同一个端口(那会让 systemd 单元因端口被占反复重启失败) |
| 端口不通,systemd 单元未在运行 | 执行 `config.yml` 里 `mysql.tunnel.script`(默认 `autossh.sh`),最多等 30 秒 |
| 仍不通 | 如实报错,不再假装成功 |
```bash
python tunnel.py # 手动探测 + 按需修复,退出码 0=通 / 1=仍不通
python tunnel.py --dry-run # 只看会做什么,不执行
```
配置:
```yaml
mysql:
tunnel:
enabled: 1
script: autossh.sh # 相对项目根
systemd_unit: xwlb-tunnel.service # 该单元 active 时只等待,不抢端口
wait_seconds: 30
connect_timeout: 2
```
(直连远端数据库时,`host` 不是本机地址 → 判定为"不适用隧道",不会去跑 autossh.sh。)
### 3.9 故障排查
| 现象 | 原因 / 处理 |
|---|---|
| `MySQL 连接失败 ...` | 隧道不通。**现在会自动恢复**:连接失败时探测端口,不通则执行 `autossh.sh` 并等待(见 3.9 隧道自愈)。仍失败再手动 `bash autossh.sh`,并确认 `config.yml` 的 `mysql.port` 是隧道端口 `13306` |
| `缺少敏感配置 ...` | `.env` 未填 `MYSQL_PASSWORD` / `DASHSCOPE_API_KEY` / `DEEPSEEK_API_KEY` |
| `externally-managed-environment` | 不要用系统 `pip`,用 `.venv/bin/pip` |
| `No module named 'audioop'` | Python 3.13 需装 `audioop-lts`(已在 requirements 中) |
| 某天识别成功但精编为 0 条 | 切分返回非 JSON;现会自动用强化提示词重试一次,仍失败则保留原文不写库,可 `newsProcess.py <日期> --force` 重试 |
| 校对结果与原文数字不一致 | 事实守卫已拦截并回退原文,日志会打印 `⚠️ 校对改动了数值事实,已回退 ASR 原文` |
| 想完全关掉 LLM 校对 | `config.yml` 设 `llm_correct.enabled: 0` |
| `接入点配置错误: ...` | `routes.*` 指向了不存在的接入点、`kind` 拼错、或缺 `base_url`;按日志提示补 `endpoints` 字段 |
| `ASR 接入点 ... kind=openai:实时语音识别仅支持 kind=dashscope` | ASR 换**厂商**需新增适配器;换区域 / 网关请保留 `kind: dashscope` 只改 url |
| 换供应商后报 401 / 403 | `.env` 里没有该接入点 `api_key_env` 指定的那个变量(不是把新 Key 塞进旧变量名) |
| 换供应商后报模型不存在 | `models.correct_model` / `models.split_model` 还是旧供应商的模型名,需同步修改 |
| `getVideo5` 报某天 404 | 该天页面尚未上线(当天节目过期或未发布),日志会明确跳过 |
| 磁盘被中间产物占满 | `python cleanup.py --dry-run` 查看,再 `python cleanup.py` |
| 某天识别到一半卡死(无新日志、无 CPU) | ASR 的 `Recognition.call` 没有超时参数,长连接可能悬挂;定时任务有 40 分钟上限并在 21:30/22:00 重试。手动跑请自行 `timeout` |
| 定时任务没跑 | `systemctl list-timers xwlb-daily.timer`;`systemctl status xwlb-daily.service`;日志 `journalctl -u xwlb-daily -n 100` |
| 定时任务报数据库连接失败 | 隧道服务:`systemctl status xwlb-tunnel`;手动兜底 `bash autossh.sh` |
---
## 四、相关文档
| 文档 | 内容 |
|---|---|
| [`docs/ARCHITECTURE.md`](./docs/ARCHITECTURE.md) | 完整技术说明:数据流、模块与函数索引、数据库结构、配置项全表、外部调用实测参数、时序图 |
| [`docs/REPORT_raw_vs_improve.md`](./docs/REPORT_raw_vs_improve.md) | 校对效果评估:7973 条真实数据统计、实测 token 开销、合并/关闭校对的对比与结论 |
| [`docs/BUGS.md`](./docs/BUGS.md) | 15 项缺陷清单(含严重度、位置、影响、证据、修复),以及本次修复的实测验证记录与后续待办 |
**已知待办**(详见 `docs/BUGS.md`):
- `main_videos.py` 的日期区间仍写死在文件内,尚未改为命令行参数;
- 历史数据治理(截至 2026-09-25):`xwlb_daily` 有 **100 行**空白分片、**202 天**缺 `xwlb_daily_ext`,可用上述分步入口补齐;
- 项目尚未 `git init`(`.gitignore` 已就绪);`main.log` 无轮转(历史 34MB 作为证据保留)。