Files
xwlb/tunnel.py
T
simon 60f8c263f2 《新闻联播》每日抓取入库:全链路 + 可移植化 + 定时任务
从 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 项离线自检(配置/清理/事实守卫/解析/隧道)
2026-09-25 11:17:46 +08:00

200 lines
8.2 KiB
Python
Raw 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.
"""数据库隧道自愈: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())