crawapi_http.py 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343
  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, sanitize_error_detail
  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 CrawapiBusinessError(RuntimeError):
  24. """Non-rate-limit crawapi business failure with the raw response detail.
  25. This remains a RuntimeError for compatibility with callers/tests that treat
  26. ordinary platform business failures as PLATFORM_REQUEST_FAILED.
  27. """
  28. def __init__(self, message: str, detail: dict[str, Any]) -> None:
  29. super().__init__(message)
  30. self.detail = sanitize_error_detail(detail)
  31. class RateLimiter:
  32. def __init__(
  33. self,
  34. min_interval_seconds: float = 30.0,
  35. max_interval_seconds: float | None = None,
  36. now_fn: Callable[[], float] = time.monotonic,
  37. sleep_fn: Callable[[float], None] = time.sleep,
  38. random_fn: Callable[[float, float], float] = random.uniform,
  39. ) -> None:
  40. self.min_interval_seconds = min_interval_seconds
  41. self.max_interval_seconds = max_interval_seconds
  42. self.now_fn = now_fn
  43. self.sleep_fn = sleep_fn
  44. self.random_fn = random_fn
  45. self._last_call_by_bucket: dict[str, float] = {}
  46. def wait(self, bucket: str) -> None:
  47. last = self._last_call_by_bucket.get(bucket)
  48. if last is not None:
  49. remaining = self._next_interval_seconds() - (self.now_fn() - last)
  50. if remaining > 0:
  51. self.sleep_fn(remaining)
  52. self._last_call_by_bucket[bucket] = self.now_fn()
  53. def _next_interval_seconds(self) -> float:
  54. if self.max_interval_seconds is None or self.max_interval_seconds <= self.min_interval_seconds:
  55. return self.min_interval_seconds
  56. return self.random_fn(self.min_interval_seconds, self.max_interval_seconds)
  57. def is_rate_limit_business_error(
  58. code: Any, data: dict[str, Any], *, business_codes: set[str]
  59. ) -> bool:
  60. if str(code) in business_codes:
  61. return True
  62. message = str(data.get("msg") or data.get("message") or "").lower()
  63. return any(token in message for token in RATE_LIMIT_MESSAGE_TOKENS)
  64. def post_crawapi_json(
  65. *,
  66. http_client: Any,
  67. base_url: str,
  68. path: str,
  69. payload: dict[str, Any],
  70. operation: str,
  71. timeout_seconds: float,
  72. rate_limiter: RateLimiter | None = None,
  73. rate_limit_bucket: str | None = None,
  74. business_codes: set[str],
  75. transient_business_codes: set[str] = frozenset(),
  76. ) -> dict[str, Any]:
  77. if rate_limit_bucket and rate_limiter:
  78. rate_limiter.wait(rate_limit_bucket)
  79. url = urljoin(base_url, path)
  80. try:
  81. response = http_client.post(
  82. url,
  83. json=payload,
  84. headers={"Content-Type": "application/json"},
  85. timeout=timeout_config.as_httpx_timeout(timeout_seconds, read=timeout_config.read_timeout("crawapi")),
  86. )
  87. response.raise_for_status()
  88. data = response.json()
  89. except httpx.HTTPStatusError as exc:
  90. status_code = exc.response.status_code if exc.response is not None else "unknown"
  91. if status_code == 429:
  92. raise ContentAgentError(
  93. ErrorCode.PLATFORM_RATE_LIMITED,
  94. f"crawapi {operation} failed: rate_limited",
  95. {
  96. "operation": operation,
  97. "status_code": 429,
  98. "response_summary": _response_summary(exc.response),
  99. },
  100. ) from exc
  101. raise RuntimeError(
  102. f"crawapi {operation} failed: HTTP {status_code}; "
  103. f"response={_response_summary(exc.response)}"
  104. ) from exc
  105. except httpx.HTTPError as exc:
  106. raise CrawapiTransientError(
  107. f"crawapi {operation} failed: network_error "
  108. f"exception_type={type(exc).__name__} message={_truncate_text(str(exc))}"
  109. ) from exc
  110. except ValueError as exc:
  111. response_summary = _response_summary(response) if "response" in locals() else {}
  112. raise RuntimeError(
  113. f"crawapi {operation} failed: bad_json; response={response_summary}"
  114. ) from exc
  115. if not isinstance(data, dict):
  116. raise RuntimeError(f"crawapi {operation} failed: bad_response")
  117. code = data.get("code")
  118. if code is not None and code not in (0, "0"):
  119. if is_rate_limit_business_error(code, data, business_codes=business_codes):
  120. raise ContentAgentError(
  121. ErrorCode.PLATFORM_RATE_LIMITED,
  122. f"crawapi {operation} failed: rate_limited",
  123. {
  124. "operation": operation,
  125. "business_code": str(code),
  126. "business_message": _business_message(data),
  127. },
  128. )
  129. if str(code) in transient_business_codes:
  130. raise CrawapiTransientError(
  131. f"crawapi {operation} failed: transient_business_error "
  132. f"code={code} message={_business_message(data)}"
  133. )
  134. message = (
  135. f"crawapi {operation} failed: business_error "
  136. f"code={code} message={_business_message(data)} "
  137. f"keys={sorted(str(key) for key in data.keys())}"
  138. )
  139. raise CrawapiBusinessError(
  140. message,
  141. _business_error_detail(
  142. operation=operation,
  143. response=response,
  144. response_json=data,
  145. request_payload=payload,
  146. ),
  147. )
  148. return data
  149. def _response_summary(response: httpx.Response | None) -> dict[str, Any]:
  150. if response is None:
  151. return {}
  152. summary: dict[str, Any] = {
  153. "status_code": response.status_code,
  154. "content_type": response.headers.get("content-type"),
  155. }
  156. try:
  157. data = response.json()
  158. except ValueError:
  159. text = response.text
  160. if text:
  161. summary["body_summary"] = _truncate_text(text)
  162. return summary
  163. if isinstance(data, dict):
  164. summary["json_keys"] = sorted(str(key) for key in data.keys())
  165. for key in ("code", "status", "msg", "message", "error"):
  166. if key in data:
  167. summary[key] = _truncate_text(str(data.get(key)))
  168. else:
  169. summary["json_type"] = type(data).__name__
  170. return summary
  171. def _business_error_detail(
  172. *,
  173. operation: str,
  174. response: httpx.Response | None,
  175. response_json: dict[str, Any],
  176. request_payload: dict[str, Any],
  177. ) -> dict[str, Any]:
  178. code = response_json.get("code")
  179. return {
  180. "operation": operation,
  181. "business_code": str(code) if code is not None else None,
  182. "business_message": _business_message(response_json),
  183. "response_summary": _response_summary(response),
  184. "response_json_keys": sorted(str(key) for key in response_json.keys()),
  185. "business_data": response_json.get("data"),
  186. "trace_refs": _extract_trace_refs(response_json, response),
  187. "request_payload_summary": _request_payload_summary(request_payload),
  188. }
  189. def _extract_trace_refs(
  190. response_json: dict[str, Any],
  191. response: httpx.Response | None,
  192. ) -> dict[str, str]:
  193. refs: dict[str, str] = {}
  194. for source in (response_json, response_json.get("data")):
  195. if not isinstance(source, dict):
  196. continue
  197. for key in (
  198. "request_id",
  199. "requestId",
  200. "trace_id",
  201. "traceId",
  202. "log_id",
  203. "logId",
  204. "rid",
  205. ):
  206. value = source.get(key)
  207. if value:
  208. refs[key] = _truncate_text(str(value))
  209. if response is not None:
  210. for key in (
  211. "x-request-id",
  212. "x-trace-id",
  213. "x-tt-logid",
  214. "x-log-id",
  215. "trace-id",
  216. ):
  217. value = response.headers.get(key)
  218. if value:
  219. refs[key] = _truncate_text(str(value))
  220. return refs
  221. def _request_payload_summary(payload: dict[str, Any]) -> dict[str, Any]:
  222. summary: dict[str, Any] = {}
  223. for key, value in payload.items():
  224. if key in {
  225. "keyword",
  226. "content_type",
  227. "sort_type",
  228. "publish_time",
  229. "duration",
  230. "cursor",
  231. "cookie_batch",
  232. }:
  233. summary[key] = value
  234. elif key.endswith("_id") or key in {"account_id", "sec_uid"}:
  235. summary[key] = "<redacted>"
  236. return summary
  237. def _business_message(data: dict[str, Any]) -> str:
  238. return _truncate_text(str(data.get("msg") or data.get("message") or ""))
  239. def _truncate_text(text: str, limit: int = _MAX_RESPONSE_SUMMARY_LENGTH) -> str:
  240. if len(text) <= limit:
  241. return text
  242. return f"{text[:limit]}..."
  243. def _load_env_file(env_path: str | Path) -> dict[str, str]:
  244. path = Path(env_path)
  245. if not path.exists():
  246. return {}
  247. env: dict[str, str] = {}
  248. for line in path.read_text(encoding="utf-8").splitlines():
  249. stripped = line.strip()
  250. if not stripped or stripped.startswith("#") or "=" not in stripped:
  251. continue
  252. key, value = stripped.split("=", 1)
  253. env[key.strip()] = value.strip().strip('"').strip("'")
  254. return env
  255. def _env(
  256. key: str,
  257. file_env: dict[str, str],
  258. default: str | None = None,
  259. required: bool = False,
  260. ) -> str:
  261. value = file_env.get(key) or os.getenv(key) or default
  262. if required and not value:
  263. raise RuntimeError(f"missing required env: {key}")
  264. return value or ""
  265. def _optional_positive_int(value: str) -> int | None:
  266. try:
  267. parsed = int(value)
  268. except ValueError:
  269. return None
  270. return parsed if parsed > 0 else None
  271. def search_previous_discovery_step(query: dict[str, Any]) -> str:
  272. """搜索结果的来源步:游走标签搜索标 hashtag_to_query,首轮标 search_query_direct。
  273. 修真跑发现的 labeling bug——此前所有搜索结果硬编码 search_query_direct,
  274. 导致游走带回的视频被当成首轮、前端首轮/游走分不开。
  275. """
  276. if query.get("search_query_generation_method") == "tag_query":
  277. return "hashtag_to_query"
  278. return "search_query_direct"
  279. def content_format(raw_content_type: str) -> str:
  280. if "图文" in raw_content_type:
  281. return "image_text"
  282. if "文本" in raw_content_type:
  283. return "text"
  284. if "直播" in raw_content_type:
  285. return "live"
  286. return "video"
  287. def score_from_statistics(statistics: dict[str, Any]) -> int:
  288. digg = int(statistics.get("digg_count") or 0)
  289. comment = int(statistics.get("comment_count") or 0)
  290. share = int(statistics.get("share_count") or 0)
  291. weighted = digg + comment * 3 + share * 4
  292. if weighted >= 3000:
  293. return 72
  294. if weighted >= 1000:
  295. return 62
  296. if weighted >= 300:
  297. return 55
  298. return 45