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

从 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
+200
View File
@@ -0,0 +1,200 @@
"""数据库隧道自愈:13306 不通就自动拉起 autossh.sh
背景:数据库经 SSH 隧道访问(autossh.sh,本地 13306 → 远端 3306)。隧道断掉时,
任何入口都只会得到一句"MySQL 连接失败",不会自己恢复 —— 凌晨的定时任务因此整晚失败。
行为(挂在 `MySQLDB.connect()` 上,所以**所有入口都生效**,不只是定时任务):
1. 探测 `host:port` 是否可连;
2. 可连 → 什么都不做(正常路径零开销,不做任何多余动作);
3. 不通 →
a. 若 systemd 隧道单元处于 active(说明由 systemd 托管,它会自动重连)→ **只等待**,
不另起一个 autossh 去抢同一个端口(那会让 systemd 单元因端口被占而反复重启失败);
b. 否则执行 `config.yml` 里 `mysql.tunnel.script`(默认项目根的 `autossh.sh`);
4. 最多等 `wait_seconds` 秒,最后再探测一次并**如实报告**结果(不通就说通不了)。
配置(config.yml):
mysql:
tunnel:
enabled: 1 # 关掉则完全不探测
script: autossh.sh # 相对项目根
systemd_unit: xwlb-tunnel.service
wait_seconds: 30
connect_timeout: 2
命令行自检:
python tunnel.py # 探测并按需修复,退出码 0=通 / 1=仍不通
python tunnel.py --dry-run # 只看会做什么,不执行
"""
import argparse
import logging
import os
import socket
import subprocess
import sys
import time
from pathlib import Path
import config
logger = logging.getLogger(__name__)
LOCAL_HOSTS = {'localhost', '127.0.0.1', '::1', '0.0.0.0'}
# 状态常量(便于调用方与测试断言,不要用字符串字面量比较)
ST_DISABLED = 'disabled'
ST_NOT_LOCAL = 'not-local'
ST_ALREADY_OPEN = 'already-open'
ST_SYSTEMD_WAITED = 'systemd-waited'
ST_SCRIPT_STARTED = 'script-started'
ST_UNAVAILABLE = 'unavailable'
def _cfg(dotted, default):
return config.get(f'mysql.tunnel.{dotted}', default)
def is_local(host) -> bool:
return str(host or '').strip().lower() in LOCAL_HOSTS
def port_open(host, port, timeout=2.0) -> bool:
"""TCP 层探测端口是否可连(不涉及 MySQL 握手,快且无副作用)"""
try:
with socket.create_connection((host, int(port)), timeout=timeout):
return True
except (OSError, ValueError):
return False
def systemd_unit_active(unit, timeout=5) -> bool:
"""systemd 单元是否 active;systemctl 不可用或单元不存在时返回 False"""
if not unit:
return False
try:
out = subprocess.run(['systemctl', 'is-active', str(unit)],
capture_output=True, text=True, timeout=timeout)
return out.stdout.strip() == 'active'
except (OSError, subprocess.SubprocessError):
return False
def _wait_for_port(host, port, seconds, interval=2.0):
"""轮询等待端口就绪;返回实际等待秒数(未就绪返回总等待时长)"""
deadline = time.time() + max(0, seconds)
waited = 0.0
while True:
if port_open(host, port, timeout=2.0):
return waited
if time.time() >= deadline:
return waited
time.sleep(interval)
waited = min(interval, max(0.0, seconds - waited)) + waited
def ensure_tunnel(host=None, port=None, reason='', dry_run=False, wait_seconds=None):
"""确保数据库端口可连;不通则按配置自愈
参数:
host/port: 不传则取 config.yml 的 mysql.host / mysql.port
reason: 写进日志的触发原因(如"MySQL 连接失败")
dry_run: 只报告会做什么,不执行脚本
wait_seconds: 覆盖配置里的等待时长
返回值:
dict: {'status': 见 ST_* 常量, 'detail': 说明, 'waited': 等待秒数,
'host': ..., 'port': ..., 'healed': bool}
"""
cfg = config.db_config()
host = host or cfg['host']
port = int(port or cfg['port'])
prefix = f"({reason})" if reason else ''
result = {'host': host, 'port': port, 'waited': 0.0, 'healed': False}
if not config.get_bool('mysql.tunnel.enabled', True):
result.update(status=ST_DISABLED, detail='配置 mysql.tunnel.enabled=0,跳过探测')
return result
if not is_local(host):
# 直连远端数据库时没有隧道可言,不去跑 autossh.sh
result.update(status=ST_NOT_LOCAL, detail=f'{host} 不是本机地址,不适用 SSH 隧道')
return result
if port_open(host, port, timeout=float(_cfg('connect_timeout', 2) or 2)):
result.update(status=ST_ALREADY_OPEN, detail=f'{host}:{port} 已可连')
return result
wait_seconds = float(_cfg('wait_seconds', 30) if wait_seconds is None else wait_seconds)
unit = _cfg('systemd_unit', 'xwlb-tunnel.service')
# 分支 a:systemd 托管中(它会自动重连),只等,不抢端口
if not dry_run and systemd_unit_active(unit):
logger.warning("数据库端口 %s:%s 不通%s,systemd 单元 %s 处于 active,等待其自动重连…",
host, port, prefix, unit)
waited = _wait_for_port(host, port, wait_seconds)
ok = port_open(host, port)
result.update(status=ST_SYSTEMD_WAITED if ok else ST_UNAVAILABLE,
detail=(f'等待 systemd 单元 {unit} 重连' + ('成功' if ok else '超时')),
waited=waited, healed=ok)
if not ok:
logger.error("等待 %s 秒后 %s:%s 仍不通,请检查: systemctl status %s",
int(waited), host, port, unit)
return result
# 分支 b:执行 autossh.sh
script = _cfg('script', 'autossh.sh')
script_path = Path(script)
if not script_path.is_absolute():
script_path = Path(config.BASE_DIR) / script
if dry_run:
result.update(status=ST_SCRIPT_STARTED, healed=False,
detail=f'[dry-run] 将执行 {script_path}')
return result
if not script_path.exists():
logger.error("隧道脚本不存在: %s(config.yml: mysql.tunnel.script)", script_path)
result.update(status=ST_UNAVAILABLE, detail=f'脚本不存在: {script_path}')
return result
logger.warning("数据库端口 %s:%s 不通%s,执行 %s", host, port, prefix, script_path.name)
try:
proc = subprocess.run(['bash', str(script_path)], capture_output=True, text=True, timeout=60)
if proc.returncode != 0:
logger.warning("%s 退出码 %s: %s", script_path.name, proc.returncode,
(proc.stderr or proc.stdout or '').strip()[:300])
except (OSError, subprocess.SubprocessError) as e:
logger.error("执行 %s 失败: %s", script_path, e)
waited = _wait_for_port(host, port, wait_seconds)
ok = port_open(host, port)
result.update(status=ST_SCRIPT_STARTED if ok else ST_UNAVAILABLE,
detail=(f'执行 {script_path.name}' + ('后端口已就绪' if ok else '后端口仍不通')),
waited=waited, healed=ok)
if ok:
logger.info("✓ 隧道已恢复:%s:%s(等待 %.0f 秒)", host, port, waited)
else:
logger.error("执行 %s 并等待 %.0f 秒后 %s:%s 仍不通,请手动检查隧道",
script_path.name, waited, host, port)
return result
def main():
ap = argparse.ArgumentParser(description='数据库隧道探测/自愈')
ap.add_argument('--dry-run', action='store_true', help='只报告会做什么,不执行脚本')
ap.add_argument('--quiet', action='store_true', help='只输出一行结果(供脚本调用)')
args = ap.parse_args()
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s')
r = ensure_tunnel(reason='手动探测', dry_run=args.dry_run)
if args.quiet:
print(f"{r['status']} {r['host']}:{r['port']} {r['detail']}")
else:
print(f"状态: {r['status']}\n地址: {r['host']}:{r['port']}\n说明: {r['detail']}\n等待: {r['waited']:.0f}s")
# 退出码:端口可连才算成功(dry-run 不判定)
if args.dry_run:
return 0
return 0 if port_open(r['host'], r['port']) else 1
if __name__ == '__main__':
sys.exit(main())