App 端免签名接口(设备注册 + detail + CDN 直链),一体化流水线 下载→抽音→传COS→记录→删本地,AI 作品四档判定,四份文档: README(使用指南)、PROGRESS(批次进度)、RE(逆向全过程)、AI(判定规则)。 Co-Authored-By: Claude Code <noreply@anthropic.com>
290 lines
10 KiB
Python
290 lines
10 KiB
Python
"""抖音 Web 接口客户端。
|
||
|
||
实测要点(都是打真实接口验出来的):
|
||
|
||
1. 空 body 有两种成因,必须分开看:
|
||
- 新 session 的第一个请求必然空,第二个才通 —— cookie 预热,见 _warm_up()
|
||
- 服务端主动拦截 —— 响应头 X-Whale-Throughput-Abort-Data 里有 base64 原因
|
||
2. /aweme/detail/ 对匿名用户返回 abort={"name":"强制登录"},必须带登录 Cookie。
|
||
3. /aweme/related/ 匿名可用,返回完整 play_addr,但不含目标视频自身。
|
||
4. 请求头 UA 必须与签名时的 UA 完全一致,params 里的 browser_version 也要跟着对齐。
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import random
|
||
import string
|
||
import time
|
||
from dataclasses import dataclass
|
||
from typing import Any, Dict, List, Optional
|
||
from urllib.parse import urlencode
|
||
|
||
import requests
|
||
|
||
from .signer import Signer
|
||
|
||
DETAIL_URL = "https://www.douyin.com/aweme/v1/web/aweme/detail/"
|
||
RELATED_URL = "https://www.douyin.com/aweme/v1/web/aweme/related/"
|
||
TTWID_URL = "https://ttwid.bytedance.com/ttwid/union/register/"
|
||
|
||
BASE_PARAMS: Dict[str, str] = {
|
||
"device_platform": "webapp",
|
||
"aid": "6383",
|
||
"channel": "channel_pc_web",
|
||
"pc_client_type": "1",
|
||
"version_code": "190500",
|
||
"version_name": "19.5.0",
|
||
"cookie_enabled": "true",
|
||
"screen_width": "1920",
|
||
"screen_height": "1080",
|
||
"browser_language": "zh-CN",
|
||
"browser_platform": "Win32",
|
||
"browser_name": "Chrome",
|
||
"browser_version": "126.0.0.0",
|
||
"browser_online": "true",
|
||
"engine_name": "Blink",
|
||
"engine_version": "126.0.0.0",
|
||
"os_name": "Windows",
|
||
"os_version": "10",
|
||
"cpu_core_num": "8",
|
||
"device_memory": "8",
|
||
"platform": "PC",
|
||
"downlink": "10",
|
||
"effective_type": "4g",
|
||
"round_trip_time": "50",
|
||
}
|
||
|
||
|
||
PLAY_URL = "https://www.douyin.com/aweme/v1/play/"
|
||
|
||
|
||
def play_url_from_uri(uri: str, ratio: str = "1080p", watermark: bool = False) -> str:
|
||
"""由 video URI 拼出播放直链。
|
||
|
||
实测这个接口完全免鉴权:无 a_bogus、无 Cookie、无登录,裸 requests 即可,
|
||
302 跳到 douyinvod.com 的 CDN 地址。所以只要拿到了 URI,后续下载不再需要
|
||
任何身份——URI 值得缓存复用。
|
||
"""
|
||
path = "playwm" if watermark else "play"
|
||
return f"https://www.douyin.com/aweme/v1/{path}/?video_id={uri}&ratio={ratio}&line=0"
|
||
|
||
|
||
class DouyinError(RuntimeError):
|
||
pass
|
||
|
||
|
||
class LoginRequired(DouyinError):
|
||
"""服务端明确要求登录。"""
|
||
|
||
|
||
@dataclass
|
||
class Aweme:
|
||
aweme_id: str
|
||
desc: str
|
||
author: str
|
||
create_time: int
|
||
video_urls: List[str]
|
||
cover_urls: List[str]
|
||
music_url: Optional[str]
|
||
uri: str
|
||
raw: Dict[str, Any]
|
||
|
||
@property
|
||
def is_video(self) -> bool:
|
||
return bool(self.video_urls or self.uri)
|
||
|
||
def play_url(self, ratio: str = "1080p") -> str:
|
||
"""免鉴权直链。play_addr 里的地址带时效,URI 换出来的是现算的。"""
|
||
if not self.uri:
|
||
raise DouyinError("该作品没有 video URI")
|
||
return play_url_from_uri(self.uri, ratio)
|
||
|
||
|
||
def _rand(n: int, pool: str) -> str:
|
||
return "".join(random.choice(pool) for _ in range(n))
|
||
|
||
|
||
def gen_ms_token(length: int = 107) -> str:
|
||
return _rand(length, string.ascii_letters + string.digits + "=-_")
|
||
|
||
|
||
def gen_s_v_web_id() -> str:
|
||
body = "".join(
|
||
"_" if i in (8, 13, 18, 23) else random.choice("0123456789abcdef")
|
||
for i in range(32)
|
||
)
|
||
return f"verify_{format(int(time.time()), 'x')}_{body}"
|
||
|
||
|
||
def decode_abort(resp: requests.Response) -> str:
|
||
"""解出服务端拦截原因;没有则返回空串。"""
|
||
raw = resp.headers.get("X-Whale-Throughput-Abort-Data")
|
||
if not raw:
|
||
return ""
|
||
try:
|
||
info = json.loads(base64.b64decode(raw))
|
||
return info.get("name") or info.get("content") or ""
|
||
except (ValueError, TypeError):
|
||
return "unknown"
|
||
|
||
|
||
class DouyinClient:
|
||
def __init__(self, signer: Signer, cookie: str = "", timeout: float = 25.0,
|
||
warm_up: bool = True) -> None:
|
||
self.signer = signer
|
||
self.timeout = timeout
|
||
self.user_agent = signer.user_agent
|
||
self.fp = gen_s_v_web_id()
|
||
|
||
# params 里的浏览器版本必须跟签名用的 UA 对上,否则服务端会判不一致
|
||
self.params = dict(BASE_PARAMS)
|
||
if "Chrome/" in self.user_agent:
|
||
ver = self.user_agent.split("Chrome/")[1].split(" ")[0]
|
||
self.params["browser_version"] = ver
|
||
self.params["engine_version"] = ver
|
||
|
||
self.session = requests.Session()
|
||
self.session.headers.update({
|
||
"User-Agent": self.user_agent,
|
||
"Accept": "application/json, text/plain, */*",
|
||
"Accept-Language": "zh-CN,zh;q=0.9",
|
||
"Referer": "https://www.douyin.com/",
|
||
})
|
||
|
||
self._logged_in = bool(cookie)
|
||
if cookie:
|
||
self.session.headers["Cookie"] = cookie
|
||
else:
|
||
self._bootstrap_cookies()
|
||
|
||
self._warmed = False
|
||
if warm_up:
|
||
self._warm_up()
|
||
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def _bootstrap_cookies(self) -> None:
|
||
try:
|
||
self.session.post(TTWID_URL, timeout=self.timeout, json={
|
||
"region": "cn", "aid": 1768, "needFid": False,
|
||
"service": "www.ixigua.com",
|
||
"migrate_info": {"ticket": "", "source": "node"},
|
||
"cbUrlProtocol": "https", "union": True,
|
||
})
|
||
except requests.RequestException:
|
||
pass
|
||
self.session.cookies.set("s_v_web_id", self.fp, domain=".douyin.com")
|
||
self.session.cookies.set("odin_tt", _rand(160, "0123456789abcdef"),
|
||
domain=".douyin.com")
|
||
|
||
def _warm_up(self) -> None:
|
||
"""新 session 的第一个接口请求必然返回空,先打一发把它消耗掉。"""
|
||
if self._warmed:
|
||
return
|
||
try:
|
||
self._get(RELATED_URL, {"aweme_id": "1", "count": "1", "filterGids": "1"})
|
||
except (DouyinError, requests.RequestException):
|
||
pass
|
||
self._warmed = True
|
||
|
||
def _build_url(self, base: str, extra: Dict[str, str]) -> str:
|
||
p = dict(self.params)
|
||
p.update(extra)
|
||
p["msToken"] = gen_ms_token()
|
||
return f"{base}?{urlencode(p)}"
|
||
|
||
def _get(self, base: str, extra: Dict[str, str],
|
||
retries: int = 3) -> Dict[str, Any]:
|
||
"""签名 + 请求 + 解析。
|
||
|
||
「空 body 且无拦截原因」是可重试的瞬时态(session 还没热起来),
|
||
「空 body 且原因含登录」是硬门槛,立即抛错不重试。
|
||
"""
|
||
last = ""
|
||
for attempt in range(1, retries + 1):
|
||
url = self.signer.sign_url(self._build_url(base, extra), self.fp)
|
||
resp = self.session.get(url, timeout=self.timeout)
|
||
|
||
if resp.content:
|
||
try:
|
||
return resp.json()
|
||
except ValueError as exc:
|
||
raise DouyinError(f"响应不是 JSON: {resp.text[:200]!r}") from exc
|
||
|
||
reason = decode_abort(resp)
|
||
if "登录" in reason:
|
||
raise LoginRequired(
|
||
f"接口要求登录(服务端返回 {reason!r})。"
|
||
"请用 --cookie 传入已登录的抖音 Cookie。"
|
||
)
|
||
last = (f"HTTP {resp.status_code}"
|
||
+ (f",拦截原因 {reason!r}" if reason else ",无拦截原因"))
|
||
if attempt < retries:
|
||
time.sleep(2 * attempt)
|
||
|
||
raise DouyinError(f"接口连续 {retries} 次返回空 body({last})")
|
||
|
||
# ------------------------------------------------------------------ #
|
||
|
||
def fetch_detail(self, aweme_id: str) -> Aweme:
|
||
"""按 aweme_id 取作品详情。匿名调用会抛 LoginRequired。"""
|
||
self.session.headers["Referer"] = f"https://www.douyin.com/video/{aweme_id}"
|
||
data = self._get(DETAIL_URL, {"aweme_id": aweme_id})
|
||
|
||
detail = data.get("aweme_detail")
|
||
if not detail:
|
||
raise DouyinError(
|
||
f"响应无 aweme_detail,status_code={data.get('status_code')},"
|
||
"作品可能已删除或为私密"
|
||
)
|
||
return self.parse(detail)
|
||
|
||
def fetch_related(self, aweme_id: str, count: int = 10) -> List[Aweme]:
|
||
"""相关推荐。匿名可用,但不含 aweme_id 自身。"""
|
||
data = self._get(RELATED_URL, {
|
||
"aweme_id": aweme_id, "count": str(count), "filterGids": aweme_id,
|
||
})
|
||
return [self.parse(a) for a in (data.get("aweme_list") or [])]
|
||
|
||
# ------------------------------------------------------------------ #
|
||
|
||
@staticmethod
|
||
def parse(detail: Dict[str, Any]) -> Aweme:
|
||
video = detail.get("video") or {}
|
||
|
||
# play_addr 带水印,playwm -> play 即无水印源。
|
||
# bit_rate 里有多档清晰度,优先挑码率最高的。
|
||
urls: List[str] = []
|
||
gears = sorted(
|
||
(b for b in (video.get("bit_rate") or []) if b.get("play_addr")),
|
||
key=lambda b: b.get("bit_rate") or 0, reverse=True,
|
||
)
|
||
for gear in gears:
|
||
urls.extend((gear["play_addr"].get("url_list") or []))
|
||
urls.extend((video.get("play_addr") or {}).get("url_list") or [])
|
||
|
||
seen: set[str] = set()
|
||
video_urls = []
|
||
for u in urls:
|
||
u = u.replace("playwm", "play")
|
||
if u not in seen:
|
||
seen.add(u)
|
||
video_urls.append(u)
|
||
|
||
cover = video.get("origin_cover") or video.get("cover") or {}
|
||
music_list = ((detail.get("music") or {}).get("play_url") or {}).get("url_list") or []
|
||
|
||
return Aweme(
|
||
aweme_id=str(detail.get("aweme_id", "")),
|
||
desc=(detail.get("desc") or "").strip(),
|
||
author=((detail.get("author") or {}).get("nickname") or "").strip(),
|
||
create_time=int(detail.get("create_time") or 0),
|
||
video_urls=video_urls,
|
||
cover_urls=list(cover.get("url_list") or []),
|
||
music_url=music_list[0] if music_list else None,
|
||
uri=(video.get("play_addr") or {}).get("uri") or "",
|
||
raw=detail,
|
||
)
|