crawapi_http.py 8.1 KB

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