Files
hello_js_reverse_skill/tcaptcha/protocol/client.py
T
yml2213 74a863c8b3 feat(icon): 轨迹时序指纹修复 —— 1ms 图标 4/8→7/8=0,errorCode 语义与轨迹对拍收口
- 真因:dt 分布与真机严重不符(旧 50% 落 0~16ms,真机 79% 落 13~20ms)+ 点数
  随机 57~65(真机 6/6 恒 61)。按 6 条真机轨迹重拟合 _sample_dt,新增 _fit_points
  钉死 61 点(补齐用 [0,0,dt] 驻留点,不能复制位移点——净位移会被放大)。
- A/B:1ms 图标 0×4→0×7(前 7 连 0);残余偶发 12 为连发频控形态,低频使用即可。
- errorCode 语义标定:50=答案被否(容差实测 25~40px,我们点偏仅 2~3px),
  12=包级拒答;判点位只看 50/0。
- 顺带修 build_plain:轨迹落末槽(k=37)时 chunk2 恒补前导逗号 → 非法 JSON。
- 新增 scripts/verify/send_campaign.py(批量真发+落图+统计)与 judge_icons.py
  (提示目标 vs 点击点并排判读);账本 #64~#74 + 专文 ICON2408.md。

复验:selftest 全绿;判读图 24/24 点位全对;mliev 图标 9/10=0。
2026-10-12 14:32:14 +08:00

740 lines
35 KiB
Python

"""
腾讯天御点选验证码纯协议客户端 (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.telemetry.values import drag_trajectory_points
from tcaptcha.solvers.slider import SliderChallenge, SliderSolver, piece_from_sprite
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, KIND_SLIDER),
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._slider_solver: Optional[SliderSolver] = None
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
@property
def slider_solver(self) -> SliderSolver:
if self._slider_solver is None:
self._slider_solver = SliderSolver()
return self._slider_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]] = []
slider_ch: Optional[SliderChallenge] = None
drag_traj: Optional[List[List[int]]] = None
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
elif challenge.kind == 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
else: # KIND_SLIDER (2401/2402)
import cv2
import numpy as np
try:
slider_ch = SliderChallenge.from_challenge(challenge.raw or {})
except ValueError as exc:
wait_refresh = random.uniform(2.5, 3.8)
logger.warning("滑块课题配置解析失败 (%s), 等待 %.2fs 后刷新", exc, wait_refresh)
refreshes += 1
challenge = None
time.sleep(wait_refresh)
continue
bg = bg_bytes if bg_bytes else self.fetch_image(slider_ch.bg_url, img_index=1)
sprite = cv2.imdecode(
np.frombuffer(self.fetch_image(slider_ch.sprite_url, img_index=0), np.uint8),
cv2.IMREAD_UNCHANGED)
if bg is None or sprite is None:
wait_refresh = random.uniform(2.5, 3.8)
logger.warning("滑块图片拉取失败, 等待 %.2fs 后刷新题目", wait_refresh)
refreshes += 1
challenge = None
time.sleep(wait_refresh)
continue
# 落盘一张底图便于人工核对缺口定位
(debug_dir / f"{ts}_slider_bg.jpg").write_bytes(bg)
crop = piece_from_sprite(sprite, slider_ch.sprite_rect)
(debug_dir / f"{ts}_slider_piece.png").write_bytes(
cv2.imencode(".png", crop)[1].tobytes())
sres = self.slider_solver.solve(bg, crop,
piece_x_image=slider_ch.init_pos[0],
piece_y_image=slider_ch.init_pos[1],
scale=1.0)
logger.info("滑块求解: %s", sres.describe())
if not sres.ok:
wait_refresh = random.uniform(2.5, 3.8)
logger.warning("滑块缺口定位置信度不足 (score=%.3f margin=%.3f), 等待 %.2fs 后刷新题目",
sres.score, sres.margin, wait_refresh)
refreshes += 1
challenge = None
time.sleep(wait_refresh)
continue
target_x = slider_ch.clamp_x(sres.target_x_image)
points = [(int(round(target_x)), int(sres.y_image))]
drag_traj = drag_trajectory_points(slider_ch.init_pos, points[0],
img_w=slider_ch.bg_size[0] or 672)
logger.info("滑块答案: elem_id=%d target=(%d,%d) dx=%d 轨迹点=%d",
slider_ch.elem_id, points[0][0], points[0][1],
points[0][0] - int(slider_ch.init_pos[0]), len(drag_traj))
# ---- 模拟真实人类阅读审题、定位字形与逐个点击耗时 (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
if slider_ch is not None:
# 滑块: 单个元素, elem_id / data_type 都取自课题配置的 move_cfg
ans_list = slider_ch.answer(points[0][0], points[0][1])
else:
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,
trajectory=drag_traj,
)
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_values",
choices=["python_values"],
help="遥测方式: 只有 python_values(纯离线;模板回填/Node 两条老路已删除)")
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()