douyin_search.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330
  1. """
  2. 抖音关键词搜索工具
  3. 调用内部爬虫服务进行抖音关键词搜索。
  4. """
  5. from __future__ import annotations
  6. import asyncio
  7. import json
  8. import logging
  9. import time
  10. from typing import Any, Optional
  11. import httpx
  12. from agents.find_agent.support.search_persistence import persist_search_payload
  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. # API 基础配置
  18. DOUYIN_SEARCH_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/keyword"
  19. DEFAULT_TIMEOUT = 60.0
  20. DOUYIN_ACCOUNT_ID = "771431222"
  21. DEFAULT_MIN_DURATION_SECONDS = 30
  22. def _safe_int(value: Any, default: int = 0) -> int:
  23. if isinstance(value, bool) or value is None:
  24. return default
  25. try:
  26. return int(float(str(value).strip()))
  27. except (TypeError, ValueError):
  28. return default
  29. def _extract_duration_ms(item: dict[str, Any]) -> int:
  30. video = item.get("video") if isinstance(item.get("video"), dict) else {}
  31. return _safe_int(
  32. item.get("duration_ms")
  33. or video.get("duration")
  34. or item.get("duration")
  35. )
  36. def _extract_publish_at(item: dict[str, Any]) -> Any:
  37. return (
  38. item.get("publish_at")
  39. or item.get("create_time")
  40. or item.get("create_timestamp")
  41. or item.get("publish_timestamp")
  42. )
  43. def _build_search_results(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
  44. """将 API 原始条目转换为结构化搜索结果。"""
  45. results = []
  46. for item in items:
  47. author = item.get("author", {}) if isinstance(item.get("author"), dict) else {}
  48. stats = item.get("statistics", {}) if isinstance(item.get("statistics"), dict) else {}
  49. aweme_id = item.get("aweme_id", "")
  50. results.append(
  51. {
  52. "aweme_id": aweme_id,
  53. "desc": (item.get("desc") or item.get("item_title") or "无标题")[:100],
  54. "url": f"https://www.douyin.com/video/{aweme_id}" if aweme_id else "",
  55. "author": {
  56. "nickname": author.get("nickname", "未知作者"),
  57. "sec_uid": author.get("sec_uid", ""),
  58. },
  59. "statistics": {
  60. "digg_count": stats.get("digg_count", 0),
  61. "comment_count": stats.get("comment_count", 0),
  62. "share_count": stats.get("share_count", 0),
  63. },
  64. "duration_ms": _extract_duration_ms(item),
  65. "publish_at": _extract_publish_at(item),
  66. }
  67. )
  68. return results
  69. def _build_output_summary(
  70. keyword: str,
  71. items: list[dict[str, Any]],
  72. has_more: bool,
  73. cursor_value: str,
  74. ) -> str:
  75. """生成给 LLM 阅读的文本摘要。"""
  76. lines = [f"搜索关键词「{keyword}」"]
  77. lines.append(
  78. f"找到 {len(items)} 条结果"
  79. + (f",还有更多(cursor={cursor_value})" if has_more else "")
  80. )
  81. lines.append("")
  82. for i, item in enumerate(items, 1):
  83. aweme_id = item.get("aweme_id", "unknown")
  84. desc = (item.get("desc") or item.get("item_title") or "无标题")[:50]
  85. author = item.get("author", {}) if isinstance(item.get("author"), dict) else {}
  86. author_name = author.get("nickname", "未知作者")
  87. author_id = author.get("sec_uid", "")
  88. stats = item.get("statistics", {}) if isinstance(item.get("statistics"), dict) else {}
  89. digg_count = stats.get("digg_count", 0)
  90. comment_count = stats.get("comment_count", 0)
  91. share_count = stats.get("share_count", 0)
  92. lines.append(f"{i}. {desc}")
  93. lines.append(f" ID: {aweme_id}")
  94. lines.append(f" 链接: https://www.douyin.com/video/{aweme_id}")
  95. lines.append(f" 作者: {author_name}")
  96. lines.append(f" sec_uid: {author_id}")
  97. lines.append(f" 数据: 点赞 {digg_count:,} | 评论 {comment_count:,} | 分享 {share_count:,}")
  98. lines.append("")
  99. return "\n".join(lines)
  100. def _success_result(
  101. keyword: str,
  102. data: dict[str, Any],
  103. items: list[dict[str, Any]],
  104. has_more: bool,
  105. cursor_value: str,
  106. duration_ms: int,
  107. filtered_count: int = 0,
  108. ) -> str:
  109. """构建成功时的 JSON 字符串返回值。"""
  110. search_results = _build_search_results(items)
  111. payload = {
  112. "title": f"抖音搜索: {keyword}",
  113. "output": _build_output_summary(keyword, items, has_more, cursor_value),
  114. "keyword": keyword,
  115. "results_count": len(items),
  116. "filtered_count": filtered_count,
  117. "has_more": has_more,
  118. "next_cursor": cursor_value,
  119. "search_results": search_results,
  120. "duration_ms": duration_ms,
  121. }
  122. return json.dumps(payload, ensure_ascii=False)
  123. def _error_result(error: str, *, title: str = "抖音搜索失败") -> str:
  124. """构建失败时的 JSON 字符串返回值。"""
  125. return json.dumps({"error": error, "title": title}, ensure_ascii=False)
  126. async def _douyin_search_raw(
  127. keyword: str,
  128. content_type: str = "视频",
  129. sort_type: str = "综合排序",
  130. publish_time: str = "不限",
  131. cursor: str = "0",
  132. account_id: str = DOUYIN_ACCOUNT_ID,
  133. min_duration_seconds: int = DEFAULT_MIN_DURATION_SECONDS,
  134. timeout: Optional[float] = None,
  135. ) -> str:
  136. """
  137. 抖音关键词搜索
  138. 通过关键词搜索抖音平台的视频内容,支持多种排序和筛选方式。
  139. Args:
  140. keyword: 搜索关键词
  141. content_type: 内容类型(可选:视频/图文, 默认 "视频")
  142. sort_type: 排序方式(可选:综合排序/最新发布/最多点赞, 默认 "综合排序")
  143. publish_time: 发布时间范围(可选:不限/一天内/一周内/半年内, 默认 "不限")
  144. cursor: 分页游标,用于获取下一页结果,默认 "0"
  145. account_id: 账号ID(可选)
  146. min_duration_seconds: 客户端最短时长过滤,固定不低于 30 秒。
  147. timeout: 超时时间(秒),默认 60
  148. Returns:
  149. JSON 字符串,包含 output(文本摘要)和 search_results(结构化列表)。
  150. search_results 中每项含 aweme_id、desc、author、statistics。
  151. 使用 next_cursor 可获取下一页。
  152. """
  153. start_time = time.time()
  154. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  155. try:
  156. global _last_request_monotonic
  157. async with _rate_limit_lock:
  158. now_mono = time.monotonic()
  159. wait_seconds = _MIN_REQUEST_INTERVAL_SECONDS - (now_mono - _last_request_monotonic)
  160. if wait_seconds > 0:
  161. await asyncio.sleep(wait_seconds)
  162. _last_request_monotonic = time.monotonic()
  163. payload = {
  164. "keyword": keyword,
  165. "content_type": content_type,
  166. "sort_type": sort_type,
  167. "publish_time": publish_time,
  168. "cursor": cursor,
  169. "account_id": account_id,
  170. }
  171. async with httpx.AsyncClient(timeout=request_timeout) as client:
  172. response = await client.post(
  173. DOUYIN_SEARCH_API,
  174. json=payload,
  175. headers={"Content-Type": "application/json"},
  176. )
  177. response.raise_for_status()
  178. data = response.json()
  179. data_block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
  180. items = data_block.get("data", []) if isinstance(data_block.get("data"), list) else []
  181. minimum_ms = max(
  182. DEFAULT_MIN_DURATION_SECONDS,
  183. int(min_duration_seconds or DEFAULT_MIN_DURATION_SECONDS),
  184. ) * 1000
  185. filtered_count = 0
  186. filtered_items: list[dict[str, Any]] = []
  187. for item in items:
  188. duration = _extract_duration_ms(item)
  189. if duration and duration < minimum_ms:
  190. filtered_count += 1
  191. continue
  192. filtered_items.append(item)
  193. items = filtered_items
  194. has_more = bool(data_block.get("has_more", False))
  195. cursor_value = str(data_block.get("next_cursor", ""))
  196. duration_ms = int((time.time() - start_time) * 1000)
  197. logger.info(
  198. "douyin_search completed: keyword=%s results=%d has_more=%s duration_ms=%d",
  199. keyword,
  200. len(items),
  201. has_more,
  202. duration_ms,
  203. )
  204. return _success_result(
  205. keyword,
  206. data,
  207. items,
  208. has_more,
  209. cursor_value,
  210. duration_ms,
  211. filtered_count,
  212. )
  213. except httpx.HTTPStatusError as e:
  214. logger.error(
  215. "douyin_search HTTP error: keyword=%s status=%d",
  216. keyword,
  217. e.response.status_code,
  218. )
  219. return _error_result(f"HTTP {e.response.status_code}: {e.response.text}")
  220. except httpx.TimeoutException:
  221. logger.error("douyin_search timeout: keyword=%s timeout=%s", keyword, request_timeout)
  222. return _error_result(f"请求超时({request_timeout}秒)")
  223. except httpx.RequestError as e:
  224. logger.error("douyin_search network error: keyword=%s error=%s", keyword, e)
  225. return _error_result(f"网络错误: {e}")
  226. except Exception as e:
  227. logger.error("douyin_search unexpected error: keyword=%s error=%s", keyword, e, exc_info=True)
  228. return _error_result(f"未知错误: {e}")
  229. async def douyin_search(
  230. run_id: str,
  231. keyword: str,
  232. query_reason: str,
  233. source_type: str,
  234. source_value: str | None = None,
  235. parent_search_id: int | None = None,
  236. page_no: int = 1,
  237. content_type: str = "视频",
  238. sort_type: str = "综合排序",
  239. publish_time: str = "不限",
  240. cursor: str = "0",
  241. account_id: str = DOUYIN_ACCOUNT_ID,
  242. min_duration_seconds: int = DEFAULT_MIN_DURATION_SECONDS,
  243. timeout: Optional[float] = None,
  244. ) -> str:
  245. """
  246. 搜索一页抖音视频,创建搜索记录和候选记录,返回视频基础信息及数据库 ID。
  247. """
  248. result = await _douyin_search_raw(
  249. keyword=keyword,
  250. content_type=content_type,
  251. sort_type=sort_type,
  252. publish_time=publish_time,
  253. cursor=cursor,
  254. account_id=account_id,
  255. min_duration_seconds=min_duration_seconds,
  256. timeout=timeout,
  257. )
  258. return await asyncio.to_thread(
  259. persist_search_payload,
  260. result,
  261. run_id=run_id,
  262. keyword=keyword,
  263. query_reason=query_reason,
  264. source_type=source_type,
  265. source_value=source_value,
  266. parent_search_id=parent_search_id,
  267. cursor=cursor,
  268. page_no=page_no,
  269. provider="internal_keyword",
  270. content_type=content_type,
  271. sort_type=sort_type,
  272. publish_time=publish_time,
  273. )
  274. async def main() -> None:
  275. result_json = await _douyin_search_raw(
  276. keyword="养老政策",
  277. account_id=DOUYIN_ACCOUNT_ID,
  278. )
  279. result = json.loads(result_json)
  280. if "error" in result:
  281. print(f"搜索失败: {result['error']}")
  282. else:
  283. print(result["output"])
  284. print(f"\n共 {result['results_count']} 条结果")
  285. if __name__ == "__main__":
  286. asyncio.run(main())