kuaishou.py 17 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427
  1. """快手(kuaishou)接入 client.
  2. 复用 crawapi_http 共享基座。M3 接入关键词搜索、内容详情、账号信息和作者作品。
  3. """
  4. from __future__ import annotations
  5. import re
  6. from pathlib import Path
  7. from typing import Any
  8. from content_agent.integrations.crawapi_http import (
  9. RateLimiter,
  10. _env,
  11. _load_env_file,
  12. _optional_positive_int,
  13. content_format,
  14. post_crawapi_json,
  15. score_from_statistics,
  16. search_previous_discovery_step,
  17. )
  18. from content_agent.integrations import platform_video_url
  19. SEARCH_RATE_LIMIT_BUCKET = "kuaishou_search"
  20. DETAIL_RATE_LIMIT_BUCKET = "kuaishou_detail"
  21. ACCOUNT_RATE_LIMIT_BUCKET = "kuaishou_account_info"
  22. BLOGGER_RATE_LIMIT_BUCKET = "kuaishou_blogger"
  23. KUAISHOU_SEARCH_MIN_INTERVAL_SECONDS = 10.0
  24. KUAISHOU_SEARCH_MAX_INTERVAL_SECONDS = 12.0
  25. _TAG_RE = re.compile(r"#([^\s#@((]+)")
  26. def _statistics(item: dict[str, Any]) -> dict[str, int]:
  27. return {
  28. "digg_count": int(item.get("like_count") or 0),
  29. "comment_count": int(item.get("comment_count") or 0),
  30. "share_count": int(item.get("share_count") or 0),
  31. "collect_count": int(item.get("collect_count") or 0),
  32. "play_count": int(item.get("view_count") or 0),
  33. }
  34. def _extract_tags(item: dict[str, Any]) -> list[str]:
  35. topic_list = item.get("topic_list") or []
  36. tags: list[str] = []
  37. for topic in topic_list:
  38. if isinstance(topic, dict):
  39. topic = topic.get("name") or topic.get("tag_name") or topic.get("topic")
  40. if topic:
  41. text = str(topic)
  42. tags.append(text if text.startswith("#") else f"#{text}")
  43. if not tags:
  44. text = " ".join(str(item.get(field) or "") for field in ("body_text", "title"))
  45. tags = [f"#{match}" for match in _TAG_RE.findall(text)]
  46. return list(dict.fromkeys(tags))
  47. def _publish_time_seconds(item: dict[str, Any]) -> int | None:
  48. publish_ms = item.get("publish_timestamp")
  49. if not publish_ms:
  50. return None
  51. return int(publish_ms) // 1000
  52. def _strip_transient_fields(result: dict[str, Any]) -> dict[str, Any]:
  53. return {key: value for key, value in result.items() if not str(key).startswith("_platform_")}
  54. def _normalize_kuaishou_item(
  55. query: dict[str, Any],
  56. item: dict[str, Any],
  57. index: int,
  58. has_more: bool,
  59. next_cursor: str,
  60. video_url_selection: dict[str, Any] | None = None,
  61. ) -> dict[str, Any]:
  62. video_url_selection = video_url_selection or platform_video_url.select_video_url(
  63. "kuaishou",
  64. [("search", item)],
  65. probe_fn=None,
  66. )
  67. statistics = _statistics(item)
  68. platform_content_id = str(item.get("channel_content_id") or "")
  69. platform_author_id = str(item.get("channel_account_id") or "")
  70. platform_raw_payload = {
  71. "channel_content_id": platform_content_id,
  72. "channel_account_id": platform_author_id,
  73. **dict(video_url_selection.get("platform_raw_payload") or {}),
  74. }
  75. result = {
  76. "content_discovery_id": f"{query['search_query_id']}_content_{index:03d}",
  77. "search_query_id": query["search_query_id"],
  78. "platform": "kuaishou",
  79. "platform_content_id": platform_content_id,
  80. "platform_content_url": item.get("content_link"),
  81. "platform_content_format": content_format(item.get("content_type") or "video"),
  82. "play_url": video_url_selection.get("play_url"),
  83. "description": item.get("body_text") or item.get("title") or "",
  84. "platform_author_id": platform_author_id,
  85. "author_display_name": item.get("channel_account_name") or "",
  86. "statistics": statistics,
  87. "tags": _extract_tags(item),
  88. "text_extra": [],
  89. "create_time": _publish_time_seconds(item),
  90. "has_more": has_more,
  91. "next_cursor": next_cursor,
  92. "score": score_from_statistics(statistics),
  93. "risk_level": "unknown",
  94. "discovery_relation": "derived_from_pattern_demand",
  95. "discovery_start_source": query["discovery_start_source"],
  96. "previous_discovery_step": search_previous_discovery_step(query),
  97. "content_metadata_source": "kuaishou_keyword_search",
  98. "platform_auth_mode": "no_bearer",
  99. "platform_raw_payload": platform_raw_payload,
  100. }
  101. if video_url_selection.get("media_failure_reason"):
  102. result["media_failure_reason"] = video_url_selection["media_failure_reason"]
  103. if video_url_selection.get("video_url_candidates"):
  104. result["video_url_candidates"] = video_url_selection["video_url_candidates"]
  105. return result
  106. class CrawapiKuaishouClient:
  107. requires_progressive_search_rate_limit = True
  108. def __init__(
  109. self,
  110. base_url: str,
  111. keyword_path: str = "/crawler/kuai_shou/keyword_v2",
  112. detail_path: str = "/crawler/kuai_shou/detail",
  113. account_info_path: str = "/crawler/kuai_shou/account_info",
  114. blogger_path: str = "/crawler/kuai_shou/blogger",
  115. timeout_seconds: float = 60.0,
  116. max_results_per_query: int | None = 5,
  117. http_client: Any | None = None,
  118. rate_limiter: RateLimiter | None = None,
  119. video_url_probe_fn: platform_video_url.ProbeFn | None = None,
  120. ) -> None:
  121. import httpx
  122. self.base_url = base_url.rstrip("/") + "/"
  123. self.keyword_path = keyword_path.lstrip("/")
  124. self.detail_path = detail_path.lstrip("/")
  125. self.account_info_path = account_info_path.lstrip("/")
  126. self.blogger_path = blogger_path.lstrip("/")
  127. self.timeout_seconds = timeout_seconds
  128. self.max_results_per_query = max_results_per_query
  129. self.http_client = http_client or httpx.Client(timeout=timeout_seconds)
  130. self.rate_limiter = rate_limiter
  131. self.video_url_probe_fn = video_url_probe_fn
  132. @classmethod
  133. def from_env(cls, env_path: str | Path = ".env") -> "CrawapiKuaishouClient":
  134. env = _load_env_file(env_path)
  135. return cls(
  136. base_url=_env("CONTENTFIND_API_CRAWAPI_BASE_URL", env, required=True),
  137. keyword_path=_env(
  138. "CONTENTFIND_KUAISHOU_KEYWORD_PATH",
  139. env,
  140. default="/crawler/kuai_shou/keyword_v2",
  141. ),
  142. detail_path=_env(
  143. "CONTENTFIND_KUAISHOU_DETAIL_PATH",
  144. env,
  145. default="/crawler/kuai_shou/detail",
  146. ),
  147. account_info_path=_env(
  148. "CONTENTFIND_KUAISHOU_ACCOUNT_INFO_PATH",
  149. env,
  150. default="/crawler/kuai_shou/account_info",
  151. ),
  152. blogger_path=_env(
  153. "CONTENTFIND_KUAISHOU_BLOGGER_PATH",
  154. env,
  155. default="/crawler/kuai_shou/blogger",
  156. ),
  157. timeout_seconds=float(
  158. _env("CONTENTFIND_API_CRAWAPI_TIMEOUT_SECONDS", env, default="180")
  159. ),
  160. max_results_per_query=_optional_positive_int(
  161. _env("CONTENTFIND_KUAISHOU_MAX_RESULTS_PER_QUERY", env, default="5")
  162. ),
  163. rate_limiter=RateLimiter(
  164. min_interval_seconds=KUAISHOU_SEARCH_MIN_INTERVAL_SECONDS,
  165. max_interval_seconds=KUAISHOU_SEARCH_MAX_INTERVAL_SECONDS,
  166. ),
  167. )
  168. def search(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  169. return self._search(query, self.max_results_per_query)
  170. def search_full_page(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  171. return self._search(query, None)
  172. def search_full_page_metadata(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  173. return self._search(query, None, hydrate_video=False)
  174. def hydrate_search_result_media(
  175. self,
  176. result: dict[str, Any],
  177. query: dict[str, Any],
  178. ) -> dict[str, Any]:
  179. item = result.get("_platform_search_item")
  180. if not isinstance(item, dict):
  181. return _strip_transient_fields(result)
  182. selection = self._select_video_url_with_detail_fallback(item)
  183. hydrated = _normalize_kuaishou_item(
  184. query,
  185. item,
  186. 1,
  187. bool(result.get("has_more")),
  188. str(result.get("next_cursor") or ""),
  189. selection,
  190. )
  191. merged = {
  192. **_strip_transient_fields(result),
  193. "play_url": hydrated.get("play_url"),
  194. "platform_raw_payload": hydrated.get("platform_raw_payload", {}),
  195. }
  196. if hydrated.get("video_url_candidates"):
  197. merged["video_url_candidates"] = hydrated["video_url_candidates"]
  198. if selection.get("media_failure_reason"):
  199. merged["media_failure_reason"] = selection["media_failure_reason"]
  200. else:
  201. merged.pop("media_failure_reason", None)
  202. return merged
  203. def _search(
  204. self,
  205. query: dict[str, Any],
  206. max_results_per_query: int | None,
  207. *,
  208. hydrate_video: bool = True,
  209. ) -> list[dict[str, Any]]:
  210. data = self._post_json(
  211. self.keyword_path,
  212. {"keyword": query["search_query"]},
  213. operation="keyword_search",
  214. rate_limit_bucket=SEARCH_RATE_LIMIT_BUCKET,
  215. )
  216. block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
  217. items = block.get("data", []) if isinstance(block.get("data"), list) else []
  218. has_more = bool(block.get("has_more", False))
  219. next_cursor = str(block.get("next_cursor") or "")
  220. selected = items[:max_results_per_query] if max_results_per_query else items
  221. results = []
  222. for index, item in enumerate(selected, start=1):
  223. selection = (
  224. self._select_video_url_with_detail_fallback(item) if hydrate_video else None
  225. )
  226. normalized = _normalize_kuaishou_item(
  227. query,
  228. item,
  229. index,
  230. has_more,
  231. next_cursor,
  232. selection,
  233. )
  234. if not hydrate_video:
  235. normalized["_platform_search_item"] = item
  236. results.append(normalized)
  237. return results
  238. def fetch_detail(self, content_id: str) -> dict[str, Any]:
  239. detail = self._fetch_detail_item(content_id)
  240. statistics = _statistics(detail)
  241. selection = self._select_video_url([("detail", detail)])
  242. platform_raw_payload = {
  243. "channel_content_id": str(detail.get("channel_content_id") or content_id),
  244. "channel_account_id": str(detail.get("channel_account_id") or ""),
  245. **dict(selection.get("platform_raw_payload") or {}),
  246. }
  247. result = {
  248. "platform": "kuaishou",
  249. "platform_content_id": str(detail.get("channel_content_id") or content_id),
  250. "platform_content_url": detail.get("content_link"),
  251. "platform_content_format": content_format(detail.get("content_type") or "video"),
  252. "description": detail.get("body_text") or detail.get("title") or "",
  253. "platform_author_id": str(detail.get("channel_account_id") or ""),
  254. "author_display_name": detail.get("channel_account_name") or "",
  255. "statistics": statistics,
  256. "tags": _extract_tags(detail),
  257. "play_url": selection.get("play_url"),
  258. "create_time": _publish_time_seconds(detail),
  259. "content_metadata_source": "kuaishou_detail",
  260. "platform_raw_payload": platform_raw_payload,
  261. }
  262. if selection.get("media_failure_reason"):
  263. result["media_failure_reason"] = selection["media_failure_reason"]
  264. if selection.get("video_url_candidates"):
  265. result["video_url_candidates"] = selection["video_url_candidates"]
  266. return result
  267. def _select_video_url(self, sources: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]:
  268. return platform_video_url.select_video_url(
  269. "kuaishou",
  270. sources,
  271. probe_fn=self.video_url_probe_fn or self._probe_video_url,
  272. )
  273. def _select_video_url_with_detail_fallback(self, item: dict[str, Any]) -> dict[str, Any]:
  274. search_selection = self._select_video_url([("search", item)])
  275. if search_selection.get("play_url"):
  276. return search_selection
  277. content_id = str(item.get("channel_content_id") or "")
  278. if not content_id:
  279. return search_selection
  280. try:
  281. detail = self._fetch_detail_item(content_id)
  282. except Exception as exc: # noqa: BLE001 - keep search diagnostics if detail is unavailable.
  283. payload = dict(search_selection.get("platform_raw_payload") or {})
  284. payload.update(
  285. {
  286. "kuaishou_detail_fallback_attempted": True,
  287. "kuaishou_detail_fallback_status": "failed",
  288. "kuaishou_detail_fallback_exception_type": type(exc).__name__,
  289. "kuaishou_detail_fallback_error": str(exc)[:300],
  290. }
  291. )
  292. return {**search_selection, "platform_raw_payload": payload}
  293. detail_selection = self._select_video_url([("search", item), ("detail", detail)])
  294. payload = dict(detail_selection.get("platform_raw_payload") or {})
  295. payload.update(
  296. {
  297. "kuaishou_detail_fallback_attempted": True,
  298. "kuaishou_detail_fallback_status": (
  299. "used" if detail_selection.get("play_url") else "no_valid_play_url"
  300. ),
  301. }
  302. )
  303. return {**detail_selection, "platform_raw_payload": payload}
  304. def _fetch_detail_item(self, content_id: str) -> dict[str, Any]:
  305. data = self._post_json(
  306. self.detail_path,
  307. {"content_id": str(content_id)},
  308. operation="detail",
  309. rate_limit_bucket=DETAIL_RATE_LIMIT_BUCKET,
  310. )
  311. block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
  312. return block.get("data", block) if isinstance(block, dict) else {}
  313. def _probe_video_url(self, url: str, platform: str) -> dict[str, Any]:
  314. return platform_video_url.probe_url_with_httpx(
  315. url,
  316. platform,
  317. http_client=self.http_client,
  318. )
  319. def fetch_account_info(self, account_id: str, is_cache: bool = True) -> dict[str, Any]:
  320. data = self._post_json(
  321. self.account_info_path,
  322. {"account_id": str(account_id), "is_cache": is_cache},
  323. operation="account_info",
  324. rate_limit_bucket=ACCOUNT_RATE_LIMIT_BUCKET,
  325. )
  326. block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
  327. account = block.get("data", block) if isinstance(block, dict) else {}
  328. platform_author_id = str(
  329. account.get("channel_account_id") or account.get("account_id") or account_id
  330. )
  331. return {
  332. "platform": "kuaishou",
  333. "platform_author_id": platform_author_id,
  334. "author_display_name": account.get("account_name") or "",
  335. "profile_snapshot": {
  336. "channel_account_id": platform_author_id,
  337. "ks_id": account.get("ks_id"),
  338. "digit_id": account.get("digit_id"),
  339. "account_link": account.get("account_link"),
  340. "avatar_url": account.get("avatar_url"),
  341. "gender": account.get("gender"),
  342. "description": account.get("description"),
  343. "tags": account.get("tags") or [],
  344. "follower_count": int(account.get("follower_count") or 0),
  345. "publish_count": int(account.get("publish_count") or 0),
  346. "like_count": int(account.get("like_count") or 0),
  347. "update_timestamp": account.get("update_timestamp"),
  348. },
  349. "raw_payload": account,
  350. }
  351. def fetch_author_works(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  352. author_id = str(query["platform_author_id"])
  353. data = self._post_json(
  354. self.blogger_path,
  355. {"account_id": author_id},
  356. operation="blogger",
  357. rate_limit_bucket=BLOGGER_RATE_LIMIT_BUCKET,
  358. )
  359. block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
  360. items = block.get("data", []) if isinstance(block.get("data"), list) else []
  361. has_more = bool(block.get("has_more", False))
  362. next_cursor = str(block.get("next_cursor") or "")
  363. results: list[dict[str, Any]] = []
  364. for index, item in enumerate(items, start=1):
  365. normalized = _normalize_kuaishou_item(
  366. query,
  367. item,
  368. index,
  369. has_more,
  370. next_cursor,
  371. self._select_video_url_with_detail_fallback(item),
  372. )
  373. normalized["previous_discovery_step"] = "author_works"
  374. normalized["content_metadata_source"] = "kuaishou_blogger"
  375. results.append(normalized)
  376. return results
  377. def _post_json(
  378. self,
  379. path: str,
  380. payload: dict[str, Any],
  381. operation: str,
  382. rate_limit_bucket: str | None = None,
  383. ) -> dict[str, Any]:
  384. return post_crawapi_json(
  385. http_client=self.http_client,
  386. base_url=self.base_url,
  387. path=path,
  388. payload=payload,
  389. operation=operation,
  390. timeout_seconds=self.timeout_seconds,
  391. rate_limiter=self.rate_limiter,
  392. rate_limit_bucket=rate_limit_bucket,
  393. business_codes=set(),
  394. )