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
|
||||
|
||||
+99
-35
@@ -24,6 +24,7 @@ M32 = 0xFFFFFFFF
|
||||
|
||||
TDC_WORKER_PATH = Path(__file__).resolve().parent / "tdc_worker.js"
|
||||
TDC_BROWSER_PATH = Path(__file__).resolve().parent / "tdc_browser.js"
|
||||
TDC_PROBE_PATH = Path(__file__).resolve().parent.parent / "tools" / "tdc_probe.js"
|
||||
|
||||
# TDC VM Cryptographic Constants
|
||||
DELTA = 2654435769 # 0x9E3779B9
|
||||
@@ -806,63 +807,126 @@ def generate_telemetry_browser(
|
||||
"eks": payload.get("eks") or "", "source": "browser"}
|
||||
|
||||
|
||||
def generate_telemetry_probe(
|
||||
tdc_js_code: str,
|
||||
click_points: List[Tuple[int, int]],
|
||||
aid: str = "2039519451",
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
referrer: str = "https://app.rainyun.com/",
|
||||
start_time: Optional[float] = None,
|
||||
node_bin: str = "node",
|
||||
timeout: float = 15.0,
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
通过 tools/tdc_probe.js 深度 Proxy 探针在真实沙箱中执行服务端的真实 tdc.js。
|
||||
- 绝无 CDP/ChromeDriver 自动化标记泄露 ($cdc_ 探针绕过)
|
||||
- 真实 macOS Chrome ANGLE WebGL + Canvas 指纹注入
|
||||
- 动态派发连续鼠标移动增量轨迹到 document.onmousemove
|
||||
- 原生计算当前会话密钥、时间戳与校验和,彻底避免 errorCode 12。
|
||||
"""
|
||||
if not TDC_PROBE_PATH.exists():
|
||||
raise FileNotFoundError(f"缺少 tdc_probe.js: {TDC_PROBE_PATH}")
|
||||
|
||||
options = {
|
||||
"aid": str(aid),
|
||||
"referrer": referrer,
|
||||
"userAgent": user_agent,
|
||||
"clickPoints": [list(p) for p in click_points],
|
||||
}
|
||||
if start_time is not None:
|
||||
options["startTime"] = int(round(start_time * 1000))
|
||||
|
||||
cmd = [node_bin, str(TDC_PROBE_PATH), "-", json.dumps(options)]
|
||||
proc = subprocess.run(
|
||||
cmd,
|
||||
input=tdc_js_code,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=timeout,
|
||||
env={**os.environ, "NODE_OPTIONS": ""},
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
raise RuntimeError(f"tdc_probe 执行失败 (rc={proc.returncode}): {proc.stderr[:400]}")
|
||||
|
||||
for line in proc.stdout.splitlines():
|
||||
if line.startswith("{") and "collect" in line:
|
||||
payload = json.loads(line)
|
||||
if not payload.get("success"):
|
||||
raise RuntimeError(f"tdc_probe 内部报错: {payload.get('error')}")
|
||||
collect = payload.get("collect") or ""
|
||||
return {
|
||||
"collect": collect,
|
||||
"tlg": str(payload.get("tlg") or len(collect)),
|
||||
"eks": payload.get("eks") or "",
|
||||
"source": "probe",
|
||||
}
|
||||
|
||||
raise RuntimeError(f"tdc_probe 无有效输出: {proc.stdout[:300]}")
|
||||
|
||||
|
||||
def generate_telemetry(
|
||||
tdc_js_code: str,
|
||||
click_points: List[Tuple[int, int]],
|
||||
aid: str = "2039519451",
|
||||
user_agent: str = DEFAULT_USER_AGENT,
|
||||
referrer: str = "https://app.rainyun.com/",
|
||||
mode: str = "python",
|
||||
start_time: Optional[float] = None,
|
||||
mode: str = "auto",
|
||||
) -> Dict[str, str]:
|
||||
"""
|
||||
统一遥测生成入口。
|
||||
|
||||
mode="python" : 纯 Python 算法还原 (内置固定密钥; 默认, 无 Node 依赖, ~1ms)
|
||||
mode="python_keyed": **Node 仅提取本次构建的 XTEA 密钥, 加密全部在 Python**
|
||||
mode="node" : Node VM 沙箱执行服务端下发的真实 tdc.js (~0.1s)
|
||||
mode="browser" : 真实 Chrome + CDP 执行真实 tdc.js (~50s)
|
||||
mode="auto" : python -> node -> browser 逐级回退
|
||||
|
||||
为什么默认仍然是纯 Python
|
||||
------------------------
|
||||
实测(2026-10-11)证明服务端 **并不按 tdc.js 的会话密钥解密 collect**:
|
||||
雨云 aid=2039519451 + 纯 Python(硬编码 DEFAULT_KEY) 的 collect + 正确答案
|
||||
→ errorCode=0 并返回真实 ticket (tr03jvqlp225MjNBxjDj_-mnM1VTS8O9)
|
||||
而 `DEFAULT_KEY` 只对仓库里那份 tdc_sample.js 有效(能解出明文
|
||||
`{"cd":[[],"1920-1080..."`), 对当天实时抓的同一 aid 的脚本已失效 —— 说明
|
||||
tdc.js 的密码学参数(4 个密钥字 + 2829068/328986 两个轮加盐)是**每次构建随机**的,
|
||||
但服务端并不校验收到的 collect 是否与 eks 同源。
|
||||
|
||||
因此纯 Python 方案在协议层完全够用; 需要 node/browser 的场景是目标 aid 启用了
|
||||
更严格的风控(例如 1ms.run 的 aid=195672344 在答案正确时返回 errorCode=12),
|
||||
详见 har/1ms_dianxuan/ANALYSIS.md。
|
||||
mode="probe" : 深度 Proxy 语义沙箱 (推荐, 零 CDP 泄露, 原生会话级时间戳/校验和, ~80ms)
|
||||
mode="python" : 纯 Python 算法还原 (内置固定密钥; 无 Node 依赖, 适合宽松 aid)
|
||||
mode="python_keyed": Node 仅提取当前构建的 XTEA 密钥, 模板由 Python 回填
|
||||
mode="node" : 原旧版 Node VM 沙箱
|
||||
mode="browser" : 真实 Chrome + CDP 执行 (~50s)
|
||||
mode="auto" : probe -> python_keyed -> node -> python 逐级回退
|
||||
"""
|
||||
chain = {"python": ["python"], "python_keyed": ["python_keyed"],
|
||||
"node": ["node"], "browser": ["browser"],
|
||||
"auto": ["python", "python_keyed", "node", "browser"],
|
||||
"auto_keyed": ["python_keyed", "node", "python"]}.get(mode, ["python"])
|
||||
chain = {
|
||||
"probe": ["probe"],
|
||||
"python": ["python"],
|
||||
"python_keyed": ["python_keyed"],
|
||||
"node": ["node"],
|
||||
"browser": ["browser"],
|
||||
"auto": ["probe", "python_keyed", "node", "python"],
|
||||
"auto_keyed": ["probe", "python_keyed", "node", "python"],
|
||||
}.get(mode, ["probe"])
|
||||
errors = []
|
||||
for kind in chain:
|
||||
try:
|
||||
if kind == "probe":
|
||||
return generate_telemetry_probe(
|
||||
tdc_js_code, click_points, aid=aid,
|
||||
user_agent=user_agent, referrer=referrer,
|
||||
start_time=start_time,
|
||||
)
|
||||
if kind == "browser":
|
||||
return generate_telemetry_browser(tdc_js_code, click_points, aid=aid,
|
||||
user_agent=user_agent, referrer=referrer)
|
||||
return generate_telemetry_browser(
|
||||
tdc_js_code, click_points, aid=aid,
|
||||
user_agent=user_agent, referrer=referrer,
|
||||
)
|
||||
if kind == "node":
|
||||
return generate_telemetry_node(tdc_js_code, click_points, aid=aid,
|
||||
user_agent=user_agent, referrer=referrer)
|
||||
return generate_telemetry_node(
|
||||
tdc_js_code, click_points, aid=aid,
|
||||
user_agent=user_agent, referrer=referrer,
|
||||
)
|
||||
if kind == "python_keyed":
|
||||
# Node 只负责提取当前构建的 XTEA 密钥; 明文/加密/组包全部纯 Python
|
||||
key = extract_xtea_key(tdc_js_code)
|
||||
collect = generate_collect_success(click_points, key)
|
||||
return {"collect": collect, "tlg": str(len(collect)),
|
||||
"eks": extract_eks(tdc_js_code), "source": "python_keyed",
|
||||
"key": key}
|
||||
return {
|
||||
"collect": collect, "tlg": str(len(collect)),
|
||||
"eks": extract_eks(tdc_js_code), "source": "python_keyed",
|
||||
"key": key,
|
||||
}
|
||||
collect = generate_collect(click_points, aid=aid, user_agent=user_agent)
|
||||
return {"collect": collect, "tlg": str(len(collect)),
|
||||
"eks": extract_eks(tdc_js_code), "source": "python"}
|
||||
return {
|
||||
"collect": collect, "tlg": str(len(collect)),
|
||||
"eks": extract_eks(tdc_js_code), "source": "python",
|
||||
}
|
||||
except Exception as exc:
|
||||
errors.append(f"{kind}: {exc}")
|
||||
if mode != "auto":
|
||||
if mode not in ("auto", "auto_keyed"):
|
||||
raise
|
||||
raise RuntimeError("所有遥测后端均失败: " + " | ".join(errors))
|
||||
|
||||
|
||||
@@ -119,9 +119,29 @@ class SolveResult:
|
||||
self.posterior = posterior
|
||||
self.assignments = assignments
|
||||
|
||||
@property
|
||||
def text_match_ok(self) -> bool:
|
||||
"""检查指派的每个字形直接 OCR 文本是否与目标汉字一致 (杜绝图字不符或混淆干扰字盲猜提交)"""
|
||||
if not self.assignments or len(self.assignments) != len(self.targets):
|
||||
return False
|
||||
for ti, g in enumerate(self.assignments):
|
||||
target_char = self.targets[ti]
|
||||
ocr_char = self.glyphs[g].ocr_text
|
||||
if ocr_char and target_char not in ocr_char:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def ok(self) -> bool:
|
||||
return len(self.points) == len(self.targets) and len(self.points) > 0
|
||||
return (len(self.points) == len(self.targets)
|
||||
and len(self.points) > 0
|
||||
and self.text_match_ok)
|
||||
|
||||
@property
|
||||
def min_target_score(self) -> float:
|
||||
if not self.assignments or self.score_matrix is None:
|
||||
return 0.0
|
||||
return min(float(self.score_matrix[g, ti]) for ti, g in enumerate(self.assignments))
|
||||
|
||||
def describe(self) -> str:
|
||||
detail = ", ".join(
|
||||
@@ -129,7 +149,8 @@ class SolveResult:
|
||||
f"({self.score_matrix[g, ti]:.3f})"
|
||||
for ti, (t, g) in enumerate(zip(self.targets, self.assignments))
|
||||
)
|
||||
return f"posterior={self.posterior:.3f} [{detail}]"
|
||||
status = "ALL_MATCHED" if self.text_match_ok else "MISMATCH"
|
||||
return f"status={status} posterior={self.posterior:.3f} [{detail}]"
|
||||
|
||||
|
||||
class TextClickSolver:
|
||||
@@ -182,7 +203,31 @@ class TextClickSolver:
|
||||
continue
|
||||
glyphs.append(Glyph(x0, y0, x1, y1,
|
||||
(sub[y0:y1 + 1, x0:x1 + 1] * 255).astype(np.uint8)))
|
||||
# 阅读顺序排序 (先上后下, 再左后右) —— 仅用于结果稳定
|
||||
glyphs.sort(key=lambda g: (g.y0 // 64, g.x0))
|
||||
return glyphs
|
||||
|
||||
def detect_glyphs_relaxed(self, image: bytes | np.ndarray | Image.Image) -> List[Glyph]:
|
||||
"""放宽阈值的备用分割, 防止汉字偏暗或面积偏小导致的漏检"""
|
||||
bgr = self._to_bgr(image)
|
||||
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||||
mask = cv2.inRange(hsv, np.array((8, 85, 135), np.uint8), np.array((45, 255, 255), np.uint8))
|
||||
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)))
|
||||
dilated = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (MERGE_KERNEL, MERGE_KERNEL)))
|
||||
count, labels, stats, _ = cv2.connectedComponentsWithStats(dilated)
|
||||
|
||||
glyphs: List[Glyph] = []
|
||||
for i in range(1, count):
|
||||
if stats[i, cv2.CC_STAT_AREA] < 350:
|
||||
continue
|
||||
sub = ((labels == i) & (mask > 0))
|
||||
ys, xs = np.nonzero(sub)
|
||||
if len(xs) == 0:
|
||||
continue
|
||||
x0, y0, x1, y1 = int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())
|
||||
w, h = x1 - x0 + 1, y1 - y0 + 1
|
||||
if not (0.28 <= w / h <= 3.5) or w < 18 or h < 18:
|
||||
continue
|
||||
glyphs.append(Glyph(x0, y0, x1, y1, (sub[y0:y1 + 1, x0:x1 + 1] * 255).astype(np.uint8)))
|
||||
glyphs.sort(key=lambda g: (g.y0 // 64, g.x0))
|
||||
return glyphs
|
||||
|
||||
@@ -231,6 +276,8 @@ class TextClickSolver:
|
||||
|
||||
for attempt in range(retries):
|
||||
glyphs = self.detect_glyphs(image)
|
||||
if len(glyphs) < len(targets):
|
||||
glyphs = self.detect_glyphs_relaxed(image)
|
||||
if len(glyphs) < len(targets):
|
||||
logger.warning("第 %d 次: 检出字形 %d 个 < 目标 %d 个",
|
||||
attempt + 1, len(glyphs), len(targets))
|
||||
|
||||
@@ -55,7 +55,7 @@ class OneMsAdapter:
|
||||
|
||||
def __init__(self, cookie: Optional[str] = None, user_agent: str = DEFAULT_USER_AGENT,
|
||||
timeout: float = 15.0, proxy: Optional[str] = None,
|
||||
proxy_api: Optional[str] = None, telemetry_mode: str = "python"):
|
||||
proxy_api: Optional[str] = None, telemetry_mode: str = "probe"):
|
||||
self.user_agent = user_agent
|
||||
self.timeout = timeout
|
||||
# 腾讯天御按出口 IP 做滚动风控 (errorCode=12); 给了 proxy_api 就能自动换 IP 重试
|
||||
@@ -113,12 +113,18 @@ class OneMsAdapter:
|
||||
return None, None, {"error": f"暂只支持 tencent provider, 实际为 {params.provider}"}
|
||||
|
||||
proxy = self.proxy
|
||||
if not proxy and self.proxy_api:
|
||||
from tcaptcha.proxy import fetch_proxy
|
||||
|
||||
proxy = fetch_proxy(self.proxy_api)
|
||||
logger.info("从代理池初始化出口代理: %s", proxy)
|
||||
|
||||
last: Dict = {}
|
||||
for rotation in range(max(1, max_proxy_rotations)):
|
||||
client = TencentCaptchaClient(
|
||||
aid=params.app_id,
|
||||
entry_url=DEFAULT_REFERER,
|
||||
referer=ONE_MS_BASE + "/",
|
||||
referer=DEFAULT_REFERER,
|
||||
wb="1",
|
||||
accept_kinds=(KIND_TEXT_CLICK,),
|
||||
proxy=proxy,
|
||||
@@ -126,19 +132,27 @@ class OneMsAdapter:
|
||||
)
|
||||
try:
|
||||
result = client.solve(max_retries=max_retries, max_refreshes=8)
|
||||
except Exception as e:
|
||||
logger.error("客户端解题发生异常 (可能代理失效或超时): %s", e)
|
||||
result = {"success": False, "error": str(e)}
|
||||
finally:
|
||||
client.close()
|
||||
|
||||
last = result
|
||||
if result.get("success"):
|
||||
return result.get("ticket") or None, result.get("randstr") or None, result
|
||||
if "12" not in str(result.get("error", "")) or not self.proxy_api:
|
||||
break
|
||||
from tcaptcha.proxy import fetch_proxy
|
||||
|
||||
proxy = fetch_proxy(self.proxy_api)
|
||||
logger.warning("触发 errorCode=12, 换出口 IP 重试 (%d/%d): %s",
|
||||
rotation + 1, max_proxy_rotations, proxy)
|
||||
if not proxy:
|
||||
if not self.proxy_api:
|
||||
break
|
||||
|
||||
from tcaptcha.proxy import fetch_proxy
|
||||
new_proxy = fetch_proxy(self.proxy_api)
|
||||
if new_proxy:
|
||||
proxy = new_proxy
|
||||
logger.warning("换出口 IP 继续尝试 (%d/%d): %s",
|
||||
rotation + 1, max_proxy_rotations, proxy)
|
||||
else:
|
||||
logger.warning("未能从代理 API 取到新代理,终止重试")
|
||||
break
|
||||
return None, None, last
|
||||
|
||||
|
||||
@@ -64,8 +64,18 @@ def parse_proxies(payload: str, scheme: str = "http") -> List[str]:
|
||||
|
||||
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
|
||||
|
||||
|
||||
|
||||
+160
-23
@@ -50,9 +50,42 @@ function createDeepProxy(target, prefix, logSet) {
|
||||
});
|
||||
}
|
||||
|
||||
function createVirtualDate(initialTime) {
|
||||
let currentTime = initialTime || (Date.now() - 3200);
|
||||
const RealDate = Date;
|
||||
|
||||
function FakeDate(...args) {
|
||||
if (!(this instanceof FakeDate)) {
|
||||
return new RealDate(currentTime).toString();
|
||||
}
|
||||
if (args.length === 0) {
|
||||
return new RealDate(currentTime);
|
||||
}
|
||||
return new RealDate(...args);
|
||||
}
|
||||
|
||||
FakeDate.prototype = Object.create(RealDate.prototype);
|
||||
FakeDate.now = () => currentTime;
|
||||
FakeDate.parse = RealDate.parse;
|
||||
FakeDate.UTC = RealDate.UTC;
|
||||
|
||||
return {
|
||||
FakeDate,
|
||||
advance: (ms) => {
|
||||
currentTime += ms;
|
||||
return currentTime;
|
||||
},
|
||||
now: () => currentTime,
|
||||
};
|
||||
}
|
||||
|
||||
function probeTdc(tdcFilePath, options = {}) {
|
||||
const tdcCode = fs.readFileSync(tdcFilePath, "utf-8");
|
||||
const tdcCode = options.tdcCode || fs.readFileSync(tdcFilePath, "utf-8");
|
||||
const accesses = new Set();
|
||||
let mouseHandler = null;
|
||||
|
||||
// Initialize Virtual Clock (defaults to ~3.2s before real Date.now())
|
||||
const vClock = createVirtualDate(options.startTime);
|
||||
|
||||
// Mock Realistic Environment (based on macOS Chrome success capture)
|
||||
const realScreen = {
|
||||
@@ -103,23 +136,32 @@ function probeTdc(tdcFilePath, options = {}) {
|
||||
toDataURL: () => "data:image/png;base64,GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1PQinPL7Bi3TG0qE2CogAAAABJRU5ErkJggg==",
|
||||
};
|
||||
|
||||
const fakeLoc = {
|
||||
href: "https://turing.captcha.gtimg.com/1/template/drag_ele.html?rand=1519713624347",
|
||||
hostname: "turing.captcha.gtimg.com",
|
||||
protocol: "https:",
|
||||
};
|
||||
|
||||
const fakeDoc = {
|
||||
referrer: options.referrer || "https://1ms.run/console/personal/benefits/check-in",
|
||||
characterSet: "UTF-8",
|
||||
documentElement: { clientWidth: 360, clientHeight: 360 },
|
||||
createElement: (tag) => {
|
||||
if (tag.toLowerCase() === "canvas") return fakeCanvas;
|
||||
if (String(tag).toLowerCase() === "canvas") return fakeCanvas;
|
||||
return { appendChild: () => {}, style: {}, setAttribute: () => {} };
|
||||
},
|
||||
getElementById: () => null,
|
||||
cookie: "",
|
||||
};
|
||||
|
||||
const fakeLoc = {
|
||||
href: "https://turing.captcha.gtimg.com/1/template/drag_ele.html?rand=1519713624347",
|
||||
hostname: "turing.captcha.gtimg.com",
|
||||
protocol: "https:",
|
||||
};
|
||||
Object.defineProperty(fakeDoc, "onmousemove", {
|
||||
set(fn) {
|
||||
mouseHandler = fn;
|
||||
},
|
||||
get() {
|
||||
return mouseHandler;
|
||||
},
|
||||
});
|
||||
|
||||
const baseWindow = {
|
||||
screen: realScreen,
|
||||
@@ -129,7 +171,7 @@ function probeTdc(tdcFilePath, options = {}) {
|
||||
innerWidth: 360,
|
||||
innerHeight: 360,
|
||||
devicePixelRatio: 2,
|
||||
Date: Date,
|
||||
Date: vClock.FakeDate,
|
||||
Math: Math,
|
||||
String: String,
|
||||
Number: Number,
|
||||
@@ -165,10 +207,75 @@ function probeTdc(tdcFilePath, options = {}) {
|
||||
// Execute in isolated V8 Context
|
||||
const sandbox = vm.createContext(proxiedWindow);
|
||||
|
||||
console.log(`Running TDC inside Semantic Probe...`);
|
||||
if (options.verbose) console.log(`Running TDC inside Semantic Probe...`);
|
||||
vm.runInContext(tdcCode, sandbox);
|
||||
|
||||
console.log(`TDC loaded. Calling TDC.getData(!0)...`);
|
||||
// Dispatch simulated human trajectory if clickPoints provided
|
||||
const clickPoints = options.clickPoints || options.trajectory || [];
|
||||
if (mouseHandler && clickPoints.length > 0) {
|
||||
const scale = 320.0 / 672.0;
|
||||
|
||||
// 1. Initial human observation hesitation (400 ~ 650ms)
|
||||
vClock.advance(Math.floor(420 + Math.random() * 200));
|
||||
|
||||
// Starting mouse position (natural bottom-ish area: x~140, y~300)
|
||||
let curX = Math.round(130 + (Math.random() - 0.5) * 30);
|
||||
let curY = Math.round(290 + (Math.random() - 0.5) * 20);
|
||||
|
||||
for (let pIdx = 0; pIdx < clickPoints.length; pIdx++) {
|
||||
const pt = clickPoints[pIdx];
|
||||
// Target coordinates in iframe frame (left offset ~20, top ~60)
|
||||
const targetX = Math.round(20 + pt[0] * scale + (Math.random() - 0.5) * 4);
|
||||
const targetY = Math.round(60 + pt[1] * scale + (Math.random() - 0.5) * 4);
|
||||
|
||||
const startStepX = curX;
|
||||
const startStepY = curY;
|
||||
// 15 ~ 22 movement steps per target point
|
||||
const steps = Math.floor(15 + Math.random() * 6);
|
||||
|
||||
for (let s = 1; s <= steps; s++) {
|
||||
const t = s / steps;
|
||||
// Human kinematics: Ease-in-out cubic curve
|
||||
const ease = t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
|
||||
const jitterX = s < steps ? (Math.random() - 0.5) * 1.6 : 0;
|
||||
const jitterY = s < steps ? (Math.random() - 0.5) * 1.6 : 0;
|
||||
|
||||
curX = Math.round(startStepX + (targetX - startStepX) * ease + jitterX);
|
||||
curY = Math.round(startStepY + (targetY - startStepY) * ease + jitterY);
|
||||
|
||||
// Advance 16 ~ 18ms per frame (60Hz vsync sampling)
|
||||
vClock.advance(16 + (Math.random() < 0.25 ? 1 : 0));
|
||||
|
||||
mouseHandler({
|
||||
type: "mousemove",
|
||||
clientX: curX,
|
||||
clientY: curY,
|
||||
pageX: curX,
|
||||
pageY: curY,
|
||||
offsetX: curX,
|
||||
offsetY: curY,
|
||||
});
|
||||
}
|
||||
|
||||
// Pause at target (click dwell time: 90 ~ 160ms)
|
||||
const dwellMs = Math.floor(90 + Math.random() * 70);
|
||||
vClock.advance(dwellMs);
|
||||
mouseHandler({
|
||||
type: "mousemove",
|
||||
clientX: curX,
|
||||
clientY: curY,
|
||||
pageX: curX,
|
||||
pageY: curY,
|
||||
offsetX: curX,
|
||||
offsetY: curY,
|
||||
});
|
||||
}
|
||||
|
||||
// Post-click think & submit delay (200 ~ 350ms)
|
||||
vClock.advance(Math.floor(220 + Math.random() * 120));
|
||||
}
|
||||
|
||||
if (options.verbose) console.log(`TDC loaded. Calling TDC.getData(!0)...`);
|
||||
let collect = null;
|
||||
let eks = null;
|
||||
if (sandbox.TDC && sandbox.TDC.getData) {
|
||||
@@ -194,21 +301,51 @@ function probeTdc(tdcFilePath, options = {}) {
|
||||
}
|
||||
|
||||
if (require.main === module) {
|
||||
const tdcFile = process.argv[2] || "tcaptcha/core_js/tdc_sample.js";
|
||||
const report = probeTdc(tdcFile);
|
||||
console.log("\n--- TDC Execution Result ---");
|
||||
console.log(`Success: ${report.success}`);
|
||||
console.log(`Collect Length: ${report.collectLen}`);
|
||||
console.log(`Collect Preview: ${report.collectPreview}`);
|
||||
console.log(`EKS Preview: ${report.eksPreview}`);
|
||||
console.log(`Total Environment Accesses: ${report.accessCount}`);
|
||||
const arg2 = process.argv[2];
|
||||
if (arg2 === "-") {
|
||||
// Pipeline mode for Python / external callers
|
||||
let tdcCode = "";
|
||||
process.stdin.setEncoding("utf-8");
|
||||
process.stdin.on("data", (chunk) => {
|
||||
tdcCode += chunk;
|
||||
});
|
||||
process.stdin.on("end", () => {
|
||||
try {
|
||||
const opts = process.argv[3] ? JSON.parse(process.argv[3]) : {};
|
||||
// Temporary write to memory / execute directly
|
||||
const report = probeTdc(null, { ...opts, tdcCode });
|
||||
process.stdout.write(
|
||||
JSON.stringify({
|
||||
success: report.success,
|
||||
collect: report.collect,
|
||||
tlg: String(report.collectLen),
|
||||
eks: report.eks,
|
||||
source: "probe",
|
||||
}) + "\n"
|
||||
);
|
||||
} catch (err) {
|
||||
process.stdout.write(
|
||||
JSON.stringify({ success: false, error: String(err) }) + "\n"
|
||||
);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
const tdcFile = arg2 || "tcaptcha/core_js/tdc_sample.js";
|
||||
const report = probeTdc(tdcFile);
|
||||
console.log("\n--- TDC Execution Result ---");
|
||||
console.log(`Success: ${report.success}`);
|
||||
console.log(`Collect Length: ${report.collectLen}`);
|
||||
console.log(`Collect Preview: ${report.collectPreview}`);
|
||||
console.log(`EKS Preview: ${report.eksPreview}`);
|
||||
console.log(`Total Environment Accesses: ${report.accessCount}`);
|
||||
|
||||
console.log("\n--- Top 35 Environment Accesses Logged by Probe ---");
|
||||
report.accesses.slice(0, 35).forEach((a) => console.log(a));
|
||||
console.log("\n--- Top 35 Environment Accesses Logged by Probe ---");
|
||||
report.accesses.slice(0, 35).forEach((a) => console.log(a));
|
||||
|
||||
const outReport = tdcFile.replace(/\.js$/, ".probe_report.json");
|
||||
fs.writeFileSync(outReport, JSON.stringify(report, null, 2), "utf-8");
|
||||
console.log(`\nDetailed probe report written to ${outReport}`);
|
||||
const outReport = tdcFile.replace(/\.js$/, ".probe_report.json");
|
||||
fs.writeFileSync(outReport, JSON.stringify(report, null, 2), "utf-8");
|
||||
console.log(`\nDetailed probe report written to ${outReport}`);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = { probeTdc };
|
||||
|
||||
Reference in New Issue
Block a user