douyin_detail.py 12 KB

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