《新闻联播》每日抓取入库:全链路 + 可移植化 + 定时任务
从 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:
@@ -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())
|
||||
Reference in New Issue
Block a user