feat(captcha): 增加全题型图片落盘、字形一致性强校验与防秒换拟人交互机制

- 题目图片自动下载: 所有题型原图统一落盘至 scratch/captcha_debug,文字点选自动生成带编号红圈标注图便于肉眼核验
- 字形严格一致性校验: 在 SolveResult 增加 text_match_ok 检查,对图字不符或混淆干扰题直接拒答换题,避免错答引发 errorCode=12
- 防秒换与拟人延时: 遇到非预期题目换题等待 2.8~4.2s (对齐 HAR 真实抓包),求解失败等待 2.5~3.8s,作答前模拟交互 5.5~7.5s
- 代理容错增强: 在 OneMsAdapter 捕获代理断流与鉴权异常,支持动态轮换新代理重试
This commit is contained in:
yml2213
2026-10-11 20:51:12 +08:00
parent d136fb7328
commit 60ae14d54a
6 changed files with 462 additions and 115 deletions
+120 -45
View File
@@ -106,17 +106,18 @@ class TencentCaptchaClient:
min_posterior: float = 0.95,
pow_timeout: int = 4000000,
inter_attempt_delay: float = 0.25,
telemetry_mode: str = "python_keyed",
telemetry_mode: str = "probe",
proxy: Optional[str] = None,
):
"""
telemetry_mode 决定 collect/eks 怎么来 (见 tcaptcha.crypto.generate_telemetry):
"python_keyed" (默认) Node **只**从本次构建的 tdc.js 里提取 XTEA 密钥,
collect 的加密与组装全部由纯 Python 完成 (标准 XTEA)
"python" 纯 Python + crypto.py 内置固定密钥 (无 Node 依赖, 兼容宽松 aid)
"node" 在 Node VM 沙箱里整体执行真实 tdc.js 产出 collect/eks
"browser" 在真实 Chrome 里执行真实 tdc.js (CDP 驱动, 环境最真)
"auto" / "auto_keyed" 多后端逐级回退
"probe" (默认推荐) 深度 Proxy 语义沙箱: 零 CDP 特征泄露,原生时间戳/轨迹,
自动适配各版本 tdc.js 动态字段序,彻底解决 errorCode=12
"python_keyed" Node 只从本次构建的 tdc.js 提取密钥,纯 Python 组包
"python" 纯 Python + 固定密钥 (适合雨云等不校验遥测的宽松 aid)
"node" 旧版简易 Node VM 沙箱
"browser" 真实 Chrome + CDP 执行
"auto" probe -> python_keyed -> node -> python 逐级回退
实测结论 (2026-10-11):
* 雨云 aid=2039519451 用 "python" 提交正确答案 → errorCode=0 + 真实 ticket,
@@ -145,6 +146,7 @@ class TencentCaptchaClient:
# 图标题型求解器 (惰性加载, 避免只跑文字题型时白加载 56MB 权重)
self._icon_solver = solver
self._text_solver = text_solver
self.subsid = 1
self.http_client = httpx.Client(
base_url=self.BASE_URL,
@@ -153,7 +155,13 @@ class TencentCaptchaClient:
headers={
"User-Agent": self.user_agent,
"Referer": self.referer,
"Accept-Language": "zh-CN,zh;q=0.9,en;q=0.8",
"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",
},
timeout=self.timeout,
follow_redirects=True,
@@ -191,6 +199,7 @@ class TencentCaptchaClient:
"""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",
@@ -213,7 +222,7 @@ class TencentCaptchaClient:
"login_appid": "",
"wb": self.wb,
"tkid": tkid,
"subsid": str(random.randint(1, 5)),
"subsid": str(self.subsid),
"callback": cb_name,
"sess": "",
}
@@ -303,67 +312,134 @@ class TencentCaptchaClient:
while attempts < max_retries and refreshes < max_refreshes:
try:
# ---- 拿题: 首选 prehandle, 之后用 getsig 在同会话内刷新
round_start_time = time.time()
if challenge is None:
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, 继续尝试")
else:
challenge, sess = self.getsig(sess or challenge.sess, tkid)
logger.info("getsig -> kind=%s instruction=%r",
challenge.kind, challenge.instruction)
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/captcha_debug/,辅助用户肉眼判断
bg_bytes: Optional[bytes] = None
debug_dir = Path("scratch/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 抽题
logger.info("题型 %s 不在支持范围 %s, 重新 prehandle 抽题 (已抽 %d 次)",
challenge.kind, list(self.accept_kinds), prehandles)
# 严格模拟人类审题、发现不是目标题型、移动鼠标并点击换题的交互耗时 (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
if self.inter_attempt_delay and refreshes < max_refreshes:
time.sleep(self.inter_attempt_delay)
time.sleep(wait_switch)
continue
# ---- 视觉求解
points: List[Tuple[int, int]]
if challenge.kind == KIND_TEXT_CLICK:
bg = self.fetch_image(challenge.img_url, img_index=1)
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:
logger.warning("文字点选求解失败, 刷新题目")
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:
logger.warning("求解置信度过低 (%.3f < %.2f), 不提交, 刷新题目",
result.posterior, self.min_posterior)
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 = self.fetch_image(challenge.img_url, img_index=1)
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 not prompt:
logger.warning("图标题型未取到提示图, 刷新题目")
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:
logger.warning("图标点选求解失败, 刷新题目")
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",
@@ -371,11 +447,11 @@ class TencentCaptchaClient:
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,
referrer=self.referer, start_time=round_start_time, mode=self.telemetry_mode,
)
logger.info("遥测密文来源=%s collect_len=%s eks_len=%s",
telemetry.get("source"), len(telemetry["collect"]),
len(telemetry.get("eks", "")))
telemetry.get("source"), len(telemetry["collect"]),
len(telemetry.get("eks", "")))
verify_body = {
"ans": ans_str,
"collect": telemetry["collect"],
@@ -430,9 +506,8 @@ class TencentCaptchaClient:
# 服务端返回新 sess 时沿用, 否则回到 prehandle
new_sess = v_json.get("sess")
if new_sess:
challenge, sess = self.getsig(new_sess, tkid)
logger.info("verify 后刷新 -> kind=%s instruction=%r",
challenge.kind, challenge.instruction)
sess = new_sess
challenge = None
else:
challenge = None
sess = None