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

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

343
douyin/app.py Normal file
View File

@@ -0,0 +1,343 @@
"""App 端接口客户端 —— 无签名、无 Cookie、无登录。
实测链路(每一步都验过):
1. POST /service/2/device_register/ 引导调用,不需要任何签名
-> device_id / install_id
2. GET /aweme/v1/aweme/detail/ 带上真实 device_id/iid 即可
-> 完整 aweme_detail,含 video.play_addr.uri
3. GET /aweme/v1/play/?video_id=URI 免鉴权,302 到 douyinvod CDN
-> mp4
对比 web 端:web 的 /aweme/v1/web/aweme/detail/ 对匿名用户返回
{"name":"强制登录"},且要算 a_bogus。App 端两样都不需要。
早先测 App 端拿到空 body,是因为 device_id/iid 是伪造的;换成注册得来的
真实凭据就正常了 —— 空 body 不代表要签名。
"""
from __future__ import annotations
import json
import random
import time
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
from urllib.parse import urlencode
import requests
from .client import Aweme, DouyinClient, DouyinError
REGISTER_HOSTS = (
"https://log.snssdk.com/service/2/device_register/",
"https://api.amemv.com/service/2/device_register/",
"https://log-api.amemv.com/service/2/device_register/",
"https://toblog.ctobsnssdk.com/service/2/device_register/",
)
API_HOSTS = (
"https://aweme.snssdk.com",
"https://api.amemv.com",
)
APP_VERSION = "9.9.9"
VERSION_CODE = "990"
UPDATE_VERSION_CODE = "99013"
APP_ID = "1128"
CHANNEL = "douyin_official"
# 机型池。openudid/cdid 本来就每次随机,但 UA、机型、分辨率这些以前是写死的,
# 意味着我们所有注册请求在这些维度上完全一致。
#
# 注册受限时到底是按 IP 还是按"UA+机型"这类特征分桶,我们没验证过——本机没有
# 第二个出口 IP,做不了对照实验。把机型随机化是成本极低的两用手段:
# 如果是按机型特征分桶,它能缓解;如果是纯 IP,它无害。
#
# 每项是 (device_type, device_brand, os_version, os_api, resolution, dpi)。
DEVICE_POOL = (
("SM-G9750", "samsung", "12", "31", "1080*2340", "420"),
("SM-S9080", "samsung", "13", "33", "1440*3088", "560"),
("PDRM00", "OPPO", "12", "31", "1080*2400", "480"),
("V2118A", "vivo", "12", "31", "1080*2376", "480"),
("M2012K11AC", "Xiaomi", "13", "33", "1080*2400", "440"),
("ELS-AN00", "HUAWEI", "12", "31", "1176*2400", "480"),
("21091116AC", "Redmi", "12", "31", "1080*2400", "440"),
("NOH-AN01", "HONOR", "12", "31", "1344*2772", "480"),
)
# 兼容旧引用:默认取池子里第一项
DEVICE_TYPE, DEVICE_BRAND, OS_VERSION, OS_API = DEVICE_POOL[0][:4]
class DeviceRegisterError(DouyinError):
pass
class NotAVideo(DouyinError):
"""作品存在,但不是视频(图文/图集)。"""
def _hex(n: int) -> str:
return "".join(random.choice("0123456789abcdef") for _ in range(n))
class Device:
"""一台虚拟设备的身份。注册一次可长期复用,所以落盘缓存。"""
def __init__(self, device_id: str, install_id: str, openudid: str,
cdid: str, clientudid: str,
profile: Optional[tuple] = None) -> None:
self.device_id = device_id
self.install_id = install_id
self.openudid = openudid
self.cdid = cdid
self.clientudid = clientudid
# 机型档案必须跟着设备走:注册时报的是小米、后续 detail 请求的 UA
# 却是三星,这种自相矛盾本身就是个可疑特征。
self.profile = tuple(profile) if profile else DEVICE_POOL[0]
@property
def device_type(self) -> str:
return self.profile[0]
@property
def device_brand(self) -> str:
return self.profile[1]
@property
def os_version(self) -> str:
return self.profile[2]
@property
def os_api(self) -> str:
return self.profile[3]
@property
def resolution(self) -> str:
return self.profile[4]
@property
def dpi(self) -> str:
return self.profile[5]
@property
def user_agent(self) -> str:
return (f"com.ss.android.ugc.aweme/{UPDATE_VERSION_CODE} "
f"(Linux; U; Android {self.os_version}; zh_CN; {self.device_type}; "
f"Build/QP1A.190711.020; Cronet/TTNetVersion:6c7b9b1a 2022-11-29)")
def to_dict(self) -> Dict[str, Any]:
return {
"device_id": self.device_id, "install_id": self.install_id,
"openudid": self.openudid, "cdid": self.cdid,
"clientudid": self.clientudid, "profile": list(self.profile),
}
@classmethod
def from_dict(cls, d: Dict[str, Any]) -> "Device":
# profile 是后加的,旧缓存没有这个字段,退回池子第一项
return cls(d["device_id"], d["install_id"], d["openudid"],
d["cdid"], d["clientudid"], d.get("profile"))
# ------------------------------------------------------------------ #
@classmethod
def register(cls, session: Optional[requests.Session] = None,
timeout: float = 20.0, retries: int = 5,
backoff: float = 30.0) -> "Device":
"""向 ByteDance 注册一台新设备。这一步不需要签名。
短时间内注册太多次,所有 host 都会返回 device_id='0'。实测:
指纹(openudid/cdid)每次都是新的照样被拒,4 个 host 同时被拒,
约 10 分钟后自行恢复 —— 所以是临时限流,且不是按设备指纹或按
单个 host 分桶的。
至于分桶的键到底是 IP 还是 UA/机型这类特征,我们没验证过:本机
没有第二个出口 IP,做不了对照实验。DEVICE_POOL 的随机化对两种
情况都无害,权当两用。
这里做指数退避重试而不是直接抛错,否则调用方会把后续所有条目
瞬间判为失败。
"""
last: Optional[Exception] = None
for attempt in range(retries):
try:
return cls._register_once(session, timeout)
except DeviceRegisterError as exc:
last = exc
if attempt < retries - 1:
wait = backoff * (2 ** attempt)
print(f" [注册受限] 等待 {wait:.0f}s 后重试 "
f"({attempt + 1}/{retries})", flush=True)
time.sleep(wait)
raise last # type: ignore[misc]
@classmethod
def _register_once(cls, session: Optional[requests.Session] = None,
timeout: float = 20.0) -> "Device":
session = session or requests.Session()
openudid = _hex(16)
cdid = str(uuid.UUID(int=random.getrandbits(128)))
clientudid = str(uuid.UUID(int=random.getrandbits(128)))
profile = random.choice(DEVICE_POOL)
dev_type, dev_brand, os_ver, os_api, resolution, dpi = profile
ua = (f"com.ss.android.ugc.aweme/{UPDATE_VERSION_CODE} "
f"(Linux; U; Android {os_ver}; zh_CN; {dev_type}; "
f"Build/QP1A.190711.020; Cronet/TTNetVersion:6c7b9b1a 2022-11-29)")
query = {
"ac": "wifi", "channel": CHANNEL, "aid": APP_ID, "app_name": "aweme",
"version_code": VERSION_CODE, "version_name": APP_VERSION,
"device_platform": "android", "os": "android", "ssmix": "a",
"device_type": dev_type, "device_brand": dev_brand,
"language": "zh", "os_api": os_api, "os_version": os_ver,
"manifest_version_code": VERSION_CODE, "resolution": resolution,
"dpi": dpi, "update_version_code": UPDATE_VERSION_CODE,
"_rticket": str(int(time.time() * 1000)),
"cdid": cdid, "openudid": openudid,
}
payload = {
"magic_tag": "ss_app_log",
"header": {
"display_name": "抖音",
"update_version_code": int(UPDATE_VERSION_CODE),
"manifest_version_code": int(VERSION_CODE), "aid": int(APP_ID),
"channel": CHANNEL, "package": "com.ss.android.ugc.aweme",
"app_version": APP_VERSION, "version_code": int(VERSION_CODE),
"sdk_version": "2.13.5", "os": "Android",
"os_version": os_ver, "os_api": int(os_api),
"device_model": dev_type, "device_brand": dev_brand,
"device_manufacturer": dev_brand, "cpu_abi": "arm64-v8a",
"density_dpi": int(dpi), "display_density": "mdpi",
"resolution": resolution.replace("*", "x"),
"language": "zh", "timezone": 8,
"access": "wifi", "not_request_sender": 0, "region": "CN",
"tz_name": "Asia/Shanghai", "tz_offset": 28800,
"sim_region": "cn", "openudid": openudid,
"clientudid": clientudid, "cdid": cdid,
"rom": "QP1A.190711.020", "rom_version": f"{dev_brand}/{os_ver}",
"device_id": 0, "install_id": 0,
},
"_gen_time": int(time.time() * 1000),
}
headers = {
"User-Agent": ua,
"Content-Type": "application/json; charset=utf-8",
"Accept-Encoding": "gzip",
"X-SS-REQ-TICKET": str(int(time.time() * 1000)),
}
errors = []
for host in REGISTER_HOSTS:
try:
r = session.post(host + "?" + urlencode(query),
data=json.dumps(payload), headers=headers,
timeout=timeout)
data = r.json()
except (requests.RequestException, ValueError) as exc:
errors.append(f"{host}: {type(exc).__name__}")
continue
did = str(data.get("device_id_str") or data.get("device_id") or "")
iid = str(data.get("install_id_str") or data.get("install_id") or "")
if did and did != "0":
return cls(did, iid, openudid, cdid, clientudid, profile)
errors.append(f"{host}: device_id={did!r}")
raise DeviceRegisterError("设备注册全部失败: " + "; ".join(errors))
@classmethod
def load_or_register(cls, cache: str | Path,
session: Optional[requests.Session] = None) -> "Device":
cache = Path(cache)
if cache.exists():
try:
return cls.from_dict(json.loads(cache.read_text(encoding="utf-8")))
except (ValueError, KeyError, OSError):
pass
dev = cls.register(session)
cache.parent.mkdir(parents=True, exist_ok=True)
cache.write_text(json.dumps(dev.to_dict(), indent=2), encoding="utf-8")
return dev
class AppClient:
"""按 aweme_id 取详情。不需要签名、Cookie 或登录态。"""
def __init__(self, device: Optional[Device] = None,
cache: str | Path = ".device.json",
timeout: float = 25.0) -> None:
self.session = requests.Session()
self.device = device or Device.load_or_register(cache, self.session)
self.timeout = timeout
self.session.headers.update({
"User-Agent": self.device.user_agent,
"Accept-Encoding": "gzip",
"Connection": "keep-alive",
"sdk-version": "2",
})
def _params(self) -> Dict[str, str]:
return {
"device_platform": "android", "os": "android", "ssmix": "a",
"_rticket": str(int(time.time() * 1000)),
"cdid": self.device.cdid, "channel": CHANNEL, "aid": APP_ID,
"app_name": "aweme", "version_code": VERSION_CODE,
"version_name": APP_VERSION, "manifest_version_code": VERSION_CODE,
"update_version_code": UPDATE_VERSION_CODE,
"resolution": self.device.resolution, "dpi": self.device.dpi,
"device_type": self.device.device_type,
"device_brand": self.device.device_brand,
"language": "zh", "os_api": self.device.os_api,
"os_version": self.device.os_version,
"openudid": self.device.openudid,
"device_id": self.device.device_id, "iid": self.device.install_id,
"ac": "wifi", "ts": str(int(time.time())),
}
def fetch_detail(self, aweme_id: str, retries: int = 3) -> Aweme:
last = ""
for attempt in range(retries):
host = API_HOSTS[attempt % len(API_HOSTS)]
params = self._params()
params["aweme_id"] = aweme_id
url = f"{host}/aweme/v1/aweme/detail/?" + urlencode(params)
try:
r = self.session.get(url, timeout=self.timeout)
except requests.RequestException as exc:
last = f"{type(exc).__name__}: {exc}"
continue
if not r.content:
last = f"HTTP {r.status_code} 空 body"
time.sleep(1 + attempt)
continue
try:
data: Dict[str, Any] = r.json()
except ValueError:
last = f"非 JSON: {r.text[:120]!r}"
continue
detail = data.get("aweme_detail")
if not detail:
# 服务端会在 filter_detail 里说明为什么不给正片,
# 例如图文作品 filter_reason=images_base
f = data.get("filter_detail") or {}
reason = f.get("filter_reason") or ""
if reason == "images_base":
raise NotAVideo(f"{aweme_id} 是图文作品(图集),不是视频")
if reason:
raise DouyinError(
f"{aweme_id} 被服务端过滤:{reason}"
+ (f"({f.get('notice')})" if f.get("notice") else "")
)
raise DouyinError(
f"无 aweme_detail,status_code={data.get('status_code')},"
"作品可能已删除或为私密"
)
return DouyinClient.parse(detail)
raise DouyinError(f"App detail 连续 {retries} 次失败({last})")