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
+99 -35
View File
@@ -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))
+50 -3
View File
@@ -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))
+23 -9
View File
@@ -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
+10
View File
@@ -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
View File
@@ -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 };