douyin_search.py 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226
  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 supply_agent.tools import tool
  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. @tool
  101. async def douyin_search(
  102. keyword: str,
  103. content_type: str = "视频",
  104. sort_type: str = "综合排序",
  105. publish_time: str = "不限",
  106. cursor: str = "0",
  107. account_id: str = DOUYIN_ACCOUNT_ID,
  108. timeout: Optional[float] = None,
  109. ) -> str:
  110. """
  111. 抖音关键词搜索
  112. 通过关键词搜索抖音平台的视频内容,支持多种排序和筛选方式。
  113. Args:
  114. keyword: 搜索关键词
  115. content_type: 内容类型(可选:视频/图文, 默认 "视频")
  116. sort_type: 排序方式(可选:综合排序/最新发布/最多点赞, 默认 "综合排序")
  117. publish_time: 发布时间范围(可选:不限/一天内/一周内/半年内, 默认 "不限")
  118. cursor: 分页游标,用于获取下一页结果,默认 "0"
  119. account_id: 账号ID(可选)
  120. timeout: 超时时间(秒),默认 60
  121. Returns:
  122. JSON 字符串,包含 output(文本摘要)和 search_results(结构化列表)。
  123. search_results 中每项含 aweme_id、desc、author、statistics。
  124. 使用 next_cursor 可获取下一页。
  125. """
  126. start_time = time.time()
  127. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  128. try:
  129. global _last_request_monotonic
  130. async with _rate_limit_lock:
  131. now_mono = time.monotonic()
  132. wait_seconds = _MIN_REQUEST_INTERVAL_SECONDS - (now_mono - _last_request_monotonic)
  133. if wait_seconds > 0:
  134. await asyncio.sleep(wait_seconds)
  135. _last_request_monotonic = time.monotonic()
  136. payload = {
  137. "keyword": keyword,
  138. "content_type": content_type,
  139. "sort_type": sort_type,
  140. "publish_time": publish_time,
  141. "cursor": cursor,
  142. "account_id": account_id,
  143. }
  144. async with httpx.AsyncClient(timeout=request_timeout) as client:
  145. response = await client.post(
  146. DOUYIN_SEARCH_API,
  147. json=payload,
  148. headers={"Content-Type": "application/json"},
  149. )
  150. response.raise_for_status()
  151. data = response.json()
  152. data_block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
  153. items = data_block.get("data", []) if isinstance(data_block.get("data"), list) else []
  154. has_more = bool(data_block.get("has_more", False))
  155. cursor_value = str(data_block.get("next_cursor", ""))
  156. duration_ms = int((time.time() - start_time) * 1000)
  157. logger.info(
  158. "douyin_search completed: keyword=%s results=%d has_more=%s duration_ms=%d",
  159. keyword,
  160. len(items),
  161. has_more,
  162. duration_ms,
  163. )
  164. return _success_result(keyword, data, items, has_more, cursor_value, duration_ms)
  165. except httpx.HTTPStatusError as e:
  166. logger.error(
  167. "douyin_search HTTP error: keyword=%s status=%d",
  168. keyword,
  169. e.response.status_code,
  170. )
  171. return _error_result(f"HTTP {e.response.status_code}: {e.response.text}")
  172. except httpx.TimeoutException:
  173. logger.error("douyin_search timeout: keyword=%s timeout=%s", keyword, request_timeout)
  174. return _error_result(f"请求超时({request_timeout}秒)")
  175. except httpx.RequestError as e:
  176. logger.error("douyin_search network error: keyword=%s error=%s", keyword, e)
  177. return _error_result(f"网络错误: {e}")
  178. except Exception as e:
  179. logger.error("douyin_search unexpected error: keyword=%s error=%s", keyword, e, exc_info=True)
  180. return _error_result(f"未知错误: {e}")
  181. async def main() -> None:
  182. result_json = await douyin_search(keyword="养老政策", account_id=DOUYIN_ACCOUNT_ID)
  183. result = json.loads(result_json)
  184. if "error" in result:
  185. print(f"搜索失败: {result['error']}")
  186. else:
  187. print(result["output"])
  188. print(f"\n共 {result['results_count']} 条结果")
  189. if __name__ == "__main__":
  190. asyncio.run(main())