- scripts/validate_episodes.py:结构/字段/时间戳/音频对账等校验 - scripts/fix_episodes.py:重算统计字段、引号归一、去空白(自动备份) - scripts/README.md:用法与已知数据缺口说明 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
161 lines
5.7 KiB
Python
161 lines
5.7 KiB
Python
#!/usr/bin/env python3
|
||
"""校验 family-album-usa/episodes.json 的结构与数据质量。
|
||
|
||
把人工体检固化为可重复运行的检查。每次改动内容数据后跑一遍,防止数据漂移。
|
||
|
||
用法:
|
||
python3 scripts/validate_episodes.py # 用默认路径
|
||
python3 scripts/validate_episodes.py path/to/episodes.json
|
||
|
||
退出码:发现 ERROR 返回 1,仅 WARN/INFO 返回 0。
|
||
"""
|
||
import json
|
||
import os
|
||
import re
|
||
import sys
|
||
|
||
# 默认数据路径:脚本所在目录的上一级 / family-album-usa
|
||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||
REPO = os.path.dirname(HERE)
|
||
DEFAULT_JSON = os.path.join(REPO, "family-album-usa", "episodes.json")
|
||
AUDIO_DIR = os.path.join(REPO, "family-album-usa", "audio")
|
||
|
||
# 服务路径前缀 -> 磁盘目录的映射(audio 字段写的是对外路径)
|
||
SERVE_PREFIX = "/family-album/audio/"
|
||
SPEAKER_RE = re.compile(r"^[A-Z][A-Za-z .]{1,20}:\s")
|
||
CURLY = "“”‘’"
|
||
|
||
errors = []
|
||
warns = []
|
||
infos = []
|
||
|
||
|
||
def err(msg):
|
||
errors.append(msg)
|
||
|
||
|
||
def warn(msg):
|
||
warns.append(msg)
|
||
|
||
|
||
def info(msg):
|
||
infos.append(msg)
|
||
|
||
|
||
def validate(path):
|
||
with open(path, encoding="utf-8") as f:
|
||
data = json.load(f)
|
||
|
||
# --- 顶层结构 ---
|
||
if not isinstance(data, dict) or "episodes" not in data:
|
||
err("顶层缺少 'episodes' 键")
|
||
return data
|
||
meta = data.get("meta", {})
|
||
eps = data["episodes"]
|
||
acts = [(e, a) for e in eps for a in e.get("acts", [])]
|
||
lines = [l for _, a in acts for l in a.get("lines", [])]
|
||
|
||
# --- episode id 连续性 ---
|
||
ids = [e.get("id") for e in eps]
|
||
if ids != list(range(1, len(eps) + 1)):
|
||
warn(f"episode id 非 1..{len(eps)} 连续:{ids}")
|
||
|
||
# --- 字段一致性 ---
|
||
line_keys = {tuple(sorted(l.keys())) for l in lines}
|
||
if line_keys - {("en", "t")}:
|
||
warn(f"line 字段不统一,出现:{line_keys}")
|
||
for l in lines:
|
||
if not isinstance(l.get("t"), (int, float)):
|
||
err(f"line.t 非数字:{l!r}")
|
||
elif l["t"] < 0:
|
||
err(f"line.t 为负:{l!r}")
|
||
if not str(l.get("en", "")).strip():
|
||
err(f"line.en 为空:{l!r}")
|
||
elif l["en"] != l["en"].strip():
|
||
warn(f"line.en 首尾有空白:{l['en']!r}")
|
||
|
||
# --- lineCount 与实际行数 ---
|
||
for e, a in acts:
|
||
n = len(a.get("lines", []))
|
||
if a.get("lineCount") != n:
|
||
err(f"ep{e['id']} act{a.get('act')} lineCount={a.get('lineCount')} != 实际 {n}")
|
||
|
||
# --- meta / episode 统计字段自洽 ---
|
||
if meta.get("totalEpisodes") != len(eps):
|
||
warn(f"meta.totalEpisodes={meta.get('totalEpisodes')} != 实际 {len(eps)}")
|
||
audio_acts = sum(1 for _, a in acts if a.get("hasAudio"))
|
||
if meta.get("totalActs") not in (len(acts), audio_acts):
|
||
warn(f"meta.totalActs={meta.get('totalActs')}(实际 act={len(acts)},有音频={audio_acts})口径不明")
|
||
for e in eps:
|
||
real = sum(len(a.get("lines", [])) for a in e.get("acts", []))
|
||
if "totalLines" in e and e["totalLines"] != real:
|
||
warn(f"ep{e['id']} totalLines={e['totalLines']} != 实际 {real}")
|
||
real_has = any(a.get("hasAudio") for a in e.get("acts", []))
|
||
if "hasAudio" in e and bool(e["hasAudio"]) != real_has:
|
||
warn(f"ep{e['id']} hasAudio={e['hasAudio']} != 实际 {real_has}")
|
||
|
||
# --- 时间戳单调性 ---
|
||
for e, a in acts:
|
||
ts = [l["t"] for l in a.get("lines", []) if isinstance(l.get("t"), (int, float))]
|
||
for i in range(1, len(ts)):
|
||
if ts[i] < ts[i - 1]:
|
||
warn(f"ep{e['id']} act{a['act']} 第{i}行时间戳倒退:{ts[i]} < {ts[i-1]}")
|
||
break
|
||
|
||
# --- 音频对账 ---
|
||
disk = set(os.listdir(AUDIO_DIR)) if os.path.isdir(AUDIO_DIR) else set()
|
||
if not disk:
|
||
warn(f"音频目录不存在或为空:{AUDIO_DIR}")
|
||
referenced = set()
|
||
for e, a in acts:
|
||
au = a.get("audio")
|
||
if a.get("hasAudio") and not au:
|
||
err(f"ep{e['id']} act{a['act']} hasAudio=true 但缺 audio 字段")
|
||
if au:
|
||
if not au.startswith(SERVE_PREFIX):
|
||
warn(f"ep{e['id']} act{a['act']} audio 路径前缀异常:{au}")
|
||
fn = au.rsplit("/", 1)[-1]
|
||
referenced.add(fn)
|
||
if disk and fn not in disk:
|
||
err(f"引用的音频文件磁盘缺失:{fn}")
|
||
if disk:
|
||
for fn in sorted(disk - referenced):
|
||
warn(f"游离音频(磁盘有但未被引用):{fn}")
|
||
|
||
# --- 内容缺口与格式提示(不阻断) ---
|
||
no_audio = [e["id"] for e in eps if not any(a.get("hasAudio") for a in e["acts"])]
|
||
if no_audio:
|
||
info(f"完全无音频的 episode({len(no_audio)} 集):{no_audio}")
|
||
spk = sum(1 for l in lines if SPEAKER_RE.match(l["en"]))
|
||
if spk:
|
||
per = sorted({e["id"] for e, a in acts for l in a["lines"] if SPEAKER_RE.match(l["en"])})
|
||
info(f"含说话人前缀的行:{spk} 行,分布于 episode {per}")
|
||
curly = sum(1 for l in lines if any(c in l["en"] for c in CURLY))
|
||
if curly:
|
||
info(f"含弯引号的行:{curly}(建议统一为直引号)")
|
||
info(f"规模:{len(eps)} 集 / {len(acts)} act({audio_acts} 有音频)/ {len(lines)} 行")
|
||
return data
|
||
|
||
|
||
def main():
|
||
path = sys.argv[1] if len(sys.argv) > 1 else DEFAULT_JSON
|
||
print(f"校验:{path}\n")
|
||
try:
|
||
validate(path)
|
||
except (OSError, json.JSONDecodeError) as ex:
|
||
print(f"[FATAL] 无法读取/解析:{ex}")
|
||
sys.exit(2)
|
||
|
||
for m in infos:
|
||
print(f"[INFO] {m}")
|
||
for m in warns:
|
||
print(f"[WARN] {m}")
|
||
for m in errors:
|
||
print(f"[ERROR] {m}")
|
||
print(f"\n小结:{len(errors)} 个 ERROR,{len(warns)} 个 WARN,{len(infos)} 条 INFO")
|
||
sys.exit(1 if errors else 0)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|