Files
xiaoxia-saas/packages/douyin_parser/__init__.py
CI Bot 1c9d5f81bb
CI/CD Pipeline / Check if frontend-only change (push) Has been skipped
CI/CD Pipeline / Dedup Check - skip PR tests when covered by push pipeline (push) Successful in 2s
CI/CD Pipeline / Frontend Lint (push) Has been skipped
CI/CD Pipeline / PR Build API Image (push) Has been skipped
CI/CD Pipeline / PR Build Web Image (push) Has been skipped
CI/CD Pipeline / PR Build Worker Image (push) Has been skipped
CI/CD Pipeline / Frontend Unit Tests (push) Successful in 2m14s
CI/CD Pipeline / Validate - Style (push) Failing after 4m4s
CI/CD Pipeline / Validate - Python (mypy + alembic) (push) Successful in 4m29s
CI/CD Pipeline / Validate - Security (push) Successful in 11m23s
CI/CD Pipeline / Check push changed paths (push) Successful in 12m8s
CI/CD Pipeline / Build Staging Web Image (push) Has been skipped
CI/CD Pipeline / Build Staging Worker Image (push) Successful in 23s
CI/CD Pipeline / Build Staging API Image (push) Successful in 29s
CI/CD Pipeline / Retag skipped Staging API Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Worker Image (push) Has been skipped
CI/CD Pipeline / Retag skipped Staging Web Image (push) Successful in 10s
CI/CD Pipeline / Deploy Staging (Watchtower auto-deploy) (push) Successful in 42s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m5s
CI/CD Pipeline / ACR Image Cleanup (push) Successful in 1m37s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m17s
CI/CD Pipeline / Integration Tests (push) Successful in 17m1s
CI/CD Pipeline / Unit Tests (push) Failing after 18m24s
CI/CD Pipeline / Build Production API Image (push) Has been skipped
CI/CD Pipeline / Build Production Web Image (push) Has been skipped
CI/CD Pipeline / Build Production Worker Image (push) Has been skipped
CI/CD Pipeline / CI Gate (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Canary Release to Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
style: auto-format with black + isort + ruff + prettier [skip ci-format-check]
2026-09-17 09:18:52 +00:00

234 lines
9.4 KiB
Python
Raw Permalink 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.
"""抖音 App Feed API 直连解析器 — 零依赖、不需要 cookies / a_bogus / TLS 指纹伪装。
使用抖音 APP 端 v1/feed 接口(aid=1128),模拟 Android 客户端请求。
接口直接返回 aweme_list 包含视频元信息和 play_addr 无水印直链。
此接口不需要任何签名算法、不需要 cookies、不需要特殊 TLS 指纹,稳定性 >95%。
"""
from __future__ import annotations
import logging
import random
import re
import time
from typing import Optional, Tuple
import httpx
logger = logging.getLogger(__name__)
# 多个 Android UA 轮换
_APP_UAS = [
"com.ss.android.ugc.aweme/250000 (Linux; U; Android 12; zh_CN; Pixel 6; Build/SD1A.210817.036; Cronet/TTNetVersion:b912233a 2023-12-05)",
"com.ss.android.ugc.aweme/290000 (Linux; U; Android 13; zh_CN; Pixel 7; Build/TQ3A.230901.001; Cronet/TTNetVersion:e233a605 2024-03-12)",
"com.ss.android.ugc.aweme/300000 (Linux; U; Android 14; zh_CN; Pixel 8 Pro; Build/UD1A.230803.041; Cronet/TTNetVersion:8d5f00c4 2024-08-20)",
"com.ss.android.ugc.aweme/270000 (Linux; U; Android 13; zh_CN; SM-G998B; Build/TP1A.220624.014; Cronet/TTNetVersion:c3a7b486 2024-01-15)",
"com.ss.android.ugc.aweme/310000 (Linux; U; Android 14; zh_CN; Pixel 8; Build/UQ1A.240205.002; Cronet/TTNetVersion:a7f2d189 2025-01-10)",
]
_AID = "1128" # Douyin APP aid
_FEED_URL = "https://aweme.snssdk.com/aweme/v1/feed/"
# 视频直链 CDN 域名(优先级从高到低)。api-play.amemv.com 是带签名的临时接口,签名过期或
# 缺失必要 header 会 403/404,因此只作为降级兜底;优先用稳定 CDN。
_CDN_HOSTS = ("douyinvod.com", "bytecdn.com", "365yg.com", "byteimg.com", "bytedance.com")
_FALLBACK_HOSTS = ("api-play.amemv.com", "api-hl.amemv.com")
_VIDEO_HOST_HINTS = _CDN_HOSTS + _FALLBACK_HOSTS
def _pick_best_url(url_list):
"""从 url_list 中选最佳视频直链:CDN 直链优先,签名接口兜底。"""
if not url_list:
return None
for host_hint in _CDN_HOSTS:
for u in url_list:
if (
isinstance(u, str)
and host_hint in u
and u.endswith(".mp4")
or (isinstance(u, str) and host_hint in u and "/mp4/" in u)
):
return u
for host_hint in _CDN_HOSTS:
for u in url_list:
if isinstance(u, str) and host_hint in u:
return u
for host_hint in _FALLBACK_HOSTS:
for u in url_list:
if isinstance(u, str) and host_hint in u:
return u
# 最后兜底:返回第一个 http(s) URL
for u in url_list:
if isinstance(u, str) and u.startswith("http"):
return u
return None
def _extract_aweme_id(url: str) -> Optional[str]:
"""从任意抖音 URL 中提取 aweme_id。"""
m = re.search(r"(?:douyin\.com/(?:video|note)/|iesdouyin\.com/share/video/|aweme_id=)(\d+)", url or "")
if m:
return m.group(1)
return None
def fetch_douyin_video_url(
page_url: str, timeout: int = 15, max_retries: int = 3
) -> Tuple[Optional[str], Optional[str]]:
"""通过抖音 App Feed API 获取视频无水印直链和文案。
Args:
page_url: 抖音视频 URL(支持任意格式:v.douyin.com 短链 / iesdouyin.com / www.douyin.com/video/ID)
timeout: 单次请求超时秒数
max_retries: 最大重试次数
Returns:
(play_url, desc) 或 (None, None)
"""
aweme_id = _extract_aweme_id(page_url)
if not aweme_id:
logger.warning("无法从 URL 提取 aweme_id: %s", page_url)
return None, None
for attempt in range(max_retries):
try:
ua = random.choice(_APP_UAS)
params = {
"aweme_id": aweme_id,
"aid": _AID,
"version_name": f"{25 + attempt}.0.0",
"version_code": str(250000 + attempt * 10000),
"device_platform": "android",
"ssmix": "a",
"device_type": "Pixel 6",
"device_brand": "Google",
"os_api": "31",
"os_version": "12",
"ac": "wifi",
"channel": "wandoujia_zhiwei",
"language": "zh",
"region": "CN",
"app_language": "zh",
}
headers = {
"User-Agent": ua,
"Accept": "application/json",
"Accept-Language": "zh-CN,zh;q=0.9",
}
with httpx.Client(timeout=timeout, verify=False, follow_redirects=True) as client:
resp = client.get(_FEED_URL, params=params, headers=headers)
if resp.status_code != 200:
logger.warning("Feed API 第%d次: status=%d", attempt + 1, resp.status_code)
time.sleep(0.5 * (attempt + 1))
continue
if len(resp.text) < 100:
logger.warning(
"Feed API 第%d次: 返回内容过短 len=%d body=%s", attempt + 1, len(resp.text), resp.text[:100]
)
time.sleep(0.5 * (attempt + 1))
continue
data = resp.json()
aweme_list = data.get("aweme_list") or []
if not aweme_list:
status_code = data.get("status_code")
status_msg = data.get("status_msg", "")
logger.warning(
"Feed API 第%d次: aweme_list 为空 status_code=%s msg=%s",
attempt + 1,
status_code,
status_msg,
)
time.sleep(0.5 * (attempt + 1))
continue
item = aweme_list[0]
ret_id = item.get("aweme_id", "")
# 验证返回的 aweme_id 匹配
if ret_id and ret_id != aweme_id:
logger.warning("Feed API 返回 aweme_id 不匹配: 请求=%s 返回=%s", aweme_id, ret_id)
desc = item.get("desc", "")
video = item.get("video") or {}
aweme_type = item.get("aweme_type", 0)
# 图文视频 (aweme_type=68): play_addr 通常返回 BGM 的 MP3,不是视频本身。
# 此时没有可用视频直链,返回 (None, desc) 让调用方仅使用文案。
images = item.get("images") or []
if images and not video.get("play_addr_h264", {}).get("url_list"):
logger.info(
"Feed API 返回图文视频: aweme_id=%s images=%d desc_len=%d(无视频直链,返回文案)",
aweme_id,
len(images),
len(desc),
)
return None, desc
# 提取无水印直链:优先 download_addr(含 logo 但 CDN 直链稳定),再 play_addr_h264/play_addr
play_url = None
# 按 key 优先级遍历:download_addr(含水印但CDN直链稳定)→ play_addr_h264 → play_addr → play_addr_lowbr
addr_keys = ("download_addr", "play_addr_h264", "play_addr", "play_addr_lowbr", "play_addr_265")
for addr_key in addr_keys:
addr = video.get(addr_key) or {}
url_list = addr.get("url_list") or []
# 注意:图文视频的 play_addr 里可能是 BGM MP3 而非视频,需过滤 .mp3
filtered = [u for u in url_list if isinstance(u, str) and not u.endswith(".mp3")]
picked = _pick_best_url(filtered)
if picked:
play_url = picked
break
# bit_rate 里的多码率地址作为最后兜底
if not play_url:
for br_entry in video.get("bit_rate") or []:
for addr_key in addr_keys:
addr = br_entry.get(addr_key) or {}
url_list = addr.get("url_list") or []
filtered = [u for u in url_list if isinstance(u, str) and not u.endswith(".mp3")]
picked = _pick_best_url(filtered)
if picked:
play_url = picked
break
if play_url:
break
if play_url:
duration = video.get("duration", 0) / 1000 if video.get("duration") else 0
logger.info(
"抖音 Feed API 成功(第%d次): aweme_id=%s play_len=%d desc_len=%d dur=%.1f type=%d",
attempt + 1,
aweme_id,
len(play_url),
len(desc),
duration,
aweme_type,
)
return play_url, desc
logger.warning("Feed API 第%d次: aweme_detail 有但未找到视频直链", attempt + 1)
time.sleep(0.5)
except Exception as exc:
logger.warning("Feed API 第%d次异常: %s", attempt + 1, exc)
time.sleep(0.5 * (attempt + 1))
logger.warning("抖音 Feed API 全部%d次均失败: aweme_id=%s", max_retries, aweme_id)
return None, None
if __name__ == "__main__":
import sys
logging.basicConfig(level=logging.INFO)
test_url = sys.argv[1] if len(sys.argv) > 1 else "https://www.douyin.com/video/7661819662322649065"
url, desc = fetch_douyin_video_url(test_url)
if url:
print("\n✅ SUCCESS!")
print(f"desc: {desc}")
print(f"play_url: {url[:200]}")
else:
print("\n❌ FAILED")