Files
xwlb/docs/ARCHITECTURE.md
simon 1d3ba02703 feat: 日志轮转(保留 14 天)
main.log 无轮转、已涨到 34MB,且每晚任务都会继续追加。

- logrotate/xwlb 模板 + scripts/install_logrotate.sh(幂等,装到 /etc/logrotate.d/xwlb)
- 每天轮转、保留 14 份、立即压缩 → main.log-YYYYMMDD.gz
- 必须用 copytruncate:脚本以 >>main.log 长开句柄,改名式轮转会
  让当晚日志写进归档、新文件空着
- journald 同步设 MaxRetentionSec=14day + SystemMaxUse=500M
- 安装脚本自带 logrotate -d 校验(这条立刻抓到"指令行尾注释"被当成参数值的错误)
- tests/test_logrotate.py 19 项,锁住保留天数、行尾注释、占位符替换

实测:34MB → 2.9MB,归档 32453 行与轮转前完全一致
2026-09-25 12:50:11 +08:00

402 lines
30 KiB
Markdown
Raw Permalink 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 项目技术说明
> 一句话定位:把央视《新闻联播》完整版视频自动转成**结构化单条新闻**并入库的离线流水线。
> 本文基于仓库当前源码 + `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` | 安装单元、启用定时器与隧道服务 |
| 安装日志轮转 | `bash scripts/install_logrotate.sh` | 装 `/etc/logrotate.d/xwlb`(每天/保留14天/压缩)与 journald 上限(14 天) |
| 校验轮转配置 | `sudo logrotate -d /etc/logrotate.d/xwlb` | 干跑,不实际轮转 |
| 校对效果评估 | `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` 已有实例在运行。
**日志(保留 14 天)**:任务日志 `main.log` 由 logrotate 每天轮转为
`main.log-YYYYMMDD.gz` 保留 14 份(`logrotate/xwlb` 模板 →
`scripts/install_logrotate.sh` 安装);systemd journal 通过
`/etc/systemd/journald.conf.d/xwlb.conf` 设 `MaxRetentionSec=14day` + `SystemMaxUse=500M`。
轮转必须用 `copytruncate`——脚本以 `>>main.log` 长开句柄,
改名式轮转会让当晚日志写进归档、新文件空着。
**为什么必须有 `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 且不写空行),其余分片照常入库。