douyin_search_tikhub.py 15 KB

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