douyin_detail.py 12 KB

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