providers.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403
  1. """Independent external-data clients for find_agent_v2.
  2. This module owns request validation, HTTP calls and response normalization;
  3. database persistence remains in :mod:`find_agent_v2.service`.
  4. """
  5. from __future__ import annotations
  6. import asyncio
  7. import os
  8. import re
  9. import time
  10. from typing import Any
  11. import httpx
  12. from dotenv import load_dotenv
  13. from supply_agent.paths import find_project_root
  14. INTERNAL_SEARCH_ENDPOINT = os.getenv(
  15. "FIND_AGENT_V2_INTERNAL_SEARCH_ENDPOINT",
  16. "http://crawapi.piaoquantv.com/crawler/dou_yin/keyword",
  17. )
  18. TIKHUB_SEARCH_ENDPOINT = os.getenv(
  19. "FIND_AGENT_V2_TIKHUB_SEARCH_ENDPOINT",
  20. "https://api.tikhub.io/api/v1/douyin/search/fetch_video_search_v2",
  21. )
  22. DETAIL_ENDPOINT = os.getenv(
  23. "FIND_AGENT_V2_DETAIL_ENDPOINT",
  24. "http://8.217.190.241:8888/crawler/dou_yin/detail",
  25. )
  26. CONTENT_PORTRAIT_ENDPOINT = os.getenv(
  27. "FIND_AGENT_V2_CONTENT_PORTRAIT_ENDPOINT",
  28. "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/video_like_portrait",
  29. )
  30. ACCOUNT_PORTRAIT_ENDPOINT = os.getenv(
  31. "FIND_AGENT_V2_ACCOUNT_PORTRAIT_ENDPOINT",
  32. "http://crawapi.piaoquantv.com/crawler/dou_yin/re_dian_bao/account_fans_portrait",
  33. )
  34. DEFAULT_TIMEOUT = 60.0
  35. DEFAULT_ACCOUNT_ID = "771431222"
  36. MAX_BATCH_ITEMS = 8
  37. _env_loaded = False
  38. def _ensure_env_loaded() -> None:
  39. global _env_loaded
  40. if _env_loaded:
  41. return
  42. load_dotenv(find_project_root() / ".env")
  43. _env_loaded = True
  44. class _RateLimiter:
  45. def __init__(self, interval_seconds: float) -> None:
  46. self.interval_seconds = interval_seconds
  47. self._lock = asyncio.Lock()
  48. self._last_request = 0.0
  49. async def wait(self) -> None:
  50. async with self._lock:
  51. remaining = self.interval_seconds - (time.monotonic() - self._last_request)
  52. if remaining > 0:
  53. await asyncio.sleep(remaining)
  54. self._last_request = time.monotonic()
  55. _internal_search_limiter = _RateLimiter(10.1)
  56. _tikhub_limiter = _RateLimiter(1.0)
  57. _detail_limiter = _RateLimiter(10.1)
  58. _CONTENT_TYPES = {"不限": "0", "视频": "1", "图片": "2", "图文": "2", "文章": "3"}
  59. _SORT_TYPES = {"综合排序": "0", "最多点赞": "1", "最新发布": "2"}
  60. _PUBLISH_TIMES = {"不限": "0", "一天内": "1", "最近一天": "1", "一周内": "7", "最近一周": "7", "半年内": "180", "最近半年": "180"}
  61. _DURATIONS = {"不限": "0", "一分钟内": "0-1", "1分钟以内": "0-1", "1-5分钟": "1-5", "五分钟以上": "5-10000", "5分钟以上": "5-10000"}
  62. def _safe_int(value: Any, default: int = 0) -> int:
  63. if value is None or isinstance(value, bool):
  64. return default
  65. try:
  66. return int(float(str(value).strip()))
  67. except (TypeError, ValueError):
  68. return default
  69. def _optional_int(value: Any) -> int | None:
  70. """Preserve a missing upstream metric instead of conflating it with a real zero."""
  71. if value is None or isinstance(value, bool) or str(value).strip() == "":
  72. return None
  73. try:
  74. return int(float(str(value).strip()))
  75. except (TypeError, ValueError):
  76. return None
  77. def _error(exc: Exception | str) -> dict[str, Any]:
  78. return {"error": str(exc), "search_results": [], "has_more": False}
  79. def _request_error(exc: Exception) -> str:
  80. if isinstance(exc, httpx.HTTPStatusError):
  81. return f"HTTP {exc.response.status_code}: {exc.response.text[:1000]}"
  82. if isinstance(exc, httpx.TimeoutException):
  83. return "请求超时"
  84. if isinstance(exc, httpx.RequestError):
  85. return f"网络错误: {exc}"
  86. return f"未知错误: {exc}"
  87. def _topics(aweme: dict[str, Any]) -> list[str]:
  88. result: list[str] = []
  89. for collection in (aweme.get("topic_list"), aweme.get("text_extra"), aweme.get("cha_list")):
  90. for item in collection or []:
  91. if isinstance(item, str):
  92. value = item
  93. elif isinstance(item, dict):
  94. value = item.get("topic_name") or item.get("cha_name") or item.get("hashtag_name") or item.get("name")
  95. else:
  96. value = None
  97. if value and str(value).strip() not in result:
  98. result.append(str(value).strip())
  99. return result
  100. def _normalize_search_item(item: dict[str, Any]) -> dict[str, Any] | None:
  101. aweme_id = str(item.get("aweme_id") or "").strip()
  102. if not aweme_id:
  103. return None
  104. author = item.get("author") if isinstance(item.get("author"), dict) else {}
  105. stats = item.get("statistics") if isinstance(item.get("statistics"), dict) else {}
  106. video = item.get("video") if isinstance(item.get("video"), dict) else {}
  107. return {
  108. "aweme_id": aweme_id,
  109. "desc": str(item.get("desc") or item.get("item_title") or "无标题")[:200],
  110. "url": f"https://www.douyin.com/video/{aweme_id}",
  111. "author": {
  112. "nickname": str(author.get("nickname") or "未知作者"),
  113. "sec_uid": str(author.get("sec_uid") or ""),
  114. },
  115. "statistics": {
  116. "digg_count": _optional_int(stats.get("digg_count")),
  117. "comment_count": _optional_int(stats.get("comment_count")),
  118. "share_count": _optional_int(stats.get("share_count")),
  119. "collect_count": _optional_int(stats.get("collect_count")),
  120. "play_count": _optional_int(stats.get("play_count")),
  121. },
  122. "duration_ms": _safe_int(item.get("duration_ms") or item.get("duration") or video.get("duration")),
  123. "publish_at": item.get("publish_at") or item.get("create_time") or item.get("create_timestamp") or item.get("publish_timestamp"),
  124. "topics": _topics(item),
  125. }
  126. async def search_internal(
  127. *, keyword: str, content_type: str = "视频", sort_type: str = "综合排序",
  128. publish_time: str = "不限", cursor: str = "0", min_duration_seconds: int = 30,
  129. timeout: float = DEFAULT_TIMEOUT,
  130. ) -> dict[str, Any]:
  131. """Search one page through the internal crawler and return normalized candidates."""
  132. started = time.monotonic()
  133. try:
  134. await _internal_search_limiter.wait()
  135. async with httpx.AsyncClient(timeout=timeout) as client:
  136. response = await client.post(INTERNAL_SEARCH_ENDPOINT, json={
  137. "keyword": keyword,
  138. "content_type": content_type,
  139. "sort_type": sort_type,
  140. "publish_time": publish_time,
  141. "cursor": cursor,
  142. "account_id": DEFAULT_ACCOUNT_ID,
  143. }, headers={"Content-Type": "application/json"})
  144. response.raise_for_status()
  145. body = response.json()
  146. data = body.get("data") if isinstance(body.get("data"), dict) else {}
  147. items = data.get("data") if isinstance(data.get("data"), list) else []
  148. minimum_ms = max(30, int(min_duration_seconds)) * 1000
  149. normalized = [value for item in items if (value := _normalize_search_item(item))]
  150. results = [item for item in normalized if not item["duration_ms"] or item["duration_ms"] >= minimum_ms]
  151. return {
  152. "provider": "internal_keyword", "keyword": keyword,
  153. "search_results": results, "results_count": len(results),
  154. "filtered_count": len(normalized) - len(results),
  155. "has_more": bool(data.get("has_more")),
  156. "next_cursor": str(data.get("next_cursor") or ""),
  157. "duration_ms": int((time.monotonic() - started) * 1000),
  158. "_raw_response": body,
  159. }
  160. except Exception as exc:
  161. return _error(_request_error(exc))
  162. def _enum(value: str, mapping: dict[str, str], field: str) -> str:
  163. text = str(value).strip()
  164. if text in mapping.values():
  165. return text
  166. if text not in mapping:
  167. raise ValueError(f"{field} 不支持: {value}")
  168. return mapping[text]
  169. async def search_tikhub(
  170. *, keyword: str, content_type: str = "视频", sort_type: str = "综合排序",
  171. publish_time: str = "不限", cursor: int = 0, filter_duration: str = "不限",
  172. search_id: str = "", backtrace: str = "", min_duration_seconds: int = 30,
  173. timeout: float = DEFAULT_TIMEOUT,
  174. ) -> dict[str, Any]:
  175. """Search one TikHub page and retain its pagination state."""
  176. _ensure_env_loaded()
  177. api_key = os.getenv("TIKHUB_API_KEY", "").strip()
  178. if not api_key:
  179. return _error("未设置环境变量 TIKHUB_API_KEY")
  180. try:
  181. payload = {
  182. "keyword": str(keyword).strip(), "cursor": max(0, int(cursor)),
  183. "sort_type": _enum(sort_type, _SORT_TYPES, "sort_type"),
  184. "publish_time": _enum(publish_time, _PUBLISH_TIMES, "publish_time"),
  185. "filter_duration": _enum(filter_duration, _DURATIONS, "filter_duration"),
  186. "content_type": _enum(content_type, _CONTENT_TYPES, "content_type"),
  187. "search_id": str(search_id or ""), "backtrace": str(backtrace or ""),
  188. }
  189. await _tikhub_limiter.wait()
  190. async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
  191. response = await client.post(TIKHUB_SEARCH_ENDPOINT, json=payload, headers={
  192. "Content-Type": "application/json", "Authorization": f"Bearer {api_key}",
  193. })
  194. response.raise_for_status()
  195. body = response.json()
  196. data = body.get("data") if isinstance(body.get("data"), dict) else {}
  197. raw_items = data.get("business_data") if isinstance(data.get("business_data"), list) else []
  198. config = data.get("business_config") if isinstance(data.get("business_config"), dict) else {}
  199. next_page = config.get("next_page") if isinstance(config.get("next_page"), dict) else {}
  200. minimum_ms = max(30, int(min_duration_seconds)) * 1000
  201. results: list[dict[str, Any]] = []
  202. seen: set[str] = set()
  203. for raw in raw_items:
  204. nested = raw.get("data") if isinstance(raw, dict) and isinstance(raw.get("data"), dict) else {}
  205. aweme = nested.get("aweme_info") if isinstance(nested.get("aweme_info"), dict) else {}
  206. item = _normalize_search_item(aweme)
  207. if item and item["aweme_id"] not in seen and (not item["duration_ms"] or item["duration_ms"] >= minimum_ms):
  208. seen.add(item["aweme_id"])
  209. results.append(item)
  210. return {
  211. "provider": "tikhub", "keyword": keyword, "search_results": results,
  212. "results_count": len(results), "has_more": config.get("has_more") in (1, True, "1"),
  213. "next_cursor": _safe_int(next_page.get("cursor")),
  214. "search_id": str(next_page.get("search_id") or search_id or ""),
  215. "backtrace": str(next_page.get("backtrace") or config.get("backtrace") or backtrace or ""),
  216. "_raw_response": body,
  217. }
  218. except Exception as exc:
  219. return _error(_request_error(exc))
  220. def _detail_payload(raw: dict[str, Any], content_id: str) -> dict[str, Any]:
  221. video_urls = raw.get("video_url_list") if isinstance(raw.get("video_url_list"), list) else []
  222. first_video = video_urls[0] if video_urls and isinstance(video_urls[0], dict) else {}
  223. topics = raw.get("topic_list") if isinstance(raw.get("topic_list"), list) else []
  224. return {
  225. "content_id": content_id,
  226. "content_link": raw.get("content_link") or f"https://www.douyin.com/video/{content_id}",
  227. "title": raw.get("title"), "body_text": raw.get("body_text"),
  228. "channel_account_name": raw.get("channel_account_name"),
  229. "channel_account_id": raw.get("channel_account_id"),
  230. "topic_list": topics,
  231. "duration_seconds": _safe_int(first_video.get("video_duration") or raw.get("duration_seconds")),
  232. "publish_at": raw.get("publish_at") or raw.get("publish_time") or raw.get("publish_timestamp") or raw.get("create_time") or raw.get("create_timestamp"),
  233. "play_count": raw.get("play_count") if raw.get("play_count") is not None else raw.get("view_count"),
  234. "like_count": raw.get("like_count"), "comment_count": raw.get("comment_count"),
  235. "collect_count": raw.get("collect_count"), "share_count": raw.get("share_count"),
  236. "video_url": first_video.get("video_url"),
  237. }
  238. async def fetch_details(content_ids: list[str], *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
  239. """Fetch and normalize up to eight video detail records."""
  240. ids = list(dict.fromkeys(str(value).strip() for value in content_ids if str(value).strip()))
  241. if not ids or len(ids) > MAX_BATCH_ITEMS:
  242. return {"error": f"content_ids 必须为 1~{MAX_BATCH_ITEMS} 条", "details": [], "errors": []}
  243. details: list[dict[str, Any]] = []
  244. errors: list[dict[str, str]] = []
  245. async with httpx.AsyncClient(timeout=timeout, trust_env=False, headers={"Accept": "*/*"}) as client:
  246. for content_id in ids:
  247. try:
  248. await _detail_limiter.wait()
  249. response = await client.post(DETAIL_ENDPOINT, json={"content_id": content_id})
  250. response.raise_for_status()
  251. body = response.json()
  252. if body.get("code") not in (0, None):
  253. raise RuntimeError(f"接口返回错误: {body.get('msg') or body.get('code')}")
  254. outer = body.get("data") if isinstance(body.get("data"), dict) else {}
  255. raw = outer.get("data") if isinstance(outer.get("data"), dict) else {}
  256. if not raw:
  257. raise RuntimeError("未查到视频详情")
  258. details.append(_detail_payload(raw, content_id))
  259. except Exception as exc:
  260. errors.append({"content_id": content_id, "error": _request_error(exc)})
  261. return {"details": details, "errors": errors, "success_count": len(details), "failed_count": len(errors)}
  262. def _number(value: Any) -> float | None:
  263. if value is None or isinstance(value, bool):
  264. return None
  265. try:
  266. return float(str(value).strip().replace("%", ""))
  267. except ValueError:
  268. return None
  269. def _age_dimension(portrait: Any) -> dict[str, Any]:
  270. if not isinstance(portrait, dict):
  271. return {}
  272. for key in ("portrait_data", "content", "account"):
  273. nested = _age_dimension(portrait.get(key))
  274. if nested:
  275. return nested
  276. for key in ("年龄", "age", "Age", "年龄分布"):
  277. if isinstance(portrait.get(key), dict):
  278. return portrait[key]
  279. return portrait if portrait and all(isinstance(value, dict) for value in portrait.values()) else {}
  280. def _normalize_age(portrait: Any) -> dict[str, Any]:
  281. buckets: list[dict[str, Any]] = []
  282. older_ratio = 0.0
  283. mature_ratio = 0.0
  284. weighted: list[tuple[float, float]] = []
  285. dimension = _age_dimension(portrait)
  286. for label, raw in dimension.items():
  287. metrics = raw if isinstance(raw, dict) else {}
  288. ratio = _number(metrics.get("percentage", metrics.get("ratio")))
  289. if ratio is not None and ratio > 1:
  290. ratio /= 100
  291. tgi = _number(metrics.get("preference", metrics.get("tgi")))
  292. numbers = [int(value) for value in re.findall(r"\d+", str(label))]
  293. lower, upper = (min(numbers), max(numbers)) if numbers else (0, 0)
  294. kind = "older" if lower >= 50 else "mature" if lower >= 40 or upper >= 50 else "younger"
  295. buckets.append({"label": str(label), "kind": kind, "ratio": ratio, "tgi": tgi})
  296. if ratio is not None and kind == "older":
  297. older_ratio += ratio
  298. if tgi is not None:
  299. weighted.append((tgi, ratio))
  300. elif ratio is not None and kind == "mature":
  301. mature_ratio += ratio
  302. weight = sum(item[1] for item in weighted)
  303. older_tgi = sum(tgi * ratio for tgi, ratio in weighted) / weight if weight else None
  304. strength = "missing" if not dimension else "strong" if older_ratio >= 0.35 else "moderate" if older_ratio >= 0.20 or mature_ratio >= 0.30 else "weak"
  305. return {"has_age_portrait": bool(dimension), "older_ratio": round(older_ratio, 6), "older_tgi": older_tgi, "mature_ratio": round(mature_ratio, 6), "strength": strength, "buckets": buckets}
  306. def normalize_age_pair(content: Any, account: Any) -> dict[str, Any]:
  307. content_age, account_age = _normalize_age(content), _normalize_age(account)
  308. if content_age["has_age_portrait"] and account_age["has_age_portrait"]:
  309. consistency, cap = "aligned" if (content_age["strength"] in {"strong", "moderate"}) == (account_age["strength"] in {"strong", "moderate"}) else "conflict", 1.0
  310. elif account_age["has_age_portrait"]:
  311. consistency, cap = "account_only", 0.85 if account_age["strength"] == "strong" else 0.75
  312. elif content_age["has_age_portrait"]:
  313. consistency, cap = "content_only", 1.0
  314. else:
  315. consistency, cap = "missing", 0.5
  316. return {"content": content_age, "account": account_age, "consistency": consistency, "elder_score_cap": cap}
  317. def _portrait_data(body: dict[str, Any]) -> dict[str, Any]:
  318. outer = body.get("data") if isinstance(body.get("data"), dict) else {}
  319. return outer.get("data") if isinstance(outer.get("data"), dict) else {}
  320. async def fetch_portraits(candidates: list[dict[str, Any]], *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
  321. """Fetch content/account portraits and normalize age evidence independently."""
  322. if not candidates or len(candidates) > MAX_BATCH_ITEMS:
  323. return {"error": f"candidates 必须为 1~{MAX_BATCH_ITEMS} 条", "results": []}
  324. flags = {"need_province": False, "need_city": False, "need_city_level": False, "need_gender": False, "need_age": True, "need_phone_brand": False, "need_phone_price": False}
  325. results: list[dict[str, Any]] = []
  326. async with httpx.AsyncClient(timeout=timeout) as client:
  327. for candidate in candidates:
  328. aweme_id = str(candidate.get("aweme_id") or "").strip()
  329. author_id = str(candidate.get("author_sec_uid") or "").strip()
  330. item: dict[str, Any] = {"aweme_id": aweme_id, "author_sec_uid": author_id or None, "content": {}, "account": {}, "error": None}
  331. try:
  332. if not aweme_id.isdigit():
  333. raise ValueError("aweme_id 必须为纯数字")
  334. response = await client.post(CONTENT_PORTRAIT_ENDPOINT, json={"content_id": aweme_id, **flags})
  335. response.raise_for_status()
  336. portrait = _portrait_data(response.json())
  337. item["content"] = {"ok": True, "has_portrait": bool(portrait), "portrait_data": portrait}
  338. except Exception as exc:
  339. item["content"] = {"ok": False, "has_portrait": False, "portrait_data": {}, "error": _request_error(exc)}
  340. if author_id:
  341. try:
  342. response = await client.post(ACCOUNT_PORTRAIT_ENDPOINT, json={"account_id": author_id, **flags})
  343. response.raise_for_status()
  344. portrait = _portrait_data(response.json())
  345. item["account"] = {"attempted": True, "has_portrait": bool(portrait), "portrait_data": portrait}
  346. except Exception as exc:
  347. item["account"] = {"attempted": True, "has_portrait": False, "portrait_data": {}, "error": _request_error(exc)}
  348. else:
  349. item["account"] = {"attempted": False, "has_portrait": False, "portrait_data": {}, "skipped_reason": "缺少 author_sec_uid"}
  350. item["age_normalization"] = normalize_age_pair(item["content"].get("portrait_data"), item["account"].get("portrait_data"))
  351. if item["content"].get("error") and item["account"].get("error"):
  352. item["error"] = "内容与账号画像均获取失败"
  353. results.append(item)
  354. return {"results": results, "count": len(results)}