Files
douyin_video_download/pipeline.py
whale-fall-code 2bb83f3685 初始提交:抖音视频批量采集流水线
App 端免签名接口(设备注册 + detail + CDN 直链),一体化流水线
下载→抽音→传COS→记录→删本地,AI 作品四档判定,四份文档:
README(使用指南)、PROGRESS(批次进度)、RE(逆向全过程)、AI(判定规则)。

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-09-29 16:31:10 +08:00

534 lines
22 KiB
Python
Raw 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.

#!/usr/bin/env python3
"""抖音采集一体化流水线:下载 -> 抽 mp3 -> 传 COS -> 记元信息 -> 删本地。
跟原来分阶段跑(全下完再全抽完再全传完)最大的区别是磁盘占用:
分阶段必须把所有 mp4 摊在盘上(上一批是 53 GB),这里每条走完就删,
峰值只有 workers x (mp4+mp3),约 100 MB,且与总量无关。
每条视频端到端走 9 步:
1. fetch_detail 拿元信息 + raw
2. 下载 mp4
3. ffmpeg 抽 mp3 [信号量限流,见下]
4. ffprobe 探两个的时长
5. 校验 mp3 时长 ~= mp4 时长 <- 抽取闸门
6. 上传 mp4(覆盖同名)
7. 上传 mp3(覆盖同名)
8. head_object 核对大小 <- 删除闸门(在 CosUploader.put 里)
9. 写 record,删本地两个文件
任何一步失败:保留本地文件不删,写进 failed 文件,继续下一条。
并发:下载和上传是网络密集,按 --workers 并发;ffmpeg 是 CPU 密集,
单独用信号量限到 CPU 核数,避免 8 个 worker 同时撞进第 3 步把 CPU 打满。
用法:
$env:COS_SECRET_ID="xxx"; $env:COS_SECRET_KEY="yyy"
python pipeline.py --from-file links.txt --region ap-chongqing --limit 20 --keep-local
python pipeline.py --from-file links.txt --region ap-chongqing --workers 8
"""
from __future__ import annotations
import argparse
import os
import re
import subprocess
import sys
import threading
import time
from concurrent.futures import ThreadPoolExecutor
from dataclasses import dataclass, field
from pathlib import Path
from typing import List, Optional
import requests
from douyin import (
AppClient,
DeviceRegisterError,
DouyinError,
NotAVideo,
ParseError,
download,
extract_aweme_id,
)
from douyin.audio import build_cmd, ensure_ffmpeg, probe
from douyin.batch import DevicePool
from douyin.cos import CosError, CosUploader, DEFAULT_BUCKET, DEFAULT_PREFIX
from douyin.meta import RecordLog, build_record
class StageError(RuntimeError):
"""带阶段名的失败,便于 failed 文件里归因。"""
def __init__(self, stage: str, msg: str) -> None:
super().__init__(msg)
self.stage = stage
@dataclass
class Stats:
total: int = 0
done: int = 0
skipped: int = 0
failed: int = 0
bytes_up: int = 0
lock: threading.Lock = field(default_factory=threading.Lock)
_tls = threading.local()
def session() -> requests.Session:
"""每个线程一个下载用 session。CDN 直链不需要设备头,裸 session 即可。"""
s = getattr(_tls, "dl", None)
if s is None:
s = requests.Session()
s.headers.update({"User-Agent": "okhttp/3.12.13"})
_tls.dl = s
return s
def read_targets(args) -> List[str]:
raw: List[str] = list(args.target or [])
if args.from_file:
raw += [ln.strip() for ln in
Path(args.from_file).read_text(encoding="utf-8").splitlines()
if ln.strip() and not ln.strip().startswith("#")]
return raw
def next_abort_wait(streak: int, base: int, cap: int) -> int:
"""撞 IP 级注册限流后等多久再续跑。streak = 连着撞了几次(不含这次)。
抖音封的是 IP,不是设备指纹——DevicePool 换 UA 没用,只能等。实测一轮
能跑 5~6 小时、44 次设备轮换才触顶,冷却大概几小时。连撞就翻倍,说明
还没解封;有一段跑成了就把 streak 清零、等待打回底价。
"""
if base <= 0:
return 0
return min(base * (2 ** streak), cap)
def plan_batch(pending, max_items: int):
"""把待处理队列切一段出来跑,返回 (这一段, 还有没有剩下的)。
为什么要切段:单个 python 进程跑到 1.5 万条上下,Windows 会话的桌面堆
就被几万个 ffmpeg/ffprobe 子进程耗光,之后 CreateProcess 一律失败
(rc=3221225794 / STATUS_DLL_INIT_FAILED)。表现很好认:完成数冻结、
失败数猛涨、上传量不动、work/ 暴涨(下载成功但抽音失败的 mp4 按设计
留着)。实测撞过两次 —— 三进程并跑时一次,单进程跑到 16399 条又一次,
所以是**累积量**的问题,不是并发数。
段跑完 os.execv 重启自己,桌面堆跟着重置。断点续跑本来就靠
records.jsonl,重启不会重下。max_items=0 表示不切。
"""
if max_items and len(pending) > max_items:
return pending[:max_items], True
return pending, False
def sweep_work_dir(work: Path, records: Path) -> int:
"""清掉 work/ 里已经没用的残留,返回删了几个文件。
删两类:
* aweme_id 已经在 records.jsonl 里的 —— 早传上 COS 了,留着纯属占地方;
* `.part` 半截下载 —— 断点续传的 Range 起点,进程被杀后就是垃圾。
**不碰失败项的文件**:上传失败、抽音截断这些是故意留在本地的,重跑时
省一次下载,见 test_upload_failure_keeps_local_and_writes_no_record。
为什么要有这个函数:正常路径删文件(见下面 unlink 那两行),失败路径
故意不删,可失败项的文件从此再没人回收 —— 66411 条那批跑到 2.7% 就在
work/ 里攒了 11 GB,其中 414 个的 aweme_id 已经在 records.jsonl 里。
(那 414 个的 mp4 修改时间比记录时间还晚 17~166 秒,说明是记录写完之后
才落地的,具体怎么产生的没查清;这个清扫无论如何都能兜住。)
"""
if not work.is_dir():
return 0
done = set()
if records.exists():
# aweme_id 是 RecordLog 写出的第一个字段,按行前缀扫,不解析整行 JSON
# (records.jsonl 带 raw,1.8 GB,逐行 json.loads 要几分钟)。
# 容忍冒号后的空格:紧凑写法没有,但别让格式差异把清扫悄悄变成空操作。
pat = re.compile(rb'^\{\s*"aweme_id"\s*:\s*"(\d+)"')
with open(records, "rb") as f:
for line in f:
m = pat.match(line)
if m:
done.add(m.group(1).decode())
freed = 0
for p in work.iterdir():
if not p.is_file():
continue
if p.name.endswith(".part") or p.stem in done:
try:
p.unlink()
freed += 1
except OSError:
pass # 还被占着,下一轮再说
return freed
def extract_mp3(ffmpeg: str, src: Path, dst: Path, bitrate: str,
timeout: float = 600.0) -> None:
cmd = build_cmd(ffmpeg, src, dst, "mp3", bitrate)
try:
p = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
except subprocess.TimeoutExpired:
raise StageError("audio", f"ffmpeg 超时 >{timeout}s")
if p.returncode != 0 or not dst.exists() or dst.stat().st_size == 0:
err = (p.stderr or "").strip().splitlines()
raise StageError("audio", err[-1][:150] if err else f"rc={p.returncode}")
def decoded_seconds(path: Path, ffprobe: str, timeout: float = 300.0) -> Optional[float]:
"""实际解码算出的音频时长。
不能用 `format=duration`:LAME 写的 Xing 头声明了原始总帧数,ffprobe
直接信它而不解码。实测把 mp3 截断到 20%,format=duration 报的还是
10.0078s,和完整文件一模一样——拿它做截断校验等于没做。
`-count_frames` 会真解码。代价很小:120s 的 mp3 是 0.109s 对 0.053s。
"""
cmd = [ffprobe, "-v", "error", "-select_streams", "a:0", "-count_frames",
"-show_entries", "stream=nb_read_frames,sample_rate",
"-of", "default=noprint_wrappers=1"] # 保留 key= 前缀
try:
p = subprocess.run(cmd + [str(path)], capture_output=True, text=True,
timeout=timeout)
except subprocess.TimeoutExpired:
return None
# 按 key 解析,不靠位置:ffprobe 是按流内字段顺序输出的,不是按请求顺序
kv = {}
for line in (p.stdout or "").splitlines():
if "=" in line:
k, _, v = line.partition("=")
kv[k.strip()] = v.strip()
try:
frames = int(kv["nb_read_frames"])
rate = int(kv["sample_rate"])
except (KeyError, ValueError):
return None
if frames <= 0 or rate <= 0:
return None
# MPEG1 Layer III 每帧 1152 采样;MPEG2/2.5(<32kHz)是 576
per_frame = 1152 if rate >= 32000 else 576
return frames * per_frame / rate
def check_duration(mp4: Path, mp3: Path, ffprobe: str) -> float:
"""返回 mp4 的精确时长;顺便校验 mp3 没被截断。
ffmpeg 返回 0 但产出截断文件是见过的,光看 returncode 不够。mp4 传上去
之后本地就删了,所以这道闸门必须在删除之前把话说死。
"""
d4 = probe(mp4, ffprobe)
if not d4:
raise StageError("probe", "ffprobe 读不出 mp4 时长")
d3 = decoded_seconds(mp3, ffprobe)
if not d3:
raise StageError("probe", "mp3 解不出有效音频帧")
tol = max(1.0, d4 * 0.02)
if abs(d3 - d4) > tol:
raise StageError("probe",
f"mp3 实解 {d3:.3f}s 与 mp4 {d4:.3f}s 相差超过 {tol:.3f}s,"
"疑似抽取被截断")
return d4
def run_one(aweme_id: str, source_url: str, *, pool: DevicePool, up: CosUploader,
log: RecordLog, args, ffmpeg: str, ff_sem: threading.Semaphore,
stats: Stats, fail, abort: threading.Event) -> None:
# 注册配额是 IP 级的,耗尽后换设备也没用。这时候必须整批停下来等,
# 而不是让剩下几千条一条条去撞同一堵墙、全标成失败——上一批就是这么
# 烧掉 678 条的。abort 一旦拉起,后续任务直接返回,不计失败。
if abort.is_set():
with stats.lock:
stats.skipped += 1
return
work = Path(args.work_dir)
mp4 = work / f"{aweme_id}.mp4"
mp3 = work / f"{aweme_id}.mp3"
try:
# 1. 元信息
try:
device, gen = pool.current()
except DeviceRegisterError as exc:
abort.set()
with stats.lock:
stats.failed += 1
fail(aweme_id, "abort", f"IP 级注册配额耗尽,已中止整批:{exc}")
return
try:
aweme = AppClient(device).fetch_detail(aweme_id)
except NotAVideo as exc:
raise StageError("detail", str(exc))
except DouyinError as exc:
# 空 body 多半是设备配额到了,换设备重来一次
if "空 body" in str(exc):
pool.report_dead(gen)
device, gen = pool.current()
aweme = AppClient(device).fetch_detail(aweme_id)
else:
raise StageError("detail", str(exc))
if not aweme.video_urls and not aweme.uri:
raise StageError("detail", "没有可用的视频直链")
# 2. 下载
try:
res = download(session(), aweme.video_urls or [aweme.play_url()],
mp4, progress=False)
except Exception as exc:
raise StageError("download", f"{type(exc).__name__}: {exc}")
if res.size <= 0:
raise StageError("download", "下载产物是空文件")
# 3. 抽 mp3(CPU 密集,限流)
with ff_sem:
extract_mp3(ffmpeg, mp4, mp3, args.bitrate)
# 4~5. 探时长 + 校验没被截断
duration = check_duration(mp4, mp3, args.ffprobe)
# 6~8. 上传,put() 内部做 head_object 大小校验
try:
url4, size4 = up.put(mp4, up.key_for(mp4.name))
url3, size3 = up.put(mp3, up.key_for(mp3.name))
except CosError as exc:
raise StageError("upload", str(exc))
# 9. 记录,然后才敢删
log.write(build_record(aweme, duration_sec=duration,
cos_mp4=url4, cos_mp3=url3,
size_mp4=size4, size_mp3=size3,
source_url=source_url))
if not args.keep_local:
mp4.unlink(missing_ok=True)
mp3.unlink(missing_ok=True)
with stats.lock:
stats.done += 1
stats.bytes_up += size4 + size3
except DeviceRegisterError as exc:
abort.set()
with stats.lock:
stats.failed += 1
fail(aweme_id, "abort", f"IP 级注册配额耗尽,已中止整批:{exc}")
except StageError as exc:
with stats.lock:
stats.failed += 1
fail(aweme_id, exc.stage, str(exc))
except Exception as exc: # 兜底,别让一条炸掉整批
with stats.lock:
stats.failed += 1
fail(aweme_id, "unknown", f"{type(exc).__name__}: {exc}")
def main(argv: Optional[List[str]] = None) -> int:
ap = argparse.ArgumentParser(description="抖音采集一体化流水线")
ap.add_argument("target", nargs="*", help="链接或 aweme_id")
ap.add_argument("--from-file", default="", help="从文件读链接,一行一个")
ap.add_argument("--records", default="records/records.jsonl", help="元信息落点")
ap.add_argument("--failed", default="records/pipeline_failed.txt")
ap.add_argument("--work-dir", default="work", help="mp4/mp3 临时目录,传完即删")
ap.add_argument("--region", required=True)
ap.add_argument("--bucket", default=DEFAULT_BUCKET)
ap.add_argument("--prefix", default=DEFAULT_PREFIX)
ap.add_argument("--url-style", choices=["cos", "https", "path"], default="cos")
ap.add_argument("--workers", type=int, default=8)
ap.add_argument("--bitrate", default="192k")
ap.add_argument("--ffmpeg", default="ffmpeg")
ap.add_argument("--ffprobe", default="ffprobe")
ap.add_argument("--ffmpeg-workers", type=int, default=0,
help="并发 ffmpeg 数,0=CPU 核数")
ap.add_argument("--device-quota", type=int, default=550,
help="单设备 detail 调用上限。实测约 575 次后被限流,"
"留一点余量;调高可减少注册次数")
ap.add_argument("--limit", type=int, default=0, help="只跑前 N 条,用于试跑")
ap.add_argument("--max-items", type=int, default=12000,
help="单个进程最多跑多少条,跑满就重启自己接着跑。"
"防 Windows 桌面堆耗尽,见 plan_batch。0=不切段")
ap.add_argument("--abort-wait", type=int, default=1800,
help="撞上 IP 级注册限流后等多少秒再自动续跑。"
"连撞会翻倍,见 next_abort_wait。0=不等,直接退出")
ap.add_argument("--abort-wait-cap", type=int, default=14400,
help="上面那个等待的上限秒数,默认 4 小时")
ap.add_argument("--keep-local", action="store_true",
help="上传后不删本地文件(试跑时用)")
args = ap.parse_args(argv)
targets = read_targets(args)
if not targets:
ap.error("没有目标。给链接,或用 --from-file。")
try:
ffmpeg = ensure_ffmpeg(args.ffmpeg)
except Exception as exc:
print(f"[错误] {exc}", file=sys.stderr)
return 2
try:
up = CosUploader(region=args.region, bucket=args.bucket, prefix=args.prefix,
url_style=args.url_style)
except CosError as exc:
print(f"[错误] {exc}", file=sys.stderr)
return 2
log = RecordLog(Path(args.records))
print(f"已完成 {len(log)} 条(从 {args.records} 读出),本次输入 {len(targets)} 条")
# 解析 id,顺便去重和跳过已完成的
pending = []
seen = set()
bad = 0
for raw in targets:
try:
aid = extract_aweme_id(raw, session())
except ParseError:
bad += 1
continue
if aid in seen:
continue
seen.add(aid)
if aid in log:
continue
pending.append((aid, raw))
if args.limit:
pending = pending[: args.limit]
skipped = len(seen) - len(pending) if not args.limit else 0
print(f"待处理 {len(pending)} 条"
+ (f",跳过已完成 {skipped} 条" if skipped else "")
+ (f",无法解析 {bad} 条" if bad else ""))
if not pending:
return 0
pending, more = plan_batch(pending, args.max_items)
if more:
print(f"本段只跑 {len(pending)} 条,跑完自动重启进程接着来"
f"(见 plan_batch 的注释:桌面堆)")
Path(args.work_dir).mkdir(parents=True, exist_ok=True)
if not args.keep_local:
n = sweep_work_dir(Path(args.work_dir), Path(args.records))
if n:
print(f"清掉 work/ 里 {n} 个已入库或半截的残留文件")
stats = Stats(total=len(pending))
fail_lock = threading.Lock()
fail_path = Path(args.failed)
def fail(aweme_id: str, stage: str, msg: str) -> None:
with fail_lock:
with open(fail_path, "a", encoding="utf-8") as f:
f.write(f"{aweme_id}\t{stage}\t{msg}\n")
pool = DevicePool(max_calls=args.device_quota)
# DevicePool 是惰性注册的。先主动要一次,注册不了就当场退出,
# 而不是让每个 worker 各自撞一遍、把整批标成失败。
try:
pool.current()
except DeviceRegisterError as exc:
# 开跑就注册不上 = IP 还在冷却期。这条路以前直接退出,等于
# "撞限流后自动续跑"在冷却没结束时会白白放弃,所以这里也要等。
streak = int(os.environ.get("_DY_ABORT_STREAK", "0"))
wait = next_abort_wait(streak, args.abort_wait, args.abort_wait_cap)
if wait:
os.environ["_DY_ABORT_STREAK"] = str(streak + 1)
until = time.strftime("%H:%M:%S", time.localtime(time.time() + wait))
print(f"[限流] 设备注册失败:{exc}", file=sys.stderr)
print(f"IP 还在冷却,等 {wait // 60} 分钟到 {until} 再试…", flush=True)
sys.stdout.flush()
sys.stderr.flush()
time.sleep(wait)
os.execv(sys.executable, [sys.executable] + sys.argv)
print(f"[错误] 设备注册失败:{exc}", file=sys.stderr)
return 3
n_ff = args.ffmpeg_workers or (os.cpu_count() or 4)
ff_sem = threading.Semaphore(n_ff)
print(f"并发 {args.workers},其中 ffmpeg 限 {n_ff}"
+ (",本地文件保留不删" if args.keep_local else ""))
t0 = time.time()
stop = threading.Event()
abort = threading.Event()
def report() -> None:
ticks = 0
while not stop.wait(15):
ticks += 1
with stats.lock:
d, f_, tot = stats.done, stats.failed, stats.total
gb = stats.bytes_up / 1024 ** 3
el = time.time() - t0
rate = d / el * 60 if el else 0
left = (tot - d - f_) / rate if rate else 0
print(f" [{d + f_}/{tot}] 完成{d} 失败{f_} | {gb:.2f}GB 上传 | "
f"{rate:.0f}条/分 | 剩约 {left:.0f}分", flush=True)
# 每 5 分钟回收一次:光靠开跑前扫一次,长批跑到一半磁盘就满了
if ticks % 20 == 0 and not args.keep_local:
n = sweep_work_dir(Path(args.work_dir), Path(args.records))
if n:
print(f" [清扫] 回收 {n} 个残留文件", flush=True)
threading.Thread(target=report, daemon=True).start()
try:
with ThreadPoolExecutor(max_workers=args.workers) as ex:
for aid, raw in pending:
ex.submit(run_one, aid, raw, pool=pool, up=up, log=log, args=args,
ffmpeg=ffmpeg, ff_sem=ff_sem, stats=stats, fail=fail,
abort=abort)
finally:
stop.set()
el = time.time() - t0
print(f"\n完成 {stats.done},失败 {stats.failed},"
f"上传 {stats.bytes_up / 1024 ** 3:.2f} GB,耗时 {el / 60:.1f} 分")
print(f"元信息 -> {args.records}(累计 {len(log)} 条)")
if stats.failed:
print(f"失败明细 -> {fail_path}(本地文件已保留,可直接重跑本脚本续传)")
def _restart(why: str, wait: int = 0) -> None:
"""重启自己接着跑。断点续跑靠 records.jsonl,重启不会重下。"""
if wait:
until = time.strftime("%H:%M:%S", time.localtime(time.time() + wait))
print(f"{why},等 {wait // 60} 分钟到 {until} 再续…", flush=True)
else:
print(f"{why},重启进程接着跑…", flush=True)
sys.stdout.flush()
sys.stderr.flush()
if wait:
time.sleep(wait)
os.execv(sys.executable, [sys.executable] + sys.argv)
# 连撞计数走环境变量,execv 换掉进程映像后靠它记住上一轮的状态
streak = int(os.environ.get("_DY_ABORT_STREAK", "0"))
if abort.is_set():
# IP 级注册配额耗尽。抖音封的是 IP,换设备没用,只能等冷却。
wait = next_abort_wait(streak, args.abort_wait, args.abort_wait_cap)
if wait:
# 这一轮但凡出了活就说明限流是跑着跑着才触发的,下次从底价重等
os.environ["_DY_ABORT_STREAK"] = "0" if stats.done else str(streak + 1)
_restart("撞上 IP 级注册限流", wait)
print("撞上 IP 级注册限流,已停下。等几小时 IP 冷却后重跑本命令即可续传。")
return 5
os.environ["_DY_ABORT_STREAK"] = "0"
if more:
_restart("本段跑完")
return 0 if stats.failed == 0 else 4
if __name__ == "__main__":
raise SystemExit(main())