《新闻联播》每日抓取入库:全链路 + 可移植化 + 定时任务
从 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,173 @@
|
||||
"""配置层回归测试(不依赖 pytest)
|
||||
|
||||
python tests/test_config.py
|
||||
|
||||
覆盖:
|
||||
- config.yml 能被加载,关键配置项存在且类型正确;
|
||||
- 点号取值 / 布尔与整数转换;
|
||||
- 目录解析(相对路径基于项目根);
|
||||
- 环境变量覆盖 config.yml(临时试验用);
|
||||
- 敏感项不在 config.yml 里(只能在 .env)。
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
sys.path.insert(0, str(ROOT))
|
||||
|
||||
import yaml # noqa: E402
|
||||
|
||||
import config # noqa: E402
|
||||
|
||||
# 测试不硬编码"当前配的是哪个模型/哪条路由"——那是用户随时会改的。
|
||||
# 这里直接用 yaml 独立读一遍 config.yml,与 config.py 的解析结果对比:
|
||||
# 校验的是"解析机制正确",而不是"值恰好等于某个名字"。
|
||||
with open(ROOT / 'config.yml', encoding='utf-8') as _f:
|
||||
RAW = yaml.safe_load(_f)
|
||||
|
||||
def raw(dotted, default=None):
|
||||
node = RAW
|
||||
for key in dotted.split('.'):
|
||||
if not isinstance(node, dict) or key not in node:
|
||||
return default
|
||||
node = node[key]
|
||||
return node
|
||||
|
||||
# 按 config.yml 自己的 routes/endpoints 推导"应要求哪些密钥"(独立算法)
|
||||
_expected_secrets = {'MYSQL_PASSWORD'}
|
||||
for _role in ('asr', 'correct', 'split'):
|
||||
_ep = (RAW.get('endpoints') or {}).get((RAW.get('routes') or {}).get(_role), {})
|
||||
if _ep.get('api_key_env'):
|
||||
_expected_secrets.add(_ep['api_key_env'])
|
||||
|
||||
|
||||
def main():
|
||||
failed = 0
|
||||
|
||||
def check(desc, got, want):
|
||||
nonlocal failed
|
||||
if got == want:
|
||||
print(f" ✓ {desc}: {got!r}")
|
||||
else:
|
||||
failed += 1
|
||||
print(f" ✗ {desc}: 期望 {want!r},实际 {got!r}")
|
||||
|
||||
print("config.yml 加载:")
|
||||
check('配置文件存在', config.LOADED_CONFIG_FILE is not None, True)
|
||||
check('数据库端口', config.get_int('mysql.port'), 13306)
|
||||
check('数据库库名', config.get('mysql.database'), 'myquant')
|
||||
check('ASR 模型', config.model('asr_model'), 'paraformer-realtime-v2')
|
||||
check('校对模型(与 config.yml 一致)', config.model('correct_model'), raw('models.correct_model'))
|
||||
check('切分模型(与 config.yml 一致)', config.model('split_model'), raw('models.split_model'))
|
||||
check('校对开关(默认1)', config.get_bool('llm_correct.enabled'), True)
|
||||
check('清理开关(默认1)', config.get_bool('cleanup.after_daily_run'), True)
|
||||
check('仅成功时清理', config.get_bool('cleanup.only_on_success'), True)
|
||||
check('语言提示', config.get_list('asr.language_hints'), ['zh', 'en'])
|
||||
|
||||
print("\n目录解析:")
|
||||
check('video_dir 基于项目根', config.video_dir(), ROOT / 'xwlb_video')
|
||||
check('audio_dir 基于项目根', config.audio_dir(), ROOT / 'audio_processing')
|
||||
|
||||
print("\n环境变量覆盖优先于 config.yml:")
|
||||
os.environ['DASHSCOPE_LLM_MODEL'] = 'qwen-max-test'
|
||||
os.environ['LLM_CORRECT_ENABLED'] = '0'
|
||||
cfg = config.reload_config()
|
||||
check('模型被环境变量覆盖', cfg['models']['correct_model'], 'qwen-max-test')
|
||||
check('开关被环境变量覆盖', config.get_bool('llm_correct.enabled'), False)
|
||||
del os.environ['DASHSCOPE_LLM_MODEL'], os.environ['LLM_CORRECT_ENABLED']
|
||||
config.reload_config()
|
||||
check('恢复 config.yml 值', config.model('correct_model'), raw('models.correct_model'))
|
||||
|
||||
print("\n接入点与路由(换供应商的核心):")
|
||||
check('asr 路由(与 config.yml 一致)', config.route('asr'), raw('routes.asr'))
|
||||
check('correct 路由(与 config.yml 一致)', config.route('correct'), raw('routes.correct'))
|
||||
check('correct 接入点类型(与 config.yml 一致)',
|
||||
config.endpoint_for('correct')[1]['kind'], raw(f"endpoints.{raw('routes.correct')}.kind"))
|
||||
check('split 接入点类型', config.endpoint_for('split')[1]['kind'], 'openai')
|
||||
check('split 请求地址拼接', config.openai_url('split'),
|
||||
'https://api.deepseek.com/v1/chat/completions')
|
||||
check('密钥变量名来自接入点', config.api_key_env('split'), 'DEEPSEEK_API_KEY')
|
||||
check('必需敏感项 = 路由用到的接入点密钥 + MySQL 口令',
|
||||
sorted(config.required_secrets()), sorted(_expected_secrets))
|
||||
check('默认配置无问题', config.validate_endpoints(), [])
|
||||
import dashscope
|
||||
check('dashscope SDK 地址已按配置生效', dashscope.base_http_api_url,
|
||||
config.endpoint('dashscope')['http_base_url'])
|
||||
check('dashscope WS 地址已按配置生效', dashscope.base_websocket_api_url,
|
||||
config.endpoint('dashscope')['websocket_base_url'])
|
||||
|
||||
print("\n切换供应商(把校对接管到 OpenAI 兼容接入点):")
|
||||
os.environ['XWLB_ROUTE_CORRECT'] = 'deepseek'
|
||||
os.environ['DASHSCOPE_LLM_MODEL'] = 'deepseek-chat'
|
||||
config.reload_config()
|
||||
check('correct 已切到 openai 类型', config.endpoint_for('correct')[1]['kind'], 'openai')
|
||||
check('correct 请求地址随之改变', config.openai_url('correct'),
|
||||
'https://api.deepseek.com/v1/chat/completions')
|
||||
check('correct 模型名可独立覆盖', config.model('correct_model'), 'deepseek-chat')
|
||||
check('密钥变量名随供应商变化', config.api_key_env('correct'), 'DEEPSEEK_API_KEY')
|
||||
|
||||
# 把 ASR 也切到非 dashscope 接入点:应被校验拦下(实时识别协议不支持)
|
||||
os.environ['XWLB_ROUTE_ASR'] = 'deepseek'
|
||||
config.reload_config()
|
||||
check('非 dashscope 的 ASR 被校验提示', any('仅支持 kind=dashscope' in p
|
||||
for p in config.validate_endpoints()), True)
|
||||
check('不需要 dashscope 密钥了', config.required_secrets(), ['DEEPSEEK_API_KEY', 'MYSQL_PASSWORD'])
|
||||
|
||||
# 指向不存在的接入点:必须给出可读错误
|
||||
os.environ['XWLB_ROUTE_SPLIT'] = 'not-exist'
|
||||
config.reload_config()
|
||||
try:
|
||||
config.endpoint_for('split')
|
||||
check('未知接入点应报错', '未报错', 'RuntimeError')
|
||||
except RuntimeError as e:
|
||||
check('未知接入点报错可读', '没有该定义' in str(e), True)
|
||||
|
||||
# 地址也可用环境变量覆盖
|
||||
os.environ['XWLB_ROUTE_CORRECT'] = 'deepseek'
|
||||
os.environ['DEEPSEEK_BASE_URL'] = 'https://my-gateway.example.com/llm'
|
||||
config.reload_config()
|
||||
check('base_url 可被环境变量覆盖', config.openai_url('correct'),
|
||||
'https://my-gateway.example.com/llm/chat/completions')
|
||||
|
||||
for key in ('XWLB_ROUTE_CORRECT', 'XWLB_ROUTE_ASR', 'XWLB_ROUTE_SPLIT',
|
||||
'DASHSCOPE_LLM_MODEL', 'DEEPSEEK_BASE_URL'):
|
||||
os.environ.pop(key, None)
|
||||
config.reload_config()
|
||||
check('恢复 config.yml 路由', [config.route(r) for r in ('asr', 'correct', 'split')],
|
||||
[raw('routes.asr'), raw('routes.correct'), raw('routes.split')])
|
||||
|
||||
print("\n敏感项与 config.yml 隔离:")
|
||||
import yaml
|
||||
yml = yaml.safe_load((ROOT / 'config.yml').read_text(encoding='utf-8'))
|
||||
|
||||
def walk(node, path=''):
|
||||
for key, value in (node or {}).items():
|
||||
here = f'{path}.{key}' if path else str(key)
|
||||
if isinstance(value, dict):
|
||||
yield from walk(value, here)
|
||||
else:
|
||||
yield here, value
|
||||
|
||||
leaves = list(walk(yml))
|
||||
# 1) 配置项名不得是口令/密钥类(注意排除 max_tokens 这类合法项)
|
||||
risky_names = {'password', 'passwd', 'secret', 'secret_key', 'api_key', 'apikey',
|
||||
'token', 'access_token', 'auth_token'}
|
||||
risky = [p for p, _ in leaves
|
||||
if (lambda last: last in risky_names or last.endswith(('_password', '_secret', '_api_key')))(
|
||||
p.split('.')[-1].lower())]
|
||||
check('config.yml 无口令/密钥类配置项', risky, [])
|
||||
# 2) 配置值不得等于 .env 中的真实密钥(防误粘贴)
|
||||
real_secrets = {v for v in (os.getenv('MYSQL_PASSWORD'), os.getenv('DASHSCOPE_API_KEY'),
|
||||
os.getenv('DEEPSEEK_API_KEY')) if v}
|
||||
leaked = [p for p, v in leaves if isinstance(v, str) and v in real_secrets]
|
||||
check('config.yml 未泄漏 .env 中的密钥值', leaked, [])
|
||||
check('db_config 口令来自环境变量',
|
||||
config.db_config()['password'] == os.getenv('MYSQL_PASSWORD'), True)
|
||||
|
||||
print(f"\n结果: {'全部通过' if failed == 0 else f'{failed} 项失败'}")
|
||||
return 1 if failed else 0
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user