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:
+120
-45
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user