douyin_user_videos.py 10 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301
  1. """查询抖音作者作品,输出 find_agent 统一候选格式。"""
  2. from __future__ import annotations
  3. import asyncio
  4. import json
  5. import logging
  6. import time
  7. from typing import Any
  8. import httpx
  9. from agents.find_agent.support.search_persistence import persist_search_payload
  10. logger = logging.getLogger(__name__)
  11. DOUYIN_USER_VIDEOS_API = "http://crawapi.piaoquantv.com/crawler/dou_yin/blogger"
  12. DEFAULT_TIMEOUT = 60.0
  13. _MIN_REQUEST_INTERVAL_SECONDS = 10.1
  14. _rate_limit_lock = asyncio.Lock()
  15. _last_request_monotonic = 0.0
  16. _SORT_TYPES = {"最新", "最热"}
  17. def _safe_int(value: Any, default: int = 0) -> int:
  18. if isinstance(value, bool) or value is None:
  19. return default
  20. try:
  21. return int(float(str(value).strip()))
  22. except (TypeError, ValueError):
  23. return default
  24. def _extract_topics(item: dict[str, Any]) -> list[str]:
  25. topics: list[str] = []
  26. for topic in item.get("topic_list") or []:
  27. if isinstance(topic, str):
  28. topics.append(topic.strip())
  29. elif isinstance(topic, dict):
  30. name = (
  31. topic.get("topic_name")
  32. or topic.get("cha_name")
  33. or topic.get("hashtag_name")
  34. or topic.get("name")
  35. )
  36. if name:
  37. topics.append(str(name).strip())
  38. for topic in item.get("text_extra") or []:
  39. if isinstance(topic, dict) and topic.get("hashtag_name"):
  40. topics.append(str(topic["hashtag_name"]).strip())
  41. for topic in item.get("cha_list") or []:
  42. if isinstance(topic, dict) and topic.get("cha_name"):
  43. topics.append(str(topic["cha_name"]).strip())
  44. return list(dict.fromkeys(topic for topic in topics if topic))
  45. def _normalize_video(item: dict[str, Any]) -> dict[str, Any] | None:
  46. aweme_id = str(item.get("aweme_id") or "").strip()
  47. if not aweme_id:
  48. return None
  49. author = item.get("author") if isinstance(item.get("author"), dict) else {}
  50. stats = (
  51. item.get("statistics")
  52. if isinstance(item.get("statistics"), dict)
  53. else {}
  54. )
  55. video = item.get("video") if isinstance(item.get("video"), dict) else {}
  56. duration_ms = _safe_int(video.get("duration") or item.get("duration"))
  57. return {
  58. "aweme_id": aweme_id,
  59. "desc": str(item.get("desc") or item.get("item_title") or "无标题")[:200],
  60. "url": f"https://www.douyin.com/video/{aweme_id}",
  61. "author": {
  62. "nickname": str(author.get("nickname") or "未知作者"),
  63. "sec_uid": str(author.get("sec_uid") or ""),
  64. },
  65. "statistics": {
  66. "digg_count": _safe_int(stats.get("digg_count")),
  67. "comment_count": _safe_int(stats.get("comment_count")),
  68. "share_count": _safe_int(stats.get("share_count")),
  69. "collect_count": _safe_int(stats.get("collect_count")),
  70. "play_count": _safe_int(stats.get("play_count")),
  71. },
  72. "duration_ms": duration_ms,
  73. "topics": _extract_topics(item),
  74. }
  75. def _summary(
  76. account_id: str,
  77. results: list[dict[str, Any]],
  78. *,
  79. filtered_count: int,
  80. has_more: bool,
  81. next_cursor: str,
  82. ) -> str:
  83. lines = [
  84. f"账号 {account_id} 的作品列表",
  85. (
  86. f"保留 {len(results)} 条"
  87. + (f",过滤短视频 {filtered_count} 条" if filtered_count else "")
  88. + (f",还有更多(cursor={next_cursor})" if has_more else "")
  89. ),
  90. "",
  91. ]
  92. for index, item in enumerate(results, 1):
  93. stats = item["statistics"]
  94. lines.extend(
  95. [
  96. f"{index}. {item['desc'][:50]}",
  97. f" ID: {item['aweme_id']}",
  98. f" 链接: {item['url']}",
  99. (
  100. f" 数据: 点赞 {stats['digg_count']:,} | "
  101. f"评论 {stats['comment_count']:,} | "
  102. f"分享 {stats['share_count']:,} | "
  103. f"收藏 {stats['collect_count']:,}"
  104. ),
  105. f" 标签: {'、'.join(item['topics']) or '无'}",
  106. "",
  107. ]
  108. )
  109. return "\n".join(lines).rstrip()
  110. def _error_result(error: str) -> str:
  111. return json.dumps(
  112. {"error": error, "title": "抖音作者作品获取失败"},
  113. ensure_ascii=False,
  114. )
  115. async def _wait_rate_limit() -> None:
  116. global _last_request_monotonic
  117. async with _rate_limit_lock:
  118. elapsed = time.monotonic() - _last_request_monotonic
  119. if elapsed < _MIN_REQUEST_INTERVAL_SECONDS:
  120. await asyncio.sleep(_MIN_REQUEST_INTERVAL_SECONDS - elapsed)
  121. _last_request_monotonic = time.monotonic()
  122. async def _douyin_user_videos_raw(
  123. account_id: str,
  124. sort_type: str = "最热",
  125. cursor: str = "",
  126. min_duration_seconds: int = 0,
  127. timeout: float | None = None,
  128. ) -> str:
  129. """
  130. 获取指定抖音作者的作品列表,支持最热/最新排序和游标翻页。
  131. 当候选作者的粉丝画像偏老或某条视频表现优秀时,可用该工具扩展同作者内容。
  132. 返回结构与 douyin_search.search_results 一致,可直接保存为搜索轨迹和候选。
  133. Args:
  134. account_id: author.sec_uid,必须使用完整值。
  135. sort_type: 最热 / 最新,默认最热。
  136. cursor: 首次为空;翻页使用上次返回的 next_cursor。
  137. min_duration_seconds: 最短时长过滤,默认 0 表示不过滤。
  138. timeout: 请求超时秒数,默认 60。
  139. Returns:
  140. JSON 字符串,包含 user_videos、search_results、has_more 和 next_cursor。
  141. user_videos 与 search_results 是同一个统一结构化列表。
  142. """
  143. account_text = str(account_id).strip()
  144. if not account_text:
  145. return _error_result("account_id 不能为空")
  146. if sort_type not in _SORT_TYPES:
  147. return _error_result(f"sort_type 必须是: {sorted(_SORT_TYPES)}")
  148. start_time = time.time()
  149. request_timeout = timeout if timeout is not None else DEFAULT_TIMEOUT
  150. payload = {
  151. "account_id": account_text,
  152. "sort_type": sort_type,
  153. "cursor": str(cursor or ""),
  154. }
  155. try:
  156. await _wait_rate_limit()
  157. async with httpx.AsyncClient(
  158. timeout=request_timeout,
  159. trust_env=False,
  160. headers={"Content-Type": "application/json"},
  161. ) as client:
  162. response = await client.post(DOUYIN_USER_VIDEOS_API, json=payload)
  163. response.raise_for_status()
  164. body = response.json()
  165. data = body.get("data") if isinstance(body.get("data"), dict) else {}
  166. items = data.get("data") if isinstance(data.get("data"), list) else []
  167. minimum_ms = max(0, int(min_duration_seconds)) * 1000
  168. filtered_count = 0
  169. seen: set[str] = set()
  170. results: list[dict[str, Any]] = []
  171. for raw_item in items:
  172. if not isinstance(raw_item, dict):
  173. continue
  174. normalized = _normalize_video(raw_item)
  175. if normalized is None or normalized["aweme_id"] in seen:
  176. continue
  177. if (
  178. minimum_ms
  179. and normalized["duration_ms"]
  180. and normalized["duration_ms"] < minimum_ms
  181. ):
  182. filtered_count += 1
  183. continue
  184. seen.add(normalized["aweme_id"])
  185. results.append(normalized)
  186. has_more = bool(data.get("has_more") in (1, True, "1"))
  187. next_cursor = str(data.get("next_cursor") or "")
  188. duration_ms = int((time.time() - start_time) * 1000)
  189. result = {
  190. "title": f"抖音作者作品: {account_text}",
  191. "output": _summary(
  192. account_text,
  193. results,
  194. filtered_count=filtered_count,
  195. has_more=has_more,
  196. next_cursor=next_cursor,
  197. ),
  198. "provider": "internal_blogger",
  199. "account_id": account_text,
  200. "sort_type": sort_type,
  201. "cursor": str(cursor or ""),
  202. "results_count": len(results),
  203. "filtered_count": filtered_count,
  204. "has_more": has_more,
  205. "next_cursor": next_cursor,
  206. "user_videos": results,
  207. "search_results": results,
  208. "duration_ms": duration_ms,
  209. }
  210. logger.info(
  211. "douyin_user_videos completed: account_id=%s results=%d has_more=%s duration_ms=%d",
  212. account_text,
  213. len(results),
  214. has_more,
  215. duration_ms,
  216. )
  217. return json.dumps(result, ensure_ascii=False)
  218. except httpx.HTTPStatusError as exc:
  219. text = exc.response.text[:1000]
  220. logger.error(
  221. "douyin_user_videos HTTP error: account_id=%s status=%d",
  222. account_text,
  223. exc.response.status_code,
  224. )
  225. return _error_result(f"HTTP {exc.response.status_code}: {text}")
  226. except httpx.TimeoutException:
  227. return _error_result(f"请求超时({request_timeout}秒)")
  228. except httpx.RequestError as exc:
  229. return _error_result(f"网络错误: {exc}")
  230. except Exception as exc:
  231. logger.error(
  232. "douyin_user_videos unexpected error: account_id=%s error=%s",
  233. account_text,
  234. exc,
  235. exc_info=True,
  236. )
  237. return _error_result(f"未知错误: {exc}")
  238. async def douyin_user_videos(
  239. run_id: str,
  240. account_id: str,
  241. query_reason: str,
  242. source_value: str | None = None,
  243. parent_search_id: int | None = None,
  244. page_no: int = 1,
  245. sort_type: str = "最热",
  246. cursor: str = "",
  247. min_duration_seconds: int = 0,
  248. timeout: float | None = None,
  249. ) -> str:
  250. """
  251. 获取一页作者作品,创建搜索记录和候选记录并返回对应数据库 ID。
  252. """
  253. result = await _douyin_user_videos_raw(
  254. account_id=account_id,
  255. sort_type=sort_type,
  256. cursor=cursor,
  257. min_duration_seconds=min_duration_seconds,
  258. timeout=timeout,
  259. )
  260. return await asyncio.to_thread(
  261. persist_search_payload,
  262. result,
  263. run_id=run_id,
  264. keyword=f"author:{account_id}",
  265. query_reason=query_reason,
  266. source_type="author",
  267. source_value=source_value or account_id,
  268. parent_search_id=parent_search_id,
  269. cursor=cursor,
  270. page_no=page_no,
  271. provider="internal_blogger",
  272. sort_type=sort_type,
  273. )