douyin_detail.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375
  1. """
  2. 抖音视频详情工具
  3. 根据 content_id(aweme_id)调用内部爬虫服务获取视频详情与真实播放链接。
  4. 支持单个或批量查询。
  5. """
  6. from __future__ import annotations
  7. import asyncio
  8. import json
  9. import logging
  10. import time
  11. from typing import Any, Optional
  12. import httpx
  13. from supply_infra.video_discovery_gates import parse_datetime_value
  14. logger = logging.getLogger(__name__)
  15. _MIN_REQUEST_INTERVAL_SECONDS = 10.1
  16. _rate_limit_lock = asyncio.Lock()
  17. _last_request_monotonic: float = 0.0
  18. DOUYIN_DETAIL_API = "http://8.217.190.241:8888/crawler/dou_yin/detail"
  19. DEFAULT_TIMEOUT = 60.0
  20. MAX_DETAIL_ITEMS = 8
  21. _PLAY_URL_MARKER = "douyin.com/aweme/v1/play/"
  22. # 详情接口中保留的有效字段(去掉长期无意义的空壳字段)
  23. _KEEP_FIELDS = (
  24. "channel",
  25. "channel_content_id",
  26. "content_link",
  27. "title",
  28. "content_type",
  29. "body_text",
  30. "location",
  31. "source_url",
  32. "topic_list",
  33. "image_url_list",
  34. "video_url_list",
  35. "multi_bitrate",
  36. "bgm_data",
  37. "is_original",
  38. "channel_account_id",
  39. "channel_account_name",
  40. "channel_account_avatar",
  41. "view_count",
  42. "play_count",
  43. "like_count",
  44. "collect_count",
  45. "comment_count",
  46. "share_count",
  47. "looking_count",
  48. "create_time",
  49. "create_timestamp",
  50. "publish_at",
  51. "publish_time",
  52. "publish_timestamp",
  53. "modify_timestamp",
  54. "update_timestamp",
  55. )
  56. def _is_play_url(url: str) -> bool:
  57. return bool(url) and _PLAY_URL_MARKER in url
  58. def _pick_url(*candidates: str) -> str:
  59. """优先选 aweme/v1/play 链接,否则回退第一个非空 URL。"""
  60. urls = [u for u in candidates if u]
  61. for url in urls:
  62. if _is_play_url(url):
  63. return url
  64. return urls[0] if urls else ""
  65. def _extract_video_url(detail_data: dict[str, Any]) -> str:
  66. """优先取 video_url_list[0],并偏好 aweme/v1/play 可播放链接。"""
  67. candidates: list[str] = []
  68. video_url_list = detail_data.get("video_url_list")
  69. if isinstance(video_url_list, list):
  70. for item in video_url_list:
  71. if isinstance(item, dict):
  72. url = item.get("video_url") or ""
  73. if url:
  74. candidates.append(url)
  75. multi_bitrate = detail_data.get("multi_bitrate")
  76. if isinstance(multi_bitrate, dict):
  77. for ratio in ("1080p", "720p", "540p", "default"):
  78. bit_info = multi_bitrate.get(ratio)
  79. if isinstance(bit_info, dict):
  80. url = bit_info.get("video_url") or ""
  81. if url:
  82. candidates.append(url)
  83. return _pick_url(*candidates)
  84. def _extract_cover_url(detail_data: dict[str, Any]) -> str:
  85. image_url_list = detail_data.get("image_url_list")
  86. if isinstance(image_url_list, list) and image_url_list:
  87. first = image_url_list[0]
  88. if isinstance(first, dict):
  89. return first.get("image_url") or ""
  90. return ""
  91. def _extract_video_duration(detail_data: dict[str, Any]) -> int:
  92. video_url_list = detail_data.get("video_url_list")
  93. if isinstance(video_url_list, list) and video_url_list:
  94. first = video_url_list[0]
  95. if isinstance(first, dict):
  96. try:
  97. return int(first.get("video_duration") or 0)
  98. except (TypeError, ValueError):
  99. return 0
  100. return 0
  101. def _normalize_content_ids(content_ids: list[str]) -> list[str]:
  102. """去重且保序,过滤空值。"""
  103. seen: set[str] = set()
  104. result: list[str] = []
  105. for item in content_ids:
  106. cid = str(item).strip()
  107. if not cid or cid in seen:
  108. continue
  109. seen.add(cid)
  110. result.append(cid)
  111. return result
  112. def _build_detail_result(detail: dict[str, Any], content_id: str) -> dict[str, Any]:
  113. """保留接口有效字段,并补充常用便捷字段。"""
  114. channel_content_id = str(detail.get("channel_content_id") or content_id)
  115. result: dict[str, Any] = {
  116. "content_id": content_id,
  117. "video_url": _extract_video_url(detail),
  118. "video_duration": _extract_video_duration(detail),
  119. "duration_seconds": _extract_video_duration(detail),
  120. "cover_url": _extract_cover_url(detail),
  121. }
  122. for key in _KEEP_FIELDS:
  123. if key not in detail:
  124. continue
  125. value = detail.get(key)
  126. if key == "channel_content_id":
  127. result[key] = channel_content_id
  128. elif key == "content_link":
  129. result[key] = value or (
  130. f"https://www.douyin.com/video/{channel_content_id}" if channel_content_id else ""
  131. )
  132. else:
  133. result[key] = value
  134. publish_at = parse_datetime_value(
  135. detail.get("publish_at")
  136. or detail.get("publish_time")
  137. or detail.get("publish_timestamp")
  138. or detail.get("create_time")
  139. or detail.get("create_timestamp")
  140. )
  141. result["publish_at"] = (
  142. publish_at.isoformat(timespec="seconds") if publish_at is not None else None
  143. )
  144. return result
  145. def _build_item_summary(index: int, result: dict[str, Any]) -> str:
  146. lines = [
  147. f"{index}. {result.get('title') or result.get('body_text') or '无标题'}",
  148. f" content_id: {result.get('content_id', '')}",
  149. f" 页面链接: {result.get('content_link', '')}",
  150. f" 视频链接: {result.get('video_url', '') or '未获取到'}",
  151. f" 时长: {result.get('video_duration', 0)} 秒",
  152. f" 作者: {result.get('channel_account_name', '')}",
  153. f" sec_uid: {result.get('channel_account_id', '')}",
  154. (
  155. f" 数据: 点赞 {result.get('like_count') or 0:,} | "
  156. f"评论 {result.get('comment_count') or 0:,} | "
  157. f"分享 {result.get('share_count') or 0:,} | "
  158. f"收藏 {result.get('collect_count') or 0:,}"
  159. ),
  160. ]
  161. return "\n".join(lines)
  162. def _build_output_summary(
  163. details: list[dict[str, Any]],
  164. errors: list[dict[str, str]],
  165. ) -> str:
  166. lines = [
  167. f"抖音视频详情:成功 {len(details)} 条"
  168. + (f",失败 {len(errors)} 条" if errors else "")
  169. ]
  170. lines.append("")
  171. for i, item in enumerate(details, 1):
  172. lines.append(_build_item_summary(i, item))
  173. lines.append("")
  174. if errors:
  175. lines.append("失败列表:")
  176. for err in errors:
  177. lines.append(f"- {err.get('content_id', '')}: {err.get('error', '')}")
  178. return "\n".join(lines).rstrip()
  179. def _error_result(
  180. error: str,
  181. *,
  182. title: str = "抖音详情获取失败",
  183. input_error: bool = False,
  184. ) -> str:
  185. return json.dumps(
  186. {"error": error, "title": title, "input_error": input_error},
  187. ensure_ascii=False,
  188. )
  189. async def _wait_rate_limit() -> None:
  190. global _last_request_monotonic
  191. async with _rate_limit_lock:
  192. now_mono = time.monotonic()
  193. wait_seconds = _MIN_REQUEST_INTERVAL_SECONDS - (now_mono - _last_request_monotonic)
  194. if wait_seconds > 0:
  195. await asyncio.sleep(wait_seconds)
  196. _last_request_monotonic = time.monotonic()
  197. async def _fetch_one_detail(
  198. client: httpx.AsyncClient,
  199. content_id: str,
  200. ) -> dict[str, Any]:
  201. """拉取单条详情。成功返回 detail 字典;失败抛出 Exception。"""
  202. await _wait_rate_limit()
  203. response = await client.post(
  204. DOUYIN_DETAIL_API,
  205. json={"content_id": content_id},
  206. headers={"Content-Type": "application/json"},
  207. )
  208. response.raise_for_status()
  209. body = response.json()
  210. if body.get("code") not in (0, None):
  211. raise RuntimeError(f"接口返回错误: code={body.get('code')} msg={body.get('msg')}")
  212. data_block = body.get("data", {}) if isinstance(body.get("data"), dict) else {}
  213. detail_raw = data_block.get("data", {}) if isinstance(data_block.get("data"), dict) else {}
  214. if not detail_raw:
  215. raise RuntimeError(f"未查到视频详情: content_id={content_id}")
  216. return _build_detail_result(detail_raw, content_id)
  217. async def douyin_detail(
  218. content_ids: list[str],
  219. timeout: Optional[float] = None,
  220. ) -> str:
  221. """
  222. 抖音视频详情(支持批量)
  223. 根据 content_id(搜索结果中的 aweme_id)获取视频详情与真实播放链接。
  224. 用于在 douyin_search 选中目标视频后,再拉取可播放的 video_url。
  225. Args:
  226. content_ids: 视频 ID 列表,对应搜索结果中的 aweme_id。
  227. 单个传 ["123"],多个传 ["123", "456"]
  228. timeout: 单次请求超时时间(秒),默认 60
  229. Returns:
  230. JSON 字符串,包含:
  231. - output: 文本摘要
  232. - details: 详情列表(含 video_url、作者、互动、BGM、多码率等有效字段)
  233. - errors: 失败项列表
  234. - success_count / failed_count / results_count
  235. """
  236. start_time = time.time()
  237. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  238. ids = _normalize_content_ids(content_ids)
  239. if not ids:
  240. return _error_result("content_ids 不能为空")
  241. if len(ids) > MAX_DETAIL_ITEMS:
  242. return _error_result(
  243. f"content_ids 最多 {MAX_DETAIL_ITEMS} 条,请先按相关性、分享价值和备选潜力筛选",
  244. input_error=True,
  245. )
  246. details: list[dict[str, Any]] = []
  247. errors: list[dict[str, str]] = []
  248. try:
  249. async with httpx.AsyncClient(
  250. timeout=request_timeout,
  251. trust_env=False,
  252. headers={"User-Agent": "curl/8.6.0", "Accept": "*/*"},
  253. ) as client:
  254. for content_id in ids:
  255. try:
  256. detail = await _fetch_one_detail(client, content_id)
  257. details.append(detail)
  258. except httpx.HTTPStatusError as e:
  259. msg = f"HTTP {e.response.status_code}: {e.response.text}"
  260. logger.error("douyin_detail HTTP error: content_id=%s status=%d", content_id, e.response.status_code)
  261. errors.append({"content_id": content_id, "error": msg})
  262. except httpx.TimeoutException:
  263. msg = f"请求超时({request_timeout}秒)"
  264. logger.error("douyin_detail timeout: content_id=%s", content_id)
  265. errors.append({"content_id": content_id, "error": msg})
  266. except httpx.RequestError as e:
  267. msg = f"网络错误: {e}"
  268. logger.error("douyin_detail network error: content_id=%s error=%s", content_id, e)
  269. errors.append({"content_id": content_id, "error": msg})
  270. except Exception as e:
  271. msg = str(e)
  272. logger.warning("douyin_detail item failed: content_id=%s error=%s", content_id, e)
  273. errors.append({"content_id": content_id, "error": msg})
  274. duration_ms = int((time.time() - start_time) * 1000)
  275. logger.info(
  276. "douyin_detail completed: requested=%d success=%d failed=%d duration_ms=%d",
  277. len(ids),
  278. len(details),
  279. len(errors),
  280. duration_ms,
  281. )
  282. if not details and errors:
  283. return _error_result(
  284. f"全部失败({len(errors)} 条): {errors[0].get('error', '')}"
  285. )
  286. payload = {
  287. "title": f"抖音详情: {len(details)}/{len(ids)}",
  288. "output": _build_output_summary(details, errors),
  289. "results_count": len(ids),
  290. "success_count": len(details),
  291. "failed_count": len(errors),
  292. "details": details,
  293. "errors": errors,
  294. "duration_ms": duration_ms,
  295. }
  296. # 单条时额外提供 detail,方便旧逻辑取值
  297. if len(details) == 1:
  298. payload["detail"] = details[0]
  299. return json.dumps(payload, ensure_ascii=False)
  300. except Exception as e:
  301. logger.error("douyin_detail unexpected error: error=%s", e, exc_info=True)
  302. return _error_result(f"未知错误: {e}")
  303. async def main() -> None:
  304. result_json = await douyin_detail(
  305. content_ids=["7641118685977614586", "7307654921879358747"]
  306. )
  307. result = json.loads(result_json)
  308. if "error" in result and "details" not in result:
  309. print(f"获取失败: {result['error']}")
  310. else:
  311. print(result["output"])
  312. print(f"\nsuccess={result.get('success_count')} failed={result.get('failed_count')}")
  313. for item in result.get("details", []):
  314. print(f"- {item.get('content_id')}: {item.get('video_url')}")
  315. if __name__ == "__main__":
  316. asyncio.run(main())