refactor(结构): 分层重组 + 清除全部废弃路线
做了什么:
- 包改为分层: tcaptcha/{protocol,telemetry,solvers,ocr,adapters,models,tests}
* protocol/ client.py transport.py proxy.py
* telemetry/ crypto.py values.py key.py opcodes.py
disasm.py program.py layout.py modules.py
* solvers/ text_click.py icon_click.py icon_click_rotate.py slider.py
* ocr/ engines.py slicer.py
* adapters/ one_ms.py
- tools/ 里原本是**包运行时依赖**的 tdc_{disasm,program,layout,modules}.py 并入 telemetry/
(放在 tools/ 属于分层倒置),tools/ 只留 CLI: tdc_key_pure.py / tdc_profile.py / cdp_*.js
- crypto.py 529 -> 340 行: 切除「成功样本模板回填」与「Node 插桩兜底」两条老路;
generate_telemetry 只剩 python_values 一种模式,传别的会直接报错并说明原因
- 求解器改名分清: solver.py -> solvers/icon_click.py(client 实际在用的 YOLO+ResNet18 ONNX),
icon_click_solver.py -> solvers/icon_click_rotate.py(仅测试在用,留作对照)
- pyproject: 去掉 ultralytics/onnx(训练栈已删),补上真正在用的 onnxruntime
删除了什么:
- 雨云整条线 + dist/rainyun_qinglong(104M 旧快照)
- 模板回填: models/success_collect_template.b64、tdc_worker.js、analyze_collect_layout.py
- 「补环境」时代浏览器 JS: chrome_fetch/manual_captcha/probe_captcha/solve_in_browser/tdc_browser
- 旧 VM 探索工具 10 个(tdc_collectgen/tdc_slotmap/tdc_lift/tdc_vm_run/tdc_query/tdc_probe 等)
+ 8 个基于它们的旧 verify 脚本
- 训练/标注/评估: train_classifier/train_detector/prepare_yolo/web_labeler/collector/
siamese/evaluate/benchmark_verify
- tcaptcha/core_js/(60 个分析产物)、TOOLCHAIN_REPORT.md(整篇在描述已删工具)
- 3 个已无任何引用的 PyTorch 权重:icon_classifier.pt 43M / yolo_detector.pt 5.9M /
siamese_matcher.pt 1.6M —— 线上只跑 onnx,tcaptcha/ 117M -> 66M
为什么:
- 项目后面要专门承载「滑块 / 文字点选 / 图标点选」三种求解器;扁平包 + 运行时依赖混在
tools/ + 两条已废弃的 collect 生成路线,会让后续扩展持续踩坑
怎么验证:
- .venv/bin/python -m tcaptcha.selftest 全绿
- scripts/verify/verify_offline.py 逐槽全等 + 分段几何一致
- scripts/verify/verify_values.py 4 份真机明文 152/152 逐槽全等
- scripts/verify/send_test.py --mode python_values 1ms 签到页文字点选 errorCode=0
- 回滚点: tag backup/pre-refactor-20261012
This commit is contained in:
@@ -0,0 +1,8 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""协议层:与验证码服务端交互的一切(不含视觉求解)。
|
||||
|
||||
client.py TencentCaptchaClient —— prehandle / PoW / tdc.js / 题目图片 /
|
||||
getsig / cap_union_new_verify
|
||||
transport.py httpx 与 curl_cffi 两种传输实现(传输指纹可选)
|
||||
proxy.py 代理池获取与连通性检查
|
||||
"""
|
||||
@@ -0,0 +1,671 @@
|
||||
"""
|
||||
腾讯天御点选验证码纯协议客户端 (Tencent Turing Captcha Protocol Client)
|
||||
|
||||
覆盖腾讯天御 `cap_union_new` 协议族的完整纯协议闭环:
|
||||
|
||||
1. GET /cap_union_prehandle 获取会话 sess / PoW 题目 / 题型 / 首道题目
|
||||
2. 本地求解 PoW (MD5 哈希碰撞)
|
||||
3. GET <tdc_path> 拉取当前会话动态混淆的 tdc.js, 提取会话密钥 eks
|
||||
4. GET /cap_union_new_getcapbysig 下载 672x480 背景图 (img_index=1);
|
||||
图标题型额外下载 img_index=0 提示图
|
||||
5. 视觉求解:
|
||||
* 文字点选 (subcapclass 2404) -> tcaptcha.glyph_solver.TextClickSolver
|
||||
* 图标点选 (subcapclass 2408) -> tcaptcha.solver.CaptchaSolver (YOLO+ResNet18)
|
||||
6. 生成行为遥测密文 collect (纯 Python 还原的自定义 XTEA + 贝塞尔轨迹)
|
||||
7. POST /cap_union_new_verify 提交 ans/collect/eks/sess/pow_answer
|
||||
8. 失败 → POST /cap_union_new_getsig 在同一会话内刷新下一道题并重试
|
||||
|
||||
题型识别不依赖 subcapclass 数字 (getsig 不返回该字段), 而是根据
|
||||
instruction + bg_elem_cfg.click_cfg 推断, 详见 classify_challenge()。
|
||||
"""
|
||||
|
||||
import base64
|
||||
import hashlib
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import re
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
import httpx
|
||||
from urllib.parse import urlsplit
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
if str(PROJECT_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(PROJECT_ROOT))
|
||||
|
||||
from tcaptcha.telemetry.crypto import extract_eks, generate_telemetry
|
||||
from tcaptcha.solvers.text_click import TextClickSolver, parse_instruction
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format="%(asctime)s [%(levelname)s] %(message)s")
|
||||
logger = logging.getLogger("tcaptcha_client")
|
||||
|
||||
DEFAULT_UA = ("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
|
||||
"(KHTML, like Gecko) Chrome/155.0.0.0 Safari/537.36")
|
||||
|
||||
# 题型
|
||||
KIND_TEXT_CLICK = "text_click" # 2404 文字点选
|
||||
KIND_ICON_CLICK = "icon_click" # 2408 图标点选 (需要提示图 img_index=0)
|
||||
KIND_SLIDER = "slider" # 2402 滑块拼图 (本客户端未实现)
|
||||
KIND_UNKNOWN = "unknown"
|
||||
|
||||
|
||||
@dataclass
|
||||
class Challenge:
|
||||
"""一道待求解的验证码题目"""
|
||||
|
||||
sess: str
|
||||
instruction: str = ""
|
||||
img_url: str = ""
|
||||
kind: str = KIND_UNKNOWN
|
||||
subcapclass: str = ""
|
||||
click_cfg: Optional[dict] = None
|
||||
raw: dict = field(default_factory=dict)
|
||||
|
||||
@property
|
||||
def targets(self) -> List[str]:
|
||||
return parse_instruction(self.instruction)
|
||||
|
||||
|
||||
def classify_challenge(dyn_show_info: dict, subcapclass: str = "") -> str:
|
||||
"""根据题目配置推断题型 (getsig 不返回 subcapclass, 只能靠结构判断)"""
|
||||
instruction = (dyn_show_info or {}).get("instruction", "") or ""
|
||||
click_cfg = ((dyn_show_info or {}).get("bg_elem_cfg") or {}).get("click_cfg") or {}
|
||||
data_types = click_cfg.get("data_type") or []
|
||||
if "DynAnswerType_POS" in data_types or "DynAnswerType_POINT" in data_types:
|
||||
# 有点选坐标配置: 有目标文字 => 文字点选; 无目标文字 => 图标点选(提示走提示图)
|
||||
return KIND_TEXT_CLICK if parse_instruction(instruction) else KIND_ICON_CLICK
|
||||
if "滑块" in instruction or "拖动" in instruction or subcapclass == "2402":
|
||||
return KIND_SLIDER
|
||||
if subcapclass == "2404":
|
||||
return KIND_TEXT_CLICK
|
||||
if subcapclass == "2408":
|
||||
return KIND_ICON_CLICK
|
||||
return KIND_UNKNOWN
|
||||
|
||||
|
||||
def _site_origin(url: str) -> str:
|
||||
"""取 `scheme://host[:port]/` —— 页内 <script>/<img> 资源的 Referer 是 site-only。"""
|
||||
try:
|
||||
parts = urlsplit(url)
|
||||
if parts.scheme and parts.netloc:
|
||||
return f"{parts.scheme}://{parts.netloc}/"
|
||||
except Exception: # pragma: no cover
|
||||
pass
|
||||
return url
|
||||
|
||||
|
||||
def _build_transport(transport: str, *, base_url: str, headers: Dict[str, str], timeout: float,
|
||||
proxy: Optional[str], impersonate: Optional[str]):
|
||||
"""
|
||||
构造 HTTP 传输层。
|
||||
|
||||
* transport="httpx" (默认): 原行为 (HTTP/1.1 + OpenSSL)
|
||||
* transport="curl" : curl_cffi 复刻 Chrome 网络指纹 (JA3/JA4 + HTTP/2 + 头序)
|
||||
|
||||
背景: 同一 aid / 同一 cookie / 同一参数下, 真实浏览器从不吃 errorCode=12, 而 httpx 会 ——
|
||||
12 的自变量落在"传输指纹"上 (见 har/1ms_dianxuan/ANALYSIS.md §7.6)。
|
||||
"""
|
||||
transport = (transport or "httpx").lower()
|
||||
if transport == "httpx":
|
||||
return httpx.Client(
|
||||
base_url=base_url,
|
||||
trust_env=False, # 屏蔽环境代理干扰
|
||||
proxy=proxy,
|
||||
headers=headers,
|
||||
timeout=timeout,
|
||||
follow_redirects=True,
|
||||
)
|
||||
if transport == "curl":
|
||||
from tcaptcha.protocol.transport import CurlCffiClient
|
||||
|
||||
return CurlCffiClient(base_url=base_url, headers=headers, timeout=timeout,
|
||||
proxy=proxy, impersonate=impersonate or "chrome")
|
||||
raise ValueError(f"未知 transport: {transport!r} (支持 'httpx' / 'curl')")
|
||||
|
||||
|
||||
class TencentCaptchaClient:
|
||||
BASE_URL = "https://turing.captcha.qcloud.com"
|
||||
DEFAULT_AID = "2039519451"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
aid: str = DEFAULT_AID,
|
||||
solver=None,
|
||||
text_solver: Optional[TextClickSolver] = None,
|
||||
timeout: float = 15.0,
|
||||
user_agent: Optional[str] = None,
|
||||
entry_url: str = "https://app.rainyun.com/account/reward/earn#",
|
||||
referer: Optional[str] = None,
|
||||
wb: str = "2",
|
||||
js: str = "/tcaptcha-frame.ba387dd1.js",
|
||||
accept_kinds: Sequence[str] = (KIND_TEXT_CLICK, KIND_ICON_CLICK),
|
||||
min_posterior: float = 0.95,
|
||||
pow_timeout: int = 4000000,
|
||||
inter_attempt_delay: float = 0.25,
|
||||
telemetry_mode: str = "python_values",
|
||||
proxy: Optional[str] = None,
|
||||
cookies: Optional[dict] = None,
|
||||
transport: str = "httpx",
|
||||
impersonate: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
telemetry_mode 决定 collect/eks 怎么来 (见 tcaptcha.telemetry.crypto.generate_telemetry):
|
||||
|
||||
* "python_values" (默认, 2026-10-12 起): **纯离线**按**本次会话的** tdc.js 生成整颗
|
||||
collect —— 37 个语义名 -> 38 槽取值 -> 4 段明文 -> 4 段加密,
|
||||
零 Node、零真机明文。"必须用本次会话的构建"这一点是实测出来的
|
||||
分水岭: 同一 aid 下它 3/3 拿 0, 而模板回填 2/2 恒 12
|
||||
(har/1ms_dianxuan/PROGRESS.md 已知 #58/#59)。
|
||||
* "python_keyed": 明文来自仓库里的**成功样本模板回填**
|
||||
(generate_collect_success): 槽序来自**另一份构建**、
|
||||
取值来自**另一台设备** -> 1ms aid 上实测恒 12。
|
||||
* "python": 完全不调用 Node, 用内置 DEFAULT_KEY + 同样的模板回填
|
||||
(仅对不校验遥测的宽松 aid 有效)。
|
||||
|
||||
关于 errorCode=12 (**2026-10-12 已收口, 旧结论已推翻**):
|
||||
真因不是"环境/IP/请求方", 而是 **collect 没有用本次会话的那份构建生成**。
|
||||
同 aid / 同客户端 / 同求解器、答案都正确, 只换 collect 构造方式:
|
||||
python_values -> 0 (3/3), python_keyed (旧模板回填) -> 12 (2/2)。
|
||||
见 har/1ms_dianxuan/PROGRESS.md 已知 #58/#59。
|
||||
附带一条方法学: **答错一律 50/9**, 所以任何用"答错"做的对照都没有区分度。
|
||||
|
||||
cookies: 可选, 逐条写入 captcha 域 (turing.captcha.qcloud.com) 的 Cookie 头。
|
||||
成功样本 (har/签到成功-1.har) 的 captcha 域请求带着腾讯云登录 cookie
|
||||
(uin / skey / qcmainCSRFToken / intl), 而本客户端此前完全不带 ——
|
||||
这是账本 §7.5 的 H3 单变量实验入口 (默认 None, 不改变原有行为)。
|
||||
"""
|
||||
self.aid = str(aid)
|
||||
self.timeout = timeout
|
||||
self.user_agent = user_agent or DEFAULT_UA
|
||||
self.entry_url = entry_url
|
||||
self.referer = referer or entry_url
|
||||
self.wb = str(wb)
|
||||
self.js = js
|
||||
self.accept_kinds = tuple(accept_kinds)
|
||||
self.min_posterior = min_posterior
|
||||
self.pow_timeout = pow_timeout
|
||||
# 抽题/投票之间的最小间隔 (降低接口压力; 与 errorCode=12 无因果关系)
|
||||
self.inter_attempt_delay = inter_attempt_delay
|
||||
# 遥测生成方式: 只走纯算 (见 tcaptcha.telemetry.crypto.generate_telemetry)
|
||||
self.telemetry_mode = telemetry_mode
|
||||
# 出口代理 (可选)。注意: 实测换出口 IP 并不能解决 errorCode=12
|
||||
self.proxy = proxy
|
||||
|
||||
# 图标题型求解器 (惰性加载, 避免只跑文字题型时白加载 56MB 权重)
|
||||
self._icon_solver = solver
|
||||
self._text_solver = text_solver
|
||||
self.subsid = 1
|
||||
|
||||
# 可选: 透传 captcha 域的腾讯云登录 cookie (账本 §7.5 的 H3 单变量实验入口)。
|
||||
# 注意: **不做成全局头** —— 真实浏览器只在 prehandle / tdc.js / getcapbysig
|
||||
# (页面资源 GET) 上带它; getsig / verify (跨站 POST, SameSite=Lax) 上**不带**
|
||||
# (har/签到成功-1.har 与 点选.har 实测)。这里保存, 由各调用点按需附加。
|
||||
self._cookie_header = ("; ".join(f"{k}={v}" for k, v in cookies.items())
|
||||
if cookies else None)
|
||||
self.site_origin = _site_origin(self.referer)
|
||||
self._gtimg_referer = "https://turing.captcha.gtimg.com/"
|
||||
|
||||
headers = {
|
||||
"User-Agent": self.user_agent,
|
||||
"Referer": self.referer,
|
||||
"Accept-Language": "zh-CN,zh;q=0.9,fr;q=0.8,de;q=0.7,en;q=0.6",
|
||||
"sec-ch-ua": '"Google Chrome";v="155", "Chromium";v="155", "Not(A:Brand";v="24"',
|
||||
"sec-ch-ua-mobile": "?0",
|
||||
"sec-ch-ua-platform": '"macOS"',
|
||||
"Sec-Fetch-Dest": "empty",
|
||||
"Sec-Fetch-Mode": "cors",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
}
|
||||
|
||||
self.http_client = (
|
||||
_build_transport(transport, base_url=self.BASE_URL, headers=headers,
|
||||
timeout=self.timeout, proxy=self.proxy, impersonate=impersonate)
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------- helpers
|
||||
def _resource_headers(self, dest: str, referer: str) -> Dict[str, str]:
|
||||
"""
|
||||
页面资源 GET (prehandle / tdc.js / getcapbysig) 的**真实浏览器**请求头:
|
||||
`Sec-Fetch-*` = cross-site + no-cors + Storage-Access: active, 且带 captcha 域 cookie。
|
||||
(机器人常用 XHR 语义的 empty/cors 去拉这些资源 —— 这是可测的指纹差异。)
|
||||
"""
|
||||
h = {
|
||||
"Referer": referer,
|
||||
"Accept": "*/*",
|
||||
"Sec-Fetch-Dest": dest,
|
||||
"Sec-Fetch-Mode": "no-cors",
|
||||
"Sec-Fetch-Site": "cross-site",
|
||||
"Sec-Fetch-Storage-Access": "active",
|
||||
}
|
||||
if self._cookie_header:
|
||||
h["Cookie"] = self._cookie_header
|
||||
return h
|
||||
|
||||
@property
|
||||
def text_solver(self) -> TextClickSolver:
|
||||
if self._text_solver is None:
|
||||
logger.info("初始化文字点选求解器 (ddddocr + PP-OCRv4 集成)...")
|
||||
self._text_solver = TextClickSolver()
|
||||
return self._text_solver
|
||||
|
||||
@property
|
||||
def icon_solver(self):
|
||||
if self._icon_solver is None:
|
||||
from tcaptcha.solvers.icon_click import CaptchaSolver
|
||||
|
||||
logger.info("初始化图标点选求解器 (YOLOv8 + ResNet18)...")
|
||||
self._icon_solver = CaptchaSolver()
|
||||
return self._icon_solver
|
||||
|
||||
def _solve_pow(self, prefix: str, target_md5: str) -> Tuple[str, int]:
|
||||
"""求解 Proof of Work 哈希碰撞"""
|
||||
t0 = time.time()
|
||||
for i in range(self.pow_timeout):
|
||||
candidate = f"{prefix}{i}"
|
||||
if hashlib.md5(candidate.encode("utf-8")).hexdigest() == target_md5:
|
||||
calc_time = max(1, round((time.time() - t0) * 1000))
|
||||
return candidate, calc_time
|
||||
raise RuntimeError(f"PoW MD5 collision search failed for prefix {prefix}")
|
||||
|
||||
# ------------------------------------------------------------- 1. prehandle
|
||||
def prehandle(self, tkid: str) -> Tuple[Challenge, dict, str]:
|
||||
"""GET /cap_union_prehandle —— 返回 (题目, comm_captcha_cfg, sid)"""
|
||||
cb_name = f"_aq_{random.randint(100000, 999999)}"
|
||||
ua_b64 = base64.b64encode(self.user_agent.encode()).decode()
|
||||
self.subsid += 2
|
||||
params = {
|
||||
"aid": self.aid,
|
||||
"protocol": "https",
|
||||
"accver": "1",
|
||||
"showtype": "popup",
|
||||
"ua": ua_b64,
|
||||
"noheader": "1",
|
||||
"fb": "1",
|
||||
"aged": "0",
|
||||
"enableAged": "0",
|
||||
"enableDarkMode": "0",
|
||||
"grayscale": "1",
|
||||
"clientype": "2",
|
||||
"cap_cd": "",
|
||||
"uid": "",
|
||||
"lang": "zh-cn",
|
||||
"entry_url": self.entry_url,
|
||||
"elder_captcha": "0",
|
||||
"js": self.js,
|
||||
"login_appid": "",
|
||||
"wb": self.wb,
|
||||
"tkid": tkid,
|
||||
"subsid": str(self.subsid),
|
||||
"callback": cb_name,
|
||||
"sess": "",
|
||||
}
|
||||
resp = self.http_client.get("/cap_union_prehandle", params=params,
|
||||
headers=self._resource_headers("script", self.site_origin))
|
||||
match = re.search(rf"{re.escape(cb_name)}\((.*)\)\s*$", resp.text, re.S)
|
||||
if not match:
|
||||
raise RuntimeError(f"prehandle 回调解析失败: {resp.text[:200]}")
|
||||
data = json.loads(match.group(1))
|
||||
dyn = (data.get("data") or {}).get("dyn_show_info") or {}
|
||||
sub = str(data.get("subcapclass", ""))
|
||||
challenge = Challenge(
|
||||
sess=data.get("sess", ""),
|
||||
instruction=dyn.get("instruction", ""),
|
||||
img_url=((dyn.get("bg_elem_cfg") or {}).get("img_url") or ""),
|
||||
kind=classify_challenge(dyn, sub),
|
||||
subcapclass=sub,
|
||||
click_cfg=(dyn.get("bg_elem_cfg") or {}).get("click_cfg"),
|
||||
raw=dyn,
|
||||
)
|
||||
return challenge, (data.get("data") or {}).get("comm_captcha_cfg", {}) or {}, data.get("sid", "")
|
||||
|
||||
# --------------------------------------------------------------- 2. getsig
|
||||
def getsig(self, sess: str, tkid: str) -> Tuple[Challenge, str]:
|
||||
"""POST /cap_union_new_getsig —— 在同一会话内刷新题目"""
|
||||
resp = self.http_client.post(
|
||||
"/cap_union_new_getsig",
|
||||
data={"sess": sess, "tkid": tkid},
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded",
|
||||
"Origin": "https://turing.captcha.gtimg.com",
|
||||
"Referer": "https://turing.captcha.gtimg.com/"},
|
||||
)
|
||||
payload = resp.json()
|
||||
if str(payload.get("ret")) != "0":
|
||||
raise RuntimeError(f"getsig 返回异常: {payload}")
|
||||
dyn = payload.get("data") or {}
|
||||
new_sess = payload.get("sess") or sess
|
||||
challenge = Challenge(
|
||||
sess=new_sess,
|
||||
instruction=dyn.get("instruction", ""),
|
||||
img_url=((dyn.get("bg_elem_cfg") or {}).get("img_url") or ""),
|
||||
kind=classify_challenge(dyn),
|
||||
click_cfg=(dyn.get("bg_elem_cfg") or {}).get("click_cfg"),
|
||||
raw=dyn,
|
||||
)
|
||||
return challenge, new_sess
|
||||
|
||||
# ------------------------------------------------------------ 3. download
|
||||
def fetch_tdc(self, tdc_path: str) -> str:
|
||||
resp = self.http_client.get(
|
||||
tdc_path, headers=self._resource_headers("script", self._gtimg_referer))
|
||||
return resp.text
|
||||
|
||||
def fetch_image(self, img_url: str, img_index: Optional[int] = None) -> bytes:
|
||||
if img_index is not None:
|
||||
img_url = re.sub(r"img_index=\d+", f"img_index={img_index}", img_url)
|
||||
resp = self.http_client.get(
|
||||
img_url,
|
||||
headers={**self._resource_headers("image", self._gtimg_referer),
|
||||
"Accept": "image/jxl,image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"},
|
||||
)
|
||||
return resp.content
|
||||
|
||||
# ------------------------------------------------------------------ solve
|
||||
def solve(self, max_retries: int = 3, max_refreshes: int = 6) -> Dict:
|
||||
"""
|
||||
端到端解题并提交验证。
|
||||
|
||||
返回: {success, ticket, randstr, elapsed_ms, points, kind, instruction,
|
||||
raw_response, error}
|
||||
"""
|
||||
start_time = time.time()
|
||||
tkid = str(random.randint(100000000, 999999999))
|
||||
sess: Optional[str] = None
|
||||
eks: Optional[str] = None
|
||||
tdc_code: Optional[str] = None
|
||||
pow_answer: Optional[str] = None
|
||||
pow_calc_time: Optional[int] = None
|
||||
comm_cfg: Dict = {}
|
||||
challenge: Optional[Challenge] = None
|
||||
last_raw: Dict = {}
|
||||
last_error = "未完成任何一次验证提交"
|
||||
|
||||
attempts = 0
|
||||
refreshes = 0
|
||||
prehandles = 0
|
||||
while attempts < max_retries and refreshes < max_refreshes:
|
||||
try:
|
||||
# ---- 拿题: 首选 prehandle, 之后用 getsig 在同会话内刷新
|
||||
round_start_time = time.time()
|
||||
if challenge is None:
|
||||
if sess:
|
||||
challenge, sess = self.getsig(sess, tkid)
|
||||
logger.info("getsig 刷新题目 -> kind=%s instruction=%r",
|
||||
challenge.kind, challenge.instruction)
|
||||
else:
|
||||
challenge, comm_cfg, _sid = self.prehandle(tkid)
|
||||
prehandles += 1
|
||||
logger.info("prehandle -> subcapclass=%s kind=%s instruction=%r",
|
||||
challenge.subcapclass, challenge.kind, challenge.instruction)
|
||||
pow_cfg = comm_cfg.get("pow_cfg") or {}
|
||||
if pow_cfg.get("prefix") and pow_cfg.get("md5"):
|
||||
pow_answer, pow_calc_time = self._solve_pow(pow_cfg["prefix"],
|
||||
pow_cfg["md5"])
|
||||
logger.info("PoW 命中 %s (%dms)", pow_answer, pow_calc_time)
|
||||
tdc_code = self.fetch_tdc(comm_cfg.get("tdc_path", ""))
|
||||
eks = extract_eks(tdc_code)
|
||||
if not eks:
|
||||
logger.warning("未从 tdc.js 中提取到 eks, 继续尝试")
|
||||
|
||||
# 无论是预期的还是非预期题型,第一时间下载原图落盘到系统临时目录,辅助用户肉眼判断。
|
||||
# 注意: 别再写回仓库内的 scratch/ —— 该目录已清理且不入库(.gitignore)。
|
||||
bg_bytes: Optional[bytes] = None
|
||||
debug_dir = Path(tempfile.gettempdir()) / "captcha_debug"
|
||||
debug_dir.mkdir(parents=True, exist_ok=True)
|
||||
ts = time.strftime("%Y%m%d_%H%M%S")
|
||||
safe_inst = re.sub(r"[^\w\u4e00-\u9fff]+", "_", challenge.instruction).strip("_") or "no_inst"
|
||||
|
||||
if challenge.img_url:
|
||||
try:
|
||||
bg_bytes = self.fetch_image(challenge.img_url, img_index=1)
|
||||
raw_path = debug_dir / f"{ts}_{challenge.kind}_{safe_inst}.jpg"
|
||||
raw_path.write_bytes(bg_bytes)
|
||||
logger.info("📸 [本地图片落盘] %s (题型: %s, 指令: %r)", raw_path, challenge.kind, challenge.instruction)
|
||||
except Exception as e:
|
||||
logger.warning("下载题目原图失败: %s", e)
|
||||
|
||||
if challenge.kind not in self.accept_kinds:
|
||||
# 题型在会话内是固定的 (getsig 不会换题型), 必须重新 prehandle 抽题
|
||||
# 严格模拟人类审题、发现不是目标题型、移动鼠标并点击换题的交互耗时 (2.8 ~ 4.2 秒,拒绝秒换)
|
||||
wait_switch = random.uniform(2.8, 4.2)
|
||||
logger.info("⏳ 题型 %s 不在支持范围 %s, 模拟人类思考与换题等待 %.2fs (已抽 %d 次)...",
|
||||
challenge.kind, list(self.accept_kinds), wait_switch, prehandles)
|
||||
refreshes += 1
|
||||
challenge = None
|
||||
sess = None
|
||||
eks = None
|
||||
pow_answer = None
|
||||
time.sleep(wait_switch)
|
||||
continue
|
||||
|
||||
# ---- 视觉求解
|
||||
points: List[Tuple[int, int]]
|
||||
if challenge.kind == KIND_TEXT_CLICK:
|
||||
bg = bg_bytes if bg_bytes else self.fetch_image(challenge.img_url, img_index=1)
|
||||
|
||||
result = self.text_solver.solve_ex(bg, challenge.instruction)
|
||||
logger.info("文字点选求解: %s", result.describe())
|
||||
|
||||
# 如果识别出坐标,生成带红圈和序号 (1, 2, 3) 的标注图便于肉眼核验
|
||||
if result.points:
|
||||
try:
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
bgr = cv2.imdecode(np.frombuffer(bg, np.uint8), cv2.IMREAD_COLOR)
|
||||
for idx, pt in enumerate(result.points):
|
||||
cv2.circle(bgr, (int(pt[0]), int(pt[1])), 18, (0, 0, 255), 2)
|
||||
cv2.putText(
|
||||
bgr,
|
||||
str(idx + 1),
|
||||
(int(pt[0]) - 6, int(pt[1]) + 7),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.7,
|
||||
(0, 255, 0),
|
||||
2,
|
||||
)
|
||||
anno_path = debug_dir / f"{ts}_{challenge.kind}_{safe_inst}_annotated.jpg"
|
||||
cv2.imwrite(str(anno_path), bgr)
|
||||
logger.info("📸 [标注图已落盘] %s", anno_path)
|
||||
except Exception as e:
|
||||
logger.warning("生成标注图失败: %s", e)
|
||||
|
||||
if not result.ok:
|
||||
wait_refresh = random.uniform(2.5, 3.8)
|
||||
logger.warning("文字点选求解失败(未识别全目标字), 模拟人类找字无果换题等待 %.2fs", wait_refresh)
|
||||
refreshes += 1
|
||||
challenge = None
|
||||
time.sleep(wait_refresh)
|
||||
continue
|
||||
if result.posterior < self.min_posterior or result.min_target_score < 0.2:
|
||||
wait_refresh = random.uniform(2.5, 3.8)
|
||||
logger.warning("求解置信度过低 (posterior=%.3f, min_score=%.3f), 模拟犹豫换题等待 %.2fs",
|
||||
result.posterior, result.min_target_score, wait_refresh)
|
||||
refreshes += 1
|
||||
challenge = None
|
||||
time.sleep(wait_refresh)
|
||||
continue
|
||||
points = result.points
|
||||
else: # KIND_ICON_CLICK
|
||||
bg = bg_bytes if bg_bytes else self.fetch_image(challenge.img_url, img_index=1)
|
||||
prompt = self.fetch_image(challenge.img_url, img_index=0)
|
||||
if prompt:
|
||||
prompt_path = debug_dir / f"{ts}_icon_prompt_{safe_inst}.jpg"
|
||||
prompt_path.write_bytes(prompt)
|
||||
logger.info("📸 [提示图落盘] %s", prompt_path)
|
||||
else:
|
||||
wait_refresh = random.uniform(2.5, 3.8)
|
||||
logger.warning("图标题型未取到提示图, 等待 %.2fs 后刷新题目", wait_refresh)
|
||||
refreshes += 1
|
||||
time.sleep(wait_refresh)
|
||||
continue
|
||||
points = self.icon_solver.solve(bg, prompt)
|
||||
if len(points) < 3:
|
||||
wait_refresh = random.uniform(2.5, 3.8)
|
||||
logger.warning("图标点选求解失败, 等待 %.2fs 后刷新题目", wait_refresh)
|
||||
refreshes += 1
|
||||
time.sleep(wait_refresh)
|
||||
continue
|
||||
|
||||
# ---- 模拟真实人类阅读审题、定位字形与逐个点击耗时 (5.5 ~ 7.5 秒,对齐抓包实测 7.41s)
|
||||
elapsed = time.time() - round_start_time
|
||||
target_duration = random.uniform(5.5, 7.5)
|
||||
if elapsed < target_duration:
|
||||
sleep_secs = target_duration - elapsed
|
||||
logger.info("模拟人类阅读审题与点击耗时: 等待 %.2fs (目标交互时长 %.2fs)", sleep_secs, target_duration)
|
||||
time.sleep(sleep_secs)
|
||||
|
||||
# ---- 组包并提交
|
||||
attempts += 1
|
||||
ans_list = [{"elem_id": i + 1, "type": "DynAnswerType_POS",
|
||||
"data": f"{p[0]},{p[1]}"} for i, p in enumerate(points)]
|
||||
ans_str = json.dumps(ans_list, separators=(",", ":"))
|
||||
telemetry = generate_telemetry(
|
||||
tdc_code or "", points, aid=self.aid, user_agent=self.user_agent,
|
||||
referrer=self.referer, mode=self.telemetry_mode,
|
||||
)
|
||||
logger.info("遥测密文来源=%s collect_len=%s eks_len=%s",
|
||||
telemetry.get("source"), len(telemetry["collect"]),
|
||||
len(telemetry.get("eks", "")))
|
||||
verify_body = {
|
||||
"ans": ans_str,
|
||||
"collect": telemetry["collect"],
|
||||
"tlg": telemetry["tlg"],
|
||||
"eks": telemetry.get("eks") or eks or "",
|
||||
"sess": challenge.sess,
|
||||
"tkid": tkid,
|
||||
"pow_answer": pow_answer or "",
|
||||
"pow_calc_time": str(pow_calc_time or 1),
|
||||
}
|
||||
verify_resp = self.http_client.post(
|
||||
"/cap_union_new_verify",
|
||||
data=verify_body,
|
||||
headers={
|
||||
"Content-Type": "application/x-www-form-urlencoded; charset=UTF-8",
|
||||
"Accept": "application/json, text/javascript, */*; q=0.01",
|
||||
"Origin": "https://turing.captcha.gtimg.com",
|
||||
"Referer": "https://turing.captcha.gtimg.com/",
|
||||
},
|
||||
)
|
||||
v_json = verify_resp.json()
|
||||
last_raw = v_json
|
||||
elapsed = int((time.time() - start_time) * 1000)
|
||||
|
||||
if str(v_json.get("errorCode")) == "0":
|
||||
logger.info("✅ 验证通过! randstr=%s", v_json.get("randstr"))
|
||||
return {
|
||||
"success": True,
|
||||
"ticket": v_json.get("ticket", ""),
|
||||
"randstr": v_json.get("randstr", ""),
|
||||
"elapsed_ms": elapsed,
|
||||
"points": points,
|
||||
"kind": challenge.kind,
|
||||
"instruction": challenge.instruction,
|
||||
"raw_response": v_json,
|
||||
"error": "",
|
||||
}
|
||||
|
||||
err_code = str(v_json.get("errorCode"))
|
||||
if err_code == "12":
|
||||
last_error = ("服务端返回 errorCode=12 (腾讯天御风控评估未通过)。"
|
||||
"实测与遥测后端 / 出口 IP / 静默时长均无关 "
|
||||
"(见 har/1ms_dianxuan/ANALYSIS.md §7), 不要再尝试换环境或换 IP")
|
||||
logger.error(last_error)
|
||||
break
|
||||
if err_code in ("50", "9"):
|
||||
last_error = f"坐标未命中 (errorCode: {err_code})"
|
||||
else:
|
||||
last_error = (f"验证失败 (errorCode: {err_code}, "
|
||||
f"msg: {v_json.get('errMessage', '')})")
|
||||
logger.warning(last_error)
|
||||
|
||||
# 服务端返回新 sess 时沿用, 否则回到 prehandle
|
||||
new_sess = v_json.get("sess")
|
||||
if new_sess:
|
||||
sess = new_sess
|
||||
challenge = None
|
||||
else:
|
||||
challenge = None
|
||||
sess = None
|
||||
eks = None
|
||||
pow_answer = None
|
||||
except Exception as exc: # 网络/解析异常: 丢弃当前会话重来
|
||||
last_error = f"请求异常: {exc}"
|
||||
logger.error("第 %d 次尝试异常: %s", attempts + 1, exc)
|
||||
challenge = None
|
||||
sess = None
|
||||
eks = None
|
||||
pow_answer = None
|
||||
|
||||
elapsed = int((time.time() - start_time) * 1000)
|
||||
return {
|
||||
"success": False,
|
||||
"ticket": "",
|
||||
"randstr": "",
|
||||
"elapsed_ms": elapsed,
|
||||
"points": [],
|
||||
"kind": challenge.kind if challenge else "",
|
||||
"instruction": challenge.instruction if challenge else "",
|
||||
"raw_response": last_raw,
|
||||
"error": last_error,
|
||||
}
|
||||
|
||||
def close(self):
|
||||
self.http_client.close()
|
||||
|
||||
|
||||
def main():
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Tencent Captcha Pure Protocol Solver Client")
|
||||
parser.add_argument("--aid", default="195672344", help="验证码 AppID (默认毫秒 1ms.run)")
|
||||
parser.add_argument("--entry-url",
|
||||
default="https://1ms.run/console/personal/benefits/check-in")
|
||||
parser.add_argument("--wb", default="1")
|
||||
parser.add_argument("--retries", type=int, default=3)
|
||||
parser.add_argument("--telemetry", default="python_keyed",
|
||||
choices=["python_keyed", "python"],
|
||||
help="遥测方式: python_keyed(Node 仅提密钥) / python(纯 Python 固定密钥)")
|
||||
parser.add_argument("--proxy", default=None, help="出口代理, 如 http://1.2.3.4:8080")
|
||||
parser.add_argument("--proxy-api", default=None,
|
||||
help="代理提取 API (可选; 注意换 IP 并不能解决 errorCode=12)")
|
||||
args = parser.parse_args()
|
||||
|
||||
proxy = args.proxy
|
||||
if not proxy and args.proxy_api:
|
||||
from tcaptcha.protocol.proxy import fetch_proxy
|
||||
|
||||
proxy = fetch_proxy(args.proxy_api)
|
||||
print(f"[*] 从代理 API 提取到: {proxy}")
|
||||
|
||||
client = TencentCaptchaClient(aid=args.aid, entry_url=args.entry_url,
|
||||
referer="https://1ms.run/", wb=args.wb,
|
||||
proxy=proxy, telemetry_mode=args.telemetry)
|
||||
print(f"[*] 正在发起腾讯天御验证码纯协议识别 (AppID: {args.aid}, proxy={proxy})...")
|
||||
result = client.solve(max_retries=args.retries)
|
||||
|
||||
# 注意: errorCode=12 实测与出口 IP 无关 (ANALYSIS.md §7), 这里不再自动换 IP。
|
||||
# 如需指定出口 IP, 请在启动时用 --proxy / --proxy-api。
|
||||
|
||||
if result["success"]:
|
||||
print("\n" + "=" * 50)
|
||||
print(" 🎉 验证码纯协议求解成功!")
|
||||
print("=" * 50)
|
||||
print(f"耗时: {result['elapsed_ms']} ms")
|
||||
print(f"题型: {result['kind']} 题目: {result['instruction']!r}")
|
||||
print(f"点击坐标: {result['points']}")
|
||||
print(f"randstr: {result['randstr']}")
|
||||
print(f"ticket: {result['ticket']}")
|
||||
print("=" * 50)
|
||||
else:
|
||||
print(f"\n❌ 验证失败: {result['error']}")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,101 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
"""
|
||||
代理 IP 提取与格式化 (用于绕过腾讯天御的 IP 级滚动风控)
|
||||
|
||||
腾讯验证码按出口 IP 做滚动窗口风控, 触发时会返回 `errorCode=12` (f_limit)。
|
||||
本模块从常见的「代理 IP 提取 API」取一个可用代理, 供 `TencentCaptchaClient(proxy=...)`
|
||||
与 `OneMsAdapter(proxy=...)` 使用。
|
||||
|
||||
已适配的返回格式:
|
||||
* JSON: {"status":100,"data":[{"ip":"1.2.3.4","port":"8080"}]} (讯客 xkdaili 等)
|
||||
* JSON: {"data":{"ip":"1.2.3.4","port":8080}} / [{"ip":..,"port":..}]
|
||||
* 纯文本: 一行一个 "1.2.3.4:8080" 或 "1.2.3.4,8080"
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
_IP_PORT_RE = re.compile(r"(\d{1,3}(?:\.\d{1,3}){3})\s*[:,]\s*(\d{1,5})")
|
||||
|
||||
|
||||
def format_proxy(ip: str, port, scheme: str = "http") -> str:
|
||||
return f"{scheme}://{ip}:{port}"
|
||||
|
||||
|
||||
def parse_proxies(payload: str, scheme: str = "http") -> List[str]:
|
||||
"""从 API 返回体里解析出全部代理 (兼容 JSON 与纯文本两种格式)"""
|
||||
out: List[str] = []
|
||||
text = (payload or "").strip()
|
||||
if not text:
|
||||
return out
|
||||
|
||||
try:
|
||||
import json
|
||||
|
||||
data = json.loads(text)
|
||||
|
||||
def walk(node):
|
||||
if isinstance(node, dict):
|
||||
ip = node.get("ip") or node.get("host") or node.get("server")
|
||||
port = node.get("port")
|
||||
if ip and port:
|
||||
out.append(format_proxy(str(ip), port, scheme))
|
||||
for key in ("data", "result", "proxy", "list", "proxies"):
|
||||
if key in node:
|
||||
walk(node[key])
|
||||
elif isinstance(node, list):
|
||||
for item in node:
|
||||
walk(item)
|
||||
|
||||
walk(data)
|
||||
if out:
|
||||
return out
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for ip, port in _IP_PORT_RE.findall(text):
|
||||
out.append(format_proxy(ip, port, scheme))
|
||||
return out
|
||||
|
||||
|
||||
def fetch_proxy(api_url: str, scheme: str = "http", timeout: float = 15.0) -> Optional[str]:
|
||||
"""调用代理提取 API 并返回第一个可用代理字符串, 例如 http://1.2.3.4:8080"""
|
||||
import logging
|
||||
_log = logging.getLogger("proxy")
|
||||
resp = httpx.get(api_url, timeout=timeout, trust_env=False, follow_redirects=True)
|
||||
proxies = parse_proxies(resp.text, scheme=scheme)
|
||||
if not proxies:
|
||||
try:
|
||||
import json
|
||||
err = json.loads(resp.text)
|
||||
if "info" in err or "message" in err:
|
||||
_log.error("代理 API 返回失败: %s", err.get("info") or err.get("message"))
|
||||
except Exception:
|
||||
pass
|
||||
return proxies[0] if proxies else None
|
||||
|
||||
|
||||
def check_proxy(proxy: str, url: str = "https://turing.captcha.qcloud.com/",
|
||||
timeout: float = 20.0) -> bool:
|
||||
"""探测代理是否可用 (能连上目标域名即可, 不判断业务状态码)"""
|
||||
try:
|
||||
httpx.get(url, proxy=proxy, timeout=timeout, trust_env=False,
|
||||
follow_redirects=True, headers={"User-Agent": "Mozilla/5.0"})
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
ap = argparse.ArgumentParser(description="从代理 API 取一个可用代理")
|
||||
ap.add_argument("api_url")
|
||||
ap.add_argument("--scheme", default="http")
|
||||
args = ap.parse_args()
|
||||
proxy = fetch_proxy(args.api_url, scheme=args.scheme)
|
||||
print(proxy or "未取到代理")
|
||||
@@ -0,0 +1,78 @@
|
||||
# -*- 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
|
||||
Reference in New Issue
Block a user