Files
Ubuntu 32b43f85bd a
2026-07-03 16:58:28 +08:00

257 lines
8.8 KiB
Python

"""
XHS API client transport, signing, and retry primitives.
Domain-specific endpoint methods live in ``client_mixins.py``.
"""
from __future__ import annotations
import json
import logging
import random
import time
from typing import Any
import httpx
from .client_mixins import (
AuthEndpointsMixin,
CreatorEndpointsMixin,
InteractionEndpointsMixin,
NotificationEndpointsMixin,
ReadingEndpointsMixin,
SocialEndpointsMixin,
)
from .constants import CHROME_VERSION, CREATOR_HOST, EDITH_HOST, HOME_URL, USER_AGENT
from .cookies import cookies_to_string
from .creator_signing import sign_creator
from .exceptions import (
IpBlockedError,
NeedVerifyError,
SessionExpiredError,
SignatureError,
XhsApiError,
)
from .signing import build_get_uri, sign_main_api
logger = logging.getLogger(__name__)
class XhsClient(
ReadingEndpointsMixin,
InteractionEndpointsMixin,
CreatorEndpointsMixin,
SocialEndpointsMixin,
NotificationEndpointsMixin,
AuthEndpointsMixin,
):
"""Xiaohongshu API client with automatic signing, rate limiting, and retry."""
def __init__(
self,
cookies: dict[str, str],
timeout: float = 30.0,
request_delay: float = 1.0,
max_retries: int = 3,
):
self.cookies = cookies
self._http = httpx.Client(timeout=timeout, follow_redirects=True)
self._request_delay = request_delay
self._base_request_delay = request_delay
self._max_retries = max_retries
self._last_request_time = 0.0
self._verify_count = 0
self._request_count = 0
def close(self) -> None:
self._http.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
def _rate_limit_delay(self) -> None:
"""Enforce minimum delay with Gaussian jitter to mimic human browsing."""
if self._request_delay <= 0:
return
elapsed = time.time() - self._last_request_time
if elapsed < self._request_delay:
jitter = max(0, random.gauss(0.3, 0.15))
if random.random() < 0.05:
jitter += random.uniform(2.0, 5.0)
sleep_time = self._request_delay - elapsed + jitter
logger.debug("Rate-limit delay: %.2fs", sleep_time)
time.sleep(sleep_time)
def _mark_request(self) -> None:
self._last_request_time = time.time()
self._request_count += 1
def _base_headers(self) -> dict[str, str]:
return {
"user-agent": USER_AGENT,
"content-type": "application/json;charset=UTF-8",
"cookie": cookies_to_string(self.cookies),
"origin": HOME_URL,
"referer": f"{HOME_URL}/",
"sec-ch-ua": f'"Not:A-Brand";v="99", "Google Chrome";v="{CHROME_VERSION}", "Chromium";v="{CHROME_VERSION}"',
"sec-ch-ua-mobile": "?0",
"sec-ch-ua-platform": '"macOS"',
"sec-fetch-dest": "empty",
"sec-fetch-mode": "cors",
"sec-fetch-site": "same-site",
"accept": "application/json, text/plain, */*",
"accept-language": "zh-CN,zh;q=0.9,en;q=0.8",
"dnt": "1",
"priority": "u=1, i",
}
def _handle_response(self, resp: httpx.Response) -> Any:
if resp.status_code in (461, 471):
self._verify_count += 1
cooldown = min(30, 5 * (2 ** (self._verify_count - 1)))
logger.warning(
"Captcha triggered (count=%d), cooling down %.0fs before raising",
self._verify_count, cooldown,
)
self._request_delay = max(self._request_delay, self._base_request_delay * 2)
time.sleep(cooldown)
raise NeedVerifyError(
verify_type=resp.headers.get("verifytype", "unknown"),
verify_uuid=resp.headers.get("verifyuuid", "unknown"),
)
self._verify_count = 0
text = resp.text
if not text:
return None
try:
data = json.loads(text)
except json.JSONDecodeError:
raise XhsApiError(f"Non-JSON response: {text[:200]}") from None
if data.get("success"):
return data.get("data", data.get("success"))
code = data.get("code")
if code == 300012:
raise IpBlockedError()
if code == 300015:
raise SignatureError()
if code == -100:
raise SessionExpiredError()
raise XhsApiError(
f"API error: {json.dumps(data)[:300]}",
code=code,
response=data,
)
def _merge_response_cookies(self, resp: httpx.Response) -> None:
"""Persist response cookies back into the in-memory session jar."""
for name, value in resp.cookies.items():
if not value:
continue
self.cookies[name] = value
def _request_with_retry(self, method: str, url: str, **kwargs) -> httpx.Response:
self._rate_limit_delay()
last_exc: Exception | None = None
for attempt in range(self._max_retries):
try:
resp = self._http.request(method, url, **kwargs)
self._merge_response_cookies(resp)
self._mark_request()
if resp.status_code in (429, 500, 502, 503, 504):
wait = (2 ** attempt) + random.uniform(0, 1)
logger.warning(
"HTTP %d from %s, retrying in %.1fs (attempt %d/%d)",
resp.status_code, url[:80], wait, attempt + 1, self._max_retries,
)
time.sleep(wait)
continue
return resp
except (httpx.TimeoutException, httpx.NetworkError) as exc:
last_exc = exc
wait = (2 ** attempt) + random.uniform(0, 1)
logger.warning(
"Network error: %s, retrying in %.1fs (attempt %d/%d)",
exc, wait, attempt + 1, self._max_retries,
)
time.sleep(wait)
if last_exc:
raise XhsApiError(f"Request failed after {self._max_retries} retries: {last_exc}") from last_exc
raise XhsApiError(f"Request failed after {self._max_retries} retries: HTTP {resp.status_code}")
def _main_api_get(
self,
uri: str,
params: dict[str, str | int | list[str]] | None = None,
) -> Any:
sign_headers = sign_main_api("GET", uri, self.cookies, params=params)
full_uri = build_get_uri(uri, params)
url = f"{EDITH_HOST}{full_uri}"
logger.debug("GET %s", url)
resp = self._request_with_retry("GET", url, headers={**self._base_headers(), **sign_headers})
return self._handle_response(resp)
def _main_api_post(
self,
uri: str,
data: dict[str, Any],
header_overrides: dict[str, str] | None = None,
) -> Any:
sign_headers = sign_main_api("POST", uri, self.cookies, payload=data)
url = f"{EDITH_HOST}{uri}"
headers = {**self._base_headers(), **sign_headers}
if header_overrides:
headers.update(header_overrides)
logger.debug("POST %s", url)
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
resp = self._request_with_retry("POST", url, headers=headers, content=body)
return self._handle_response(resp)
def _creator_host(self, uri: str) -> str:
return CREATOR_HOST if uri.startswith("/api/galaxy/") else EDITH_HOST
def _creator_get(
self,
uri: str,
params: dict[str, str | int] | None = None,
) -> Any:
full_uri = build_get_uri(uri, params)
sign = sign_creator(f"url={full_uri}", None, self.cookies["a1"])
host = self._creator_host(uri)
url = f"{host}{full_uri}"
headers = {
**self._base_headers(),
"x-s": sign["x-s"],
"x-t": sign["x-t"],
"origin": CREATOR_HOST,
"referer": f"{CREATOR_HOST}/",
}
logger.debug("Creator GET %s", url)
resp = self._request_with_retry("GET", url, headers=headers)
return self._handle_response(resp)
def _creator_post(self, uri: str, data: dict[str, Any]) -> Any:
sign = sign_creator(f"url={uri}", data, self.cookies["a1"])
host = self._creator_host(uri)
url = f"{host}{uri}"
headers = {
**self._base_headers(),
"x-s": sign["x-s"],
"x-t": sign["x-t"],
"origin": CREATOR_HOST,
"referer": f"{CREATOR_HOST}/",
}
logger.debug("Creator POST %s", url)
body = json.dumps(data, separators=(",", ":"), ensure_ascii=False)
resp = self._request_with_retry("POST", url, headers=headers, content=body)
return self._handle_response(resp)