providers.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393
  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 _error(exc: Exception | str) -> dict[str, Any]:
  70. return {"error": str(exc), "search_results": [], "has_more": False}
  71. def _request_error(exc: Exception) -> str:
  72. if isinstance(exc, httpx.HTTPStatusError):
  73. return f"HTTP {exc.response.status_code}: {exc.response.text[:1000]}"
  74. if isinstance(exc, httpx.TimeoutException):
  75. return "请求超时"
  76. if isinstance(exc, httpx.RequestError):
  77. return f"网络错误: {exc}"
  78. return f"未知错误: {exc}"
  79. def _topics(aweme: dict[str, Any]) -> list[str]:
  80. result: list[str] = []
  81. for collection in (aweme.get("topic_list"), aweme.get("text_extra"), aweme.get("cha_list")):
  82. for item in collection or []:
  83. if isinstance(item, str):
  84. value = item
  85. elif isinstance(item, dict):
  86. value = item.get("topic_name") or item.get("cha_name") or item.get("hashtag_name") or item.get("name")
  87. else:
  88. value = None
  89. if value and str(value).strip() not in result:
  90. result.append(str(value).strip())
  91. return result
  92. def _normalize_search_item(item: dict[str, Any]) -> dict[str, Any] | None:
  93. aweme_id = str(item.get("aweme_id") or "").strip()
  94. if not aweme_id:
  95. return None
  96. author = item.get("author") if isinstance(item.get("author"), dict) else {}
  97. stats = item.get("statistics") if isinstance(item.get("statistics"), dict) else {}
  98. video = item.get("video") if isinstance(item.get("video"), dict) else {}
  99. return {
  100. "aweme_id": aweme_id,
  101. "desc": str(item.get("desc") or item.get("item_title") or "无标题")[:200],
  102. "url": f"https://www.douyin.com/video/{aweme_id}",
  103. "author": {
  104. "nickname": str(author.get("nickname") or "未知作者"),
  105. "sec_uid": str(author.get("sec_uid") or ""),
  106. },
  107. "statistics": {
  108. "digg_count": _safe_int(stats.get("digg_count")),
  109. "comment_count": _safe_int(stats.get("comment_count")),
  110. "share_count": _safe_int(stats.get("share_count")),
  111. "collect_count": _safe_int(stats.get("collect_count")),
  112. "play_count": _safe_int(stats.get("play_count")),
  113. },
  114. "duration_ms": _safe_int(item.get("duration_ms") or item.get("duration") or video.get("duration")),
  115. "publish_at": item.get("publish_at") or item.get("create_time") or item.get("create_timestamp") or item.get("publish_timestamp"),
  116. "topics": _topics(item),
  117. }
  118. async def search_internal(
  119. *, keyword: str, content_type: str = "视频", sort_type: str = "综合排序",
  120. publish_time: str = "不限", cursor: str = "0", min_duration_seconds: int = 30,
  121. timeout: float = DEFAULT_TIMEOUT,
  122. ) -> dict[str, Any]:
  123. """Search one page through the internal crawler and return normalized candidates."""
  124. started = time.monotonic()
  125. try:
  126. await _internal_search_limiter.wait()
  127. async with httpx.AsyncClient(timeout=timeout) as client:
  128. response = await client.post(INTERNAL_SEARCH_ENDPOINT, json={
  129. "keyword": keyword,
  130. "content_type": content_type,
  131. "sort_type": sort_type,
  132. "publish_time": publish_time,
  133. "cursor": cursor,
  134. "account_id": DEFAULT_ACCOUNT_ID,
  135. }, headers={"Content-Type": "application/json"})
  136. response.raise_for_status()
  137. body = response.json()
  138. data = body.get("data") if isinstance(body.get("data"), dict) else {}
  139. items = data.get("data") if isinstance(data.get("data"), list) else []
  140. minimum_ms = max(30, int(min_duration_seconds)) * 1000
  141. normalized = [value for item in items if (value := _normalize_search_item(item))]
  142. results = [item for item in normalized if not item["duration_ms"] or item["duration_ms"] >= minimum_ms]
  143. return {
  144. "provider": "internal_keyword", "keyword": keyword,
  145. "search_results": results, "results_count": len(results),
  146. "filtered_count": len(normalized) - len(results),
  147. "has_more": bool(data.get("has_more")),
  148. "next_cursor": str(data.get("next_cursor") or ""),
  149. "duration_ms": int((time.monotonic() - started) * 1000),
  150. "_raw_response": body,
  151. }
  152. except Exception as exc:
  153. return _error(_request_error(exc))
  154. def _enum(value: str, mapping: dict[str, str], field: str) -> str:
  155. text = str(value).strip()
  156. if text in mapping.values():
  157. return text
  158. if text not in mapping:
  159. raise ValueError(f"{field} 不支持: {value}")
  160. return mapping[text]
  161. async def search_tikhub(
  162. *, keyword: str, content_type: str = "视频", sort_type: str = "综合排序",
  163. publish_time: str = "不限", cursor: int = 0, filter_duration: str = "不限",
  164. search_id: str = "", backtrace: str = "", min_duration_seconds: int = 30,
  165. timeout: float = DEFAULT_TIMEOUT,
  166. ) -> dict[str, Any]:
  167. """Search one TikHub page and retain its pagination state."""
  168. _ensure_env_loaded()
  169. api_key = os.getenv("TIKHUB_API_KEY", "").strip()
  170. if not api_key:
  171. return _error("未设置环境变量 TIKHUB_API_KEY")
  172. try:
  173. payload = {
  174. "keyword": str(keyword).strip(), "cursor": max(0, int(cursor)),
  175. "sort_type": _enum(sort_type, _SORT_TYPES, "sort_type"),
  176. "publish_time": _enum(publish_time, _PUBLISH_TIMES, "publish_time"),
  177. "filter_duration": _enum(filter_duration, _DURATIONS, "filter_duration"),
  178. "content_type": _enum(content_type, _CONTENT_TYPES, "content_type"),
  179. "search_id": str(search_id or ""), "backtrace": str(backtrace or ""),
  180. }
  181. await _tikhub_limiter.wait()
  182. async with httpx.AsyncClient(timeout=timeout, trust_env=False) as client:
  183. response = await client.post(TIKHUB_SEARCH_ENDPOINT, json=payload, headers={
  184. "Content-Type": "application/json", "Authorization": f"Bearer {api_key}",
  185. })
  186. response.raise_for_status()
  187. body = response.json()
  188. data = body.get("data") if isinstance(body.get("data"), dict) else {}
  189. raw_items = data.get("business_data") if isinstance(data.get("business_data"), list) else []
  190. config = data.get("business_config") if isinstance(data.get("business_config"), dict) else {}
  191. next_page = config.get("next_page") if isinstance(config.get("next_page"), dict) else {}
  192. minimum_ms = max(30, int(min_duration_seconds)) * 1000
  193. results: list[dict[str, Any]] = []
  194. seen: set[str] = set()
  195. for raw in raw_items:
  196. nested = raw.get("data") if isinstance(raw, dict) and isinstance(raw.get("data"), dict) else {}
  197. aweme = nested.get("aweme_info") if isinstance(nested.get("aweme_info"), dict) else {}
  198. item = _normalize_search_item(aweme)
  199. if item and item["aweme_id"] not in seen and (not item["duration_ms"] or item["duration_ms"] >= minimum_ms):
  200. seen.add(item["aweme_id"])
  201. results.append(item)
  202. return {
  203. "provider": "tikhub", "keyword": keyword, "search_results": results,
  204. "results_count": len(results), "has_more": config.get("has_more") in (1, True, "1"),
  205. "next_cursor": _safe_int(next_page.get("cursor")),
  206. "search_id": str(next_page.get("search_id") or search_id or ""),
  207. "backtrace": str(next_page.get("backtrace") or config.get("backtrace") or backtrace or ""),
  208. "_raw_response": body,
  209. }
  210. except Exception as exc:
  211. return _error(_request_error(exc))
  212. def _detail_payload(raw: dict[str, Any], content_id: str) -> dict[str, Any]:
  213. video_urls = raw.get("video_url_list") if isinstance(raw.get("video_url_list"), list) else []
  214. first_video = video_urls[0] if video_urls and isinstance(video_urls[0], dict) else {}
  215. topics = raw.get("topic_list") if isinstance(raw.get("topic_list"), list) else []
  216. return {
  217. "content_id": content_id,
  218. "content_link": raw.get("content_link") or f"https://www.douyin.com/video/{content_id}",
  219. "title": raw.get("title"), "body_text": raw.get("body_text"),
  220. "channel_account_name": raw.get("channel_account_name"),
  221. "channel_account_id": raw.get("channel_account_id"),
  222. "topic_list": topics,
  223. "duration_seconds": _safe_int(first_video.get("video_duration") or raw.get("duration_seconds")),
  224. "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"),
  225. "play_count": raw.get("play_count") if raw.get("play_count") is not None else raw.get("view_count"),
  226. "like_count": raw.get("like_count"), "comment_count": raw.get("comment_count"),
  227. "collect_count": raw.get("collect_count"), "share_count": raw.get("share_count"),
  228. "video_url": first_video.get("video_url"),
  229. }
  230. async def fetch_details(content_ids: list[str], *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
  231. """Fetch and normalize up to eight video detail records."""
  232. ids = list(dict.fromkeys(str(value).strip() for value in content_ids if str(value).strip()))
  233. if not ids or len(ids) > MAX_BATCH_ITEMS:
  234. return {"error": f"content_ids 必须为 1~{MAX_BATCH_ITEMS} 条", "details": [], "errors": []}
  235. details: list[dict[str, Any]] = []
  236. errors: list[dict[str, str]] = []
  237. async with httpx.AsyncClient(timeout=timeout, trust_env=False, headers={"Accept": "*/*"}) as client:
  238. for content_id in ids:
  239. try:
  240. await _detail_limiter.wait()
  241. response = await client.post(DETAIL_ENDPOINT, json={"content_id": content_id})
  242. response.raise_for_status()
  243. body = response.json()
  244. if body.get("code") not in (0, None):
  245. raise RuntimeError(f"接口返回错误: {body.get('msg') or body.get('code')}")
  246. outer = body.get("data") if isinstance(body.get("data"), dict) else {}
  247. raw = outer.get("data") if isinstance(outer.get("data"), dict) else {}
  248. if not raw:
  249. raise RuntimeError("未查到视频详情")
  250. details.append(_detail_payload(raw, content_id))
  251. except Exception as exc:
  252. errors.append({"content_id": content_id, "error": _request_error(exc)})
  253. return {"details": details, "errors": errors, "success_count": len(details), "failed_count": len(errors)}
  254. def _number(value: Any) -> float | None:
  255. if value is None or isinstance(value, bool):
  256. return None
  257. try:
  258. return float(str(value).strip().replace("%", ""))
  259. except ValueError:
  260. return None
  261. def _age_dimension(portrait: Any) -> dict[str, Any]:
  262. if not isinstance(portrait, dict):
  263. return {}
  264. for key in ("portrait_data", "content", "account"):
  265. nested = _age_dimension(portrait.get(key))
  266. if nested:
  267. return nested
  268. for key in ("年龄", "age", "Age", "年龄分布"):
  269. if isinstance(portrait.get(key), dict):
  270. return portrait[key]
  271. return portrait if portrait and all(isinstance(value, dict) for value in portrait.values()) else {}
  272. def _normalize_age(portrait: Any) -> dict[str, Any]:
  273. buckets: list[dict[str, Any]] = []
  274. older_ratio = 0.0
  275. mature_ratio = 0.0
  276. weighted: list[tuple[float, float]] = []
  277. dimension = _age_dimension(portrait)
  278. for label, raw in dimension.items():
  279. metrics = raw if isinstance(raw, dict) else {}
  280. ratio = _number(metrics.get("percentage", metrics.get("ratio")))
  281. if ratio is not None and ratio > 1:
  282. ratio /= 100
  283. tgi = _number(metrics.get("preference", metrics.get("tgi")))
  284. numbers = [int(value) for value in re.findall(r"\d+", str(label))]
  285. lower, upper = (min(numbers), max(numbers)) if numbers else (0, 0)
  286. kind = "older" if lower >= 50 else "mature" if lower >= 40 or upper >= 50 else "younger"
  287. buckets.append({"label": str(label), "kind": kind, "ratio": ratio, "tgi": tgi})
  288. if ratio is not None and kind == "older":
  289. older_ratio += ratio
  290. if tgi is not None:
  291. weighted.append((tgi, ratio))
  292. elif ratio is not None and kind == "mature":
  293. mature_ratio += ratio
  294. weight = sum(item[1] for item in weighted)
  295. older_tgi = sum(tgi * ratio for tgi, ratio in weighted) / weight if weight else None
  296. 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"
  297. 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}
  298. def normalize_age_pair(content: Any, account: Any) -> dict[str, Any]:
  299. content_age, account_age = _normalize_age(content), _normalize_age(account)
  300. if content_age["has_age_portrait"] and account_age["has_age_portrait"]:
  301. consistency, cap = "aligned" if (content_age["strength"] in {"strong", "moderate"}) == (account_age["strength"] in {"strong", "moderate"}) else "conflict", 1.0
  302. elif account_age["has_age_portrait"]:
  303. consistency, cap = "account_only", 0.85 if account_age["strength"] == "strong" else 0.75
  304. elif content_age["has_age_portrait"]:
  305. consistency, cap = "content_only", 1.0
  306. else:
  307. consistency, cap = "missing", 0.5
  308. return {"content": content_age, "account": account_age, "consistency": consistency, "elder_score_cap": cap}
  309. def _portrait_data(body: dict[str, Any]) -> dict[str, Any]:
  310. outer = body.get("data") if isinstance(body.get("data"), dict) else {}
  311. return outer.get("data") if isinstance(outer.get("data"), dict) else {}
  312. async def fetch_portraits(candidates: list[dict[str, Any]], *, timeout: float = DEFAULT_TIMEOUT) -> dict[str, Any]:
  313. """Fetch content/account portraits and normalize age evidence independently."""
  314. if not candidates or len(candidates) > MAX_BATCH_ITEMS:
  315. return {"error": f"candidates 必须为 1~{MAX_BATCH_ITEMS} 条", "results": []}
  316. 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}
  317. results: list[dict[str, Any]] = []
  318. async with httpx.AsyncClient(timeout=timeout) as client:
  319. for candidate in candidates:
  320. aweme_id = str(candidate.get("aweme_id") or "").strip()
  321. author_id = str(candidate.get("author_sec_uid") or "").strip()
  322. item: dict[str, Any] = {"aweme_id": aweme_id, "author_sec_uid": author_id or None, "content": {}, "account": {}, "error": None}
  323. try:
  324. if not aweme_id.isdigit():
  325. raise ValueError("aweme_id 必须为纯数字")
  326. response = await client.post(CONTENT_PORTRAIT_ENDPOINT, json={"content_id": aweme_id, **flags})
  327. response.raise_for_status()
  328. portrait = _portrait_data(response.json())
  329. item["content"] = {"ok": True, "has_portrait": bool(portrait), "portrait_data": portrait}
  330. except Exception as exc:
  331. item["content"] = {"ok": False, "has_portrait": False, "portrait_data": {}, "error": _request_error(exc)}
  332. if author_id:
  333. try:
  334. response = await client.post(ACCOUNT_PORTRAIT_ENDPOINT, json={"account_id": author_id, **flags})
  335. response.raise_for_status()
  336. portrait = _portrait_data(response.json())
  337. item["account"] = {"attempted": True, "has_portrait": bool(portrait), "portrait_data": portrait}
  338. except Exception as exc:
  339. item["account"] = {"attempted": True, "has_portrait": False, "portrait_data": {}, "error": _request_error(exc)}
  340. else:
  341. item["account"] = {"attempted": False, "has_portrait": False, "portrait_data": {}, "skipped_reason": "缺少 author_sec_uid"}
  342. item["age_normalization"] = normalize_age_pair(item["content"].get("portrait_data"), item["account"].get("portrait_data"))
  343. if item["content"].get("error") and item["account"].get("error"):
  344. item["error"] = "内容与账号画像均获取失败"
  345. results.append(item)
  346. return {"results": results, "count": len(results)}