做了什么
- 新增 tcaptcha/http_transport.py: curl_cffi(Chrome) 传输层壳, 对上层暴露 httpx.Client 同款
get/post/close; TencentCaptchaClient 增加 transport="httpx"|"curl" + impersonate 参数。
- client.py 按真实抓包(har/签到成功-1.har)逐 endpoint 保真请求头:
* prehandle/tdc.js/getcapbysig: Sec-Fetch-Dest=script/image + Mode=no-cors +
Storage-Access=active + captcha 域 cookie, Referer 为 site-origin / gtimg;
* getsig/verify: empty/cors + Origin gtimg, 且 **不带** cookie (SameSite=Lax 实测如此)。
cookie 由全局头改为按调用点附加 (此前 verify 也带, 与真实浏览器不符)。
- 新增 scripts/experiment_transport_ab.py: 只改传输层的单变量 A/B。
为什么 (AGENTS §2.1 / §5.7)
- 抓包里 1ms 的真机成功样本(errorCode=0)与真机失败样本(50/50/9)证明: 12 是"请求方身份"判定,
与答案/载荷无关 —— 需要把客户端与真机的每一处可测差异逐条单变量掉。
怎么验证 (2026-10-11 实测, 全部仍 12)
- 真实 cookie 值 (uin/skey/qcmainCSRFToken/intl) -> 12 (H3 用真值再次排除)
- curl_cffi impersonate=chrome (JA3/JA4 + HTTP/2 + 头序) + 真实 cookie -> 12 (传输层排除)
- curl_cffi + 逐 endpoint 头保真 (Sec-Fetch-*/cookie 位置) -> 12 (头保真排除)
- 我们的 collect 明文与 1ms 真实成功样本逐字节同构 (同 1353B/同字段序/同取值) -> 12 (内容排除)
- 反向: 用纯 Python 提取的密钥成功解密 1ms 真实成功样本的 collect -> 密钥链路对真机样本成立
- .venv/bin/python -m tcaptcha.selftest 全绿
账本 har/1ms_dianxuan/PROGRESS.md: 已知 #23~#25; 已排除表新增四行; 下一步改为 (a) 真机桥接保底 /
(b) identity_context 研究项。
79 lines
3.3 KiB
Python
79 lines
3.3 KiB
Python
# -*- 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
|