crawapi_http.py 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250
  1. """Shared crawapi HTTP base (V3-M1A).
  2. Extracted verbatim from douyin.py so multiple platform clients (douyin /
  3. shipinhao) reuse the same HTTP post + rate limiting + rate-limit error
  4. classification + env-file helpers, instead of each duplicating them.
  5. Pure refactor: behaviour is identical to the original douyin implementation.
  6. """
  7. from __future__ import annotations
  8. import os
  9. import random
  10. import time
  11. from pathlib import Path
  12. from typing import Any, Callable
  13. from urllib.parse import urljoin
  14. import httpx
  15. from content_agent.errors import ContentAgentError, ErrorCode
  16. from content_agent.integrations import timeout_config
  17. RATE_LIMIT_MESSAGE_TOKENS = ("限流", "请求频繁", "rate limit", "too many requests")
  18. _MAX_RESPONSE_SUMMARY_LENGTH = 500
  19. class CrawapiTransientError(RuntimeError):
  20. """Retryable crawapi failure (network/timeout, or a platform-declared
  21. transient business code such as 视频号 25011). Subclasses RuntimeError so
  22. existing `except RuntimeError` handlers keep working unchanged."""
  23. class RateLimiter:
  24. def __init__(
  25. self,
  26. min_interval_seconds: float = 30.0,
  27. max_interval_seconds: float | None = None,
  28. now_fn: Callable[[], float] = time.monotonic,
  29. sleep_fn: Callable[[float], None] = time.sleep,
  30. random_fn: Callable[[float, float], float] = random.uniform,
  31. ) -> None:
  32. self.min_interval_seconds = min_interval_seconds
  33. self.max_interval_seconds = max_interval_seconds
  34. self.now_fn = now_fn
  35. self.sleep_fn = sleep_fn
  36. self.random_fn = random_fn
  37. self._last_call_by_bucket: dict[str, float] = {}
  38. def wait(self, bucket: str) -> None:
  39. last = self._last_call_by_bucket.get(bucket)
  40. if last is not None:
  41. remaining = self._next_interval_seconds() - (self.now_fn() - last)
  42. if remaining > 0:
  43. self.sleep_fn(remaining)
  44. self._last_call_by_bucket[bucket] = self.now_fn()
  45. def _next_interval_seconds(self) -> float:
  46. if self.max_interval_seconds is None or self.max_interval_seconds <= self.min_interval_seconds:
  47. return self.min_interval_seconds
  48. return self.random_fn(self.min_interval_seconds, self.max_interval_seconds)
  49. def is_rate_limit_business_error(
  50. code: Any, data: dict[str, Any], *, business_codes: set[str]
  51. ) -> bool:
  52. if str(code) in business_codes:
  53. return True
  54. message = str(data.get("msg") or data.get("message") or "").lower()
  55. return any(token in message for token in RATE_LIMIT_MESSAGE_TOKENS)
  56. def post_crawapi_json(
  57. *,
  58. http_client: Any,
  59. base_url: str,
  60. path: str,
  61. payload: dict[str, Any],
  62. operation: str,
  63. timeout_seconds: float,
  64. rate_limiter: RateLimiter | None = None,
  65. rate_limit_bucket: str | None = None,
  66. business_codes: set[str],
  67. transient_business_codes: set[str] = frozenset(),
  68. ) -> dict[str, Any]:
  69. if rate_limit_bucket and rate_limiter:
  70. rate_limiter.wait(rate_limit_bucket)
  71. url = urljoin(base_url, path)
  72. try:
  73. response = http_client.post(
  74. url,
  75. json=payload,
  76. headers={"Content-Type": "application/json"},
  77. timeout=timeout_config.as_httpx_timeout(timeout_seconds, read=timeout_config.read_timeout("crawapi")),
  78. )
  79. response.raise_for_status()
  80. data = response.json()
  81. except httpx.HTTPStatusError as exc:
  82. status_code = exc.response.status_code if exc.response is not None else "unknown"
  83. if status_code == 429:
  84. raise ContentAgentError(
  85. ErrorCode.PLATFORM_RATE_LIMITED,
  86. f"crawapi {operation} failed: rate_limited",
  87. {
  88. "operation": operation,
  89. "status_code": 429,
  90. "response_summary": _response_summary(exc.response),
  91. },
  92. ) from exc
  93. raise RuntimeError(
  94. f"crawapi {operation} failed: HTTP {status_code}; "
  95. f"response={_response_summary(exc.response)}"
  96. ) from exc
  97. except httpx.HTTPError as exc:
  98. raise CrawapiTransientError(
  99. f"crawapi {operation} failed: network_error "
  100. f"exception_type={type(exc).__name__} message={_truncate_text(str(exc))}"
  101. ) from exc
  102. except ValueError as exc:
  103. response_summary = _response_summary(response) if "response" in locals() else {}
  104. raise RuntimeError(
  105. f"crawapi {operation} failed: bad_json; response={response_summary}"
  106. ) from exc
  107. if not isinstance(data, dict):
  108. raise RuntimeError(f"crawapi {operation} failed: bad_response")
  109. code = data.get("code")
  110. if code is not None and code not in (0, "0"):
  111. if is_rate_limit_business_error(code, data, business_codes=business_codes):
  112. raise ContentAgentError(
  113. ErrorCode.PLATFORM_RATE_LIMITED,
  114. f"crawapi {operation} failed: rate_limited",
  115. {
  116. "operation": operation,
  117. "business_code": str(code),
  118. "business_message": _business_message(data),
  119. },
  120. )
  121. if str(code) in transient_business_codes:
  122. raise CrawapiTransientError(
  123. f"crawapi {operation} failed: transient_business_error "
  124. f"code={code} message={_business_message(data)}"
  125. )
  126. raise RuntimeError(
  127. f"crawapi {operation} failed: business_error "
  128. f"code={code} message={_business_message(data)} "
  129. f"keys={sorted(str(key) for key in data.keys())}"
  130. )
  131. return data
  132. def _response_summary(response: httpx.Response | None) -> dict[str, Any]:
  133. if response is None:
  134. return {}
  135. summary: dict[str, Any] = {
  136. "status_code": response.status_code,
  137. "content_type": response.headers.get("content-type"),
  138. }
  139. try:
  140. data = response.json()
  141. except ValueError:
  142. text = response.text
  143. if text:
  144. summary["body_summary"] = _truncate_text(text)
  145. return summary
  146. if isinstance(data, dict):
  147. summary["json_keys"] = sorted(str(key) for key in data.keys())
  148. for key in ("code", "status", "msg", "message", "error"):
  149. if key in data:
  150. summary[key] = _truncate_text(str(data.get(key)))
  151. else:
  152. summary["json_type"] = type(data).__name__
  153. return summary
  154. def _business_message(data: dict[str, Any]) -> str:
  155. return _truncate_text(str(data.get("msg") or data.get("message") or ""))
  156. def _truncate_text(text: str, limit: int = _MAX_RESPONSE_SUMMARY_LENGTH) -> str:
  157. if len(text) <= limit:
  158. return text
  159. return f"{text[:limit]}..."
  160. def _load_env_file(env_path: str | Path) -> dict[str, str]:
  161. path = Path(env_path)
  162. if not path.exists():
  163. return {}
  164. env: dict[str, str] = {}
  165. for line in path.read_text(encoding="utf-8").splitlines():
  166. stripped = line.strip()
  167. if not stripped or stripped.startswith("#") or "=" not in stripped:
  168. continue
  169. key, value = stripped.split("=", 1)
  170. env[key.strip()] = value.strip().strip('"').strip("'")
  171. return env
  172. def _env(
  173. key: str,
  174. file_env: dict[str, str],
  175. default: str | None = None,
  176. required: bool = False,
  177. ) -> str:
  178. value = file_env.get(key) or os.getenv(key) or default
  179. if required and not value:
  180. raise RuntimeError(f"missing required env: {key}")
  181. return value or ""
  182. def _optional_positive_int(value: str) -> int | None:
  183. try:
  184. parsed = int(value)
  185. except ValueError:
  186. return None
  187. return parsed if parsed > 0 else None
  188. def search_previous_discovery_step(query: dict[str, Any]) -> str:
  189. """搜索结果的来源步:游走标签搜索标 hashtag_to_query,首轮标 search_query_direct。
  190. 修真跑发现的 labeling bug——此前所有搜索结果硬编码 search_query_direct,
  191. 导致游走带回的视频被当成首轮、前端首轮/游走分不开。
  192. """
  193. if query.get("search_query_generation_method") == "tag_query":
  194. return "hashtag_to_query"
  195. return "search_query_direct"
  196. def content_format(raw_content_type: str) -> str:
  197. if "图文" in raw_content_type:
  198. return "image_text"
  199. if "文本" in raw_content_type:
  200. return "text"
  201. if "直播" in raw_content_type:
  202. return "live"
  203. return "video"
  204. def score_from_statistics(statistics: dict[str, Any]) -> int:
  205. digg = int(statistics.get("digg_count") or 0)
  206. comment = int(statistics.get("comment_count") or 0)
  207. share = int(statistics.get("share_count") or 0)
  208. weighted = digg + comment * 3 + share * 4
  209. if weighted >= 3000:
  210. return 72
  211. if weighted >= 1000:
  212. return 62
  213. if weighted >= 300:
  214. return 55
  215. return 45