douyin_user_videos.py 11 KB

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