初始提交:抖音视频批量采集流水线

App 端免签名接口(设备注册 + detail + CDN 直链),一体化流水线
下载→抽音→传COS→记录→删本地,AI 作品四档判定,四份文档:
README(使用指南)、PROGRESS(批次进度)、RE(逆向全过程)、AI(判定规则)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
2026-09-29 16:31:10 +08:00
commit 2bb83f3685
39 changed files with 298874 additions and 0 deletions

174
scripts/clean_ai.py Normal file
View File

@@ -0,0 +1,174 @@
"""从抖音链接 jsonl 里筛出 AI 相关的作品。
判定逻辑在 douyin/aiclass.py,分 platform / strong / topic / author 四档,
这里只负责流式读写、统计和落盘。
输出:
*.txt 纯链接一行一个,直接喂给 pipeline.py --from-file
*.jsonl 原记录 + ai_tier + ai_signals,字段全留着,方便复核和再切。
这份体积接近源文件(实测 13967 条就有 169MB),而且随时能重跑
生成,所以默认**不写**,要复核再加 --with-jsonl。
用法:
python clean_ai.py -i 抖音链接20260827_40087.jsonl --stats
python clean_ai.py -i 抖音链接20260827_40087.jsonl --tier platform,strong -o ai
python clean_ai.py -i 抖音链接20260827_40087.jsonl --tier platform,strong -o ai --with-jsonl
python clean_ai.py -i xxx.jsonl --tier platform,strong --exclude-forbidden -o ai
"""
from __future__ import annotations
# 移到 scripts/ tests/ 后,直接运行时脚本目录成了 sys.path[0],
# 把项目根加回来,import douyin / pipeline 才找得到。
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
import argparse
import json
import sys
from collections import Counter
from pathlib import Path
from douyin.aiclass import TIERS, classify
def iter_jsonl(path: Path):
with open(path, "r", encoding="utf-8", errors="replace") as f:
for n, line in enumerate(f, 1):
line = line.strip()
if not line:
continue
try:
yield n, json.loads(line)
except ValueError:
print(f"[跳过] 第 {n} 行不是合法 JSON", file=sys.stderr)
def main() -> int:
ap = argparse.ArgumentParser(description="筛出 AI 相关作品")
ap.add_argument("-i", "--input", required=True)
ap.add_argument("-o", "--out", default="", help="输出前缀,生成 <前缀>.jsonl 和 <前缀>.txt")
ap.add_argument("--tier", default="platform,strong",
help=f"保留哪些档,逗号分隔。可选 {'/'.join(TIERS)}")
ap.add_argument("--exclude-forbidden", action="store_true",
help="剔掉 allow_download=false 的(抖音对 AIGC 常给 aigc_forbidden)")
ap.add_argument("--with-jsonl", action="store_true",
help="连带写出 <前缀>.jsonl(体积接近源文件,默认不写)")
ap.add_argument("--platform-needs-text", action="store_true",
help="platform 档要求文案/标签也有 AI 信号才算。抖音给 AI 配音、"
"AI 封面的普通带货视频也打 AIGC 标,露营那批 87110 条里"
"有 1288 条是这种,占 platform 档的一半")
ap.add_argument("--stats", action="store_true", help="只统计不落盘")
ap.add_argument("--samples", type=int, default=0, help="每档打印几条样例到 stderr")
args = ap.parse_args()
keep = {t.strip() for t in args.tier.split(",") if t.strip()}
bad = keep - set(TIERS)
if bad:
ap.error(f"未知档位 {sorted(bad)},可选 {TIERS}")
src = Path(args.input)
if not src.exists():
ap.error(f"找不到 {src}")
tiers = Counter()
reasons = Counter()
sigs = Counter()
dl = Counter()
dur_ms = 0
kept = 0
seen = set()
dup = 0
samples = {t: [] for t in TIERS}
out_j = out_t = None
if not args.stats:
if not args.out:
ap.error("要落盘就给 -o 前缀,或者加 --stats 只看统计")
out_t = open(f"{args.out}.txt", "w", encoding="utf-8")
out_j = open(f"{args.out}.jsonl", "w", encoding="utf-8") if args.with_jsonl else None
total = 0
for _, r in iter_jsonl(src):
total += 1
aid = str(r.get("aweme_id") or "")
if aid in seen:
dup += 1
continue
seen.add(aid)
v = classify(r)
t = v["tier"]
if t == "platform" and args.platform_needs_text:
# 平台标了 AIGC,但作品自己一个 AI 信号都没有 -> 多半是 AI 配音/封面。
# 作者昵称带 AI(@可灵AI、@松饼大人Ai剧)也算佐证 —— 是本人在做 AI,
# 不像 music 那条路会被 `@X创作的原声` 和 studio/Fluxxwave 污染。
if not any(s.startswith(("desc:", "author:")) for s in v["signals"]):
t = "none"
reasons["platform 但无文案/作者佐证,被 --platform-needs-text 剔除"] += 1
tiers[t] += 1
if args.samples and len(samples[t]) < args.samples:
samples[t].append((aid, (r.get("desc") or "").replace("\n", " ")[:90],
"|".join(v["signals"][:5])))
if t == "none":
continue
for s in v["signals"]:
sigs[s.split(":", 1)[0] + ":" + s.split(":")[1] if ":" in s else s] += 1
if t not in keep:
continue
ai = r.get("ai") or {}
if not ai.get("allow_download", True):
dl[ai.get("download_fail_reason") or "unknown"] += 1
if args.exclude_forbidden:
reasons["被 allow_download=false 剔除"] += 1
continue
kept += 1
dur_ms += int(r.get("duration_ms") or 0)
if out_t:
out_t.write((r.get("url") or f"https://www.douyin.com/video/{aid}") + "\n")
if out_j:
rec = {"aweme_id": aid, "ai_tier": t, "ai_signals": v["signals"]}
rec.update({k: val for k, val in r.items() if k != "aweme_id"})
out_j.write(json.dumps(rec, ensure_ascii=False, separators=(",", ":")) + "\n")
if out_t:
out_t.close()
if out_j:
out_j.close()
w = sys.stderr
print(f"读入 {total} 条,去重后 {len(seen)} 条(重复 {dup})", file=w)
print("\n档位分布:", file=w)
for t in TIERS:
mark = " <- 保留" if t in keep else ""
print(f" {t:9s} {tiers[t]:7d}{mark}", file=w)
ai_any = sum(tiers[t] for t in TIERS if t != "none")
print(f" {'AI 相关合计':9s} {ai_any:7d} ({ai_any / max(total,1):.1%})", file=w)
if dl:
print("\n保留项里 allow_download=false 的原因:", file=w)
for k, v2 in dl.most_common():
print(f" {k:24s} {v2:7d}", file=w)
for k, v2 in reasons.items():
print(f" {k}: {v2}", file=w)
print(f"\n最终保留 {kept} 条,总时长 {dur_ms/3600000:.2f} 小时", file=w)
if out_t:
print(f"写出 {args.out}.txt" + (f" 和 {args.out}.jsonl" if out_j else ""), file=w)
if args.samples:
for t in TIERS:
if not samples[t]:
continue
print(f"\n----- {t} 样例 -----", file=w)
for aid, d, s in samples[t]:
print(f" {aid} {d}\n 信号: {s}", file=w)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@@ -0,0 +1,228 @@
#!/usr/bin/env python
"""把 records.jsonl 导成交付用的样本 xlsx。
列是甲方给的字段表(序号跟着他们的编号走,中间 4/5/9 他们没要):
1 样本 ID 必填 aweme_id
2 视频标题 必填 title,为空时退回 desc 首行
3 平台 必填 固定「抖音」
6 生成方式 AI 生成 / AI 辅助 / 疑似 AI / 未标注
7 生成工具 / 模型 从文案标签里认出来的工具名,逗号分隔
8 题材 / 内容类别 按标签归类
10 分辨率 / 清晰度 必填 1024x576(540p)
11 发布时间 必填 2026-08-06 14:12:47
12 是否有 AI 标识/水印 必填 抖音 risk_infos 打的标,原文照抄
用法:
python export_sample_xlsx.py # 全量
python export_sample_xlsx.py --ai-only # 只要判定为 AI 的
python export_sample_xlsx.py --limit 500 -o 样本.xlsx
"""
from __future__ import annotations
# 移到 scripts/ tests/ 后,直接运行时脚本目录成了 sys.path[0],
# 把项目根加回来,import douyin / pipeline 才找得到。
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
import argparse
import json
import re
import sys
from pathlib import Path
from douyin.aiclass import _fields, _scan, classify
# risk_infos.type 里属于 AIGC 的那几个码,别的都是跟 AI 无关的免责声明
# (45=虚构演绎、336/351=个人观点、54=来源于网络…)。见 README §4.5。
_AIGC_TYPES = {44, 47, 76, 328}
# 生成方式:抖音标签的措辞本身就分了轻重,直接用它,别自己发明
_HOW_BY_TYPE = {
44: "AI 生成(作者声明)",
76: "AI 生成(平台判定)",
328: "AI 生成(平台判定)",
47: "疑似 AI 生成(平台检测)",
}
# 题材归类。**顺序即优先级**,第一个命中的赢 —— 一条片子既是「漫剧」又带
# 「剧情」很常见,先匹配到的那个更具体。
#
# 词要挑「只在这个题材里出现」的。踩过的坑:「古风/国风/画质」放进
# 写真/摄影,结果把一整个仙侠短片系列(169/300 条)全吸过去了 ——
# 那些是叙事片,只是标签带古风。宽词一律不要,宁可落进「其他」。
_GENRES = [
("漫剧 / 动画", "漫剧|动漫|动画|二次元|国漫|番剧|漫画"),
("短剧 / 剧情", "短剧|剧情|微短剧|电影|影视|短片|反转|结局|"
"第[一二三四五六七八九十\\d]+集|合集"),
("教程 / 科普", "教程|教学|技巧|入门|保姆级|科普|干货|工作流|提示词"),
("资讯 / 新闻", "新闻|资讯|报道|快讯|媒体精选|发布会|财经|股市"),
("带货 / 电商", "好物|同款|下单|优惠|源头工厂|旗舰店|带货|种草|清仓"),
("户外 / 露营", "露营|帐篷|户外|野营|钓鱼|自驾|徒步|登山"),
("音乐 / 舞蹈", "音乐|歌曲|翻唱|舞蹈|跳舞"),
("写真 / 摄影", "写真|摄影|人像|壁纸|头像"),
("游戏", "手游|端游|原神|王者荣耀|电竞|无畏契约|崩坏"),
("生活 / 日常", "日常|vlog|美食|旅行|萌宠|搞笑"),
]
_GENRE_RE = [(name, re.compile(pat, re.I)) for name, pat in _GENRES]
def _risk(rec: dict) -> tuple:
"""(type, content)。抖音把 AI 标识放在 raw.risk_infos 里。"""
ri = (rec.get("raw") or {}).get("risk_infos") or {}
if not isinstance(ri, dict):
return None, ""
return ri.get("type"), (ri.get("content") or "").strip()
def how_generated(rec: dict, verdict: dict) -> str:
t, _ = _risk(rec)
if t in _HOW_BY_TYPE:
return _HOW_BY_TYPE[t]
if verdict["tier"] == "strong":
return "AI 生成(作品自述)"
if verdict["tier"] in ("topic", "author"):
return "疑似 AI 相关"
return "未标注"
def tools_used(verdict: dict) -> str:
"""从判定信号里把工具名摘出来。信号形如 `desc:工具:即梦`。"""
names = []
for s in verdict["signals"]:
parts = s.split(":")
if len(parts) >= 3 and parts[1] in ("工具", "漫剧"):
n = parts[2]
if n not in names:
names.append(n)
return "、".join(names)
def genre(rec: dict) -> str:
text = " ".join([rec.get("title") or "", rec.get("desc") or ""]
+ list(rec.get("tags") or []))
for name, rx in _GENRE_RE:
if rx.search(text):
return name
return "其他"
def resolution(rec: dict) -> str:
v = rec.get("video") or {}
w, h = v.get("width"), v.get("height")
ratio = ((rec.get("raw") or {}).get("video") or {}).get("ratio") or ""
if w and h:
return f"{w}x{h}" + (f"({ratio})" if ratio else "")
return ratio or ""
def ai_mark(rec: dict) -> str:
"""是否有 AI 标识/水印 —— 抖音的原文照抄,没有就写「无」。
只认 _AIGC_TYPES;45/336/351 那些是虚构演绎、个人观点,跟 AI 无关。"""
t, content = _risk(rec)
if t in _AIGC_TYPES and content:
return f"有:{content}"
return "无"
def title_of(rec: dict) -> str:
"""标题 + 标签拼一格。
records.jsonl 里的 title 是 split_desc 把标签摘掉之后的正文,标签单独放
在 tags 里。甲方只要一列,所以拼回去 —— 顺序按原文案的习惯:正文在前,
`#标签` 跟在后面。
"""
t = (rec.get("title") or "").strip()
if not t:
d = (rec.get("desc") or "").strip().splitlines()
t = d[0][:120] if d else ""
tags = " ".join(f"#{x}" for x in (rec.get("tags") or []) if x)
return (t + " " + tags).strip() if tags else t
def main(argv=None) -> int:
ap = argparse.ArgumentParser(description="records.jsonl -> 交付样本 xlsx")
ap.add_argument("-i", "--input", default="records/records.jsonl")
ap.add_argument("-o", "--out", default="exports/AI视频样本.xlsx")
ap.add_argument("--ai-only", action="store_true",
help="只导出判定为 AI 的(platform/strong 档)")
ap.add_argument("--limit", type=int, default=0)
args = ap.parse_args(argv)
try:
from openpyxl import Workbook
except ImportError:
print("需要 openpyxl:pip install openpyxl", file=sys.stderr)
return 2
src = Path(args.input)
if not src.exists():
ap.error(f"找不到 {src}")
# write_only:11 万行常规模式会把内存吃光
wb = Workbook(write_only=True)
ws = wb.create_sheet("样本")
# 前 9 列是甲方字段表要的,后面几列是核对/取数用的:题材判得对不对,
# 光看标题看不出来,得有标签和链接才能点开验;COS 路径是拿原始文件的地址。
# 作者插在「平台」后面 —— 甲方编号 1/2/3 的三列保持原位原序,
# 作者正好落进他们跳过的 4/5 那两个空号里。
ws.append(["样本 ID", "视频标题", "平台", "作者",
"生成方式", "生成工具 / 模型",
"题材 / 内容类别", "分辨率 / 清晰度", "发布时间",
"是否有 AI 标识 / 水印",
"时长(秒)", "视频链接",
"COS 视频路径", "COS 音频路径"])
n = written = bad = 0
with open(src, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if not line:
continue
n += 1
try:
rec = json.loads(line)
except ValueError:
bad += 1
continue
v = classify(rec)
if args.ai_only and v["tier"] not in ("platform", "strong"):
continue
# 每格都过一遍 str():openpyxl 会把 None 原样写成 "None"
ws.append([
str(rec.get("aweme_id") or ""),
title_of(rec),
"抖音",
(rec.get("author") or {}).get("nickname") or "",
# 生成方式 / 生成工具:留空,人工填。
# 自动推的那版(how_generated / tools_used)只能看抖音的标和
# 文案里写没写工具名,作者不写就抓不到 —— 实测 44.8% 的工具
# 列是空的,而"生成方式"里 81.8% 全都压在"作者声明"一个值上,
# 区分度太低,不如空着让人填。两个函数留着,要用加回来即可。
"",
"",
genre(rec),
resolution(rec),
rec.get("create_time_str") or "",
ai_mark(rec),
round(float(rec.get("duration_sec") or 0), 1),
rec.get("source_url")
or f"https://www.douyin.com/video/{rec.get('aweme_id')}",
rec.get("cos_mp4") or "",
rec.get("cos_mp3") or "",
])
written += 1
if args.limit and written >= args.limit:
break
if written % 20000 == 0:
print(f" 已写 {written} 行…", flush=True)
wb.save(args.out)
print(f"读入 {n} 行(坏行 {bad}),写出 {written} 行 -> {args.out}")
return 0
if __name__ == "__main__":
raise SystemExit(main())

222
scripts/jsonl_to_xlsx.py Normal file
View File

@@ -0,0 +1,222 @@
#!/usr/bin/env python3
"""把 jsonl 清洗并导出为 xlsx。
同时认两种输入:
records.jsonl 流水线产物,字段全(标题/作者/标签/时长/COS 地址/raw)
manifest.jsonl 老格式,只有 cos_mp4 / cos_mp3 / duration
records.jsonl 内嵌了 raw,单行可能几十 KB、整个文件几百 MB,所以这里
一律逐行流式读,任何时候都不把整个文件读进内存。选中的列也只留需要的
那几个字段,raw 不会进内存驻留。
清洗动作(每一项都会报告剔除了多少):
1. 丢弃无法解析的行(被 Ctrl-C 打断可能留下半行)
2. 丢弃缺字段 / 字段类型不对的行
3. 丢弃时长非正数的行
4. 丢弃 URL 前缀不符合预期的行
5. 按 mp4 地址去重,保留首次出现
用法:
python jsonl_to_xlsx.py
python jsonl_to_xlsx.py -i records.jsonl -o "抖音-四川话.xlsx"
python jsonl_to_xlsx.py -i records.jsonl --columns cos_mp4,cos_mp3,duration_sec
python jsonl_to_xlsx.py --list-columns
"""
from __future__ import annotations
# 移到 scripts/ tests/ 后,直接运行时脚本目录成了 sys.path[0],
# 把项目根加回来,import douyin / pipeline 才找得到。
import sys as _sys
from pathlib import Path as _Path
_sys.path.insert(0, str(_Path(__file__).resolve().parent.parent))
import argparse
import json
import sys
from pathlib import Path
from typing import Any, Dict, Iterator, List
EXPECTED_PREFIX = "cos://asr-dig-data-1253181912/0005/zh/"
# 列名 -> (表头, 取值函数)。取值函数只碰需要的字段,raw 不会被带进来。
COLUMNS: Dict[str, tuple] = {
"aweme_id": ("视频ID", lambda r: str(r.get("aweme_id") or "")),
"title": ("标题", lambda r: r.get("title") or ""),
"desc": ("原始描述", lambda r: r.get("desc") or ""),
"tags": ("标签", lambda r: " ".join(r.get("tags") or [])),
"author": ("作者", lambda r: (r.get("author") or {}).get("nickname", "")),
"author_uid": ("作者UID", lambda r: (r.get("author") or {}).get("uid", "")),
"create_time": ("发布时间", lambda r: r.get("create_time_str") or ""),
"duration_sec": ("时长(秒)", _dur := (lambda r: r.get("duration_sec",
r.get("duration")))),
"duration": ("时长(秒)", _dur),
"cos_mp4": ("cosmp4地址", lambda r: r.get("cos_mp4") or ""),
"cos_mp3": ("cosmp3地址", lambda r: r.get("cos_mp3") or ""),
"size_mp4": ("mp4字节数", lambda r: r.get("size_mp4") or 0),
"size_mp3": ("mp3字节数", lambda r: r.get("size_mp3") or 0),
"play": ("播放数", lambda r: (r.get("stats") or {}).get("play", 0)),
"digg": ("点赞数", lambda r: (r.get("stats") or {}).get("digg", 0)),
"comment": ("评论数", lambda r: (r.get("stats") or {}).get("comment", 0)),
"source_url": ("原始链接", lambda r: r.get("source_url") or ""),
}
DEFAULT_COLUMNS = ["cos_mp4", "cos_mp3", "duration_sec"]
FULL_COLUMNS = ["aweme_id", "title", "author", "create_time", "tags",
"duration_sec", "cos_mp4", "cos_mp3"]
NUMERIC = {"duration_sec", "duration", "size_mp4", "size_mp3",
"play", "digg", "comment"}
def iter_lines(path: Path) -> Iterator[str]:
with open(path, "r", encoding="utf-8", errors="replace") as f:
for line in f:
line = line.strip()
if line:
yield line
def clean(path: Path, prefix: str, columns: List[str]):
"""流式清洗,只保留选中的列。"""
rows: List[List[Any]] = []
seen: set = set()
dropped = {"解析失败": 0, "字段缺失": 0, "时长非法": 0, "地址异常": 0, "重复": 0}
total = 0
dur_idx = next((i for i, c in enumerate(columns)
if c in ("duration_sec", "duration")), None)
for line in iter_lines(path):
total += 1
try:
r = json.loads(line)
except ValueError:
dropped["解析失败"] += 1
continue
mp4, mp3 = r.get("cos_mp4"), r.get("cos_mp3")
dur = r.get("duration_sec", r.get("duration"))
if not isinstance(mp4, str) or not isinstance(mp3, str) or dur is None:
dropped["字段缺失"] += 1
continue
if not isinstance(dur, (int, float)) or isinstance(dur, bool) or dur <= 0:
dropped["时长非法"] += 1
continue
if not mp4.startswith(prefix) or not mp3.startswith(prefix):
dropped["地址异常"] += 1
continue
if mp4 in seen:
dropped["重复"] += 1
continue
seen.add(mp4)
row = [COLUMNS[c][1](r) for c in columns]
if dur_idx is not None:
row[dur_idx] = float(row[dur_idx])
rows.append(row)
return rows, dropped, total, dur_idx
def write_xlsx(rows, headers, out: Path, sheet: str, dur_idx) -> None:
from openpyxl import Workbook
from openpyxl.styles import Alignment, Font, PatternFill
from openpyxl.utils import get_column_letter
wb = Workbook()
ws = wb.active
ws.title = sheet
ws.append(headers)
head_font = Font(bold=True, color="FFFFFF")
head_fill = PatternFill("solid", fgColor="4472C4")
for c in range(1, len(headers) + 1):
cell = ws.cell(row=1, column=c)
cell.font = head_font
cell.fill = head_fill
cell.alignment = Alignment(horizontal="center", vertical="center")
for r in rows:
ws.append(r)
# 时长保留 3 位小数,与 ffprobe 输出精度一致
if dur_idx is not None:
for row in ws.iter_rows(min_row=2, min_col=dur_idx + 1, max_col=dur_idx + 1):
for cell in row:
cell.number_format = "0.000"
for i, head in enumerate(headers):
widest = max((len(str(r[i])) for r in rows), default=10)
width = min(max(len(head) + 4, widest + 2), 60)
ws.column_dimensions[get_column_letter(i + 1)].width = width
ws.freeze_panes = "A2" # 表头固定
ws.auto_filter.ref = ws.dimensions # 开筛选
wb.save(out)
def main(argv: list[str] | None = None) -> int:
ap = argparse.ArgumentParser(description="jsonl -> xlsx")
ap.add_argument("-i", "--input", default="records/records.jsonl")
ap.add_argument("-o", "--output", default="exports/抖音-四川话.xlsx")
ap.add_argument("--sheet", default="抖音-四川话")
ap.add_argument("--prefix", default=EXPECTED_PREFIX)
ap.add_argument("--columns", default="",
help=f"逗号分隔的列,默认 {','.join(DEFAULT_COLUMNS)};"
"用 full 展开为常用全字段")
ap.add_argument("--list-columns", action="store_true", help="列出可用列名后退出")
args = ap.parse_args(argv)
if args.list_columns:
print("可用列:")
for k, (head, _) in COLUMNS.items():
print(f" {k:<14} {head}")
return 0
if not args.columns:
columns = list(DEFAULT_COLUMNS)
elif args.columns.strip() == "full":
columns = list(FULL_COLUMNS)
else:
columns = [c.strip() for c in args.columns.split(",") if c.strip()]
bad = [c for c in columns if c not in COLUMNS]
if bad:
print(f"[错误] 未知列名 {bad},用 --list-columns 看可选项", file=sys.stderr)
return 2
src = Path(args.input)
if not src.exists():
print(f"[错误] 找不到 {src}", file=sys.stderr)
return 2
rows, dropped, total, dur_idx = clean(src, args.prefix, columns)
print(f"读入 {total} 行")
if sum(dropped.values()):
for k, v in dropped.items():
if v:
print(f" 剔除 {k}: {v}")
else:
print(" 清洗检查全部通过,无需剔除")
print(f"保留 {len(rows)} 行")
if not rows:
print("[错误] 清洗后没有数据", file=sys.stderr)
return 3
headers = [COLUMNS[c][0] for c in columns]
out = Path(args.output)
write_xlsx(rows, headers, out, args.sheet, dur_idx)
print(f"\n已写入 {out}")
print(f" {len(rows)} 行 × {len(headers)} 列:{' | '.join(headers)}")
if dur_idx is not None:
ds = [r[dur_idx] for r in rows]
print(f" 总时长 {sum(ds) / 3600:.2f} 小时,平均 {sum(ds) / len(ds):.1f} 秒,"
f"最短 {min(ds):.3f},最长 {max(ds):.3f}")
return 0
if __name__ == "__main__":
raise SystemExit(main())