douyin_search.py 9.2 KB

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