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

从 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
+273
View File
@@ -0,0 +1,273 @@
"""对比 news_raw / news_improve(并联动 news_content),评估 LLM 校对的必要性与 token 成本
.venv/bin/python tools/compare_raw_improve.py [--limit N] [--examples N] [--md 输出文件]
分类口径(逐分片):
identical 原文与校对后完全相同 → 校对没做任何事
punct_only 去掉标点/空白后相同 → 校对只动了标点、空白、断句
numeral_only 再去掉数字(中文数字与阿拉伯数字)后相同 → 只动了数字写法或标点
word_change 仍有差异 → 真正改了字词(可能修错别字,也可能是改写/篡改)
token 估算:中文近似 1 字符 ≈ 1 token(保守上限,实际分词器约 0.6~1.0),
只用于比较两个方案的**相对**开销,绝对费用请按自己供应商单价换算。
"""
import argparse
import re
import sys
from collections import Counter
from datetime import date
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
from mysqlHandle import MySQLDB # noqa: E402
# 标点/空白(除中文、字母、数字之外的一切)
_NON_WORD = re.compile(r'[^\u4e00-\u9fffA-Za-z0-9]')
# 中文数字(与阿拉伯数字一并视为"数字")
_CN_NUM = '零〇一二两三四五六七八九十百千万亿'
_NON_WORD_OR_DIGIT = re.compile(r'[^\u4e00-\u9fffA-Za-z]')
_PUNCT_ONLY = re.compile(r'[\u4e00-\u9fffA-Za-z0-9]')
def strip_punct(text):
"""去掉标点与空白,只留中文/字母/数字"""
return _PUNCT_ONLY.findall(text or '')
def strip_punct_and_digits(text):
"""去掉标点、空白与所有数字(含中文数字)"""
return [ch for ch in (text or '') if ch not in _CN_NUM and not ch.isdigit() and _PUNCT_ONLY.match(ch)]
def punct_density(text):
"""每 100 字符中的标点数"""
text = text or ''
if not text:
return 0.0
return len(_NON_WORD.findall(text)) / len(text) * 100
def title_marks(text):
"""书名号《》出现次数(可读性的直观指标)"""
return (text or '').count('《')
def multiset_delta(a, b):
"""a 相对 b 的字符多重集差异数(近似"改了多少字",O(n))"""
ca, cb = Counter(a or ''), Counter(b or '')
return sum((ca - cb).values())
def classify(raw, improve):
if raw == improve:
return 'identical'
if strip_punct(raw) == strip_punct(improve):
return 'punct_only'
if strip_punct_and_digits(raw) == strip_punct_and_digits(improve):
return 'numeral_only'
return 'word_change'
def main():
ap = argparse.ArgumentParser()
ap.add_argument('--limit', type=int, default=0, help='只取最近 N 天')
ap.add_argument('--examples', type=int, default=2, help='每类抽样条数')
ap.add_argument('--md', default='', help='同时写出 markdown 报告')
args = ap.parse_args()
db = MySQLDB()
try:
where = '1=1'
params = None
if args.limit:
dates = db.query_data(
'xwlb_daily', 'DISTINCT news_days d',
'1=1 ORDER BY news_days DESC LIMIT %s', (args.limit,))
if not dates:
print('没有数据')
return 1
d0 = min(r['d'] for r in dates)
where, params = 'news_days >= %s', (d0,)
rows = db.query_data(
'xwlb_daily', 'news_days, daily_sub_id, news_raw, news_improve',
f'{where} ORDER BY news_days, daily_sub_id', params)
content_rows = db.query_data(
'xwlb_daily_ext', 'news_date, sub_id, news_content',
'1=1 ORDER BY news_date, sub_id', None)
finally:
db.close()
# ---------- 逐分片分类 ----------
stats = {k: {'n': 0, 'len_raw': 0, 'len_imp': 0, 'delta': 0, 'delta_words': 0}
for k in ('identical', 'punct_only', 'numeral_only', 'word_change')}
removed_chars, added_chars = Counter(), Counter()
monthly = {}
examples = {k: [] for k in stats}
total_raw = total_imp = 0
pd_raw = pd_imp = 0.0
tm_raw = tm_imp = 0
days = set()
day_changed = {}
for r in rows:
raw, imp = r['news_raw'] or '', r['news_improve'] or ''
cat = classify(raw, imp)
s = stats[cat]
s['n'] += 1
s['len_raw'] += len(raw)
s['len_imp'] += len(imp)
s['delta'] += multiset_delta(raw, imp)
# 排除标点/数字后的"真实字词"改动量
s['delta_words'] += multiset_delta(''.join(strip_punct_and_digits(raw)),
''.join(strip_punct_and_digits(imp)))
if cat == 'word_change':
removed_chars.update((Counter(raw) - Counter(imp)).elements())
added_chars.update((Counter(imp) - Counter(raw)).elements())
key = str(r['news_days'])[:7]
m = monthly.setdefault(key, {'n': 0, 'same': 0})
m['n'] += 1
if cat == 'identical':
m['same'] += 1
total_raw += len(raw)
total_imp += len(imp)
pd_raw += punct_density(raw)
pd_imp += punct_density(imp)
tm_raw += title_marks(raw)
tm_imp += title_marks(imp)
days.add(r['news_days'])
day_changed.setdefault(r['news_days'], 0)
if cat != 'identical':
day_changed[r['news_days']] += 1
if len(examples[cat]) < args.examples:
examples[cat].append((r['news_days'], r['daily_sub_id'], raw, imp))
n = len(rows) or 1
out = []
def w(line=''):
print(line)
out.append(line)
w(f"# news_raw vs news_improve 评估报告")
w()
w(f"样本:`xwlb_daily` {len(rows)} 个分片,覆盖 {len(days)} 天"
f"({min(days)} ~ {max(days)});`xwlb_daily_ext` {len(content_rows)} 条")
w()
w("## 1. 校对到底改了什么")
w()
w("| 分类 | 分片数 | 占比 | 平均改动字符数 | 含义 |")
w("|---|---|---|---|---|")
label = {
'identical': '完全没改',
'punct_only': '只改标点/空白/断句',
'numeral_only': '只改数字写法或标点',
'word_change': '**真的改了字词**',
}
for k in ('identical', 'punct_only', 'numeral_only', 'word_change'):
s = stats[k]
avg_delta = s['delta'] / s['n'] if s['n'] else 0
w(f"| {k} | {s['n']} | {s['n'] / n * 100:.1f}% | {avg_delta:.1f} | {label[k]} |")
wc = stats['word_change']
if wc['n']:
w()
w(f"「真的改了字词」的 {wc['n']} 个分片里,**排除标点与数字后**的平均改动量只有 "
f"**{wc['delta_words'] / wc['n']:.1f} 字**"
f"(占分片平均长度 {wc['len_raw'] / wc['n']:.0f} 字的 "
f"{wc['delta_words'] / max(wc['len_raw'], 1) * 100:.1f}%)")
w()
w("改动最频繁的字符(raw 有而 improve 没有 → improve 新增):")
w()
w("| 被替换掉的字符 | 次数 | 新增的字符 | 次数 |")
w("|---|---|---|---|")
top_rm = removed_chars.most_common(10)
top_ad = added_chars.most_common(10)
for i in range(max(len(top_rm), len(top_ad))):
rm = top_rm[i] if i < len(top_rm) else ('', '')
ad = top_ad[i] if i < len(top_ad) else ('', '')
rm_ch = rm[0].replace(' ', '␠').replace('\n', '⏎')
ad_ch = ad[0].replace(' ', '␠').replace('\n', '⏎')
w(f"| `{rm_ch}` | {rm[1]} | `{ad_ch}` | {ad[1]} |")
changed = n - stats['identical']['n']
w()
w(f"- 校对**实际生效**的分片:{changed}/{n}({changed / n * 100:.1f}%);"
f"完全没动的:{stats['identical']['n']}/{n}({stats['identical']['n'] / n * 100:.1f}%)")
all_same_days = sum(1 for d in days if day_changed.get(d, 0) == 0)
w(f"- 全天 11 个分片**全都没被改**的天数:{all_same_days}/{len(days)}")
w()
w("### 可读性指标(越大越接近正式书面语)")
w()
w("| 指标 | 原文 news_raw | 校对后 news_improve | 变化 |")
w("|---|---|---|---|")
w(f"| 标点密度(每百字标点数) | {pd_raw / n:.2f} | {pd_imp / n:.2f} | "
f"{(pd_imp - pd_raw) / n:+.2f} |")
w(f"| 书名号《总数 | {tm_raw} | {tm_imp} | {tm_imp - tm_raw:+d} |")
w(f"| 总字数 | {total_raw} | {total_imp} | {total_imp - total_raw:+d} |")
if stats['word_change']['n']:
w()
w("### 「真的改了字词」的抽样(判断是修错字还是改写)")
for d, sid, raw, imp in examples['word_change']:
w()
w(f"- **{d} 第{sid}片**(改动约 {multiset_delta(raw, imp)} 字)")
w(f" - raw: {raw[:120]}")
w(f" - imp: {imp[:120]}")
w()
w("### 按月的「完全没改」比例(用于识别校对失效的历史区间)")
w()
w("| 月份 | 分片数 | 完全没改 | 占比 |")
w("|---|---|---|---|")
for key in sorted(monthly):
m = monthly[key]
w(f"| {key} | {m['n']} | {m['same']} | {m['same'] / m['n'] * 100:.0f}% |")
# ---------- token 预算 ----------
w()
w("## 2. token 开销:现状 vs 两个替代方案")
w()
by_day_raw, by_day_imp = {}, {}
for r in rows:
by_day_raw[r['news_days']] = by_day_raw.get(r['news_days'], 0) + len(r['news_raw'] or '')
by_day_imp[r['news_days']] = by_day_imp.get(r['news_days'], 0) + len(r['news_improve'] or '')
by_day_content = {}
for r in content_rows:
if r['news_date'] in by_day_raw:
by_day_content[r['news_date']] = by_day_content.get(r['news_date'], 0) + len(r['news_content'] or '')
common = sorted(set(by_day_raw) & set(by_day_content))
if common:
raw_c = sum(by_day_raw[d] for d in common) / len(common)
imp_c = sum(by_day_imp[d] for d in common) / len(common)
con_c = sum(by_day_content[d] for d in common) / len(common)
corr_in, corr_out = raw_c, imp_c
split_in, split_out = imp_c, con_c
merged_in, merged_out = raw_c, con_c
cur = corr_in + corr_out + split_in + split_out
merged = merged_in + merged_out
off = raw_c + con_c
w(f"统计口径:{len(common)} 天同时有原文与精编的天数,取日均字符数(≈token 数,中文 1 字≈1 token)")
w()
w("| 方案 | 输入 | 输出 | 合计/天 | 相对现状 |")
w("|---|---|---|---|---|")
w(f"| A 现状(校对 + 切分 两次调用) | {corr_in + split_in:.0f} | {corr_out + split_out:.0f} | **{cur:.0f}** | — |")
w(f"| B 合并进切分(一次调用同时校对+切分) | {merged_in:.0f} | {merged_out:.0f} | **{merged:.0f}** | "
f"省 {(1 - merged / cur) * 100:.0f}% |")
w(f"| C 关掉校对(直接用 ASR 原文切分) | {off:.0f} | {con_c:.0f} | **{off:.0f}** | "
f"省 {(1 - off / cur) * 100:.0f}% |")
w()
w(f"其中校对这一次调用本身消耗 输入 {corr_in:.0f} + 输出 {corr_out:.0f} = "
f"**{corr_in + corr_out:.0f} token/天**,占现状总开销的 "
f"{(corr_in + corr_out) / cur * 100:.0f}%。")
w()
w(f"按 {len(common)} 天累计:校对一项约消耗 "
f"{(corr_in + corr_out) * len(common) / 1e6:.2f} M token。")
if args.md:
Path(args.md).write_text('\n'.join(out) + '\n', encoding='utf-8')
print(f"\n报告已写入 {args.md}")
return 0
if __name__ == '__main__':
sys.exit(main())