《新闻联播》每日抓取入库:全链路 + 可移植化 + 定时任务

从 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 项离线自检(配置/清理/事实守卫/解析/隧道)
This commit is contained in:
2026-09-25 11:17:46 +08:00
commit 60f8c263f2
35 changed files with 5770 additions and 0 deletions
+393
View File
@@ -0,0 +1,393 @@
# xwlb 项目技术说明
> 一句话定位:把央视《新闻联播》完整版视频自动转成**结构化单条新闻**并入库的离线流水线。
> 本文基于仓库当前源码 + `main.log`(2026-06-01 ~ 2026-09-23)实测行为整理。缺陷清单见 [`BUGS.md`](./BUGS.md)。
---
## 1. 端到端数据流
```
main.py(今天)
│ start_date = end_date = %Y%m%d
▼
┌──────────────────────────────────────────────────────────────┐
│ ① 链接发现 getVideo5.get_all_video_links(start, end) │
│ xwlb_urls() → https://tv.cctv.com/lm/xwlb/day/ │
│ YYYYMMDD.shtml (按天,含端点) │
│ get_xwlb_video_link()→ 正则/文本匹配「完整版《新闻联播》」 │
│ 的 <a>,得到 VID 详情页 URL │
│ 例 tv.cctv.com/2024/10/30/VID...shtml │
├──────────────────────────────────────────────────────────────┤
│ ② 下载与抽音 download_and_extract_audio(url, date, dir) │
│ yt-dlp format=best[ext=mp4]/best → xwlb_video/YYYYMMDD.mp4 │
│ ffmpeg -c:a libmp3lame -q:a 0 -map a → 同名 .mp3 │
├──────────────────────────────────────────────────────────────┤
│ ③ 转写落库 audioRead.process_long_audio(mp3, out_dir, date) │
│ convert_mp3_to_wav() 16kHz / 单声道 / 16bit → input.wav│
│ split_audio_by_smart_silence(audio_split.*,默认 700ms/-40) │
│ → 贪心合并为 ≤3 分钟分片 chunk_i.wav(实测 11 片/天) │
│ for each chunk: │
│ transcribe_audio() models.asr_model(paraformer-...v2) │
│ └─ merge_transcripts() 句级结果空格拼接 │
│ └─ analyze_and_correct_text() models.correct_model │
│ (默认 qwen3.7-max;受数值事实守卫约束) │
│ → 先删后插 xwlb_daily(news_days, daily_sub_id=i, ...) │
│ → os.remove(chunk) │
├──────────────────────────────────────────────────────────────┤
│ ④ 切分入库 newsProcess.news_to_db(date) │
│ 读 xwlb_daily.news_improve 按 daily_sub_id 升序 → '\n' 拼接 │
│ models.split_model(deepseek-chat,json_object) │
│ prompt:切分为独立新闻 + 每条起标题(含"国内/国际/联播快讯")│
│ → 解析 [{news_id, news_title, news_content}] │
│ → 删除该日期旧记录 → 批量 INSERT xwlb_daily_ext │
├───────────────────────────────────────────────────────────────┤
│ ⑤ 清理 cleanup.maybe_cleanup_after_run(date, day_ok) │
│ 当天全部成功才清空 xwlb_video/*.mp3|*.mp4、audio_processing/*.wav │
└───────────────────────────────────────────────────────────────┘
```
### 两道 LLM 的分工
| 阶段 | 接入点 | 模型 | 输入 | 输出 | 落库位置 |
|---|---|---|---|---|---|
| 校对 | `routes.correct`(默认 `dashscope`) | `models.correct_model`(默认 `qwen3.7-max`) | 单分片 ASR 原始文本 | "修正后全文" | `xwlb_daily.news_improve` |
| 切分 | `routes.split`(默认 `deepseek`) | `models.split_model`(默认 `deepseek-chat`) | 当天全部分片拼接文本 | JSON 数组 | `xwlb_daily_ext` 多行 |
模型名(`models.*`)与接入点(`endpoints.*` + `routes.*`)统一在 `config.yml` 管理,
**换模型或换供应商都不需要动代码**(详见 [第 4 节](#4-配置项分层敏感在-env其余在-configyml))。
---
## 2. 模块职责与函数索引
| 文件 | 关键函数 | 说明 |
|---|---|---|
| `main.py` | — | 入口:当天日期 → `process_videos` |
| `main_videos.py` | `get_missing_dates(start, end)` | 反查 `xwlb_daily` 缺哪些日期,逐日补跑(文件内示例区间写死为 2025-01-01~2025-10-25) |
| `newsRedo.py` | `_parse_date` / `main` | 手动重跑某天:`ext>5` 条→跳过;`xwlb_daily` 有行→只做 AI 切分;无行→跑全流程。支持 `yyyymmdd` 与 `yyyy-mm-dd` |
| `getVideo5.py` | `xwlb_urls` | 生成按天页面 URL 列表 |
| | `get_xwlb_video_link` | 解析单日页面找完整版链接(返回值有缺陷,见 BUGS B4) |
| | `get_all_video_links` | 逐日聚合 |
| | `download_and_extract_audio` | yt-dlp + ffmpeg |
| | `process_videos(start, end)` | 主流程编排:下载 → 转写 → 切分入库 → **成功后清理中间产物** |
| `config.py` | `load_config` / `get` / `get_int` / `get_bool` / `get_list` | 加载 `config.yml`,环境变量可覆盖(`XWLB_CONFIG_FILE` 换文件) |
| | `model(role)` | 取 `models.asr_model` / `correct_model` / `split_model` |
| | `endpoint` / `route` / `endpoint_for` | **接入点与路由**:`routes.<role>` → `endpoints.<name>`,配置不全时抛可读错误 |
| | `openai_url(role)` / `api_key_env(role)` / `api_key(role)` | OpenAI 兼容请求地址拼接、该环节密钥的变量名 / 变量值 |
| | `apply_dashscope_endpoints` | 把 dashscope 接入点地址写入 SDK(env + 模块属性,不依赖 import 顺序) |
| | `validate_endpoints` / `required_secrets` / `missing_secrets` / `check_secrets` | 启动校验;必需密钥**按路由推导**(只要求用到的那几家) |
| | `video_dir` / `audio_dir` / `db_config` | 目录解析(相对项目根)、数据库参数(口令来自 `.env`) |
| | `log_summary` | 启动打印生效配置与各环节接入点(不含敏感项)并校验 |
| `cleanup.py` | `maybe_cleanup_after_run(date, day_ok)` | 当天结束后按 `cleanup.*` 决定是否清理 |
| | `cleanup_intermediates` | 删除 `*.mp3/*.mp4` 与 `*.wav`,返回删除数/字节数;也可命令行单独执行 |
| `audioRead.py` | `convert_mp3_to_wav` | mp3 → 16k 单声道 wav(采样率见 `asr.sample_rate`) |
| | `split_audio_by_fixed_duration` | 定长切分(当前未启用) |
| | `split_audio_by_smart_silence` | **实际使用**:静音切分 + ≤3 分钟合并 |
| | `transcribe_audio` | 单分片 ASR,**只返回文本**(失败返回 `''`;校对已解耦) |
| | `_extract_llm_text` | 兼容 `output.text` 与 `output.choices[0].message.content` 两种返回形态 |
| | `merge_transcripts` | 句列表 → 空格拼接 |
| | `text_correction` | qwen 校对:显式 timeout + 重试,失败抛异常 |
| | `_number_signature` / `_number_drift` | **数值事实守卫**:中文/阿拉伯数字归一后比较数值签名,检测校对是否篡改事实 |
| | `analyze_and_correct_text` | 失败一律回退原文;**数值事实被改动时同样回退原文**;`llm_correct.enabled=0` 可关闭 |
| | `analyze_text` | 通用 qwen 文本分析(当前未使用) |
| | `_upsert_daily_chunk` | 按 `(news_days, daily_sub_id)` 先删后插,实现幂等 |
| | `process_long_audio` | 单日音频全流程 + 逐片写 `xwlb_daily` + 清理分片临时文件;返回 `{'chunks','ok','failed'}` |
| `newsProcess.py` | `normalize_date` | `YYYYMMDD` / `YYYY-MM-DD` 统一为后者 |
| | `get_news_improve_by_date` | 取当天 `news_improve` 拼接(过滤空白行);无内容返回 `None` |
| | `extract_news_rows` | 宽解析 LLM 返回(剥 ``` 围栏 / raw_decode / 形态归一 / 字段校验),失败抛 `ValueError` |
| | `news_to_db(target_date, force=False)` | 切分入库:失败重试一次 → **按日期替换**写入 `xwlb_daily_ext`;返回 `written`/`skipped`/`failed` |
| `deepseek.py` | `DeepSeekAPI(role=...)` | **OpenAI 兼容客户端**:地址/密钥变量名来自接入点配置;3 次重试、429/5xx 退避;400/401 不重试 |
| | `deepseek_text(text, prompt, ...)` | 切分便捷入口(`routes.split`,`json_object` 模式) |
| | `openai_chat(text, prompt, role='correct', ...)` | 通用对话入口:供**非 DashScope 供应商的校对**使用(不强制 JSON) |
| `env.py` | `_load_dotenv` | **只加载敏感项**(口令/Key):`XWLB_ENV_FILE` → 项目内 `.env` → 上级 `.env`,已存在的不覆盖 |
| | `missing_secrets` / `check_secrets` | 校验 `MYSQL_PASSWORD` / `DASHSCOPE_API_KEY` / `DEEPSEEK_API_KEY` 是否齐全 |
| `mysql_handler.py` | `MySQLDB` | **项目内置**的数据库层(原位于父项目 `utils/`),接口与原实现一致,另加 `execute()` / `insert_many()` / `query_one()` / 上下文管理器;连接失败立即抛错并给出修复指引 |
| `mysqlHandle.py` | — | 转发项目内 `mysql_handler.MySQLDB` |
| `tests/test_news_parse.py` | — | B3 解析逻辑回归测试(含生产日志中的真实坏返回) |
| `tests/test_fidelity_guard.py` | — | 数值事实守卫回归测试(含 qwen 真实篡改样例) |
| `tests/test_config.py` | — | 配置加载、环境变量优先级、敏感项与 config.yml 隔离 |
| `tests/test_cleanup.py` | — | 清理开关组合(用临时目录与临时 config.yml,不触碰真实文件) |
### `MySQLDB` 接口约定(调用方依赖,重写时必须保持一致)
```python
db = MySQLDB() # 无参:地址来自 config.yml,口令来自 .env
rows = db.query_data(table="t", columns="c1,c2", where="k = %s", params=(v,)) # → list[dict]
new_id = db.insert_data("t", {"col": val}) # → lastrowid
db.update_data("t", {"col": val}, "k = %s", (v,)) # → rowcount
db.close() # 可重复调用
```
`where` 子句允许携带 `order by`(调用方已在用,如 `newsProcess.py`)。
项目内置实现另提供:`execute(sql, params)` → rowcount(DELETE/DDL)、`insert_many(table, rows)`(单事务批量)、`query_one(...)` → dict|None、以及 `with MySQLDB() as db:`。
## 3. 数据库结构
### `xwlb_daily` — 音频分片级原文
| 字段 | 类型 | 说明 |
|---|---|---|
| `nid` | int PK auto_increment | |
| `news_days` | date | 日期 |
| `daily_sub_id` | int | 分片序号(当前 = chunk 下标 i,0 起;**无唯一约束**) |
| `news_raw` | text | ASR 原始文本 |
| `news_improve` | text | qwen 校对后文本(当前实际等于 raw) |
| `news_title` | text | 恒为空串(原设计留给分片标题) |
### `xwlb_daily_ext` — 单条新闻(最终产物)
| 字段 | 类型 | 说明 |
|---|---|---|
| `extid` | int PK auto_increment | |
| `news_date` | date | 日期 |
| `sub_id` | tinyint | 新闻序号(来自 LLM 的 `news_id`,1 起) |
| `news_title` | varchar(256) | 截断到 256 |
| `news_content` | text | 正文 |
> 建议补的约束:`xwlb_daily` 唯一索引 `(news_days, daily_sub_id)`;`xwlb_daily_ext` 唯一索引 `(news_date, sub_id)`。
---
## 4. 配置项(分层:敏感在 .env,其余在 config.yml)
**分层原则**:口令 / API Key 只放 `.env`(不入版本库);其余全部放 `config.yml`(可入版本库),
包含**各供应商的 base url**(`endpoints.*`)与**每个环节走哪条线**(`routes.*`)。
**优先级**:`进程环境变量 > config.yml > 代码内置默认值`
(便于临时试验,如 `DASHSCOPE_LLM_MODEL=qwen-max python audioRead.py 20260904`)。
`.env` 查找顺序:`XWLB_ENV_FILE` 指定路径 → **项目目录内 `.env`** → 上一级 / 上两级 `.env`(兼容旧 djapi 布局)。
已存在的进程环境变量优先,不会被 `.env` 覆盖。启动时打印实际来源,例如:
```
已加载敏感配置 /home/pi/project/xwlb/.env(注入 11 个变量)
配置来源: /home/pi/project/xwlb/config.yml | MySQL myquant@localhost:13306/myquant | 模型 asr=... correct=... split=... | 校对=开启 | 完成后清理=开启
```
### 4.1 `.env` —— 只有敏感项
| 变量 | 必需 | 用途 |
|---|---|---|
| `MYSQL_PASSWORD` | ✅ | 数据库口令 |
| `DASHSCOPE_API_KEY` | 按路由 | ASR + 文本校对(由 `endpoints.dashscope.api_key_env` 指定) |
| `DEEPSEEK_API_KEY` | 按路由 | 新闻切分 + 标题(由 `endpoints.deepseek.api_key_env` 指定) |
| 其他 `*_API_KEY` | 按路由 | 换供应商后在 `endpoints.<新接入点>.api_key_env` 里声明什么名字,这里就放什么 |
| `XWLB_ENV_FILE` | | 指定其他 .env 路径 |
| `XWLB_CONFIG_FILE` | | 指定其他 config.yml 路径 |
**密钥变量名不写死在代码里**:`config.required_secrets()` 按 `routes` 反查用到的接入点,
只有被使用的供应商才要求配 Key;缺失时 `config.check_secrets()` 直接 ERROR,而不是等到 401 才发现。
### 4.2 `config.yml` —— 非敏感项(含**各环节模型**)
| 配置项 | 默认 | 用途 |
|---|---|---|
| `mysql.host` / `port` / `user` / `database` | `localhost` / `13306` / `myquant` / `myquant` | 数据库地址;**经 SSH 隧道时端口为 13306**(`autossh.sh` 转本地 13306 → 远端 3306) |
| `mysql.tunnel.enabled` / `script` / `systemd_unit` / `wait_seconds` / `connect_timeout` | `1` / `autossh.sh` / `xwlb-tunnel.service` / `30` / `2` | **隧道自愈**:端口不通时的处理(见 `tunnel.py`) |
| `paths.video_dir` / `audio_dir` | `xwlb_video` / `audio_processing` | 相对项目根,也可写绝对路径 |
| `models.asr_model` | `paraformer-realtime-v2` | **语音识别模型** |
| `models.correct_model` | `qwen3.8-flash` | **ASR 文本校对模型** |
| `models.split_model` | `deepseek-flash` | **新闻切分 + 标题模型**(API 实际只提供 `deepseek-flash` / `deepseek-v4-pro`) |
| `endpoints.<名>.kind` | — | 协议类型:`dashscope`(用 SDK)/ `openai`(OpenAI 兼容 `/chat/completions`) |
| `endpoints.<名>.base_url` / `chat_completions_path` | `https://api.deepseek.com/v1` / `/chat/completions` | openai 类型的地址(拼接成完整请求 URL) |
| `endpoints.<名>.http_base_url` / `websocket_base_url` | `https://dashscope.aliyuncs.com/api/v1` / `wss://.../api-ws/v1/inference` | dashscope 类型:文本生成走 HTTP、实时 ASR 走 WS |
| `endpoints.<名>.api_key_env` | `DASHSCOPE_API_KEY` 等 | 该供应商密钥在 `.env` 中的**变量名** |
| `endpoints.<名>.extra_body` | — | 供应商特有请求体参数,如关闭思维链。参数名因厂商而异(通义 `enable_thinking`、DeepSeek `thinking`),写错会被**静默忽略** |
| `routes.asr` / `correct` / `split` | `dashscope` / `qwen` / `deepseek` | 每个环节走哪个接入点(换供应商改这里) |
| `llm_correct.extra_body` / `llm_split.extra_body` | — | 环节级覆盖接入点的 `extra_body`(优先级更高) |
| `asr.sample_rate` / `language_hints` | `16000` / `[zh, en]` | ASR 输入要求 |
| `audio_split.min_silence_ms` / `silence_thresh_db` / `keep_silence_ms` / `max_chunk_ms` | `700` / `-40` / `400` / `180000` | 静音切分参数 |
| `llm_correct.enabled` | `1` | **校对开关**:`0` = 直接把 ASR 原文作为 `news_improve`(开启时仍受数值事实守卫保护) |
| `llm_correct.max_retries` / `timeout` / `max_tokens` / `temperature` / `top_p` | `2` / `120` / `8000` / `0.1` / `0.5` | 校对调用参数 |
| `llm_split.max_tokens` / `retry_max_tokens` / `temperature` / `timeout` / `max_retries` | `60000` / `80000` / `0.5` / `180` / `3` | 切分调用参数;**推理模型的 reasoning token 也计入 max_tokens**,实测波动 14k~24k,给少了会截断甚至返回空 |
| `cleanup.after_daily_run` | `1` | 当天全程任务结束后是否自动清理中间产物 |
| `cleanup.only_on_success` | `1` | 仅当天**全部成功**才清理;`0` = 无论成败都清理 |
| `cleanup.remove_video` / `remove_audio` | `1` / `1` | 分别控制删除 `*.mp3/*.mp4` 与 `*.wav` |
改模型只改 `models.*` 三行即可,不需要动代码。
**推理模型(thinking)的取舍**(实测数据见 [`REPORT_raw_vs_improve.md`](./REPORT_raw_vs_improve.md)):
校对是机械任务,关掉思维链省约 13 倍 token 且输出几乎不变;
切分关掉会丢 4.7% 正文、少切 7 条新闻,**必须保留思考**。
### 4.3 环境准备
```bash
# 0) 端口隧道(本机访问 13306;不通时执行)
bash autossh.sh # autossh -M 0 -fN -L 13306:localhost:3306 tunnel@doorcome.cn
# 1) 虚拟环境(本机 Python 为 externally-managed,必须用 venv)
python3 -m venv .venv
.venv/bin/pip install -r requirements.txt
# 注:Python 3.13 已移除标准库 audioop,pydub 需要 requirements 中的 audioop-lts
# 2) 配置
cp .env.example .env # 只填 3 个敏感项:MYSQL_PASSWORD / DASHSCOPE_API_KEY / DEEPSEEK_API_KEY
vim config.yml # 非敏感项与模型(已随仓库提供,按需修改)
# 3) 自检(四个测试都不依赖网络与真实 API)
.venv/bin/python tests/test_config.py # 配置加载与优先级
.venv/bin/python tests/test_fidelity_guard.py # 数值事实守卫
.venv/bin/python tests/test_news_parse.py # LLM 返回解析
.venv/bin/python tests/test_cleanup.py # 清理开关组合(用临时目录,不动真实文件)
.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()"
```
系统依赖:**ffmpeg**(`/usr/bin/ffmpeg`,抽音必需)。
---
## 5. 外部调用参数(实测/代码)
| 调用 | 参数要点 | 实测耗时 |
|---|---|---|
| 央视页面 | requests,UA 伪装,`Referer: https://tv.cctv.com/`,timeout 10s | 秒级 |
| yt-dlp | `best[ext=mp4]/best`,输出 `YYYYMMDD.mp4` | ~30 分钟视频 / 104MB,约 20s~1min |
| ffmpeg | `-c:a libmp3lame -q:a 0 -map a -y` | 十几秒 |
| pydub 切分 | 静音 700ms / 阈值 -40dBFS / 保留 400ms / 单片 ≤180s | 秒级,得 11 片 |
| paraformer ASR | wav / 16k / `language_hints=['zh','en']` | 约 30~45s/片 |
| qwen 校对 | `max_tokens=8000`(可配)、`temperature=0.1`、`top_p=0.5`、`timeout=90s`、重试 2 次 | 改造后实测 **20~32s/片且有真实产出**;改造前 30~300s 且必然拿到空结果(57 次超时) |
| deepseek-chat | `json_object`、`max_tokens=20000`(重试时 32000)、`temperature=0.5`、timeout 60s、3 重试 | 实测 **27~35s** |
**单日耗时**:改造前日志实测 20:34:02 → 21:24:50,约 **51 分钟**(其中相当部分耗在"必然失败"的校对调用上)。
按改造后实测单片耗时(ASR 30~45s + 校对 20~32s)估算,11 片约 10~14 分钟,加上下载、切分与片段处理,单日约 **25~35 分钟**。
---
## 6. 存储布局
```
xwlb/
├── main.py / main_videos.py / newsRedo.py # 入口
├── getVideo5.py / audioRead.py # 采集 + 转写
├── newsProcess.py / deepseek.py # LLM 切分
├── config.yml / config.py # 非敏感配置 + 加载器(含各环节模型)
├── env.py # 敏感配置(.env)加载
├── cleanup.py # 中间产物清理(也是手动清理入口)
├── mysql_handler.py / mysqlHandle.py # 数据库层(已内置,不再依赖父项目)
├── .env / .env.example # 敏感项(口令/Key) / 模板
├── requirements.txt
├── xwlb_video/ YYYYMMDD.mp3(及 .mp4) # 当天任务成功后自动清空
├── audio_processing/ input_YYYYMMDD.wav + chunk_*.wav # 当天任务成功后自动清空
├── docs/ BUGS.md / ARCHITECTURE.md
├── tests/ test_config.py / test_cleanup.py / test_fidelity_guard.py / test_news_parse.py
├── .venv/ 项目虚拟环境(不入版本库)
├── autossh.sh SSH 隧道:本地 13306 → 远端 3306
└── main.log 历史生产日志 34MB(证据保留,无轮转)
```
命名约定:中间产物以 `YYYYMMDD` 为键(无横线),数据库字段用 `YYYY-MM-DD`(有横线);
两者由 `newsProcess.normalize_date` 与 `newsRedo._parse_date` 转换(数据库连接字面量两种格式均被接受)。
临时文件策略(改造后):分片 `chunk_*.wav` 与 `input_YYYYMMDD.wav` 在 `finally` 中删除;
**当天全程任务成功结束后**由 `cleanup.maybe_cleanup_after_run()` 清空 `xwlb_video/*.mp3|*.mp4` 与 `audio_processing/*.wav`
(失败时默认保留以便重跑,见 `config.yml` 的 `cleanup.*`;手动清理:`python cleanup.py [--dry-run]`)。
---
## 7. 入口脚本对照
| 场景 | 命令 | 行为 |
|---|---|---|
| 日常(当天) | `python main.py` | 抓当天 → 下载 → 转写 → 切分(已处理则切分阶段自动跳过) |
| 指定区间 | `python getVideo5.py 20260901 20260907` | 逐日全流程(参数校验:8 位、start ≤ end);已存在 mp3 的日期跳过下载 |
| 补缺失日 | `python main_videos.py` | 先查 `xwlb_daily` 缺失日期再逐日全流程(区间写在文件内,按需修改) |
| 重跑某天 | `python newsRedo.py 20260923` | 已有精编记录则跳过;有原文只重做切分;否则全流程 |
| 强制重切 | `python newsRedo.py 20260923 --force` | 忽略"已处理"判据,重新切分并**覆盖**该日 `xwlb_daily_ext` |
| 只重做切分 | `python newsProcess.py 2026-09-23 [--force]` | 直接对指定日期执行切分入库 |
| 只重做转写 | `python audioRead.py 20260923` | 用已有 `xwlb_video/20260923.mp3` 重跑分割+识别+落库(按分片覆盖,可安全重跑) |
| 手动清理 | `python cleanup.py [--dry-run]` | 清空 `xwlb_video/*.mp3|*.mp4` 与 `audio_processing/*.wav`(含识别完成标记) |
| **定时任务(生产入口)** | `bash scripts/run_daily.sh [YYYYMMDD]` | 全链路 + 失败重试(21:30 / 22:00);由 `systemd/xwlb-daily.timer` 每天 21:00 触发 |
| 这天跑到哪一步 | `python scripts/day_status.py <日期>` | 退出码 0=已有精编 / 1=识别完成只缺切分 / 2=需重跑全链路 |
| 安装定时任务 | `bash scripts/install_systemd.sh` | 安装单元、启用定时器与隧道服务 |
| 校对效果评估 | `python tools/compare_raw_improve.py --md <文件>` | 统计 news_raw vs news_improve 差异分类与 token 开销 |
| 合并方案评估 | `python tools/experiment_merge_correct_split.py <日期>` | 实测"校对+切分合并成一次调用"的覆盖率与事实漂移 |
| 配置自检 | `python tests/test_config.py` | 校验 config.yml 加载、优先级与敏感项隔离 |
| 清理自检 | `python tests/test_cleanup.py` | 校验清理开关组合(临时目录,不动真实文件) |
| 解析自检 | `python tests/test_news_parse.py` | 校验 LLM 返回解析逻辑 |
### `tunnel.py` —— 隧道自愈(所有入口共用)
| 函数 | 作用 |
|---|---|
| `port_open(host, port, timeout)` | TCP 层探测端口,不涉及 MySQL 握手 |
| `is_local(host)` | 是否本机地址(`localhost`/`127.0.0.1`/`::1`)→ 决定"隧道"这个概念是否适用 |
| `systemd_unit_active(unit)` | systemd 单元是否 active(systemctl 不可用时安全返回 False) |
| `ensure_tunnel(...)` | 探测 → 不通则按分支等待 systemd 或执行 autossh.sh → 如实返回 `ST_*` 状态 |
挂在 `MySQLDB.connect()`:**只在连接失败时介入**,所以正常路径没有任何额外开销;
`_ensure_connection()` 的重连也会走同一条路径,长流程中途断隧道同样能恢复。
---
## 7.1 部署:systemd 定时任务与重试语义
生产入口不是 `main.py` 而是 `scripts/run_daily.sh`,由 `systemd/xwlb-daily.timer` 每天 21:00 触发。
```
21:00 xwlb-daily.timer → xwlb-daily.service → scripts/run_daily.sh
├─ flock 防重入(已有实例则退出码 2)
├─ 检查 127.0.0.1:13306(ss 快速判断);不通则调用 tunnel.py 自愈
│ (唯一实现:systemd 单元 active 就等它重连,否则执行 autossh.sh)
├─ 第 1 次尝试:getVideo5.py <今天> <今天>,上限 40 分钟
│ 成功 → 退出 0
│ 失败 ↓
├─ 等到 21:30 → scripts/day_status.py 判定阶段
│ 退出码 0(已有精编)→ 什么都不做
│ 退出码 1(有分片 **且有 .asr_complete_<日期> 标记**)→ 只跑 newsProcess.py(省掉全部 ASR)
│ 退出码 2(其余)→ 重跑全链路
└─ 22:00 再同样重试一次;仍失败 → 退出 1(systemd 记录为 failed)
```
| 单元 | 关键设置 | 原因 |
|---|---|---|
| `xwlb-daily.timer` | `OnCalendar=*-*-* 21:00:00`、`Persistent=true` | 关机错过时刻则开机补跑 |
| `xwlb-daily.service` | `Type=oneshot`、`TimeoutStartSec=10800` | 默认 90s 会把"等待 + 重试"的服务砍掉 |
| `xwlb-tunnel.service` | autossh + `Restart=always` + `ExitOnForwardFailure=yes` | 隧道是数据库前置条件;`xwlb-daily` 通过 `Wants=`/`After=` 依赖它 |
退出码约定:`0` 成功 / `1` 重试用尽仍失败 / `2` 已有实例在运行。
**为什么必须有 `state/.asr_complete_<日期>` 标记**:识别中途卡死(B19)会留下部分分片,
若只按"库里有没有分片"判断,重试会走"只重跑切分",把**半天内容当成完整一天**入库且不报错。
标记由 `audioRead.process_long_audio` 在**全部识别成功**时写入,失败时主动删除。
标记**刻意放在 `state/` 而不是 `audio_processing/`**,也**不随 `cleanup.py` 删除**:
它是"该日识别已完成"的长期凭证。若随中间产物一起清掉,
就无法区分"已完成并被清理"与"识别只跑了一半、切分却照样写出了 ext"这两种状态。
配合 `process_videos` 的改动——**识别不完整时不执行切分**——四种状态才互不混淆:
| 有 `state/` 标记 | 有 `xwlb_daily_ext` | 含义 | 重试动作 |
|---|---|---|---|
| ✅ | ❌ | 识别完成、切分未完成 | 只重跑切分 |
| ✅ | ✅ | 已完成 | 无需动作 |
| ❌ | ❌ | 识别未完成或未开始 | 重跑全链路 |
| ❌ | ✅ | 只可能来自人工干预 | 视为已完成 |
---
## 8. 当前环境状态(改造后,本机实测)
| 项 | 状态 | 说明 |
|---|---|---|
| Python / venv | ✅ `.venv`(3.13.5) | 依赖已装齐(含 `audioop-lts` 以支持 3.13 下的 pydub) |
| 数据库 | ✅ 已连通 | SSH 隧道 `localhost:13306` → 远端 MariaDB 10.11;`xwlb_daily` 7973 行、`xwlb_daily_ext` 11460 行 |
| `MySQLDB` | ✅ 已内置 | `mysql_handler.py`,接口与原父项目实现一致 |
| `.env` | ✅ 已精简 | 只留 3 个敏感项(口令/Key);地址、模型等已迁至 `config.yml` |
| `config.yml` | ✅ 已就位 | 含 MySQL 地址、目录、**三个模型**、切分参数、清理开关 |
| ffmpeg | ✅ `/usr/bin/ffmpeg` | 可用 |
| 下载/输出目录 | ✅ 基于项目目录 | 默认 `./xwlb_video`、`./audio_processing`(`config.yml: paths.*`) |
| 中间产物清理 | ✅ 已启用 | 当天成功结束后自动清空 mp3/mp4/wav;实测清掉 15 个文件 / 474.3 MB |
| 版本管理 | ❌ 仍非 git 仓库 | 已提供 `.gitignore`,建议后续 `git init` |
**端到端验证**:在测试日期 `1900-01-01` 上用裁剪后的真实音频完整跑过「分割 → ASR → 校对 → `xwlb_daily` → DeepSeek 切分 → `xwlb_daily_ext`」,全部成功,测试数据已清理。详见 [BUGS.md 的验证记录](./BUGS.md#本次修复的验证记录实测非推断)。
---
## 9. 时序图(单日,改造后)
```
20:34 抓页面 → 找到 VID 页(失败日期明确跳过)
20:35 yt-dlp 下载 ~100MB mp4 → ffmpeg 抽 mp3(已存在则跳过下载)
20:35 mp3 → input_YYYYMMDD.wav(16k) → 静音切分为 ~11 片
20:36 chunk_0 ASR(30~45s) → 校对(20~32s;失败或改动数值事实则用原文) → 覆盖写 xwlb_daily ┐
... │ 约 10~14 min
21:0x chunk_10 同上 → 删除该分片 ┘
21:0x 拼接 11 行 news_improve(~6900 字)→ DeepSeek(27~35s)
21:0x 解析 JSON(失败则强化提示词重试一次)→ 删除当日旧记录 → 批量写入 xwlb_daily_ext ×N
21:0x 清理中间产物:mp3/mp4/wav 全部删除(仅当本日全部成功;失败则保留供重跑)
```
每个分片顺序执行,无并发;单个分片识别失败只影响该片(记 ERROR 且不写空行),其余分片照常入库。
+363
View File
@@ -0,0 +1,363 @@
# xwlb 项目缺陷清单
审查范围:仓库内全部 Python 源码 + `main.log`(2026-06-01 ~ 2026-09-23,34MB,约 46 个有效运行日)。
结论口径:每条缺陷给出**位置 → 问题 → 影响 → 证据 → 修复方向**。证据栏区分"日志实测"与"代码推断"。
严重度定义:
- **P0**:造成数据丢失或数据错误(静默发生、无人察觉)
- **P1**:功能不生效、崩溃、成本浪费、磁盘/日志失控
- **P2**:工程与可移植性问题(换环境即失败)
---
## 汇总表
| # | 严重度 | 位置 | 一句话问题 | 实际影响 | 本次状态 |
|---|---|---|---|---|---|
| B1 | P0 | `audioRead.py:186,191-193,260` | qwen 校对超时(300s)被当成 ASR 失败,整片文本丢弃 | 46 天、57 次,每天约丢 2/11 分片(~1400 字)静默缺口 | ✅ 已修复并实测 |
| B2 | P0 | `audioRead.py:275,289-292` | 校对结果恒为 `None`,回退原文 | 校对功能 0 收益,却贡献了全部 57 次超时与 token 成本 | ✅ 已修复并实测 |
| B3 | P0 | `newsProcess.py:89,119-121` | DeepSeek 返回非纯 JSON(```json 围栏 / 截断)即整批失败 | 22 天 `xwlb_daily_ext` 当天 0 条 | ✅ 已修复(回归测试覆盖) |
| B4 | P0 | `getVideo5.py:69,92,156` | `get_xwlb_video_link()` 返回值错误(只回最后一个 / 未定义 / `[]`) | 取错链接、`UnboundLocalError`、无效 URL 仍进下载 | ✅ 已修复并实测(联网) |
| B5 | P0 | `getVideo5.py:104-146`、`audioRead.py:389-399` | 下载与分片落库无幂等 | 重跑 → 重复下载 + `xwlb_daily` 重复行 → 新闻重复、token 翻倍 | ✅ 已修复并实测 |
| B6 | P1 | `audioRead.py:260`、`getVideo5.py:132-140` | 关键外部调用无 timeout/重试;ffmpeg 输出未接管 | 单点失败无补救;`main.log` 34MB 且无轮转 | 🟡 部分(校对已加超时/重试;ffmpeg 输出已接管;yt-dlp 进度与日志轮转未处理) |
| B7 | P1 | `deepseek.py:257-268` | 降级分支 `return result`(变量错/可能未绑定) | DeepSeek 失败时抛 `NameError`,降级形同虚设 | ✅ 已修复(`return fallback_result`) |
| B8 | P1 | `audioRead.py:422-439` | `__main__` 示例用 3 参数调 4 参数签名 | `python audioRead.py` 必错 | ✅ 已修复(改为 `python audioRead.py [YYYYMMDD]`) |
| B9 | P1 | `audioRead.py:387`、`getVideo5.py:104-146` | 临时文件不清理(chunk/input.wav/mp4) | 单日约 180MB 常驻,磁盘持续增长 | 🟡 部分(分片与按日 WAV 已在 `finally` 清理;mp4/mp3 保留策略待定) |
| B10 | P2 | `mysqlHandle.py:5` | 依赖父项目 `utils.mysql_handler`,本机不存在 | 所有脚本 import 即 `ModuleNotFoundError` | ✅ 已修复(内置 `mysql_handler.py`) |
| B11 | P2 | `getVideo5.py:170,178`、`audioRead.py` | 硬编码 `/home/simon/myquant/djapi/...` | 换机即废(本机已复现) | ✅ 已修复(基于项目目录 + 环境变量可覆盖) |
| B12 | P2 | `env.py:9` | `.env` 定位按老 djapi 目录层级,且不覆盖已有变量 | 指向 `/home/pi/.env`,实际不存在 | ✅ 已修复(项目内 `.env`,支持 `XWLB_ENV_FILE`) |
| B13 | P2 | `newsProcess.py:56`、`newsRedo.py:69` | "已处理"判据为 `COUNT(*) > 5` | 少于 5 条的日期被判定未处理,无限重跑 | ✅ 已修复(存在任意记录即视为已处理,`--force` 可强制) |
| B14 | P2 | 仓库根目录 | 产物入库、非 git 仓库 | 354MB 产物 + 34MB 日志 + 失效 `.pyc` | 🟡 部分(已加 `.gitignore`、清理失效 `__pycache__`;未 `git init`、历史产物未清) |
| B15 | P2 | 全局 | 无 `.env.example`,密钥缺失/失效时报错不指向配置 | 401 当天 33 次报警,排查成本高 | 🟡 部分(已加 `.env.example` 与连接失败明确报错;启动期配置校验未做) |
---
## 本次修复的验证记录(实测,非推断)
验证环境:项目 venv(Python 3.13.5)+ 真实 DashScope/DeepSeek key + 通过 SSH 隧道(13306)连接生产 MariaDB。
端到端验证使用**测试日期 `1900-01-01`** 与裁剪后的 5 分钟音频,验证完成后已删除测试数据(`xwlb_daily` 2 行、`xwlb_daily_ext` 3 行,复查均为 0)。
| 验证项 | 方法 | 结果 |
|---|---|---|
| B12 配置加载 | 运行任意脚本 | `已加载配置文件 /home/pi/project/xwlb/.env(注入 18 个变量)` |
| B10 DB 层 | `import mysqlHandle` 后直接构造 `MySQLDB()` | 连接成功;故意用错端口时抛出带修复指引的 `RuntimeError`(不再是 `AttributeError`) |
| B8/B1/B2 真实链路 | `process_long_audio()` 处理 5 分钟真实音频 | 2 个分片全部成功;校对**有真实产出**:677→699、635→632 字;`返回 {'chunks': 2, 'ok': 2, 'failed': 0}`;落库 2 行(2011/2079、1885/1880 字),**空白行 0** |
| B5 分片幂等 | 同 `(日期, 分片)` 连续写入 3 次(其中 2 次同键) | 只保留 2 行(子号 0、1)且为最新内容;对照旧逻辑会累积成 3 行 |
| B3 解析健壮性 | `tests/test_news_parse.py`,含日志中真实坏返回 | 15 项全部通过(```json 围栏、夹解释文字、对象包装、混入字符串、缺字段等均可解析;截断/非 JSON 按预期抛错触发重试) |
| B3/B5/B13 切分入库 | 对同一测试日期连续执行 3 次 `news_to_db` | 第 1 次切出 3 条并写入;第 2 次(无 force)正确跳过;第 3 次(force)**替换**旧 3 条 → 仍为 3 条,重复 `sub_id` 组数 = 0 |
| B4 抓取 | 联网解析 20260923/20260924/20260925 三天页面 | 返回类型为 list;09-23、09-24 各解析出 1 条完整版链接;09-25(尚不存在,404)被明确记录并跳过,不再产生空 URL |
| **2026-09-04 生产级实跑** | `getVideo5.py 20260904 20260904` 完整跑一遍(下载 98MB → 抽音 → 11 片 ASR+校对 → 切分入库) | 11/11 分片成功(原空白分片 7 补回 2091 字);切分首轮遇 `Unterminated string`(**正是当初让该日 0 条精编的原因**),第 2 轮强化提示词成功切出 34 条;见下方"事实篡改"一节,修复后复查 0 漂移 |
| 数值事实守卫 | `tests/test_fidelity_guard.py`(13 用例) | 全部通过:`2027→2024`、`十一→九`、`十五五→十四五`、`5.3→5.31`、`300→30` 均拦下;`7↔七`、`2026↔二零二六`、`55%↔百分之五十五` 均放行 |
> 未验证项:本轮未对 `xwlb_daily` 中既有的 101 行空白记录与 203 天缺失 `xwlb_daily_ext` 做数据修复/回填——那属于数据治理,需你确认后再执行(会消耗 API 额度)。
---
## P0 详情
### B1 qwen 校对超时导致已成功的 ASR 结果被静默丢弃
**位置**:`audioRead.py:186`(`transcribe_audio` 内调用 `analyze_and_correct_text`)、`:260`(`Generation.call`,未设 timeout)、`:191-193`(异常兜底 `return ['', '']`)、`:369-399`(`process_long_audio` 落库,无条件插入)
**问题**:ASR 识别与"qwen 文本校对"被串在同一个 `try` 里。校对调用一旦抛异常(实测全部是 300s 读超时),异常被 `transcribe_audio` 的兜底捕获,函数返回 `['', '']`——**已经识别成功的文本不再返回**。`process_long_audio` 随后照常以空字符串插入一行。
**影响**:
- 每个受影响分片丢失约 700 字识别文本,且**没有任何失败标记**(`news_raw=''`、`news_improve=''` 与"正常但短"无法区分);
- 46 个运行日受影响,每天约 2 个分片,累计约 4 万字内容缺口;
- `news_to_db` 拿到的是缺失版全文,DeepSeek 切分出的新闻条数/内容随之缺失,且上游完全无感知。
**证据(日志实测)**:`read timeout=300` 共 57 次,**前一行 100% 是"调用通义千问模型进行文本修正"**(校验:`grep -B1 "read timeout=300" | grep -c 调用通义千问` = 57,`grep -c 开始识别音频` = 0),说明超时发生在校对而非识别。分布 2026-06-18 ~ 2026-09-23,覆盖 46 个不同日期,最后一天(09-23)仍在发生,**属存活缺陷**。
**修复方向**:
1. 解耦:ASR 成功即先落库 `news_raw`,校对作为独立后续步骤(失败保留原文,不丢数据);
2. 校对调用单独设 `timeout`(dashscope 支持 `timeout=`)+ 有限重试,超时按"放弃校对"处理而非"放弃识别";
3. 空文本不入库,或入库但显式记录 `status=failed` 供 `news_to_db` 跳过。
---
### B2 qwen 校对功能实际从未生效
**位置**:`audioRead.py:275`(`return response.output.text`)、`:277-299`(`analyze_and_correct_text`)
**问题**:`text_correction` 返回 `None`(未抛异常),`analyze_and_correct_text:290-292` 判断为 `None` 后回退原文。
**影响**:
- 校对本应修错别字/标点,实际 `news_improve == news_raw`,**功能 0 收益**;
- 代价是全部 57 次 300s 超时(B1 的根因)、每个分片 30~300s 的额外耗时,以及 qwen 的全部 token 成本;
- 它同时把"校对"变成了"随机丢分片"的赌局。
**证据(日志实测)**:每个分片固定出现 `✓ 文本修正完成` → `WARNING - 文本修正返回 None,使用原始文本` → `原始文本长度: N` / `修正后文本长度: N`(两值恒等;如 09-23 的 798/798、708/708、688/688)。
**疑似原因**:`Generation.call` 走 `messages` 时该 SDK 版本的文本取法应为 `response.output.choices[0].message.content`,`.output.text` 为空;或 `max_tokens=30000` 超出模型上限导致 output 为空但 status 仍 200。
**修复方向**:修正结果字段取法(或改用 OpenAI 兼容接口);若确认无收益则直接删掉这一步,只保留 ASR。
---
### B2 补充(2026-09-25 实测发现):校对修好后会**篡改事实**
把 B2 修好之后校对第一次真正生效,随即在 2026-09-04 的真实数据上发现它**改错了事实**:
| 内容 | ASR 原文 | qwen 校对后 | 判定 |
|---|---|---|---|
| 条例施行日期 | 自 **2027** 年1月1日起施行 | 自 **2024** 年1月1日起施行 | ❌ 改错(新签署的条例不可能追溯生效) |
| 五年规划 | **十五五** | **十四五** | ❌ 改错(2026 年应为十五五) |
| 东方经济论坛届次 | **第十一**届 | **第九**届 | ❌ 改错(同一天开场提要也作"第十一届") |
| 年份(多处) | 2025 / 2026 | 2023 / 2024 | ❌ 改错 |
| 数量 | 55 / 300亿 | 60 / 30亿 | ❌ 改错 |
11 个分片中 **8 个**存在数值/序数被改写;这些错误会经 `news_improve` 直接进入最终 `xwlb_daily_ext`(DeepSeek 忠实复制了它的输入)。
**影响**:这比"校对不生效"更危险——原文虽是 ASR 结果但事实可信,校对后反而产生**看起来通顺但事实错误**的文本。
**已实施的修复**:
1. **数值事实守卫**(`audioRead._number_drift`):中文数字与阿拉伯数字统一归一到数值后比较,若校对前后数值集合与拼接串均不一致,则该分片**整片回退 ASR 原文**;`百分之X` 与 `X%`、`7 ↔ 七`、`2026 ↔ 二零二六` 等纯书写差异不会误拦。
2. **提示词加固**:明确禁止改动数字/年份/日期/届次/数量/机构名/人名/地名/专有名词。
3. **开关**:`LLM_CORRECT_ENABLED=0` 可完全关闭校对(直接以 ASR 原文入库)。
4. 回归测试 `tests/test_fidelity_guard.py`(13 个用例,含上表中的真实篡改样例)。
**取舍**:被守卫拦下的分片会连"合理的 ASR 修复"一起放弃(例如同一分片里的 `弗拉迪沃斯。波克` → `符拉迪沃斯托克`、`丁学祥` → `丁薛祥`)。守卫的取向是**宁保留原文,不接受 LLM 改数**。
**待你决定**:`LLM_CORRECT_ENABLED` 的默认值。当前默认 `1`(开启+守卫),另一选择是默认 `0`(完全不改写原文,最保守,且每天省下 11 次 qwen 调用)。
---
### B3 DeepSeek 返回非纯 JSON 时整批切分丢失
**位置**:`newsProcess.py:86-89`(提示词与 `deepseek_text`)、`:119-121`(`json.JSONDecodeError` 仅记录后放弃)
**问题**:依赖 `response_format={"type":"json_object"}` 保证纯 JSON,但模型仍会输出 ```json 围栏或超长截断(`max_tokens=20000`),解析失败后**不重试、不降级**,直接丢弃当天全部文本。
**影响**:22 个日期 `xwlb_daily_ext` 当天 0 条记录;由于 B13 的判据,这些日期之后会被反复重跑,而每次重跑又触发 B5 的重复写入。
**证据(日志实测)**:`JSON解析失败` 22 次,覆盖 22 个不同日期(2026-06-03 ~ 2026-09-14);报错内容含 `Expecting value: line 1 column 1`(前后被 ``` 包裹,日志里可见 `DeepSeek API返回内容: ```json`)与 `Unterminated string starting at: line 80 column 21`(截断)。另有 1 次 `插入数据库失败: string indices must be integers`,说明 `news_list` 元素有时是字符串而非 dict(`news["news_id"]` 直接炸)。
**修复方向**:剥离围栏后再解析 → 失败则重试一次(提高 max_tokens / 降低 temperature)→ 仍失败则按空行/段落降级切分;对 `news_list` 元素做类型校验与字段缺失兜底。
---
### B4 `get_xwlb_video_link()` 返回值错误
**位置**:`getVideo5.py:34-69`(构建 `video_links` 但 `return video_url`)、`:86-94`(`get_all_video_links`)、`:149-171`(`process_videos`)
**问题**:三处叠加。
1. `:69` 返回的是循环里最后一次赋值的**单个 URL 字符串**,`video_links` 列表被丢弃 → 当天若有多个匹配链接只取最后一个;
2. 请求异常路径 `return []`(`:28,:31`),但 `get_all_video_links:92` 仍包装成 `{"url": [], "date": ...}`;
3. `process_videos:156` 用 `if sub_url:` 判断——非空 dict 恒为真,**错误值不会被拦截**,`[]` 会被送进 yt-dlp;
4. 若一个 a 标签都没匹配到,`video_url` 从未赋值 → `UnboundLocalError`(不是被捕获的异常类型)。
**影响**:抓取失败/页面改版时不是"跳过并告警",而是带着无效 URL 继续下载,或用错链接下载到非新闻联播内容;幂等性判断(B5)也因此失效。
**证据**:代码推断(`get_xwlb_video_link` 内 `video_links` 变量在 `:34` 定义、`:61` 追加、`:69` 未被返回,可静态确认)。
**修复方向**:`return video_links`;调用方展开列表并对空值 `continue`;`if sub_url.get('url')` 显式判空。
---
### B5 下载与分片落库无幂等
**位置**:`getVideo5.py:104-146`(`download_and_extract_audio` 不检查 mp4/mp3 是否已存在)、`audioRead.py:389-399`(无条件 `insert_data`)
**问题**:`newsRedo.py:79-108` / `main_videos.py:23-58` 判断"是否需要处理"只看 `xwlb_daily` 有没有当天的行。而重跑会**追加**一整组 `daily_sub_id = 0..N` 的新行(`nid` 自增,`(news_days, daily_sub_id)` 无唯一约束)。
**影响**:
- `get_news_improve_by_date`(`newsProcess.py:61-65`)按 `daily_sub_id` 排序拼接,会把同一天的文本**重复拼两遍**送进 DeepSeek → 切分结果重复、token 翻倍、可能超出上下文;
- 重复下载 100MB 级 mp4(B9 磁盘问题同步放大);
- 与 B13 组合会形成"重跑 → 重复 → 再重跑"的循环。
**证据**:代码推断(无唯一索引、无存在性检查)。
**修复方向**:`(news_days, daily_sub_id)` 加唯一索引 + `INSERT ... ON DUPLICATE KEY UPDATE`;下载前检查目标 mp4/mp3 是否存在且非空;`daily_sub_id` 用分片序号而非可变下标。
---
## P1 详情
### B6 外部调用无超时/重试,子进程输出未接管
**位置**:`audioRead.py:258-266`(`Generation.call` 无 timeout、无重试)、`:168-186`(`Recognition.call` 单次失败即放弃)、`getVideo5.py:132-140`(ffmpeg `check=True` 但 `stdout=None, stderr=None`)、`:123`(yt-dlp 进度 hook 直接 `print`)
**影响**:
- 单分片 ASR 失败无任何补救,直接进入下一片;
- ffmpeg 的完整 banner(版本、编译参数、流信息)与 yt-dlp 的逐帧进度全部落到 stdout,`main.log` 已达 34MB 且**无 logrotate**;一次失败排查需要在数万行进度里翻找;
- 超时阈值 300s 是 SDK 默认,与业务预期(每天 51 分钟跑完)不匹配。
**修复方向**:统一 timeout + 指数退避重试;ffmpeg 用 `stdout=subprocess.DEVNULL, stderr=subprocess.PIPE` 并只在失败时打印尾部;日志加 `RotatingFileHandler` 或把进度输出降级为 DEBUG。
---
### B7 `deepseek.py` 降级分支变量写错
**位置**:`deepseek.py:241-268`
**问题**:`try` 中 `result = api_client.process_text(...)`;`except` 里调用 `process_text_with_fallback(...)` 得到 `fallback_result`,却 `return result`。异常路径下 `result` 未绑定 → `NameError`(若部分成功则返回错误内容)。
**影响**:本应"降级返回原文"的兜底变成二次异常,被 `newsProcess.py:122` 的宽 `except` 吞成一行 `插入数据库失败`,与 B3 的静默失败叠加。
**证据**:代码推断(`deepseek.py:261-268`)。**附带**:`deepseek.py:271` 的 `deepseek_text()` 缺必填参数,`python deepseek.py` 直接 `TypeError`。
**修复方向**:`return fallback_result`;`__main__` 补参数或改成 CLI。
---
### B8 `audioRead.py` 的 `__main__` 示例已过期
**位置**:`audioRead.py:422-439`
**问题**:`process_long_audio(mp3_path, prompt, output_folder)` 传 3 个位置参数,而签名是 `(mp3_path, output_folder, date_str)` → `prompt` 被当作输出目录,`date_str` 缺失。
**影响**:任何人按文件底部示例单独调试都会失败,且会把中文提示词当目录名创建。
**修复方向**:改为 `process_long_audio(mp3_path, output_folder, date_str)`,或直接删掉示例。
---
### B9 临时文件与产物不清理
**位置**:`audioRead.py:387`(仅成功分支 `os.remove(chunk_path)`)、`:352-354`(`input.wav` 不删)、`getVideo5.py:113-114`(mp4/mp3 长期保留)
**影响**:单日约 104MB mp4 + 24MB mp3 + 55MB wav ≈ 180MB;仓库当前已含 `xwlb_video/` 299MB、`audio_processing/input.wav` 55MB。长期运行必然打满磁盘,且在失败分支 chunk 会残留。
**修复方向**:处理完即删中间产物(或按保留天数清理);mp3 作为可重建产物不必长期保存;`finally` 中清理 chunk。
---
## P2 详情
### B10 `utils.mysql_handler` 缺失(当前无法运行的首要原因)
**位置**:`mysqlHandle.py:4-5`
**问题**:`sys.path.insert(0, <xwlb 的父目录>)` 后 `from utils.mysql_handler import MySQLDB`,而 `/home/pi/project/utils/` 不存在。
**影响**:所有脚本(包括仅查库的 `newsRedo.py`)**import 阶段即失败**:已在本机复现 `ModuleNotFoundError: No module named 'utils'`。
**证据(日志实测,反向线索)**:日志中 `查询失败: local variable 'cursor' referenced before assignment` 说明该模块在连接失败时自身还有未初始化变量缺陷——重写时应一并避免。
**修复方向**:在项目内提供自包含的 `mysql_handler.py`,保持 `MySQLDB()` / `query_data(table, columns, where, params)` / `insert_data(table, data)` / `close()` 四个接口不变,配置从环境变量读取。
---
### B11 硬编码绝对路径
**位置**:`getVideo5.py:170`(`/home/simon/myquant/djapi/api/video/xwlb_video`)、`:178`(同前缀 `audio_processing`)、`audioRead.py:353` 的输出目录同样来自该前缀
**影响**:迁移到任何其他机器都不可用;本机 `pwd` 为 `/home/pi/project/xwlb`,正确位置应是仓库内的 `xwlb_video/` 与 `audio_processing/`(数据也确实已放在那里)。
**修复方向**:以 `Path(__file__).parent` 为基准,或由 `env` 提供 `XWLB_VIDEO_DIR` / `XWLB_AUDIO_DIR`。
---
### B12 `.env` 定位与优先级
**位置**:`env.py:9`(`parent.parent.parent / '.env'` → `/home/pi/.env`)、`:23`(`if key not in os.environ` 不覆盖)
**影响**:按老 `djapi/api/video/` 三层结构推导,本项目只有一层,指向错误位置且文件不存在 → 密钥全部缺失;"不覆盖已有变量"的行为未文档化,容易与容器注入的变量混淆。
**修复方向**:改为项目根/`xwlb` 目录下的 `.env`,并支持 `XWLB_ENV_FILE` 覆盖;在文档中写明优先级(环境变量 > `.env`)。
---
### B13 "已处理"判据用行数阈值
**位置**:`newsProcess.py:49-57`、`newsRedo.py:62-71`
**影响**:某天若只切出 ≤5 条新闻(短节目/切分异常),会被永久判定为"未处理"并在每次补漏时重跑;配合 B5 造成重复数据。
**修复方向**:以"是否已经跑过切分"的显式状态为准(如 `xwlb_daily_ext` 存在任意行即视为已处理,或新增处理状态表/字段)。
---
### B14 仓库卫生
**位置**:仓库根目录
**问题**:`main.log` 34MB、`xwlb_video/` 299MB、`audio_processing/input.wav` 55MB 与源码混放;`__pycache__/` 内含 `ai.cpython-310.pyc`、`parsem3u8.cpython-310.pyc` 等**已删除模块**的产物(3.10/3.11,与当前 3.13 不符);目录**不是 git 仓库**,无变更历史可回溯。
**修复方向**:加 `.gitignore`(`*.mp4 *.mp3 *.wav main.log __pycache__/`)、`git init` 纳入版本管理、清理失效 `__pycache__`。
---
### B15 配置与密钥错误不可诊断
**位置**:全局(`deepseek.py:21-23` 只 warning、`audioRead.py:166` 直接赋值空串)
**影响**:`DASHSCOPE_API_KEY` 无效/缺失时表现为 2026-06-01 那天连续 33 条 `401 Unauthorized` + 11 个分片全废,但报错信息不指向"检查 `.env`";`DeepSeekAPI` 缺 key 仅 warning,随后才在调用处抛异常。
**修复方向**:提供 `.env.example`;启动时做一次配置校验(缺 key 直接 fail-fast 并打印期望的文件路径)。
---
## 本轮新发现(2026-09-25,B16~B19)
### B16(严重 · 静默换模型)兜底分支偷偷用另一个模型
**位置**:`deepseek.py` `process_text_with_fallback`
**问题**:兜底调用 `self.process_text(prompt, text, system_prompt)` **未传 model**,于是用函数签名里的硬编码默认值 `deepseek-chat`。
**影响**:配置里的模型名无效(如 `deepseek-v4.1-flash`)时,主调用 400 报错、兜底却用另一个模型成功返回,**"按配置运行"看起来完全正常,实际配置从未生效**——排障方向会被彻底带偏。
**修复**:兜底显式传入同一模型与参数;切分环节改为 `use_fallback=False`(它自带重试与解析校验),配置错误直接暴露。现已验证:模型名写错立刻报 `The supported API model names are ...`。
### B17(严重 · 数据完整性)重试判定会把"半天"当"一天"
**位置**:`scripts/day_status.py`(本轮新增)
**问题**:只看"库里有没有分片"就判断"只缺切分"。识别中途卡死同样会留下部分分片,
于是重试走"只重跑切分",把半天内容切分入库并标记为完成。
**影响**:静默产生**不完整的一天**,且不会报错;事后极难发现(那天看起来是正常的)。
**修复**:识别全部完成后写标记 `state/.asr_complete_<日期>`(放 `state/`、**不随清理删除**,
否则无法与"识别只跑了一半但切分照样写了 ext"区分),`day_status.py` 只有**同时**满足
"有分片 + 有标记"才允许只重跑切分,否则重跑全链路;
并在 `process_videos` 中改成**识别不完整就不执行切分**,不发布"看起来完整"的半天数据。
### B18(高 · 推理模型截断/空回复)max_tokens 未按推理模型调整
**位置**:`config.yml` `llm_split.max_tokens`(原 20000 / 重试 32000)
**问题**:`deepseek-flash` 是推理模型,reasoning token 实测在 14k~24k 间波动且**计入 max_tokens**。
20000 时实测出现两种故障:输出截断在 JSON 中途(`finish_reason=length`)、
或推理耗尽全部额度导致 **content 为空**(报"LLM 响应为空")。
**影响**:切分随机失败或写入被截断的新闻;每晚概率性发生。
**修复**:上限提到 60000(重试 80000)——按实际产出计费,给足上限不额外花钱;
`timeout` 60→180 秒(2 万 token 级别输出需要更久)。
### B19(高 · 可用性)ASR 的 `Recognition.call` 无超时,长连接悬挂会卡死整晚
**位置**:`audioRead.transcribe_audio` → dashscope `Recognition.call`
**问题**:SDK 内部 `for part in responses` 无限等待 websocket 响应,**没有任何超时参数**
(签名无 timeout,内部 `while True`)。实测 2026-09-24 重跑时卡在 chunk 3 之后:
进程 0 CPU、`futex_do_wait`、无新日志,**15 分钟以上无进展**。
**影响**:手动跑会一直挂着;定时任务靠外层上限兜底,但会白白耗掉一次尝试。
**缓解**:`scripts/run_daily.sh` 单次尝试 40 分钟上限 + 21:30/22:00 重试;
systemd `TimeoutStartSec=10800`。**根治需要给 ASR 加子进程看门狗**(列入待办)。
---
## 后续待办(本轮新增)
7. **ASR 看门狗**(对应 B19):把 `Recognition.call` 放进子进程执行并加超时,
超时终止子进程、该分片记失败后**继续下一片**,而不是整晚挂死。
8. **回补校对失效期**(见 `REPORT_raw_vs_improve.md`):2026-06-15~2026-09-23 的
`news_improve` 实际等于 ASR 原文(2026-07/08 为 100% 未改动)。校对现已修复且
关掉思维链后成本降到约 1/13,回补约 100 天 ≈ 1M token,需确认是否执行。
9. **重试跳过已识别分片**:当前重试会重跑全部 ASR(识别已完成的片也重做)。
加"已入库分片跳过"可让重试只补缺失片,需注意别让 `newsRedo.py` 的强制重跑被静默跳过。
---
## 后续待办(本轮未做)
1. **数据治理**:`xwlb_daily` 现存 101 行空白记录(可识别、可重跑修复);725 天原文里 203 天没有 `xwlb_daily_ext`——回填需重新调用 DeepSeek,属于有成本的操作,需你确认范围后再执行。
2. **B6 收尾**:yt-dlp 进度 hook 仍直接 `print`;`main.log` 无轮转(建议 `RotatingFileHandler` 或由外部 logrotate 管理)。
3. **B9 收尾**:`xwlb_video/*.mp4` 与 `*.mp3` 是否保留需定策略(mp3 可重跑,mp4 价值较低)。
4. **B14 收尾**:`git init` 纳入版本管理,清理历史大文件(34MB 日志、299MB 视频)。
5. **B15 收尾**:启动期做一次配置校验(缺 key / 连不上库时 fail-fast 并打印期望文件路径)。
6. **可选加固**:给 `xwlb_daily` 加唯一索引 `(news_days, daily_sub_id)`、`xwlb_daily_ext` 加 `(news_date, sub_id)`。当前代码已用「先删后插」实现幂等,索引属额外保险;注意建索引前必须先清理既有重复行(如 2026-06-15 全量重复 2 份、2026-06-18 ext 重复 3 份)。
## 修复优先级建议(原始评估,供回看)
1. **B2 + B1 一起做**(改/删校对步骤,保住已识别文本)—— 直接消除最大的静默数据缺口,同时省下每分片 30~300s 与全部 qwen 成本。
2. **B3**(JSON 解析健壮化 + 重试)—— 消除 22 天的整天数据缺失。
3. **B10 + B11 + B12**(可移植化)—— 让项目在任何机器可运行,是继续验证与修复的前提。
4. **B5 + B13**(幂等 + 判据)—— 防止修复过程中把重复数据写进生产库。
5. **B4**(抓取返回值)—— 抓取链路的正确性兜底。
6. B6~B9、B14、B15 —— 稳定性与工程卫生。
> 注:`main.log` 中 `'str' object has no attribute 'append'`(22 次)与 `object of type 'NoneType' has no len()`(11 次)**仅出现在 2026-06-01 与 06-16**,当前 `audioRead.py` 已分别通过"返回 list"与"`None` 回退原文"修掉,故未单列;但它们与 B1/B2 同源,重写校对步骤时不要再引入同类返回类型不一致的写法。
+142
View File
@@ -0,0 +1,142 @@
# news_raw vs news_improve 评估报告
样本:`xwlb_daily` 7981 个分片,覆盖 727 天(2024-09-26 ~ 2099-01-01);`xwlb_daily_ext` 11494 条
## 1. 校对到底改了什么
| 分类 | 分片数 | 占比 | 平均改动字符数 | 含义 |
|---|---|---|---|---|
| identical | 1094 | 13.7% | 0.0 | 完全没改 |
| punct_only | 1 | 0.0% | 0.0 | 只改标点/空白/断句 |
| numeral_only | 1 | 0.0% | 2.0 | 只改数字写法或标点 |
| word_change | 6885 | 86.3% | 38.5 | **真的改了字词** |
「真的改了字词」的 6885 个分片里,**排除标点与数字后**的平均改动量只有 **29.7 字**(占分片平均长度 718 字的 4.1%)
改动最频繁的字符(raw 有而 improve 没有 → improve 新增):
| 被替换掉的字符 | 次数 | 新增的字符 | 次数 |
|---|---|---|---|
| `。` | 20087 | `⏎` | 38761 |
| `␠` | 19329 | `”` | 16765 |
| `号` | 10326 | `“` | 16763 |
| `的` | 8440 | `,` | 12433 |
| `,` | 3649 | `日` | 11487 |
| `了` | 3457 | `、` | 9422 |
| `这` | 2780 | `;` | 7558 |
| `个` | 2666 | `␠` | 5625 |
| `是` | 2640 | `的` | 4465 |
| `到` | 2478 | `《` | 4254 |
- 校对**实际生效**的分片:6887/7981(86.3%);完全没动的:1094/7981(13.7%)
- 全天 11 个分片**全都没被改**的天数:98/727
### 可读性指标(越大越接近正式书面语)
| 指标 | 原文 news_raw | 校对后 news_improve | 变化 |
|---|---|---|---|
| 标点密度(每百字标点数) | 8.52 | 9.81 | +1.28 |
| 书名号《总数 | 0 | 4254 | +4254 |
| 总字数 | 5675790 | 5810634 | +134844 |
### 「真的改了字词」的抽样(判断是修错字还是改写)
- **2024-09-26 第0片**(改动约 31 字)
- raw: 各位观众晚上好晚上好,今天是9月26号星期四,农历8月24,欢迎收看新闻联播节目。首先为您介绍今天节目的主要内容。中共中央政治局召开会议,分析研究当前经济形势和经济工作,中共中央总书记习近平主持会议。习近平给中国传媒大学全体师生回信,强调突
- imp: 各位观众晚上好,今天是9月26日,星期四,农历八月二十四,欢迎收看新闻联播节目。首先为您介绍今天节目的主要内容:中共中央政治局召开会议,分析研究当前经济形势和经济工作,中共中央总书记习近平主持会议;习近平给中国传媒大学全体师生回信,强调突出
- **2024-09-26 第1片**(改动约 10 字)
- raw: 会议强调,要加大财政货币政策逆周期调节力度,保证必要的财政支出,切实做好基层三保工作。要发行使用好超长期特别国债和地方政府专项债,更好发挥政府投资带动作用。要降低存款准备金率,实施有力度的降息。要促进房地产市场止跌回稳,对商品房建设要严控增
- imp: 会议强调,要加大财政货币政策逆周期调节力度,保证必要的财政支出,切实做好基层“三保”工作。要发行使用好超长期特别国债和地方政府专项债券,更好发挥政府投资的带动作用。要降低存款准备金率,实施有力度的降息。要促进房地产市场止跌回稳,对商品房建设
### 按月的「完全没改」比例(用于识别校对失效的历史区间)
| 月份 | 分片数 | 完全没改 | 占比 |
|---|---|---|---|
| 2024-09 | 61 | 0 | 0% |
| 2024-10 | 322 | 0 | 0% |
| 2024-11 | 321 | 0 | 0% |
| 2024-12 | 334 | 0 | 0% |
| 2025-01 | 327 | 0 | 0% |
| 2025-02 | 289 | 0 | 0% |
| 2025-03 | 363 | 0 | 0% |
| 2025-04 | 332 | 0 | 0% |
| 2025-05 | 324 | 0 | 0% |
| 2025-06 | 305 | 0 | 0% |
| 2025-07 | 334 | 0 | 0% |
| 2025-08 | 325 | 0 | 0% |
| 2025-09 | 326 | 0 | 0% |
| 2025-10 | 359 | 0 | 0% |
| 2025-11 | 334 | 0 | 0% |
| 2025-12 | 330 | 0 | 0% |
| 2026-01 | 340 | 0 | 0% |
| 2026-02 | 309 | 0 | 0% |
| 2026-03 | 384 | 0 | 0% |
| 2026-04 | 334 | 0 | 0% |
| 2026-05 | 347 | 0 | 0% |
| 2026-06 | 343 | 165 | 48% |
| 2026-07 | 344 | 344 | 100% |
| 2026-08 | 331 | 331 | 100% |
| 2026-09 | 261 | 254 | 97% |
| 2099-01 | 2 | 0 | 0% |
## 2. token 开销:现状 vs 两个替代方案
统计口径:523 天同时有原文与精编的天数,取日均字符数(≈token 数,中文 1 字≈1 token)
| 方案 | 输入 | 输出 | 合计/天 | 相对现状 |
|---|---|---|---|---|
| A 现状(校对 + 切分 两次调用) | 15777 | 15646 | **31423** | — |
| B 合并进切分(一次调用同时校对+切分) | 7787 | 7657 | **15444** | 省 51% |
| C 关掉校对(直接用 ASR 原文切分) | 15444 | 7657 | **15444** | 省 51% |
其中校对这一次调用本身消耗 输入 7787 + 输出 7990 = **15777 token/天**,占现状总开销的 50%。
按 523 天累计:校对一项约消耗 8.25 M token。
---
## 3. 实测 token 开销(真实 API 返回的 usage,不是估算)
用**你当前配置的模型**各跑一次真实调用,读 `usage` 字段(2026-09-23 / 2026-06-08 数据):
| 环节 | 模型 | 输入 | 输出 | 其中推理 | 说明 |
|---|---|---|---|---|---|
| 校对(思考开) | `qwen3.8-flash` | 487 | 5,932 | 5,616 | 545 字文本 |
| 校对(思考**关**) | `qwen3.8-flash` | 451 | **312** | — | 同文本,输出只差一个"的"字 |
| 校对(旧配置对照) | `qwen3.7-max` | 449 | 2,338 | 2,025 | 原来也在做推理 |
| 切分(思考开) | `deepseek-flash` | 4,206 | 19,077 | 14,426 | 全天 6,940 字 |
| 切分(思考关) | `deepseek-flash` | 4,182 | 4,656 | — | **但正文覆盖率掉到 95.34%** |
推算到每天(11 个分片 + 1 次切分):
| 方案 | 每天 token | 相对现状 |
|---|---|---|
| **现状**(校对思考开 + 切分) | ≈ 100,000 | — |
| **建议**(校对思考关 + 切分思考开) | ≈ **33,000** | **省 67%** |
| 合并(校对+切分一次调用,思考开) | ≈ 24,000 | 省 76% |
| 关掉校对(只切分) | ≈ 23,000 | 省 77% |
## 4. 三个方案的实测对比(同一天 2026-09-23)
| 指标 | 现状两次调用 | 合并成一次 | 只切分 |
|---|---|---|---|
| 条数 | 27 | 27 | — |
| 正文覆盖率 | **100%** | 99.02% | 95.34%(思考关时) |
| 书名号《 | 0(该日数据是失效期产物) | 3 | 0 |
| 引号 | 0 | 15 | 0 |
| **数值事实漂移** | 无(有按分片守卫) | **有**:`44.9→44.93`、丢失 `3`、多出 `1000`/`5` | — |
| 按分片回退保护 | ✅ 有 | ❌ 无(只能整天丢弃) | ❌ 无 |
## 5. 结论
1. **校对有必要,但只值"关掉思考"这一刀。** 它的真实产出是标点与格式:
引号 33,524 次、书名号《》4,250 次、日期归一("9月26号"→"9月26日")10,322 次、
段落换行 38,750 次——ASR 原文**从来不会**产生书名号和引号。
2. **不要把 correct 合并进 split。** 收益只剩 ~27%(因为校对关思考后已经很便宜),
代价是失去按分片的数值守卫,且实测确实把 `44.9` 改成了 `44.93`、丢了数字。
数值事实一旦被改,事后无法察觉——这正是 `docs/BUGS.md` B2 那类事故。
3. **切分环节不要关思考**:省 4 倍 token,但正文覆盖率 99.67% → 95.34%、条数 27 → 20。
4. **注意历史欠账**:2026-07 与 2026-08 是 **100% "完全没改"**(校对失效期),
2026-06 有 48%。也就是说 2026-06 中旬至 2026-09 的 `news_improve` 其实等于 ASR 原文,
并没有被校对过。现在校对已修复且成本降低约 13 倍,是否有必要回补这段,见 README「后续可选」。