Files
HeXi/hexi/plugins/nonebot_plugin_video_analysis/fetchers/douyin_parser.py
T

426 lines
14 KiB
Python
Raw Normal View History

"""抖音 API 响应解析 — 纯数据提取,不涉及网络/下载"""
import json
import re
import urllib.parse
from dataclasses import dataclass
from datetime import datetime
from typing import Dict, List, Optional
from nonebot import logger
from ..models import DouyinFetchError
from ..utils import slugify
# ============================= 数据结构 =============================
@dataclass
class ParsedDouyinContent:
"""从 API 响应解析出的抖音内容元数据"""
raw_title: str
raw_nickname: str
media_type: str # "视频" | "图片"
file_name: str # 构建好的文件名 stem
# ============================= URL 工具 =============================
def classify_url(url: str) -> str:
"""返回 FULL / VIDEO / AUDIO / UNKNOWN"""
if "/media-video-" in url:
return "VIDEO"
if "/media-audio-" in url:
return "AUDIO"
if "mime_type=video_mp4" in url:
return "FULL"
return "UNKNOWN"
def extract_group_key(url: str) -> str:
"""提取分组 key(视频ID + l参数)"""
try:
m = re.search(r"/([0-9a-f]{8})/", url)
vid = m.group(1) if m else "unknown"
parsed = urllib.parse.urlparse(url)
qs = urllib.parse.parse_qs(parsed.query)
l = qs.get("l", [""])[0]
return f"{vid}_{l}"
except Exception:
return url
def extract_br(url: str) -> int:
"""提取码率"""
try:
parsed = urllib.parse.urlparse(url)
qs = urllib.parse.parse_qs(parsed.query)
return int(qs.get("br", [0])[0])
except Exception:
return 0
def extract_trailing_digits(url):
"""从 URL 末尾提取数字 ID"""
match = re.search(r"/(\d+)(?:\?|#|$)", url)
return match.group(1) if match else None
# ============================= 内容提取 =============================
def extract_title_from_api(api_response: dict) -> str:
"""从 API 响应提取标题,优先级: preview_title > desc > caption > aweme_list[0].desc"""
aweme_detail = api_response.get("aweme_detail") or {}
title = aweme_detail.get("preview_title")
if not title:
title = aweme_detail.get("desc")
if not title:
title = aweme_detail.get("caption")
if not title:
aweme_list = api_response.get("aweme_list") or []
if aweme_list and isinstance(aweme_list, list):
first = aweme_list[0] or {}
title = first.get("desc")
title_str = (title or "").strip()
logger.info(f"RAW标题:{title_str}")
if not title_str:
logger.info("未获取到标题")
return title_str
def extract_author_nickname(api_response: dict) -> str:
"""从 API 响应提取作者昵称,优先级: aweme_detail.author.nickname > aweme_list[0].author.nickname"""
aweme_detail = api_response.get("aweme_detail") or {}
author = aweme_detail.get("author") or {}
nickname = author.get("nickname")
if not nickname:
aweme_list = api_response.get("aweme_list") or []
if aweme_list and isinstance(aweme_list, list):
first = aweme_list[0] or {}
author = first.get("author") or {}
nickname = author.get("nickname")
logger.info(f"RAW作者昵称:{nickname}")
if not nickname:
nickname = "未知作者"
logger.info("未获取到作者昵称,使用默认值")
return nickname
def detect_media_type(referer_url: str | None) -> str | None:
"""根据页面 URL 检测媒体类型(视频/图片)"""
if referer_url is None:
return None
if "video" in referer_url:
return "视频"
elif "note" in referer_url:
return "图片"
return None
# ============================= 媒体 URL 解析 =============================
def parse_video_urls(api_response: dict) -> Dict[str, Dict[str, List[dict]]]:
"""从 API 响应解析视频 URL,返回分组结构: {group_id: {VIDEO: [...], AUDIO: [...], FULL: [...]}}"""
captured = []
try:
video_data = api_response.get("aweme_detail", {}).get("video", {})
bit_rates = video_data.get("bit_rate", [])
for item in bit_rates:
play_addr = item.get("play_addr", {})
url_list = play_addr.get("url_list", [])
br = item.get("bit_rate", 0)
if url_list:
url = url_list[0]
captured.append(
{
"url": url,
"br": br,
"type": classify_url(url),
"group": extract_group_key(url),
}
)
logger.info(f"从 API 解析出 {len(captured)} 个视频链接")
except Exception as e:
logger.error(f"解析视频 URL 失败: {e}")
raise DouyinFetchError(f"解析视频 URL 失败: {e}")
groups: Dict[str, Dict[str, List[dict]]] = {}
for item in captured:
g = item["group"]
if g not in groups:
groups[g] = {"FULL": [], "VIDEO": [], "AUDIO": []}
if item["type"] in groups[g]:
groups[g][item["type"]].append(item)
return groups
def parse_note_images(
api_response: dict, api_response_favorite: dict, aweme_id: str
) -> tuple[List[List[str]], Optional[str]]:
"""
从 API 响应解析图文/图+视频链接
images 列表中每项有两种结构:
- 纯图片: 取 url_list
- 视频: 取 video.play_addr.url_list
返回:
(images_list, video_url)
- images_list: 图片 URL 列表 [[url1, ...], ...]
- video_url: 混合作品中的视频 URL(无视频时为 None)
"""
images_list: List[List[str]] = []
video_url: Optional[str] = None
try:
aweme_list = api_response.get("aweme_list", None)
if not aweme_list:
raise DouyinFetchError(
f"获取到的作品图片列表为空,解析图文链接失败:{aweme_list}"
)
images_part = aweme_list[0]
images = images_part.get("images", [])
if images:
for image in images:
if not isinstance(image, dict):
continue
# 优先检查 video.play_addr(有视频的项)
video = image.get("video")
if video and isinstance(video, dict):
play_addr = video.get("play_addr")
if play_addr and isinstance(play_addr, dict):
play_urls = play_addr.get("url_list") or []
if play_urls:
video_url = play_urls[0]
logger.info(
f"从 images 中发现视频: {video_url[:60]}..."
)
continue
# 无视频的项取 url_list
url_list = image.get("url_list", [])
if url_list:
images_list.append(url_list)
logger.info(f"从 API 解析出 {len(images_list)} 张图片")
if video_url:
logger.info("混合作品包含视频")
else:
raise DouyinFetchError(
f"获取到的作品图片列表为空,解析图文链接失败:{aweme_list}"
)
except DouyinFetchError:
raise
except Exception as e:
logger.error(f"解析图文链接失败: {e}")
raise DouyinFetchError(f"解析图文链接失败: {e}")
return images_list, video_url
# ============================= SSR 页面回退解析 =============================
def _extract_rsc_payload(html: str) -> Optional[str]:
"""从 HTML 中提取 __pace_f.push 的 RSC 流数据(7:[...] 格式)"""
pos = 0
while True:
m = re.search(r'self\.__pace_f\.push\(\[(\d+),"', html[pos:])
if not m:
break
start = pos + m.end()
# 手动找闭合引号,处理转义
end = start
while end < len(html):
ch = html[end]
if ch == "\\":
end += 2
elif ch == '"':
break
else:
end += 1
# RSC payload 是 JS 字符串字面量(\" \\n \\\\ 等转义),
# 用 json.loads 按字符串语义一次解码,避免手动替换顺序错误
# (2026-08-13 实测:双重转义页手动替换会产出非法 JSON)
try:
raw = json.loads('"' + html[start:end] + '"')
except json.JSONDecodeError:
pos = end + 1
continue
# 找 RSC 流块: "7:[...]"
if raw.startswith("7:["):
return raw[2:] # 去掉 "7:" 前缀
pos = end + 1
return None
def _rsc_to_json(rsc: str) -> dict:
"""将 React Flight 格式转为普通 JSON dict,处理 $undefined / $Lx 等特殊 token"""
# $undefined -> null
text = re.sub(r'\$undefined', 'null', rsc)
# $L9 等 lazy ref -> null(避免解析报错)
text = re.sub(r'\$L\d+', 'null', text)
try:
return json.loads(text)
except json.JSONDecodeError:
logger.error(f"SSR 页面 JSON 解析失败,数据前 500 字: {text[:500]}")
raise DouyinFetchError("SSR 页面数据解析失败")
def parse_ssr_page(html: str) -> Optional[dict]:
"""
从 SSR 页面 HTML 中提取图文数据,转换为 aweme_list 格式
当 API 拦截未触发时作为回退方案
返回的 dict 可直接用于 parse_note_images / parse_animated_note_videos /
extract_title_from_api / extract_author_nickname
"""
rsc = _extract_rsc_payload(html)
if not rsc:
return None
data = _rsc_to_json(rsc)
if not isinstance(data, list) or len(data) < 4:
return None
# React Flight 格式: ["$","$L9",null,{awemeId,aweme:{detail:{...}}}]
page_data = data[3]
if not isinstance(page_data, dict):
return None
aweme = page_data.get("aweme") or {}
if isinstance(aweme, str):
aweme = {}
detail = aweme.get("detail") or {}
if isinstance(detail, str):
detail = {}
author_info = detail.get("authorInfo") or {}
# 标准化: 构建 aweme_list 格式(图文解析函数依赖此结构)
item = {
"desc": detail.get("desc") or "",
"images": detail.get("images") or [],
"media_type": detail.get("awemeType") or 0,
"author": {
"nickname": author_info.get("nickname") or "",
"uid": author_info.get("uid") or "",
},
}
# 标准化 urlList -> url_list, downloadUrlList -> download_url_list
for img in item["images"]:
if "urlList" in img and "url_list" not in img:
img["url_list"] = img.pop("urlList")
if "downloadUrlList" in img and "download_url_list" not in img:
img["download_url_list"] = img.pop("downloadUrlList")
# 动图的 video 字段
video = img.get("video")
if isinstance(video, dict):
if "playAddr" in video and "play_addr" not in video:
video["play_addr"] = video.pop("playAddr")
logger.info(f"SSR 页面解析成功: {len(item['images'])} 张图片, "
f"media_type={item['media_type']}")
return {"aweme_list": [item]}
# ============================= 文件名构建 =============================
def build_file_name(
raw_title: str,
raw_nickname: str,
media_type: str,
) -> str:
"""
构建文件名 stem,格式: {作者}_{标题}_{类型}_{时间戳}
昵称不限长,标题最多 15 字符(slugify 后),末尾 HHMMSS 防覆盖。
"""
slug_nickname = slugify(raw_nickname)
if raw_title:
slug_title = slugify(raw_title, max_length=15)
else:
slug_title = ""
if not slug_title:
slug_title = datetime.now().strftime("%H%M%S")
logger.info(f"标题为空,使用短时间戳: {slug_title}")
slug_type = slugify(media_type)
time_suffix = datetime.now().strftime("%H%M%S")
return f"{slug_nickname}_{slug_title}_{slug_type}_{time_suffix}"
# ============================= 动图检测 =============================
def is_animated_note(api_response: dict) -> bool:
"""检测图文是否为动图(media_type == 42),动图每张图片内含无声 mp4 视频"""
aweme_list = api_response.get("aweme_list") or []
if aweme_list and isinstance(aweme_list, list):
return aweme_list[0].get("media_type") == 42
return False
def parse_animated_note_videos(api_response: dict) -> List[str]:
"""从动图 API 响应解析视频链接(无音轨),返回最佳质量的视频 URL 列表"""
video_urls = []
try:
aweme_list = api_response.get("aweme_list") or []
if not aweme_list:
raise DouyinFetchError("动图作品列表为空")
images = aweme_list[0].get("images") or []
for image in images:
video = image.get("video") or {}
play_addr = video.get("play_addr") or {}
url_list = play_addr.get("url_list") or []
if url_list:
video_urls.append(url_list[0])
logger.info(f"从动图 API 解析出 {len(video_urls)} 个视频")
except DouyinFetchError:
raise
except Exception as e:
logger.error(f"解析动图视频链接失败: {e}")
raise DouyinFetchError(f"解析动图视频链接失败: {e}")
if not video_urls:
raise DouyinFetchError("未找到动图视频链接")
return video_urls
# ============================= 一站式解析 =============================
def parse_douyin_response(
api_response: dict, referer_url: str | None
) -> ParsedDouyinContent:
"""
一站式解析: 从 API 响应 + referer URL 中提取所有元数据并构建文件名
"""
media_type = detect_media_type(referer_url)
if not media_type:
raise DouyinFetchError("媒体类型无法获取,请检查url是否正确")
raw_title = extract_title_from_api(api_response)
raw_nickname = extract_author_nickname(api_response)
file_name = build_file_name(raw_title, raw_nickname, media_type)
logger.info(
f"内容标题: {raw_title}, 作者: {raw_nickname}, "
f"类型: {media_type}, 文件名: {file_name}"
)
return ParsedDouyinContent(
raw_title=raw_title,
raw_nickname=raw_nickname,
media_type=media_type,
file_name=file_name,
)