# 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 等都有明确日志与修复指引。 - **116 项离线自检**:配置、清理、事实守卫、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 自检与测试 六个测试套件共 **116 项断言**,全部离线运行(不消耗 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 项:隧道自愈各分支(临时端口,不碰真实隧道) .venv/bin/python tests/test_logrotate.py # 19 项:轮转指令、保留天数、行尾注释、占位符替换 ``` 其中隧道测试**不会碰真实的 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 日志轮转 ```bash bash scripts/install_logrotate.sh # 安装/更新(幂等,可反复执行) ``` 两处一起收口,都是**保留 14 天**: | 日志 | 位置 | 策略 | |---|---|---| | 任务日志 `main.log` | 项目根(`>>main.log` 追加) | logrotate:**每天轮转、保留 14 份、立即压缩** → `main.log-20260925.gz` | | systemd journal | `/var/log/journal` | journald:`MaxRetentionSec=14day` + `SystemMaxUse=500M` | 实测效果:34MB 的 `main.log` 轮转后成 2.9MB 压缩归档,**归档行数与轮转前完全一致**(32453 行),磁盘立即回收。 配置里有两个容易踩的点: - **必须用 `copytruncate`**:脚本以 `>>main.log` 方式持续写入,一次任务跑几十分钟、句柄一直开着。 默认的"改名+新建"会让运行中的进程继续写旧文件,当晚日志被切进归档、新文件却空着。 - **指令后面不能写注释**:logrotate 会把注释当成参数值(实测报 `bad rotation count '14 # 保留 14 份'`),注释只能单独成行。 `tests/test_logrotate.py` 专门锁住了这条。 轮转由系统自带的 `logrotate.timer`(每天 00:46)驱动,无需额外定时任务。 ```bash sudo logrotate -d /etc/logrotate.d/xwlb # 干跑校验配置 sudo logrotate -f /etc/logrotate.d/xwlb # 立即轮转一次 zcat main.log-20260925.gz | less # 看归档 ls -lh main.log* # 看当前归档与占用 ``` ### 3.10 故障排查 | 现象 | 原因 / 处理 | |---|---| | `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` | | `main.log` 越来越大 | 已配 logrotate 保留 14 天(见 3.9);未装则 `bash scripts/install_logrotate.sh` | | 日志轮转报 `bad rotation count` | 指令行写了行尾注释;logrotate 不支持,注释要单独成行 | | 某天识别到一半卡死(无新日志、无 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 作为证据保留)。