Files
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

403 lines
15 KiB
Python
Raw Permalink 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.
"""非敏感配置加载器:config.yml + 环境变量覆盖 + 内置默认值
分工:
- 敏感配置(数据库口令、API Key)留在 .env,由 env.py 加载;
- 其余配置(数据库地址、目录、**各环节使用的模型**、切分参数、清理开关)都写在 config.yml;
- 可用 XWLB_CONFIG_FILE 指定其他配置文件。
优先级:**进程环境变量 > config.yml > 本文件内置默认值**
(便于临时试验,例如 `DEEPSEEK_MODEL=deepseek-reasoner python newsProcess.py`)
"""
import copy
import logging
import os
from pathlib import Path
import env # noqa: F401 — 先加载 .env,使其中的变量可覆盖 config.yml
logger = logging.getLogger(__name__)
BASE_DIR = Path(__file__).resolve().parent
# 环境变量 -> 配置项(点号路径)的覆盖映射
ENV_OVERRIDES = {
'MYSQL_HOST': 'mysql.host',
'MYSQL_PORT': 'mysql.port',
'MYSQL_USER': 'mysql.user',
'MYSQL_DATABASE': 'mysql.database',
'XWLB_VIDEO_DIR': 'paths.video_dir',
'XWLB_AUDIO_DIR': 'paths.audio_dir',
'DASHSCOPE_ASR_MODEL': 'models.asr_model',
'DASHSCOPE_LLM_MODEL': 'models.correct_model',
'DEEPSEEK_MODEL': 'models.split_model',
'LLM_CORRECT_ENABLED': 'llm_correct.enabled',
'LLM_CORRECT_RETRIES': 'llm_correct.max_retries',
'LLM_CORRECT_TIMEOUT': 'llm_correct.timeout',
'LLM_CORRECT_MAX_TOKENS': 'llm_correct.max_tokens',
# 接入点地址与路由(换供应商时也可用环境变量临时试)
'DASHSCOPE_HTTP_BASE_URL': 'endpoints.dashscope.http_base_url',
'DASHSCOPE_WEBSOCKET_BASE_URL': 'endpoints.dashscope.websocket_base_url',
'DEEPSEEK_BASE_URL': 'endpoints.deepseek.base_url',
'XWLB_ROUTE_ASR': 'routes.asr',
'XWLB_ROUTE_CORRECT': 'routes.correct',
'XWLB_ROUTE_SPLIT': 'routes.split',
}
# 各环节默认走哪个接入点
DEFAULT_ROUTES = {'asr': 'dashscope', 'correct': 'dashscope', 'split': 'deepseek'}
# 内置默认值:config.yml 缺失时项目仍可运行
DEFAULTS = {
'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': 'qwen-plus',
'split_model': 'deepseek-chat',
},
'endpoints': {
'dashscope': {
'kind': 'dashscope',
'http_base_url': 'https://dashscope.aliyuncs.com/api/v1',
'websocket_base_url': 'wss://dashscope.aliyuncs.com/api-ws/v1/inference',
'api_key_env': 'DASHSCOPE_API_KEY',
},
'deepseek': {
'kind': 'openai',
'base_url': 'https://api.deepseek.com/v1',
'chat_completions_path': '/chat/completions',
'api_key_env': 'DEEPSEEK_API_KEY',
},
},
'routes': dict(DEFAULT_ROUTES),
'asr': {'sample_rate': 16000, 'language_hints': ['zh', 'en']},
'audio_split': {
'min_silence_ms': 700,
'silence_thresh_db': -40,
'keep_silence_ms': 400,
'max_chunk_ms': 180000,
},
'llm_correct': {
'enabled': 1, 'max_retries': 2, 'timeout': 90,
'max_tokens': 8000, 'temperature': 0.1, 'top_p': 0.5,
},
'llm_split': {
'max_tokens': 20000, 'retry_max_tokens': 32000,
'temperature': 0.5, 'timeout': 60, 'max_retries': 3,
},
'cleanup': {
'after_daily_run': 1, 'only_on_success': 1,
'remove_video': 1, 'remove_audio': 1,
},
}
def config_path() -> Path:
"""配置文件路径(XWLB_CONFIG_FILE 可覆盖)"""
override = os.getenv('XWLB_CONFIG_FILE')
if override:
return Path(override).expanduser()
return BASE_DIR / 'config.yml'
def _deep_merge(base: dict, override: dict) -> dict:
"""递归合并,override 优先"""
result = copy.deepcopy(base)
for key, value in (override or {}).items():
if isinstance(value, dict) and isinstance(result.get(key), dict):
result[key] = _deep_merge(result[key], value)
else:
result[key] = value
return result
def _set_by_path(target: dict, dotted: str, value):
"""按点号路径写入嵌套 dict"""
keys = dotted.split('.')
node = target
for key in keys[:-1]:
node = node.setdefault(key, {})
node[keys[-1]] = value
def load_config(path=None) -> dict:
"""加载配置:默认值 ← config.yml ← 环境变量"""
path = Path(path) if path else config_path()
file_config = {}
if path.is_file():
import yaml # 延迟导入,未使用配置文件时不强依赖
try:
with open(path, encoding='utf-8') as f:
file_config = yaml.safe_load(f) or {}
except yaml.YAMLError as e:
raise RuntimeError(f"配置文件解析失败({path}):{e}") from e
if not isinstance(file_config, dict):
raise RuntimeError(f"配置文件格式错误({path}):顶层应为键值映射")
merged = _deep_merge(DEFAULTS, file_config)
for env_key, dotted in ENV_OVERRIDES.items():
raw = os.getenv(env_key)
if raw is None or raw == '':
continue
_set_by_path(merged, dotted, raw)
return merged
CONFIG = load_config()
LOADED_CONFIG_FILE = config_path() if config_path().is_file() else None
def reload_config():
"""重新加载(供测试使用)"""
global CONFIG, LOADED_CONFIG_FILE
CONFIG = load_config()
LOADED_CONFIG_FILE = config_path() if config_path().is_file() else None
apply_dashscope_endpoints()
return CONFIG
# --------------------------------------------------------------------- 取值
def get(dotted: str, default=None):
"""按点号路径取值,如 get('models.asr_model')"""
node = CONFIG
for key in dotted.split('.'):
if not isinstance(node, dict) or key not in node:
return default
node = node[key]
return node
def get_int(dotted: str, default: int = 0) -> int:
try:
return int(get(dotted, default))
except (TypeError, ValueError):
logger.warning("配置项 %s 不是整数,使用默认值 %s", dotted, default)
return default
def get_bool(dotted: str, default: bool = False) -> bool:
value = get(dotted, default)
if isinstance(value, bool):
return value
if isinstance(value, (int, float)):
return bool(value)
return str(value).strip().lower() in ('1', 'true', 'yes', 'on')
def get_dict(dotted: str, default=None):
"""取字典配置(如 extra_body);非字典时返回默认值"""
value = get(dotted, None)
if isinstance(value, dict):
return dict(value)
if value not in (None, ''):
logger.warning("配置项 %s 不是字典,已忽略: %r", dotted, value)
return dict(default or {})
def get_list(dotted: str, default=None):
value = get(dotted, None)
if isinstance(value, (list, tuple)):
return list(value)
if isinstance(value, str) and value.strip():
return [item.strip() for item in value.split(',') if item.strip()]
return list(default or [])
# --------------------------------------------------------------------- 派生
def model(role: str) -> str:
"""取某环节实际使用的模型名:role ∈ {asr_model, correct_model, split_model}"""
return str(get(f'models.{role}', ''))
# ----------------------------------------------------------------- 接入点/路由
def endpoint(name: str) -> dict:
"""取某个接入点定义(endpoints.<name>)"""
return get(f'endpoints.{name}', {}) or {}
def route(role: str) -> str:
"""取某环节走哪个接入点:role ∈ {asr, correct, split}"""
return str(get(f'routes.{role}', DEFAULT_ROUTES.get(role, '')))
def endpoint_for(role: str):
"""
解析某环节的接入点,返回 (接入点名字, 接入点定义)
未配置或缺少该协议必需字段时抛 RuntimeError(附修复指引),避免调用时才报难懂的错。
"""
name = route(role)
if not name:
raise RuntimeError(f"config.yml 未配置 routes.{role}")
cfg = endpoint(name)
if not cfg:
raise RuntimeError(
f"config.yml 的 routes.{role} 指向接入点 '{name}',但 endpoints 下没有该定义")
kind = str(cfg.get('kind', 'openai')).lower()
if kind == 'dashscope':
if not cfg.get('http_base_url') and not cfg.get('websocket_base_url'):
raise RuntimeError(f"接入点 '{name}' 是 dashscope 类型,但缺少 http_base_url / websocket_base_url")
elif kind == 'openai':
if not cfg.get('base_url'):
raise RuntimeError(f"接入点 '{name}' 是 openai 类型,但缺少 base_url")
else:
raise RuntimeError(f"接入点 '{name}' 的 kind='{kind}' 不支持(仅支持 dashscope / openai)")
if not cfg.get('api_key_env'):
raise RuntimeError(f"接入点 '{name}' 未配置 api_key_env(密钥在 .env 中的变量名)")
return name, cfg
def openai_url(role: str) -> str:
"""OpenAI 兼容接入点的完整请求地址(base_url + chat_completions_path 拼接)"""
_, cfg = endpoint_for(role)
base = str(cfg.get('base_url', '')).rstrip('/')
path = str(cfg.get('chat_completions_path', '/chat/completions'))
if not path.startswith('/'):
path = '/' + path
return base + path
def api_key_env(role: str) -> str:
"""该环节密钥所在的 .env 变量名"""
return str(endpoint_for(role)[1].get('api_key_env', ''))
def api_key(role: str) -> str:
"""该环节的密钥值(从环境变量取,即 .env)"""
return os.getenv(api_key_env(role), '')
def apply_dashscope_endpoints(dashscope_module=None):
"""
把 config.yml 里 dashscope 类型的接入点地址写入 SDK
dashscope SDK 在 **import 时**读取 `DASHSCOPE_HTTP_BASE_URL` /
`DASHSCOPE_WEBSOCKET_BASE_URL`,因此在 config 加载阶段就写环境变量,
并在已 import 之后再次赋值模块属性,避免依赖 import 顺序。
"""
http_url = ws_url = ''
try:
if route('correct') and endpoint(route('correct')).get('kind') == 'dashscope':
http_url = endpoint(route('correct')).get('http_base_url', '')
if route('asr') and endpoint(route('asr')).get('kind') == 'dashscope':
ws_url = endpoint(route('asr')).get('websocket_base_url', '')
except Exception: # 配置不全时不阻塞启动,调用时才会明确报错
return
if not http_url:
http_url = endpoint('dashscope').get('http_base_url', '')
if not ws_url:
ws_url = endpoint('dashscope').get('websocket_base_url', '')
if http_url and not os.environ.get('DASHSCOPE_HTTP_BASE_URL'):
os.environ['DASHSCOPE_HTTP_BASE_URL'] = http_url
if ws_url and not os.environ.get('DASHSCOPE_WEBSOCKET_BASE_URL'):
os.environ['DASHSCOPE_WEBSOCKET_BASE_URL'] = ws_url
if dashscope_module is not None:
if http_url:
dashscope_module.base_http_api_url = http_url
if ws_url:
dashscope_module.base_websocket_api_url = ws_url
def validate_endpoints():
"""返回配置问题清单(供启动日志提示,不抛异常)"""
problems = []
for role in ('asr', 'correct', 'split'):
try:
name, cfg = endpoint_for(role)
except RuntimeError as e:
problems.append(str(e))
continue
if role == 'asr' and str(cfg.get('kind', '')).lower() != 'dashscope':
problems.append(
f"routes.asr -> '{name}'(kind={cfg.get('kind')}):实时语音识别仅支持 kind=dashscope,"
f"换 ASR 供应商需要新增适配器")
key_env = cfg.get('api_key_env', '')
if key_env and not os.getenv(key_env):
problems.append(f"接入点 '{name}' 的密钥未设置:请在 .env 中填写 {key_env}")
return problems
# ----------------------------------------------------------------- 敏感项校验
def required_secrets():
"""运行所需的全部敏感项:数据库口令 + 各环节接入点的密钥"""
keys = {'MYSQL_PASSWORD'}
for role in ('asr', 'correct', 'split'):
try:
key_env = api_key_env(role)
except RuntimeError:
continue
if key_env:
keys.add(key_env)
return sorted(keys)
def missing_secrets():
"""返回缺失的敏感项列表"""
return [key for key in required_secrets() if not os.getenv(key)]
def check_secrets():
"""校验敏感项;缺失时记录 ERROR(不抛异常,便于只做只读操作的场景)"""
missing = missing_secrets()
if missing:
logger.error("缺少敏感配置 %s;请在 %s 中填写(模板见 .env.example)", ', '.join(missing), env.env_file())
return missing
def resolve_dir(value) -> Path:
"""目录配置解析:相对路径基于项目根"""
path = Path(str(value)).expanduser()
return path if path.is_absolute() else (BASE_DIR / path)
def video_dir() -> Path:
return resolve_dir(get('paths.video_dir', 'xwlb_video'))
def audio_dir() -> Path:
return resolve_dir(get('paths.audio_dir', 'audio_processing'))
def db_config() -> dict:
"""数据库连接参数(口令来自 .env)"""
return {
'host': get('mysql.host', 'localhost'),
'port': get_int('mysql.port', 3306),
'username': get('mysql.user', 'myquant'),
'password': os.getenv('MYSQL_PASSWORD', ''),
'database': get('mysql.database', 'myquant'),
}
def log_summary():
"""启动时打印一次生效配置(不含敏感项),并校验敏感项与接入点配置"""
source = str(LOADED_CONFIG_FILE) if LOADED_CONFIG_FILE else '内置默认值(未找到 config.yml)'
logger.info(
"配置来源: %s | MySQL %s@%s:%s/%s | 模型 asr=%s correct=%s split=%s | 校对=%s | 完成后清理=%s",
source,
get('mysql.user'), get('mysql.host'), get('mysql.port'), get('mysql.database'),
model('asr_model'), model('correct_model'), model('split_model'),
'开启' if get_bool('llm_correct.enabled', True) else '关闭',
'开启' if get_bool('cleanup.after_daily_run', True) else '关闭',
)
for role in ('asr', 'correct', 'split'):
try:
name, cfg = endpoint_for(role)
except RuntimeError as e:
logger.error("接入点配置错误: %s", e)
continue
if str(cfg.get('kind', '')).lower() == 'dashscope':
url = (cfg.get('websocket_base_url', '') if role == 'asr' else '') or cfg.get('http_base_url', '')
else:
url = openai_url(role)
logger.info("接入点 %-5s -> %-10s kind=%-9s %s(密钥变量 %s)",
role, name, cfg.get('kind'), url, cfg.get('api_key_env'))
for problem in validate_endpoints():
logger.error("配置检查: %s", problem)
check_secrets()
# config 加载阶段就把 dashscope 地址写入环境变量(SDK 在 import 时读取)
apply_dashscope_endpoints()