douyin_search_tikhub.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456
  1. """通过 TikHub 搜索抖音视频,输出 find_agent 统一候选格式。"""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import logging
  6. import os
  7. import time
  8. from typing import Any
  9. import httpx
  10. from dotenv import load_dotenv
  11. from agents.find_agent.support.search_persistence import persist_search_payload
  12. from supply_agent.paths import find_project_root
  13. logger = logging.getLogger(__name__)
  14. DOUYIN_SEARCH_TIKHUB_API = (
  15. "https://api.tikhub.io/api/v1/douyin/search/fetch_video_search_v2"
  16. )
  17. DEFAULT_TIMEOUT = 60.0
  18. _MIN_REQUEST_INTERVAL_SECONDS = 1.0
  19. _rate_limit_lock = asyncio.Lock()
  20. _last_request_monotonic = 0.0
  21. _env_loaded = False
  22. _CONTENT_TYPE_MAP = {
  23. "不限": "0",
  24. "视频": "1",
  25. "图片": "2",
  26. "图文": "2",
  27. "文章": "3",
  28. "0": "0",
  29. "1": "1",
  30. "2": "2",
  31. "3": "3",
  32. }
  33. _SORT_TYPE_MAP = {
  34. "综合排序": "0",
  35. "最多点赞": "1",
  36. "最新发布": "2",
  37. "0": "0",
  38. "1": "1",
  39. "2": "2",
  40. }
  41. _PUBLISH_TIME_MAP = {
  42. "不限": "0",
  43. "一天内": "1",
  44. "最近一天": "1",
  45. "一周内": "7",
  46. "最近一周": "7",
  47. "半年内": "180",
  48. "最近半年": "180",
  49. "0": "0",
  50. "1": "1",
  51. "7": "7",
  52. "180": "180",
  53. }
  54. _DURATION_MAP = {
  55. "不限": "0",
  56. "一分钟内": "0-1",
  57. "1分钟以内": "0-1",
  58. "1-5分钟": "1-5",
  59. "五分钟以上": "5-10000",
  60. "5分钟以上": "5-10000",
  61. "0": "0",
  62. "0-1": "0-1",
  63. "1-5": "1-5",
  64. "5-10000": "5-10000",
  65. }
  66. def _ensure_env_loaded() -> None:
  67. global _env_loaded
  68. if _env_loaded:
  69. return
  70. load_dotenv(find_project_root() / ".env")
  71. _env_loaded = True
  72. def _safe_int(value: Any, default: int = 0) -> int:
  73. if isinstance(value, bool) or value is None:
  74. return default
  75. try:
  76. return int(float(str(value).strip()))
  77. except (TypeError, ValueError):
  78. return default
  79. def _enum_value(value: str, mapping: dict[str, str], field: str) -> str:
  80. normalized = str(value).strip()
  81. if normalized not in mapping:
  82. choices = " / ".join(key for key in mapping if not key.isdigit())
  83. raise ValueError(f"{field} 不支持「{value}」,可选:{choices}")
  84. return mapping[normalized]
  85. def _get_aweme_info(item: Any) -> dict[str, Any]:
  86. if not isinstance(item, dict):
  87. return {}
  88. data = item.get("data")
  89. if not isinstance(data, dict):
  90. return {}
  91. aweme_info = data.get("aweme_info")
  92. return aweme_info if isinstance(aweme_info, dict) else {}
  93. def _extract_topics(aweme: dict[str, Any]) -> list[str]:
  94. topics: list[str] = []
  95. for item in aweme.get("topic_list") or []:
  96. if isinstance(item, str):
  97. topics.append(item.strip())
  98. elif isinstance(item, dict):
  99. topic = (
  100. item.get("topic_name")
  101. or item.get("cha_name")
  102. or item.get("hashtag_name")
  103. or item.get("name")
  104. )
  105. if topic:
  106. topics.append(str(topic).strip())
  107. for item in aweme.get("text_extra") or []:
  108. if isinstance(item, dict):
  109. topic = item.get("hashtag_name")
  110. if topic:
  111. topics.append(str(topic).strip())
  112. for item in aweme.get("cha_list") or []:
  113. if isinstance(item, dict):
  114. topic = item.get("cha_name")
  115. if topic:
  116. topics.append(str(topic).strip())
  117. return list(dict.fromkeys(topic for topic in topics if topic))
  118. def _normalize_aweme(aweme: dict[str, Any]) -> dict[str, Any] | None:
  119. aweme_id = str(aweme.get("aweme_id") or "").strip()
  120. if not aweme_id:
  121. return None
  122. author = aweme.get("author") if isinstance(aweme.get("author"), dict) else {}
  123. stats = (
  124. aweme.get("statistics")
  125. if isinstance(aweme.get("statistics"), dict)
  126. else {}
  127. )
  128. return {
  129. "aweme_id": aweme_id,
  130. "desc": str(
  131. aweme.get("desc") or aweme.get("item_title") or "无标题"
  132. )[:200],
  133. "url": f"https://www.douyin.com/video/{aweme_id}",
  134. "author": {
  135. "nickname": str(author.get("nickname") or "未知作者"),
  136. "sec_uid": str(author.get("sec_uid") or ""),
  137. },
  138. "statistics": {
  139. "digg_count": _safe_int(stats.get("digg_count")),
  140. "comment_count": _safe_int(stats.get("comment_count")),
  141. "share_count": _safe_int(stats.get("share_count")),
  142. "collect_count": _safe_int(stats.get("collect_count")),
  143. "play_count": _safe_int(stats.get("play_count")),
  144. },
  145. "duration_ms": _safe_int(aweme.get("duration")),
  146. "topics": _extract_topics(aweme),
  147. }
  148. def _summary(
  149. keyword: str,
  150. results: list[dict[str, Any]],
  151. *,
  152. filtered_count: int,
  153. has_more: bool,
  154. next_cursor: int,
  155. search_id: str,
  156. ) -> str:
  157. lines = [
  158. f"TikHub 搜索关键词「{keyword}」",
  159. (
  160. f"保留 {len(results)} 条"
  161. + (f",过滤短视频 {filtered_count} 条" if filtered_count else "")
  162. + (
  163. f",还有更多(cursor={next_cursor}, search_id={search_id})"
  164. if has_more
  165. else ""
  166. )
  167. ),
  168. "",
  169. ]
  170. for index, item in enumerate(results, 1):
  171. stats = item["statistics"]
  172. lines.extend(
  173. [
  174. f"{index}. {item['desc'][:50]}",
  175. f" ID: {item['aweme_id']}",
  176. f" 链接: {item['url']}",
  177. (
  178. f" 作者: {item['author']['nickname']} | "
  179. f"sec_uid: {item['author']['sec_uid']}"
  180. ),
  181. (
  182. f" 数据: 点赞 {stats['digg_count']:,} | "
  183. f"评论 {stats['comment_count']:,} | "
  184. f"分享 {stats['share_count']:,} | "
  185. f"收藏 {stats['collect_count']:,}"
  186. ),
  187. f" 标签: {'、'.join(item['topics']) or '无'}",
  188. "",
  189. ]
  190. )
  191. return "\n".join(lines).rstrip()
  192. def _error_result(error: str) -> str:
  193. return json.dumps(
  194. {"error": error, "title": "TikHub 抖音搜索失败"},
  195. ensure_ascii=False,
  196. )
  197. async def _wait_rate_limit() -> None:
  198. global _last_request_monotonic
  199. async with _rate_limit_lock:
  200. elapsed = time.monotonic() - _last_request_monotonic
  201. if elapsed < _MIN_REQUEST_INTERVAL_SECONDS:
  202. await asyncio.sleep(_MIN_REQUEST_INTERVAL_SECONDS - elapsed)
  203. _last_request_monotonic = time.monotonic()
  204. async def _douyin_search_tikhub_raw(
  205. keyword: str,
  206. content_type: str = "视频",
  207. sort_type: str = "综合排序",
  208. publish_time: str = "不限",
  209. cursor: int = 0,
  210. filter_duration: str = "不限",
  211. search_id: str = "",
  212. backtrace: str = "",
  213. min_duration_seconds: int = 0,
  214. timeout: float | None = None,
  215. ) -> str:
  216. """
  217. 使用 TikHub 搜索抖音视频,支持多关键词探索和完整分页状态。
  218. 这是 douyin_search 的独立搜索来源。首次搜索 cursor=0、search_id/backtrace 为空;
  219. 翻页时必须把上次返回的 next_cursor、search_id、backtrace 原样传回。
  220. Args:
  221. keyword: Agent 自主确定的实际搜索词。
  222. content_type: 不限 / 视频 / 图片 / 文章,默认视频;也兼容 TikHub 数字代码。
  223. sort_type: 综合排序 / 最多点赞 / 最新发布;也兼容 0 / 1 / 2。
  224. publish_time: 不限 / 一天内 / 一周内 / 半年内;也兼容 0 / 1 / 7 / 180。
  225. cursor: 首次为 0,翻页使用上次返回的 next_cursor。
  226. filter_duration: 不限 / 一分钟内 / 1-5分钟 / 5分钟以上。
  227. search_id: 翻页状态,必须使用同一搜索返回值。
  228. backtrace: 翻页回溯状态,必须使用同一搜索返回值。
  229. min_duration_seconds: 客户端最短时长过滤,默认 0 表示不过滤。
  230. timeout: 请求超时秒数,默认 60。
  231. Returns:
  232. JSON 字符串。search_results 与 douyin_search 格式兼容,并额外包含
  233. duration_ms、topics、收藏数和播放数;分页字段为 has_more、next_cursor、
  234. search_id、backtrace。
  235. """
  236. keyword_text = str(keyword).strip()
  237. if not keyword_text:
  238. return _error_result("keyword 不能为空")
  239. try:
  240. content_type_value = _enum_value(content_type, _CONTENT_TYPE_MAP, "content_type")
  241. sort_type_value = _enum_value(sort_type, _SORT_TYPE_MAP, "sort_type")
  242. publish_time_value = _enum_value(
  243. publish_time, _PUBLISH_TIME_MAP, "publish_time"
  244. )
  245. duration_value = _enum_value(
  246. filter_duration, _DURATION_MAP, "filter_duration"
  247. )
  248. except ValueError as exc:
  249. return _error_result(str(exc))
  250. _ensure_env_loaded()
  251. api_key = os.getenv("TIKHUB_API_KEY", "").strip()
  252. if not api_key:
  253. return _error_result("未设置环境变量 TIKHUB_API_KEY")
  254. start_time = time.time()
  255. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  256. payload = {
  257. "keyword": keyword_text,
  258. "cursor": max(0, int(cursor)),
  259. "sort_type": sort_type_value,
  260. "publish_time": publish_time_value,
  261. "filter_duration": duration_value,
  262. "content_type": content_type_value,
  263. "search_id": str(search_id or ""),
  264. "backtrace": str(backtrace or ""),
  265. }
  266. try:
  267. await _wait_rate_limit()
  268. async with httpx.AsyncClient(
  269. timeout=request_timeout,
  270. trust_env=False,
  271. headers={
  272. "Content-Type": "application/json",
  273. "Authorization": f"Bearer {api_key}",
  274. },
  275. ) as client:
  276. response = await client.post(DOUYIN_SEARCH_TIKHUB_API, json=payload)
  277. response.raise_for_status()
  278. body = response.json()
  279. data = body.get("data") if isinstance(body.get("data"), dict) else {}
  280. business_data = (
  281. data.get("business_data")
  282. if isinstance(data.get("business_data"), list)
  283. else []
  284. )
  285. config = (
  286. data.get("business_config")
  287. if isinstance(data.get("business_config"), dict)
  288. else {}
  289. )
  290. next_page = (
  291. config.get("next_page")
  292. if isinstance(config.get("next_page"), dict)
  293. else {}
  294. )
  295. results: list[dict[str, Any]] = []
  296. seen: set[str] = set()
  297. filtered_count = 0
  298. minimum_ms = max(0, int(min_duration_seconds)) * 1000
  299. for raw_item in business_data:
  300. normalized = _normalize_aweme(_get_aweme_info(raw_item))
  301. if normalized is None or normalized["aweme_id"] in seen:
  302. continue
  303. if (
  304. minimum_ms
  305. and normalized["duration_ms"]
  306. and normalized["duration_ms"] < minimum_ms
  307. ):
  308. filtered_count += 1
  309. continue
  310. seen.add(normalized["aweme_id"])
  311. results.append(normalized)
  312. has_more = bool(config.get("has_more") in (1, True, "1"))
  313. next_cursor = _safe_int(next_page.get("cursor"))
  314. next_search_id = str(next_page.get("search_id") or search_id or "")
  315. next_backtrace = str(
  316. next_page.get("backtrace") or config.get("backtrace") or backtrace or ""
  317. )
  318. duration_ms = int((time.time() - start_time) * 1000)
  319. result = {
  320. "title": f"TikHub 抖音搜索: {keyword_text}",
  321. "output": _summary(
  322. keyword_text,
  323. results,
  324. filtered_count=filtered_count,
  325. has_more=has_more,
  326. next_cursor=next_cursor,
  327. search_id=next_search_id,
  328. ),
  329. "provider": "tikhub",
  330. "keyword": keyword_text,
  331. "request_params": payload,
  332. "results_count": len(results),
  333. "filtered_count": filtered_count,
  334. "has_more": has_more,
  335. "next_cursor": next_cursor,
  336. "search_id": next_search_id,
  337. "backtrace": next_backtrace,
  338. "search_results": results,
  339. "duration_ms": duration_ms,
  340. }
  341. logger.info(
  342. "douyin_search_tikhub completed: keyword=%s results=%d has_more=%s duration_ms=%d",
  343. keyword_text,
  344. len(results),
  345. has_more,
  346. duration_ms,
  347. )
  348. return json.dumps(result, ensure_ascii=False)
  349. except httpx.HTTPStatusError as exc:
  350. text = exc.response.text[:1000]
  351. logger.error(
  352. "douyin_search_tikhub HTTP error: keyword=%s status=%d",
  353. keyword_text,
  354. exc.response.status_code,
  355. )
  356. return _error_result(f"HTTP {exc.response.status_code}: {text}")
  357. except httpx.TimeoutException:
  358. return _error_result(f"请求超时({request_timeout}秒)")
  359. except httpx.RequestError as exc:
  360. return _error_result(f"网络错误: {exc}")
  361. except Exception as exc:
  362. logger.error(
  363. "douyin_search_tikhub unexpected error: keyword=%s error=%s",
  364. keyword_text,
  365. exc,
  366. exc_info=True,
  367. )
  368. return _error_result(f"未知错误: {exc}")
  369. async def douyin_search_tikhub(
  370. run_id: str,
  371. keyword: str,
  372. query_reason: str,
  373. source_type: str,
  374. source_value: str | None = None,
  375. parent_search_id: int | None = None,
  376. page_no: int = 1,
  377. content_type: str = "视频",
  378. sort_type: str = "综合排序",
  379. publish_time: str = "不限",
  380. cursor: int = 0,
  381. filter_duration: str = "不限",
  382. search_id: str = "",
  383. backtrace: str = "",
  384. min_duration_seconds: int = 0,
  385. timeout: float | None = None,
  386. ) -> str:
  387. """
  388. 使用 TikHub 搜索一页抖音视频,返回候选基础信息、数据库 ID 和分页状态。
  389. """
  390. result = await _douyin_search_tikhub_raw(
  391. keyword=keyword,
  392. content_type=content_type,
  393. sort_type=sort_type,
  394. publish_time=publish_time,
  395. cursor=cursor,
  396. filter_duration=filter_duration,
  397. search_id=search_id,
  398. backtrace=backtrace,
  399. min_duration_seconds=min_duration_seconds,
  400. timeout=timeout,
  401. )
  402. return await asyncio.to_thread(
  403. persist_search_payload,
  404. result,
  405. run_id=run_id,
  406. keyword=keyword,
  407. query_reason=query_reason,
  408. source_type=source_type,
  409. source_value=source_value,
  410. parent_search_id=parent_search_id,
  411. cursor=str(cursor),
  412. page_no=page_no,
  413. provider="tikhub",
  414. content_type=content_type,
  415. sort_type=sort_type,
  416. publish_time=publish_time,
  417. provider_state={"search_id": search_id, "backtrace": backtrace},
  418. )