# -*- coding: utf-8 -*- """ HTTP 传输层可选实现 —— 为 `TencentCaptchaClient` 提供"浏览器级"传输指纹。 背景 (2026-10-11 实测, 见 har/1ms_dianxuan/ANALYSIS.md §7.6): 同一 aid (1ms 195672344)、同一套腾讯云登录 cookie、同一 prehandle 参数下, **真实浏览器**对 cap_union_new_verify 只会得到 50 / 9 / 0 (从不 12); 而 httpx 客户端(HTTP/1.1 + OpenSSL, 头序与浏览器不同) 永远得到 12。 即 12 的自变量不在"载荷内容"(答案已对、密钥已用真机密钥), 而在"谁在发这个 HTTP"。 本模块用 curl_cffi 复刻 Chrome 的网络指纹(**JA3/JA4 + HTTP/2 + 头序 + 头大小写**), 对上层暴露与 `httpx.Client` 相同的最小接口 (get / post / close), 便于单变量对照。 用法:: TencentCaptchaClient(..., transport="curl", impersonate="chrome") 依赖: `curl_cffi` (uv add curl_cffi)。 """ from __future__ import annotations import logging from typing import Any, Dict, Optional logger = logging.getLogger("tcaptcha_http") try: # 依赖可选: 未安装时只影响 transport="curl" from curl_cffi import requests as _cr except Exception: # pragma: no cover _cr = None class CurlCffiClient: """`httpx.Client` 的最小兼容壳 —— 走 curl_cffi 的浏览器指纹。""" def __init__(self, base_url: str, headers: Dict[str, str], timeout: float = 15.0, proxy: Optional[str] = None, impersonate: str = "chrome", follow_redirects: bool = True) -> None: if _cr is None: raise RuntimeError("curl_cffi 未安装: 先执行 `uv add curl_cffi`") self._base = base_url.rstrip("/") self._headers = {k: v for k, v in headers.items() if v is not None} self._timeout = timeout self._proxy = proxy self._follow = follow_redirects self._session = _cr.Session(impersonate=impersonate) logger.debug("CurlCffiClient 就绪 (impersonate=%s)", impersonate) # ------------------------------------------------------------------ 兼容面 def _url(self, path: str) -> str: return path if str(path).startswith("http") else self._base + str(path) def _proxy_kw(self) -> Dict[str, Any]: if not self._proxy: return {} return {"proxies": {"http": self._proxy, "https": self._proxy}} def _merge(self, extra: Optional[Dict[str, str]]) -> Dict[str, str]: out = dict(self._headers) if extra: out.update({k: v for k, v in extra.items() if v is not None}) return out def get(self, path: str, **kw: Any): headers = self._merge(kw.pop("headers", None)) return self._session.get(self._url(path), headers=headers, timeout=self._timeout, allow_redirects=self._follow, **self._proxy_kw(), **kw) def post(self, path: str, **kw: Any): headers = self._merge(kw.pop("headers", None)) return self._session.post(self._url(path), headers=headers, timeout=self._timeout, allow_redirects=self._follow, **self._proxy_kw(), **kw) def close(self) -> None: try: self._session.close() except Exception: # pragma: no cover pass