shipinhao.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346
  1. """视频号(shipinhao)接入 client (V3-M1C).
  2. 复用 crawapi_http 共享基座(HTTP/限流/env)。search 对暂时性故障(25011/网络/
  3. 超时)按 platform_profiles/shipinhao.json 的口径重试(3 次、退避 1-2-4s),试满
  4. 抛 ContentAgentError 走既有失败通道。归一化输出与抖音同构(canonical 键集合一致)。
  5. blogger/account_info 上游 blocked,fetch_author_works 返回 [] 不请求、不抛。
  6. """
  7. from __future__ import annotations
  8. import re
  9. import time
  10. from pathlib import Path
  11. from typing import Any, Callable
  12. from content_agent.errors import ContentAgentError, ErrorCode
  13. from content_agent.integrations.crawapi_http import (
  14. CrawapiTransientError,
  15. RateLimiter,
  16. _env,
  17. _load_env_file,
  18. _optional_positive_int,
  19. content_format,
  20. post_crawapi_json,
  21. score_from_statistics,
  22. search_previous_discovery_step,
  23. )
  24. from content_agent.integrations import platform_video_url
  25. SEARCH_RATE_LIMIT_BUCKET = "shipinhao_search"
  26. TRANSIENT_BUSINESS_CODES = {"25011"}
  27. SHIPINHAO_SEARCH_MIN_INTERVAL_SECONDS = 10.0
  28. SHIPINHAO_SEARCH_MAX_INTERVAL_SECONDS = 12.0
  29. _TAG_RE = re.compile(r"#([^\s#@((]+)")
  30. def _retry_transient(
  31. fn: Callable[[], Any],
  32. *,
  33. attempts: int,
  34. backoff_seconds: tuple[int, ...],
  35. sleep_fn: Callable[[float], None],
  36. ) -> Any:
  37. for attempt in range(attempts):
  38. try:
  39. return fn()
  40. except CrawapiTransientError:
  41. if attempt == attempts - 1:
  42. raise
  43. sleep_fn(backoff_seconds[min(attempt, len(backoff_seconds) - 1)])
  44. def _normalize_shipinhao_item(
  45. query: dict[str, Any],
  46. item: dict[str, Any],
  47. index: int,
  48. has_more: bool,
  49. next_cursor: str,
  50. video_url_selection: dict[str, Any] | None = None,
  51. ) -> dict[str, Any]:
  52. video_url_selection = video_url_selection or platform_video_url.select_video_url(
  53. "shipinhao",
  54. [("search", item)],
  55. probe_fn=None,
  56. )
  57. title = item.get("title") or ""
  58. statistics = {
  59. "digg_count": int(item.get("like_count") or 0),
  60. "comment_count": int(item.get("comment_count") or 0),
  61. "share_count": int(item.get("share_count") or 0),
  62. "collect_count": int(item.get("collect_count") or 0),
  63. "play_count": int(item.get("play_count") or 0),
  64. }
  65. topic_list = item.get("topic_list") or []
  66. tags = [t if str(t).startswith("#") else f"#{t}" for t in topic_list if t]
  67. if not tags:
  68. tags = [f"#{m}" for m in _TAG_RE.findall(title)]
  69. platform_content_id = str(item.get("channel_content_id") or "")
  70. platform_author_id = str(item.get("channel_account_id") or "")
  71. publish_ms = item.get("publish_timestamp")
  72. platform_raw_payload = {
  73. "channel_content_id": platform_content_id,
  74. "channel_account_id": platform_author_id,
  75. **dict(video_url_selection.get("platform_raw_payload") or {}),
  76. }
  77. result = {
  78. "content_discovery_id": f"{query['search_query_id']}_content_{index:03d}",
  79. "search_query_id": query["search_query_id"],
  80. "platform": "shipinhao",
  81. "platform_content_id": platform_content_id,
  82. "platform_content_format": content_format(item.get("content_type") or "video"),
  83. "play_url": video_url_selection.get("play_url"),
  84. "description": title,
  85. "platform_author_id": platform_author_id,
  86. "author_display_name": item.get("channel_account_name") or "",
  87. "statistics": statistics,
  88. "tags": list(dict.fromkeys(tags)),
  89. "text_extra": [],
  90. "create_time": int(publish_ms) // 1000 if publish_ms else None,
  91. "has_more": has_more,
  92. "next_cursor": next_cursor,
  93. "score": score_from_statistics(statistics),
  94. "risk_level": "unknown",
  95. "discovery_relation": "derived_from_pattern_demand",
  96. "discovery_start_source": query["discovery_start_source"],
  97. "previous_discovery_step": search_previous_discovery_step(query),
  98. "content_metadata_source": "shipinhao_keyword_search",
  99. "platform_auth_mode": "no_bearer",
  100. "platform_raw_payload": platform_raw_payload,
  101. }
  102. if video_url_selection.get("media_failure_reason"):
  103. result["media_failure_reason"] = video_url_selection["media_failure_reason"]
  104. if video_url_selection.get("video_url_candidates"):
  105. result["video_url_candidates"] = video_url_selection["video_url_candidates"]
  106. return result
  107. def _research_keyword(item: dict[str, Any], query: dict[str, Any]) -> str:
  108. title = " ".join(str(item.get("title") or "").split())
  109. if title:
  110. return title[:80]
  111. return str(query.get("search_query") or "")[:80]
  112. def _strip_transient_fields(result: dict[str, Any]) -> dict[str, Any]:
  113. return {key: value for key, value in result.items() if not str(key).startswith("_platform_")}
  114. class CrawapiShipinhaoClient:
  115. requires_progressive_search_rate_limit = True
  116. def __init__(
  117. self,
  118. base_url: str,
  119. keyword_path: str = "/crawler/shi_pin_hao/keyword",
  120. timeout_seconds: float = 60.0,
  121. max_results_per_query: int | None = 5,
  122. max_attempts: int = 3,
  123. backoff_seconds: tuple[int, ...] = (1, 2, 4),
  124. http_client: Any | None = None,
  125. rate_limiter: RateLimiter | None = None,
  126. sleep_fn: Callable[[float], None] = time.sleep,
  127. video_url_probe_fn: platform_video_url.ProbeFn | None = None,
  128. ) -> None:
  129. import httpx
  130. self.base_url = base_url.rstrip("/") + "/"
  131. self.keyword_path = keyword_path.lstrip("/")
  132. self.timeout_seconds = timeout_seconds
  133. self.max_results_per_query = max_results_per_query
  134. self.max_attempts = max_attempts
  135. self.backoff_seconds = backoff_seconds
  136. self.http_client = http_client or httpx.Client(timeout=timeout_seconds)
  137. self.rate_limiter = rate_limiter
  138. self.sleep_fn = sleep_fn
  139. self.video_url_probe_fn = video_url_probe_fn
  140. @classmethod
  141. def from_env(cls, env_path: str | Path = ".env") -> "CrawapiShipinhaoClient":
  142. env = _load_env_file(env_path)
  143. return cls(
  144. base_url=_env("CONTENTFIND_API_CRAWAPI_BASE_URL", env, required=True),
  145. timeout_seconds=float(
  146. _env("CONTENTFIND_API_CRAWAPI_TIMEOUT_SECONDS", env, default="180")
  147. ),
  148. max_results_per_query=_optional_positive_int(
  149. _env("CONTENTFIND_SHIPINHAO_MAX_RESULTS_PER_QUERY", env, default="5")
  150. ),
  151. rate_limiter=RateLimiter(
  152. min_interval_seconds=SHIPINHAO_SEARCH_MIN_INTERVAL_SECONDS,
  153. max_interval_seconds=SHIPINHAO_SEARCH_MAX_INTERVAL_SECONDS,
  154. ),
  155. )
  156. def search(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  157. return self._search(query, self.max_results_per_query)
  158. def search_full_page(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  159. return self._search(query, None)
  160. def search_full_page_metadata(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  161. return self._search(query, None, hydrate_video=False)
  162. def hydrate_search_result_media(
  163. self,
  164. result: dict[str, Any],
  165. query: dict[str, Any],
  166. ) -> dict[str, Any]:
  167. item = result.get("_platform_search_item")
  168. if not isinstance(item, dict):
  169. return _strip_transient_fields(result)
  170. selection = self._select_video_url_with_title_research(item, query)
  171. hydrated = _normalize_shipinhao_item(
  172. query,
  173. item,
  174. 1,
  175. bool(result.get("has_more")),
  176. str(result.get("next_cursor") or ""),
  177. selection,
  178. )
  179. merged = {
  180. **_strip_transient_fields(result),
  181. "play_url": hydrated.get("play_url"),
  182. "platform_raw_payload": hydrated.get("platform_raw_payload", {}),
  183. }
  184. if hydrated.get("video_url_candidates"):
  185. merged["video_url_candidates"] = hydrated["video_url_candidates"]
  186. if selection.get("media_failure_reason"):
  187. merged["media_failure_reason"] = selection["media_failure_reason"]
  188. else:
  189. merged.pop("media_failure_reason", None)
  190. return merged
  191. def _search(
  192. self,
  193. query: dict[str, Any],
  194. max_results_per_query: int | None,
  195. *,
  196. hydrate_video: bool = True,
  197. ) -> list[dict[str, Any]]:
  198. data = self._keyword_search(
  199. {"keyword": query["search_query"], "cursor": str(query.get("page_cursor") or "")}
  200. )
  201. block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
  202. items = block.get("data", []) if isinstance(block.get("data"), list) else []
  203. has_more = bool(block.get("has_more", False))
  204. next_cursor = str(block.get("next_cursor") or "")
  205. selected = items[:max_results_per_query] if max_results_per_query else items
  206. results = []
  207. for index, item in enumerate(selected, start=1):
  208. selection = (
  209. self._select_video_url_with_title_research(item, query) if hydrate_video else None
  210. )
  211. normalized = _normalize_shipinhao_item(
  212. query,
  213. item,
  214. index,
  215. has_more,
  216. next_cursor,
  217. selection,
  218. )
  219. if not hydrate_video:
  220. normalized["_platform_search_item"] = item
  221. results.append(normalized)
  222. return results
  223. def _keyword_search(self, payload: dict[str, Any]) -> dict[str, Any]:
  224. def _call() -> dict[str, Any]:
  225. return post_crawapi_json(
  226. http_client=self.http_client,
  227. base_url=self.base_url,
  228. path=self.keyword_path,
  229. payload=payload,
  230. operation="keyword_search",
  231. timeout_seconds=self.timeout_seconds,
  232. rate_limiter=self.rate_limiter,
  233. rate_limit_bucket=SEARCH_RATE_LIMIT_BUCKET,
  234. business_codes=set(),
  235. transient_business_codes=TRANSIENT_BUSINESS_CODES,
  236. )
  237. try:
  238. return _retry_transient(
  239. _call,
  240. attempts=self.max_attempts,
  241. backoff_seconds=self.backoff_seconds,
  242. sleep_fn=self.sleep_fn,
  243. )
  244. except CrawapiTransientError as exc:
  245. raise ContentAgentError(
  246. ErrorCode.PLATFORM_REQUEST_FAILED,
  247. "shipinhao search exhausted after retries",
  248. {"operation": "keyword_search", "max_attempts": self.max_attempts},
  249. ) from exc
  250. def _select_video_url(self, sources: list[tuple[str, dict[str, Any]]]) -> dict[str, Any]:
  251. return platform_video_url.select_video_url(
  252. "shipinhao",
  253. sources,
  254. probe_fn=self.video_url_probe_fn or self._probe_video_url,
  255. )
  256. def _select_video_url_with_title_research(
  257. self,
  258. item: dict[str, Any],
  259. query: dict[str, Any],
  260. ) -> dict[str, Any]:
  261. search_selection = self._select_video_url([("search", item)])
  262. if search_selection.get("play_url"):
  263. return search_selection
  264. content_id = str(item.get("channel_content_id") or "")
  265. title_keyword = _research_keyword(item, query)
  266. if not content_id or not title_keyword:
  267. return search_selection
  268. try:
  269. data = self._keyword_search({"keyword": title_keyword, "cursor": ""})
  270. except Exception as exc: # noqa: BLE001 - retain original no-valid diagnostics.
  271. payload = dict(search_selection.get("platform_raw_payload") or {})
  272. payload.update(
  273. {
  274. "shipinhao_research_source": "title",
  275. "shipinhao_research_status": "failed",
  276. "shipinhao_research_exception_type": type(exc).__name__,
  277. "shipinhao_research_error": str(exc)[:300],
  278. }
  279. )
  280. return {**search_selection, "platform_raw_payload": payload}
  281. block = data.get("data", {}) if isinstance(data.get("data"), dict) else {}
  282. rows = block.get("data", []) if isinstance(block.get("data"), list) else []
  283. matched = next(
  284. (row for row in rows if str(row.get("channel_content_id") or "") == content_id),
  285. None,
  286. )
  287. if not matched:
  288. payload = dict(search_selection.get("platform_raw_payload") or {})
  289. payload.update(
  290. {
  291. "shipinhao_research_source": "title",
  292. "shipinhao_research_status": "not_matched",
  293. "shipinhao_research_item_count": len(rows),
  294. }
  295. )
  296. return {**search_selection, "platform_raw_payload": payload}
  297. selection = self._select_video_url([("search", item), ("research", matched)])
  298. payload = dict(selection.get("platform_raw_payload") or {})
  299. payload.update(
  300. {
  301. "shipinhao_research_source": "title",
  302. "shipinhao_research_status": (
  303. "used" if selection.get("play_url") else "no_valid_play_url"
  304. ),
  305. "shipinhao_research_item_count": len(rows),
  306. }
  307. )
  308. return {**selection, "platform_raw_payload": payload}
  309. def _probe_video_url(self, url: str, platform: str) -> dict[str, Any]:
  310. return platform_video_url.probe_url_with_httpx(
  311. url,
  312. platform,
  313. http_client=self.http_client,
  314. )
  315. def fetch_author_works(self, query: dict[str, Any]) -> list[dict[str, Any]]:
  316. # 上游 blogger 接口 blocked(code=25011),不发请求、不抛,游走自然退化。
  317. return []