154 lines
6.3 KiB
Python
154 lines
6.3 KiB
Python
"""抖音图文 SSR 静态解析 — 不走浏览器,直接解析服务端渲染页面
|
||||
|
|
|
|||
|
|
与 douyin_api.py 的 playwright 拦截方案互补:
|
|||
|
|
- 视频作品 → fetch_douyin_content(playwright 拦截,可取全部清晰度)
|
|||
|
|
- 图文作品 → fetch_douyin_note_ssr(本模块,按 aweme_id 精准渲染,秒级返回)
|
|||
|
|
|
|||
|
|
SSR 数据(window._ROUTER_DATA / __pace_f RSC)是服务端按作品 ID 渲染的页面数据,
|
|||
|
|
不存在 playwright 拦截 aweme/v1/web/aweme/post 时取到作者其他作品的问题。
|
|||
|
|
|
|||
|
|
注意:必须用移动端 UA 请求 iesdouyin.com/share 端点,桌面 UA 会被 302 回主站
|
|||
|
|
并返回 JS 反爬壳(无任何 SSR 数据)。
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
import re
|
|||
|
|
from pathlib import Path
|
|||
|
|
from typing import List, Optional
|
|||
|
|
|
|||
|
|
import httpx
|
|||
|
|
from nonebot import logger
|
|||
|
|
|
|||
|
|
from ..models import DouyinFetchError
|
|||
|
|
from ..utils import get_temp_root
|
|||
|
|
from .douyin_api import _process_animated_note, _process_note_with_parsed
|
|||
|
|
from .douyin_parser import parse_douyin_response, parse_note_images, parse_ssr_page
|
|||
|
|
|
|||
|
|
MOBILE_UA = (
|
|||
|
|
"Mozilla/5.0 (iPhone; CPU iPhone OS 17_2 like Mac OS X) "
|
|||
|
|
"AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.2 "
|
|||
|
|
"Mobile/15E148 Safari/604.1"
|
|||
|
|
)
|
|||
|
|
ROUTER_DATA_RE = re.compile(r"window\._ROUTER_DATA\s*=\s*(.*?)</script>", re.S)
|
|||
|
|
# 短链重定向后统一为 iesdouyin.com/share/video/{id} 形态(图文作品也如此),
|
|||
|
|
# 因此 note / video 两种形态都要能提取 ID
|
|||
|
|
AWEME_ID_RE = re.compile(r"/(?:share/)?(?:note|video)/(\d+)")
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def fetch_douyin_note_ssr(
|
|||
|
|
note_url: str,
|
|||
|
|
cookies: List[dict],
|
|||
|
|
) -> tuple[Optional[str], Optional[List[Path]]]:
|
|||
|
|
"""
|
|||
|
|
解析图文作品(SSR 静态解析,秒级返回)
|
|||
|
|
|
|||
|
|
接受 note / video 任意形态的抖音链接(重定向后图文也常是 share/video),
|
|||
|
|
按 SSR 内容判定:有 images → 图文/动图下载;无 images(真视频)→
|
|||
|
|
返回 (None, None) 交由 playwright 链路。
|
|||
|
|
|
|||
|
|
Returns:
|
|||
|
|
(title, file_paths) — 图片/视频本地路径列表;非图文返回 (None, None);
|
|||
|
|
失败抛 DouyinFetchError
|
|||
|
|
"""
|
|||
|
|
m = AWEME_ID_RE.search(note_url)
|
|||
|
|
if not m:
|
|||
|
|
raise DouyinFetchError(f"无法从链接提取作品 ID: {note_url}")
|
|||
|
|
vid = m.group(1)
|
|||
|
|
|
|||
|
|
api_response = await _fetch_ssr_api_response(note_url, vid, cookies)
|
|||
|
|
|
|||
|
|
# 内容判定:SSR 数据无 images → 真视频作品,回落 playwright
|
|||
|
|
aweme = (api_response.get("aweme_list") or [None])[0] or {}
|
|||
|
|
if not aweme.get("images"):
|
|||
|
|
logger.info("SSR 内容为视频作品(无 images),回退 playwright 链路")
|
|||
|
|
return None, None
|
|||
|
|
|
|||
|
|
# 复用原有解析链:标题 / 作者 / 文件名(referer 统一用 note 形态,
|
|||
|
|
# 避免输入是 share/video 时被误判为"视频")
|
|||
|
|
parsed = parse_douyin_response(
|
|||
|
|
api_response, f"https://www.douyin.com/note/{vid}"
|
|||
|
|
)
|
|||
|
|
logger.info(f"SSR 标题: {parsed.raw_title}")
|
|||
|
|
logger.info(f"SSR 作者: {parsed.raw_nickname}")
|
|||
|
|
|
|||
|
|
tmp_root = get_temp_root("douyin")
|
|||
|
|
headers = {
|
|||
|
|
"Referer": "https://www.douyin.com/",
|
|||
|
|
"User-Agent": (
|
|||
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|||
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|||
|
|
"Chrome/122.0.0.0 Safari/537.36"
|
|||
|
|
),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
# 复用原有分派:纯图文 / 图+视频混合 → 并发下载;纯动图 → 逐张下载 mp4
|
|||
|
|
images_urls, video_url = parse_note_images(api_response, None, vid)
|
|||
|
|
if images_urls:
|
|||
|
|
file_paths = await _process_note_with_parsed(
|
|||
|
|
images_urls, video_url, tmp_root, parsed.file_name, headers
|
|||
|
|
)
|
|||
|
|
else:
|
|||
|
|
file_paths = await _process_animated_note(
|
|||
|
|
api_response, tmp_root, parsed.file_name, headers
|
|||
|
|
)
|
|||
|
|
return parsed.file_name, file_paths
|
|||
|
|
|
|||
|
|
|
|||
|
|
async def _fetch_ssr_api_response(
|
|||
|
|
note_url: str, vid: str, cookies: List[dict]
|
|||
|
|
) -> dict:
|
|||
|
|
"""请求 douyin.com note 页面并提取 SSR 数据,规范化为 aweme_list 格式
|
|||
|
|
|
|||
|
|
2026-08-13 起 iesdouyin.com/share/note 端点对所有请求(含登录态)只返回
|
|||
|
|
33KB 壳页(无任何作品数据),改从 www.douyin.com/note/{id} 提取
|
|||
|
|
__pace_f RSC 数据——服务端按作品 id 精确渲染,与 URL 一一对应,
|
|||
|
|
不存在 aweme/post 作者列表"取错作品"的问题。
|
|||
|
|
必须用桌面 UA + 登录 cookie(whale 风控对未登录请求截断作品数据)。
|
|||
|
|
"""
|
|||
|
|
url = f"https://www.douyin.com/note/{vid}"
|
|||
|
|
cookie_map = {c["name"]: c["value"] for c in cookies} if cookies else None
|
|||
|
|
headers = {
|
|||
|
|
"User-Agent": (
|
|||
|
|
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
|
|||
|
|
"AppleWebKit/537.36 (KHTML, like Gecko) "
|
|||
|
|
"Chrome/151.0.0.0 Safari/537.36"
|
|||
|
|
),
|
|||
|
|
"Referer": "https://www.douyin.com/",
|
|||
|
|
}
|
|||
|
|
async with httpx.AsyncClient(
|
|||
|
|
headers=headers, timeout=20, follow_redirects=True
|
|||
|
|
) as client:
|
|||
|
|
resp = await client.get(url, cookies=cookie_map)
|
|||
|
|
if resp.status_code >= 400:
|
|||
|
|
raise DouyinFetchError(f"SSR 页面请求失败: status={resp.status_code}")
|
|||
|
|
html = resp.text
|
|||
|
|
logger.info(f"SSR 页面: {url} status={resp.status_code} size={len(html)}")
|
|||
|
|
|
|||
|
|
# 1. 旧版 SSR: window._ROUTER_DATA(snake_case 结构,可直接复用原有解析函数)
|
|||
|
|
if m := ROUTER_DATA_RE.search(html):
|
|||
|
|
data = _clean_js_json(m.group(1))
|
|||
|
|
if data:
|
|||
|
|
loader = data.get("loaderData", {})
|
|||
|
|
page = loader.get("note_(id)/page") or loader.get("video_(id)/page")
|
|||
|
|
item_list = (page.get("videoInfoRes") or {}).get("item_list") or []
|
|||
|
|
if item_list:
|
|||
|
|
logger.info("SSR 数据来源: _ROUTER_DATA")
|
|||
|
|
return {"aweme_list": [item_list[0]]}
|
|||
|
|
# 2. 新版 SSR: __pace_f.push RSC 流(parse_ssr_page 已规范化为 aweme_list 格式)
|
|||
|
|
if parsed := parse_ssr_page(html):
|
|||
|
|
logger.info("SSR 数据来源: __pace_f RSC")
|
|||
|
|
return parsed
|
|||
|
|
raise DouyinFetchError("SSR 页面无图文数据(可能已被删除或风控)")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _clean_js_json(raw: str) -> Optional[dict]:
|
|||
|
|
"""清洗 JS 对象文本为合法 JSON($undefined / $Lx 占位符)"""
|
|||
|
|
text = raw.strip().rstrip(";").strip()
|
|||
|
|
text = re.sub(r"\$undefined", "null", text)
|
|||
|
|
text = re.sub(r"\$L\d+", "null", text)
|
|||
|
|
try:
|
|||
|
|
return json.loads(text)
|
|||
|
|
except json.JSONDecodeError:
|
|||
|
|
logger.warning("SSR JSON 解析失败")
|
|||
|
|
return None
|