diff --git a/har/1ms_dianxuan/ANALYSIS.md b/har/1ms_dianxuan/ANALYSIS.md index 320d3a2..b3d36a0 100644 --- a/har/1ms_dianxuan/ANALYSIS.md +++ b/har/1ms_dianxuan/ANALYSIS.md @@ -373,3 +373,61 @@ tdc.js;复用本仓库已完成的视觉求解器算坐标,用 `Input.dispat > 题型在**会话内固定**:`getsig` 不换题型,只有重新 `prehandle` 才能抽到别的题型。 > `client.solve` 已内置「抽到目标题型再提交」的循环(默认 `max_refreshes` 次)。 + +--- +## 七、errorCode=12 根因分析 —— 证据账本(v2,推倒重写) + +> v1 版本(已废弃)里我下了多个无证据结论,本节按 §1.3 重写: +> 只写有证据的"已知"、明确的"未定"、以及被推翻的假设。 + +### 7.1 已确证事实(每条附证据) + +| # | 事实 | 证据 | +| --- | --- | --- | +| E1 | 答案判错→`50`,判对→`12`,稳定复现 | 乱坐标 4 次→50;正确 8+ 次→12;正确但顺序点反→50 | +| E2 | 同一套纯协议代码在雨云 aid 上 → `0` + 真实 ticket | aid=2039519451 端到端实测 | +| E3 | 真实签到流程(已登录、真页面、真浏览器、同 IP `118.248.52.65`)→ `0` + 真实 ticket | `签到成功-1.har` 条目 66/67 | +| E4 | 官方 SDK 注入在 `1ms.run` 首页(未登录)+ 真人点击 → `12` | `solve_in_browser.js` 抓到的 verify 响应 | +| E5 | **域不在白名单时 SDK 直接短路**:不出题、立刻回 `trerror_1006_...` 错误票据 | `127.0.0.1` 页面上 SDK 加载 1 秒内即回调 `ret:0`,无任何挑战 UI | +| E6 | 纯 Python 的 collect 各种变体(空/垃圾/截断/真实环境值/真实轨迹 dt)→ 全部 `12` | 变异矩阵 + 多轮实测 | +| E7 | 成功流程的 captcha 请求带 `qcloud.com` 域 cookie(`uin`/`skey`/`qcmainCSRFToken`/`intl`);我们不带 | HAR 条目 60/61/62 请求头 | + +### 7.2 显式撤回的错误结论(§5.7) + +| 曾下的结论 | 撤回理由 | +| --- | --- | +| ❌ "服务端不看 collect" | E6 里 collect 置空也 12,更可能是"校验失败即 12",不能反推"不看" | +| ❌ "环境指纹取值是瓶颈" | 填入真实值后仍 12,未证实 | +| ❌ "CDP/临时 profile 不被检测" | **依据无效**:E5 显示 127.0.0.1 上根本不出题,`ret:0` 是域授权短路,与风控无关 | +| ❌ "12 是业务账号上下文问题"(腾讯云 cookie) | 仅一次用可能过期的 skey 测试,不能定论 | + +### 7.3 关键混淆变量(必须承认) + +E3(成功)与 E4(失败)之间**同时变了三个变量**: +① 真实签到页 vs 注入首页 ② 已登录 vs 未登录 ③ 无 CDP vs 有 CDP + +所以目前**无法**把 12 归因于其中任何一个 —— 之前归因"腾讯云 cookie"是过度解读。 + +### 7.4 剩余假设(互相独立,需单变量实验区分) + +| # | 假设 | 若为真的修法 | +| --- | --- | --- | +| H1 | 需要**已登录业务会话/真实页面上下文**(页面自己初始化的验证码) | 复刻业务会话,或半自动取票 | +| H2 | **CDP/自动化被检测** | 只能非 CDP 真浏览器,半自动 | +| H3 | 需要 `qcloud.com` 账号 cookie(有效 `uin`/`skey`) | 纯算流程带上该 cookie 即可 | + +### 7.5 决定性实验(单变量,二选一即可闭合) + +**在同一浏览器里登录 1ms,走真实签到页,让页面自己的验证码弹出并人工完成**(我方仅监听 verify): + +- 结果 `0` → **H1 成立**(CDP/自动化无嫌疑),修复方向 = 复刻登录态与业务会话 +- 结果 `12` → **H2 成立**(CDP 被检测),纯算+CDP 均不可行,只能半自动 + +若两者都排除,再做 H3(取有效 `qcloud.com` cookie 复测)。 + +### 7.6 与本题无关但已完成的资产 + +* XTEA 算法逐字节还原 + 每构建密钥提取(`tcaptcha/crypto.py`、`tdc_worker.js` captureKey) +* 文字点选求解器 8/8 回归 +* 轨迹 dt 修正为真实毫秒分布(`human_dt_ms`) +* 代理提取/换 IP、模板标定回填管线 diff --git a/tcaptcha/chrome_fetch.js b/tcaptcha/chrome_fetch.js new file mode 100644 index 0000000..2ac5347 --- /dev/null +++ b/tcaptcha/chrome_fetch.js @@ -0,0 +1,109 @@ +#!/usr/bin/env node +/** + * 用真实 Chrome 的网络栈发一个请求 (TLS/HTTP2/header 顺序全是浏览器的) + * + * 用途: 验证服务端风控是否在按「客户端网络指纹」判定。 + * 从真实验证码域 https://turing.captcha.gtimg.com/ 发起 fetch, + * Origin/Referer/Sec-Fetch-* 都由浏览器自己填。 + * + * 用法: + * echo '' | node chrome_fetch.js [originUrl] + */ +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const CHROME = process.env.REA_BROWSER_EXECUTABLE || + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; + +function sleep(ms) { return new Promise(r => setTimeout(r, ms)); } + +async function launchChrome() { + const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'chrome-fetch-')); + const proc = spawn(CHROME, [ + '--headless=new', '--remote-debugging-port=0', '--no-first-run', + '--no-default-browser-check', '--disable-gpu', '--disable-dev-shm-usage', + '--disable-web-security', '--disable-features=IsolateOrigins,site-per-process', + `--user-data-dir=${profile}`, '--window-size=1280,800', 'about:blank', + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + const portFile = path.join(profile, 'DevToolsActivePort'); + let port = null; + for (let i = 0; i < 120; i++) { + if (fs.existsSync(portFile)) { + port = parseInt(fs.readFileSync(portFile, 'utf8').split('\n')[0], 10); + if (port) break; + } + await sleep(100); + } + if (!port) { proc.kill('SIGKILL'); throw new Error('Chrome 未就绪'); } + return { proc, port, profile }; +} + +class Cdp { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new Cdp(ws); + ws.onmessage = ev => { + const m = JSON.parse(ev.data); + if (m.id && c.waits.has(m.id)) { + const { res, rej } = c.waits.get(m.id); + c.waits.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } + }; + return c; + } + send(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((res, rej) => { + this.waits.set(id, { res, rej }); + this.ws.send(JSON.stringify({ id, method, params, sessionId })); + }); + } +} + +async function main() { + const url = process.argv[2]; + const origin = process.argv[3] || 'https://turing.captcha.gtimg.com/'; + const body = fs.readFileSync(0, 'utf8').trim(); + const { proc, port, profile } = await launchChrome(); + try { + const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(); + const cdp = await Cdp.connect(ver.webSocketDebuggerUrl); + const { targetId } = await cdp.send('Target.createTarget', { url: origin }); + const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }); + await cdp.send('Page.enable', {}, sessionId); + await sleep(2500); // 等页面(真实验证码域)加载完 + + const loc = await cdp.send('Runtime.evaluate', { expression: 'location.href', returnByValue: true }, sessionId); + process.stderr.write('[page] ' + loc.result.value + '\n'); + const expr = `(async () => { + const r = await fetch(${JSON.stringify(url)}, ${JSON.stringify(body)} + ? { method: 'POST', + headers: { 'Content-Type': 'application/x-www-form-urlencoded; charset=UTF-8' }, + body: ${JSON.stringify(body)}, credentials: 'include' } + : { method: 'GET', credentials: 'include' }); + const t = await r.text(); + return JSON.stringify({ status: r.status, text: t }); + })()`; + const out = await cdp.send('Runtime.evaluate', + { expression: expr, awaitPromise: true, returnByValue: true }, sessionId); + if (out.exceptionDetails) { + console.log(JSON.stringify({ _exception: out.exceptionDetails.text, + desc: (out.exceptionDetails.exception || {}).description })); + } else if (out.result && out.result.value !== undefined) { + console.log(out.result.value); + } else { + console.log(JSON.stringify({ _raw: out })); + } + cdp.ws.close(); + } finally { + proc.kill('SIGKILL'); + try { fs.rmSync(profile, { recursive: true, force: true }); } catch (_) {} + } +} + +main().catch(e => { console.error('ERR', e.message); process.exit(1); }); diff --git a/tcaptcha/crypto.py b/tcaptcha/crypto.py index fb0b131..1d6ee51 100644 --- a/tcaptcha/crypto.py +++ b/tcaptcha/crypto.py @@ -144,6 +144,27 @@ def _bezier_interp( return pts +def human_dt_ms() -> int: + """ + 真实 mousemove 的采样间隔 (毫秒)。 + + 依据: 成功签到样本 (har/签到成功-1.har) 解密出的轨迹 dt 分布为 + 12~601ms, 均值 63.6ms —— 浏览器 mousemove 由合成器按 60~120Hz 派发, + 连续移动时下限就是 8~17ms, 人手换向/停顿会出现数十到数百毫秒的长间隔。 + + 之前这里用 random.choice([0,0,0,1]), 等于声称"几十次鼠标移动全部发生在 + 20 毫秒内", 物理上不可能 —— 这是被判 errorCode=12 的直接原因。 + """ + r = random.random() + if r < 0.68: + return random.randint(13, 19) # 连续采样 (60~75Hz) + if r < 0.90: + return random.randint(20, 45) # 轻微停顿 + if r < 0.98: + return random.randint(46, 120) # 明显停顿 (换向/瞄准) + return random.randint(121, 400) # 长停顿 + + def generate_trajectory_payload( click_points: List[Tuple[int, int]], target_len: int = 600, @@ -169,27 +190,28 @@ def generate_trajectory_payload( ) events: List[List[int]] = [] - events.append([current_pt[0], current_pt[1], 2]) # 2 represents mouse movement event + # 首个三元组的第三位是"进入验证码区域"的初始停顿 (成功样本实测 601ms) + events.append([current_pt[0], current_pt[1], random.randint(280, 820)]) last_x, last_y = current_pt[0], current_pt[1] for target in mapped_targets: - steps = random.randint(16, 24) + # 真实样本 3 次点击只记到 18 个三元组 -> 每次点击 5~8 个采样点 + steps = random.randint(5, 8) curve = _bezier_interp((last_x, last_y), target, steps) for pt in curve[1:]: dx = pt[0] - last_x dy = pt[1] - last_y - dt = random.choice([0, 0, 0, 1]) - events.append([dx, dy, dt]) + events.append([dx, dy, human_dt_ms()]) last_x, last_y = pt[0], pt[1] # Dwell hover on target icon - dwell_count = random.randint(3, 5) + dwell_count = random.randint(1, 2) for _ in range(dwell_count): dx = random.randint(-1, 1) dy = random.randint(-1, 1) - events.append([dx, dy, random.choice([0, 1])]) + events.append([dx, dy, human_dt_ms()]) last_x += dx last_y += dy @@ -375,6 +397,131 @@ def recover_payload_template(tdc_js_code: str, node_bin: str = "node"): return plain.decode("latin1"), key +# --------------------------------------------------------------------------- # +# 环境指纹的"真实取值" (纯 Python 填值, 与加密无关) +# +# 取值来源: /Users/yml/Desktop/抓包/毫秒/签到成功-1.har 里那次 errorCode=0 的 collect, +# 用该会话自己的 tdc.js 提取密钥解密后读出的真实字段。 +# 之前 Node VM 沙箱跑出来的模板把这些写成了 Apple M1 / 1x1 空白 canvas / 空厂商, +# 与真实 Chrome on macOS 的取值不符 —— 这是被判定为不可信环境的主因。 +# --------------------------------------------------------------------------- # +REAL_SCREEN_W = 2560 +REAL_SCREEN_H = 1440 +REAL_WINDOW_W = 1440 +REAL_PLATFORM = "MacIntel" +# macOS 上真实 Chrome 报的是 ANGLE 包装串, 不是 "Apple M1" +REAL_GL_RENDERER = "ANGLE (Apple, ANGLE Metal Renderer: Apple M4, Unspecified Version)" +REAL_GL_VENDOR = "Google Inc. (Apple)" +# 真实 canvas 指纹 (成功样本原值, 368 bytes PNG) +REAL_CANVAS_FP = ("GgoAAAANSUhEUgAAASwAAACWCAYAAATf3v8bh+ryWcfSBSLkmN3N1PQinPL7Bi3" + "TG0qE2CogAAAABJRU5ErkJggg==") +REAL_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") + +_CANVAS_RE = re.compile(r'"[A-Za-z0-9+/]{40,}={0,2}"') +_RENDERER_RE = re.compile(r'"(?:ANGLE \([^"]*"|[^"]*Apple M\d[^"]*|[^"]*Metal Renderer[^"]*)"') + + +def apply_real_environment(text: str) -> str: + """ + 把明文模板里的环境字段替换为真实画像取值 (纯字符串填值, 与加密无关)。 + + 处理项: + * WebGL 渲染器 -> REAL_GL_RENDERER (ANGLE 串) + * WebGL 厂商 -> REAL_GL_VENDOR + * canvas 指纹 -> REAL_CANVAS_FP + * 屏幕/窗口尺寸 -> 2560 / 1440 + * UA -> REAL_UA + """ + out = text + # 1) 渲染器: 含 ANGLE / Metal / Apple M 的字符串 + out = _RENDERER_RE.sub('"%s"' % REAL_GL_RENDERER, out, count=1) + # 2) 厂商: 只有 Apple / Google Inc. (Apple) 这类短串 + out = re.sub(r'"(?:Apple|Google Inc\. \(Apple\)|Google Inc\.)"', + '"%s"' % REAL_GL_VENDOR, out, count=1) + # 3) canvas 指纹: 取最长的 base64 串 (1x1 空白图也是 base64, 一并换掉) + cands = _CANVAS_RE.findall(out) + if cands: + longest = max(cands, key=len) + out = out.replace(longest, '"%s"' % REAL_CANVAS_FP, 1) + # 4) 尺寸: 只替换独立出现的 1920/1080 (屏幕/窗口宽高) + out = re.sub(r'(? str: + """ + 按「成功签到样本」实测统计生成轨迹 (61 点, dt 均值 30.6ms): + * 首点为绝对坐标(含 300~600ms 初始停顿), 之后为增量 + * 依次经过 3 个目标(验证码 iframe 360x360 显示坐标系), 沿途高频采样 + * dt: 50% 在 0~16ms(浏览器合并事件会出现 0), 25% 17~24, 15% 25~60, + 8% 60~160, 2% 160~400 —— 与成功样本的实测分布一致 + * 移动样本的增量可达 ±30px(快速换目标), 驻留时为 0/±1px + """ + scale = 320.0 / img_w + targets = [(int(round(20 + x * scale)), int(round(60 + y * scale))) + for x, y in click_points] + cur = [random.randint(140, 220), random.randint(300, 340)] + events = [[cur[0], cur[1], random.randint(300, 600)]] + + def dt(): + r = random.random() + if r < 0.50: return random.randint(0, 16) + if r < 0.75: return random.randint(17, 24) + if r < 0.90: return random.randint(25, 60) + if r < 0.98: return random.randint(61, 160) + return random.randint(161, 400) + + for tx, ty in targets: + seg = random.randint(14, 20) + for i in range(1, seg + 1): + nx = cur[0] + (tx - cur[0]) * i / seg + random.randint(-1, 1) + ny = cur[1] + (ty - cur[1]) * i / seg + random.randint(-1, 1) + events.append([int(nx) - cur[0], int(ny) - cur[1], dt()]) + cur = [int(nx), int(ny)] + for _ in range(random.randint(2, 5)): # 驻留 + events.append([random.randint(-1, 1), random.randint(-1, 1), dt()]) + return json.dumps(events, separators=(",", ":")) + + +def generate_collect_success( + click_points: List[Tuple[int, int]], + key: List[int], + entry_url: str = "https://1ms.run/console/personal/benefits/check-in", +) -> str: + """成功样本模板 + Python 回填。加密/组包/编码全部纯 Python。""" + if not _SUCCESS_TPL_PATH.exists(): + raise FileNotFoundError(f"缺少成功样本模板: {_SUCCESS_TPL_PATH}") + text = base64.b64decode(_SUCCESS_TPL_PATH.read_text(encoding="ascii")).decode("latin1") + text = text.replace( + '"https://1ms.run/console/personal/benefits/check-in"', '"%s"' % entry_url, 1) + text = _TRAJ_RE.sub(lambda m: _success_style_trajectory(click_points), text, count=1) + now = int(time.time()) + text = text.replace("1791715501", str(now), 1) # 采集时刻 + text = text.replace("1791715508", str(now + random.randint(4, 12)), 1) + text = text.replace("1791706326", str(now - random.randint(6000, 12000)), 1) + text = text.replace("699129805", str(random.randint(100000000, 999999999)), 1) + data = text.encode("latin1") + data += b"\x00" * ((8 - len(data) % 8) % 8) # 成功样本用 \x00 填充 + return urllib.parse.quote_from_bytes( + base64.b64encode(xtea_encrypt_bytes(data, key)), safe="") + + def generate_collect_from_template( template: str, click_points: List[Tuple[int, int]], @@ -390,6 +537,8 @@ def generate_collect_from_template( """ text = template.decode("latin1") if isinstance(template, (bytes, bytearray)) else template + text = apply_real_environment(text) + traj = generate_trajectory_payload(click_points, target_len=0).rstrip() m = _TRAJ_RE.search(text) if not m: @@ -702,9 +851,9 @@ def generate_telemetry( return generate_telemetry_node(tdc_js_code, click_points, aid=aid, user_agent=user_agent, referrer=referrer) if kind == "python_keyed": - # Node 只负责跑出 XTEA 密钥 + 标定一次明文模板; 加密与组包全在 Python - collect, key = generate_collect_pure(tdc_js_code, click_points, aid=aid, - user_agent=user_agent) + # 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} diff --git a/tcaptcha/manual_captcha.js b/tcaptcha/manual_captcha.js new file mode 100644 index 0000000..56b7868 --- /dev/null +++ b/tcaptcha/manual_captcha.js @@ -0,0 +1,171 @@ +#!/usr/bin/env node +/** + * 纯监听模式: 只打开真实验证码页面, **不派发任何鼠标事件**。 + * 由人工手动点选, 脚本在后台记录官方 SDK 自己发出的 verify 请求与响应, + * 用来判定「人手动过验证码是否也会被 errorCode=12 拒绝」。 + * + * 用法: node manual_captcha.js [--proxy=http://ip:port] [--minutes=20] + */ +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const CHROME = process.env.REA_BROWSER_EXECUTABLE || + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const ORIGIN = process.argv[2] || 'https://1ms.run/'; +const AID = process.argv[3] || '195672344'; +const PROXY_ARG = process.argv.find(a => a.startsWith('--proxy=')); +const PROXY = PROXY_ARG ? PROXY_ARG.slice(8) : null; +const MIN_ARG = process.argv.find(a => a.startsWith('--minutes=')); +const MINUTES = MIN_ARG ? parseInt(MIN_ARG.slice(10), 10) : 20; +const MONITOR_ONLY = process.argv.includes('--monitor-only'); +const OUT = '/tmp/1ms'; +fs.mkdirSync(OUT, { recursive: true }); +const LOG = fs.createWriteStream(path.join(OUT, 'manual.log'), { flags: 'a' }); +const say = m => { const line = `[${new Date().toISOString().slice(11, 19)}] ${m}`; + process.stderr.write(line + '\n'); LOG.write(line + '\n'); }; +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +async function launchChrome(profile) { + const args = ['--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', + `--user-data-dir=${profile}`, '--window-size=1100,900', '--window-position=80,40']; + if (PROXY) args.push(`--proxy-server=${PROXY}`); + args.push('about:blank'); + const proc = spawn(CHROME, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const portFile = path.join(profile, 'DevToolsActivePort'); + for (let i = 0; i < 150; i++) { + if (fs.existsSync(portFile)) { + const p = parseInt(fs.readFileSync(portFile, 'utf8').split('\n')[0], 10); + if (p) return { proc, port: p, profile }; + } + await sleep(100); + } + proc.kill('SIGKILL'); + throw new Error('Chrome 未就绪'); +} + +class Cdp { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.handlers = []; } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new Cdp(ws); + ws.onmessage = ev => { + const m = JSON.parse(ev.data); + if (m.id && c.waits.has(m.id)) { + const { res, rej } = c.waits.get(m.id); + c.waits.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } else if (m.method) c.handlers.forEach(h => h(m)); + }; + return c; + } + send(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((res, rej) => { + this.waits.set(id, { res, rej }); + this.ws.send(JSON.stringify({ id, method, params, sessionId })); + }); + } + async eval(expr, sessionId) { + const r = await this.send('Runtime.evaluate', + { expression: expr, awaitPromise: true, returnByValue: true }, sessionId); + return r.exceptionDetails ? null : r.result.value; + } +} + +async function main() { + const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'manual-cap-')); + const { proc, port } = await launchChrome(profile); + try { + const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(); + const cdp = await Cdp.connect(ver.webSocketDebuggerUrl); + const { targetId } = await cdp.send('Target.createTarget', { url: ORIGIN }); + const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }); + await cdp.send('Page.enable', {}, sessionId); + await cdp.send('Runtime.enable', {}, sessionId); + await cdp.send('Network.enable', {}, sessionId); + await sleep(3000); + + const pending = new Set(); + let seq = 0; + cdp.handlers.push(m => { + if (m.method === 'Network.requestWillBeSent' && + /cap_union_new_verify/.test(m.params.request.url)) { + const n = ++seq; + pending.add(m.params.requestId); + fs.writeFileSync(path.join(OUT, `manual_verify_req_${n}.txt`), + m.params.request.postData || ''); + say(`官方 verify #${n} 已发出 (body ${(m.params.request.postData || '').length} 字节)`); + } + if (m.method === 'Network.loadingFinished' && pending.has(m.params.requestId)) { + const id = m.params.requestId; + pending.delete(id); + (async () => { + try { + const rb = await cdp.send('Network.getResponseBody', { requestId: id }, sessionId); + const txt = rb.base64Encoded + ? Buffer.from(rb.body, 'base64').toString('utf8') : rb.body; + let code = '?'; + try { code = JSON.parse(txt).errorCode; } catch (_) {} + const rec = { errorCode: code, response: txt, at: new Date().toISOString() }; + fs.writeFileSync(path.join(OUT, 'manual_result.json'), + JSON.stringify(rec, null, 2)); + say(`★★★★ verify 响应 errorCode=${code} 已写入 ${OUT}/manual_result.json`); + } catch (e) { say('取响应体失败: ' + e.message); } + })(); + } + }); + + if (MONITOR_ONLY) { + say('====== [仅监听] 不做任何注入, 请在窗口里登录并走真实签到流程 ======'); + say('====== 真实验证码出现后请手动完成, 我会记录 verify 结果 ======'); + const until2 = Date.now() + MINUTES * 60000; + while (Date.now() < until2) { + await sleep(3000); + const cb = await cdp.eval('window.__capcb || ""', sessionId).catch(() => ''); + if (cb) say('SDK 回调: ' + cb); + } + return; + } + await cdp.eval(`(async () => { + window.__capcb = null; + await new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js'; + s.onload = res; s.onerror = () => rej(new Error('sdk')); + document.head.appendChild(s); + }); + new TencentCaptcha(${JSON.stringify(AID)}, r => { + window.__capcb = JSON.stringify(r); + document.title = 'CAPCB:' + JSON.stringify(r).slice(0, 80); + }).show(); + return 'ok'; + })()`, sessionId); + + say('====== 页面已就绪, 验证码已弹出 ======'); + say('====== 请手动点选汉字 -> 点「确定」, 我不做任何点击 ======'); + say(`====== 代理: ${PROXY || '直连'} 监听 ${MINUTES} 分钟 ======`); + + const until = Date.now() + MINUTES * 60000; + let lastCb = ''; + while (Date.now() < until) { + await sleep(3000); + const cb = await cdp.eval('window.__capcb || ""', sessionId); + if (cb && cb !== lastCb) { + lastCb = cb; + say('★★★ SDK 回调 (成功才会触发): ' + cb); + try { + fs.writeFileSync(path.join(OUT, 'manual_result.json'), + JSON.stringify({ callback: JSON.parse(cb) }, null, 2)); + } catch (_) {} + } + } + say('监听结束, 浏览器保持打开'); + } catch (e) { + say('异常: ' + e.message); + } +} + +main().catch(e => { console.error('ERR', e.message); process.exit(1); }); diff --git a/tcaptcha/models/success_collect_template.b64 b/tcaptcha/models/success_collect_template.b64 new file mode 100644 index 0000000..87dcb92 --- /dev/null +++ b/tcaptcha/models/success_collect_template.b64 @@ -0,0 +1 @@ +eyJjZCI6WzI1NjAsIk1hY0ludGVsIiwiR2dvQUFBQU5TVWhFVWdBQUFTd0FBQUNXQ0FZQUFBVGYzdjhiaCtyeVdjZlNCU0xrbU4zTjFQUWluUEw3QmkzVEcwcUUyQ29nQUFBQUJKUlU1RXJrSmdnZz09IiwiVVRGLTgiLDIsMCwwLDE1OTAyOTkxNzgsImh0dHBzOi8vMW1zLnJ1bi9jb25zb2xlL3BlcnNvbmFsL2JlbmVmaXRzL2NoZWNrLWluIiwiTW96aWxsYS81LjAgKE1hY2ludG9zaDsgSW50ZWwgTWFjIE9TIFggMTBfMTVfNykgQXBwbGVXZWJLaXQvNTM3LjM2IChLSFRNTCwgbGlrZSBHZWNrbykgQ2hyb21lLzE1NS4wLjAuMCBTYWZhcmkvNTM3LjM2IiwwLDI0LFtdLCIiLDE0NDAsIkFOR0xFIChBcHBsZSwgQU5HTEUgTWV0YWwgUmVuZGVyZXI6IEFwcGxlIE00LCBVbnNwZWNpZmllZCBWZXJzaW9uKSIsMTAsIiswOCIsIkdvb2dsZSBJbmMuIChBcHBsZSkiLDE3OTE3MTU1MDEsICAgICAgICAgICAgICAgW1szMjMsNTksNjAxXSxbLTI3LDgsMTddLFstNSwxLDE4XSxbLTIsMSwxNl0sWy0yLDAsMThdLFswLDAsMTA0XSxbMCwwLDM0XSxbLTEsMSwxNF0sWy0zLDMsMTVdLFstMiwxLDE3XSxbLTEyLDEzLDE3XSxbLTEzLDE2LDE2XSxbLTExLDE1LDE4XSxbLTEzLDE0LDE2XSxbLTE0LDE0LDE2XSxbLTMsMywxN10sWy0zLDMsMTddLFswLDAsMTZdLFstMSwzLDE3XSxbMCwxLDE3XSxbLTEsMSwyMV0sWzAsMCwxNjJdLFswLDAsMTddLFswLDAsMTddLFswLDMsMTZdLFstMSwzLDE3XSxbLTEyLDIzLDE2XSxbLTUsOCwxN10sWy04LDEzLDE2XSxbLTIsMywxN10sWy0yLDUsMTddLFstMyw0LDE1XSxbLTIsNCwxN10sWy03LDcsMTZdLFstMTIsOSwzNV0sWy0xMCw0LDBdLFstNiwyLDE0XSxbLTEsMCwxN10sWy00LDEsMTddLFswLDAsMTZdLFstMSwwLDE4XSxbMCwwLDE2XSxbMCwwLDE3XSxbLTMsLTEsMTZdLFstMSwwLDI1XSxbLTYsLTEsOF0sWy0xLDAsMTddLFstNCwtMSwxNl0sWy01LDAsMTddLFstMiwwLDE4XSxbLTQsMCwxNl0sWy01LDAsMTddLFswLDAsMTZdLFstMywxLDE2XSxbLTEsMCwxOV0sWzAsMCwxNV0sWy0xLDAsMTddLFswLDAsMTddLFswLDAsMzNdLFswLDAsMThdLFsxLDEsMTJdXSAgICAgICAgLFsiemgtQ04iLCJ6aCIsImZyIiwiZGUiLCJlbiJdLDE3OTE3MTU1MDgsImh0dHBzOi8vdHVyaW5nLmNhcHRjaGEuZ3RpbWcuY29tLzEvdGVtcGxhdGUvZHJhZ19lbGUuaHRtbD9yYW5kPTE1MTk3MTM2MjQzNDciLDAsIjk4ayIsNjk5MTI5ODA1LDE3OTE3MDYzMjYsIjI1NjAtMTQ0MC0xMzQ1LTI0LSotKi18LSoiLDEsWzM2MCwzNjBdLCJpZnJhbWUiLDEwMjMsMSwiIiwwLDAsIiJdLCAgICAgICAgICAgICAgICAgICAgICAgInNkIjp7Im9kIjoiQyIsImZ0IjoicWZfN1BmX19IIn19 \ No newline at end of file diff --git a/tcaptcha/probe_captcha.js b/tcaptcha/probe_captcha.js new file mode 100644 index 0000000..59c70a5 --- /dev/null +++ b/tcaptcha/probe_captcha.js @@ -0,0 +1,134 @@ +#!/usr/bin/env node +/** + * 在真实浏览器里用目标 aid 实例化验证码, 抓出它自己发出的真实请求。 + * + * 与 tdc_worker / 纯协议实现的区别: 这里 Origin 是真业务域, SDK 是腾讯当前版本, + * 请求参数完全由官方前端构造 —— 用来和我们的协议实现做逐字段对照。 + * + * 用法: node probe_captcha.js [waitMs] + */ +const { spawn } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const CHROME = process.env.REA_BROWSER_EXECUTABLE || + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const ORIGIN = process.argv[2] || 'https://1ms.run/'; +const AID = process.argv[3] || '195672344'; +const WAIT = parseInt(process.argv[4] || '12000', 10); + +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +async function launchChrome() { + const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'probe-cap-')); + const proc = spawn(CHROME, [ + '--headless=new', '--remote-debugging-port=0', '--no-first-run', + '--no-default-browser-check', '--disable-gpu', '--disable-dev-shm-usage', + `--user-data-dir=${profile}`, '--window-size=1280,900', 'about:blank', + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + const portFile = path.join(profile, 'DevToolsActivePort'); + let port = null; + for (let i = 0; i < 150; i++) { + if (fs.existsSync(portFile)) { + port = parseInt(fs.readFileSync(portFile, 'utf8').split('\n')[0], 10); + if (port) break; + } + await sleep(100); + } + if (!port) { proc.kill('SIGKILL'); throw new Error('Chrome 未就绪'); } + return { proc, port, profile }; +} + +class Cdp { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.on = () => {}; } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new Cdp(ws); + ws.onmessage = ev => { + const m = JSON.parse(ev.data); + if (m.id && c.waits.has(m.id)) { + const { res, rej } = c.waits.get(m.id); + c.waits.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } else if (m.method) c.on(m); + }; + return c; + } + send(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((res, rej) => { + this.waits.set(id, { res, rej }); + this.ws.send(JSON.stringify({ id, method, params, sessionId })); + }); + } +} + +async function main() { + const { proc, port, profile } = await launchChrome(); + const captured = []; + let sessionId = null; + try { + const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(); + const cdp = await Cdp.connect(ver.webSocketDebuggerUrl); + const { targetId } = await cdp.send('Target.createTarget', { url: ORIGIN }); + ({ sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true })); + cdp.on = m => { + if (m.method === 'Network.requestWillBeSent') { + const r = m.params.request; + if (/turing\.captcha|cap_union/.test(r.url)) { + captured.push({ url: r.url, method: r.method, headers: r.headers, + postData: (r.postData || '').slice(0, 4000) }); + } + } else if (m.method === 'Runtime.consoleAPICalled') { + const t = (m.params.args || []).map(a => a.value).join(' '); + if (t) process.stderr.write('[console] ' + t.slice(0, 300) + '\n'); + } + }; + await cdp.send('Network.enable', {}, sessionId); + await cdp.send('Page.enable', {}, sessionId); + await cdp.send('Runtime.enable', {}, sessionId); + await sleep(3000); + + const loc = await cdp.send('Runtime.evaluate', + { expression: 'location.href', returnByValue: true }, sessionId); + process.stderr.write('[page] ' + loc.result.value + '\n'); + + // 注入官方 SDK 并实例化目标 aid 的验证码 + const inject = `(async () => { + window.__capcb = null; + await new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js'; + s.onload = res; s.onerror = () => rej(new Error('SDK 加载失败')); + document.head.appendChild(s); + }); + if (typeof TencentCaptcha !== 'function') + return 'TencentCaptcha 未定义: ' + Object.keys(window).filter(k=>/captcha/i.test(k)).join(','); + window.__cap = new TencentCaptcha(${JSON.stringify(AID)}, function (res) { + window.__capcb = JSON.stringify(res); + }); + window.__cap.show(); + return 'shown'; + })()`; + const r = await cdp.send('Runtime.evaluate', + { expression: inject, awaitPromise: true, returnByValue: true }, sessionId); + process.stderr.write('[inject] ' + JSON.stringify(r.result.value || r.exceptionDetails) + '\n'); + + await sleep(WAIT); + + // 如果验证码已经自己完成 (拖动/点选由我们后续处理), 这里先看回调 + const cb = await cdp.send('Runtime.evaluate', + { expression: 'window.__capcb || ""', returnByValue: true }, sessionId); + if (cb.result.value) process.stderr.write('[callback] ' + cb.result.value + '\n'); + + console.log(JSON.stringify({ captured }, null, 1)); + cdp.ws.close(); + } finally { + proc.kill('SIGKILL'); + try { fs.rmSync(profile, { recursive: true, force: true }); } catch (_) {} + } +} + +main().catch(e => { console.error('ERR', e.message); process.exit(1); }); diff --git a/tcaptcha/solve_in_browser.js b/tcaptcha/solve_in_browser.js new file mode 100644 index 0000000..0906ba3 --- /dev/null +++ b/tcaptcha/solve_in_browser.js @@ -0,0 +1,323 @@ +#!/usr/bin/env node +/** + * 端到端真实浏览器求解: 官方 SDK 实例化验证码 -> 本仓库视觉求解器算坐标 + * -> CDP 可信鼠标事件在 iframe 内真实点击 -> 由 SDK 自己提交 verify -> 读回调 + * + * 判定「服务端到底在检测什么」的终极实验: + * 若官方 SDK + 真实浏览器点选也拿不到 ticket, 则检测在 aid/环境层, 与协议实现无关。 + * + * 用法: node solve_in_browser.js [--headed] + */ +const { spawn, execFileSync } = require('child_process'); +const fs = require('fs'); +const os = require('os'); +const path = require('path'); + +const CHROME = process.env.REA_BROWSER_EXECUTABLE || + '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const ORIGIN = process.argv[2] || 'https://1ms.run/'; +const AID = process.argv[3] || '195672344'; +const HEADED = process.argv.includes('--headed'); +const KEEP_OPEN = process.argv.includes('--keep-open'); +const PROXY_ARG = process.argv.find(a => a.startsWith('--proxy=')); +const PROXY = PROXY_ARG ? PROXY_ARG.slice(8) : null; +const PY = path.resolve(__dirname, '..', '.venv', 'bin', 'python'); +const ROOT = path.resolve(__dirname, '..'); +const TMP = fs.mkdtempSync(path.join(os.tmpdir(), 'cap-img-')); +const OUT = '/tmp/1ms'; +try { fs.mkdirSync(OUT, { recursive: true }); } catch (_) {} +const sleep = ms => new Promise(r => setTimeout(r, ms)); + +async function launchChrome() { + const profile = fs.mkdtempSync(path.join(os.tmpdir(), 'solve-cap-')); + const args = ['--remote-debugging-port=0', '--no-first-run', '--no-default-browser-check', + '--disable-dev-shm-usage', `--user-data-dir=${profile}`, '--window-size=1100,900']; + if (PROXY) args.push(`--proxy-server=${PROXY}`); + if (!HEADED) args.push('--headless=new', '--disable-gpu'); + args.push('about:blank'); + const proc = spawn(CHROME, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const portFile = path.join(profile, 'DevToolsActivePort'); + let port = null; + for (let i = 0; i < 150; i++) { + if (fs.existsSync(portFile)) { + port = parseInt(fs.readFileSync(portFile, 'utf8').split('\n')[0], 10); + if (port) break; + } + await sleep(100); + } + if (!port) { proc.kill('SIGKILL'); throw new Error('Chrome 未就绪'); } + return { proc, port, profile }; +} + +class Cdp { + constructor(ws) { this.ws = ws; this.id = 0; this.waits = new Map(); this.handlers = []; } + static async connect(url) { + const ws = new WebSocket(url); + await new Promise((res, rej) => { ws.onopen = res; ws.onerror = rej; }); + const c = new Cdp(ws); + ws.onmessage = ev => { + const m = JSON.parse(ev.data); + if (m.id && c.waits.has(m.id)) { + const { res, rej } = c.waits.get(m.id); + c.waits.delete(m.id); + m.error ? rej(new Error(JSON.stringify(m.error))) : res(m.result); + } else if (m.method) c.handlers.forEach(h => h(m)); + }; + return c; + } + send(method, params = {}, sessionId) { + const id = ++this.id; + return new Promise((res, rej) => { + this.waits.set(id, { res, rej }); + this.ws.send(JSON.stringify({ id, method, params, sessionId })); + }); + } + async eval(expr, sessionId) { + const r = await this.send('Runtime.evaluate', + { expression: expr, awaitPromise: true, returnByValue: true }, sessionId); + if (r.exceptionDetails) throw new Error('eval: ' + JSON.stringify(r.exceptionDetails).slice(0, 300)); + return r.result.value; + } +} + +const PY_SOLVE = ` +import sys, json +sys.path.insert(0, ${JSON.stringify(ROOT)}) +from tcaptcha.glyph_solver import TextClickSolver +res = TextClickSolver().solve_ex(open(sys.argv[1],'rb').read(), sys.argv[2]) +print(json.dumps({"ok": res.ok, "posterior": res.posterior, "points": res.points})) +`; + +async function main() { + const { proc, port, profile } = await launchChrome(); + try { + const ver = await (await fetch(`http://127.0.0.1:${port}/json/version`)).json(); + const cdp = await Cdp.connect(ver.webSocketDebuggerUrl); + const { targetId } = await cdp.send('Target.createTarget', { url: ORIGIN }); + const { sessionId } = await cdp.send('Target.attachToTarget', { targetId, flatten: true }); + await cdp.send('Page.enable', {}, sessionId); + await cdp.send('Runtime.enable', {}, sessionId); + await cdp.send('Network.enable', {}, sessionId); + let imgReqId = null; + const pendingVerify = new Set(); + let verifyResults = []; + let verifySeq = 0; + cdp.handlers.push(m => { + if (m.method === 'Network.requestWillBeSent' && /getcapbysig/.test(m.params.request.url) + && /img_index=1/.test(m.params.request.url)) { + imgReqId = m.params.requestId; + } + if (m.method === 'Network.requestWillBeSent' && + /cap_union_new_verify/.test(m.params.request.url)) { + const id = m.params.requestId; + pendingVerify.add(id); + const n = ++verifySeq; + // 把官方 SDK 自己发的 verify 请求体落盘, 便于与我们纯协议实现的做逐字段对照 + try { fs.writeFileSync(path.join(OUT, `official_verify_${n}.txt`), + m.params.request.postData || ''); } catch (_) {} + process.stderr.write(`[net] 官方 verify #${n} 已发出 (body ` + + ((m.params.request.postData || '').length) + ' 字节)\n'); + } + // 竞态修复: 用 loadingFinished 立即取响应体, 不等主循环轮询 + if (m.method === 'Network.loadingFinished' && pendingVerify.has(m.params.requestId)) { + const id = m.params.requestId; + pendingVerify.delete(id); + (async () => { + try { + const rb = await cdp.send('Network.getResponseBody', { requestId: id }, sessionId); + const txt = rb.base64Encoded + ? Buffer.from(rb.body, 'base64').toString('utf8') : rb.body; + let code = '?'; + try { code = JSON.parse(txt).errorCode; } catch (_) {} + verifyResults.push({ code, txt }); + try { fs.writeFileSync(path.join(OUT, `official_verify_resp_${verifyResults.length}.json`), txt); } catch (_) {} + process.stderr.write(` [net] 官方 verify 响应 -> errorCode=${code}\n`); + } catch (e) { + process.stderr.write(' [net] 取响应体失败: ' + e.message + '\n'); + } + })(); + } + }); + await sleep(3000); + + +// ---- 类人鼠标: 三次贝塞尔路径 + 变速时间戳 + 微抖动 ---- +// 之前的实现只发 3 次"瞬移 + 按下", tdc.js 记到的轨迹是纯机器人的。 +function humanPath(x0, y0, x1, y1, n) { + const j = () => (Math.random() - 0.5) * 36; + const cx1 = x0 + (x1 - x0) * 0.3 + j(), cy1 = y0 + (y1 - y0) * 0.3 + j(); + const cx2 = x0 + (x1 - x0) * 0.7 + j(), cy2 = y0 + (y1 - y0) * 0.7 + j(); + const pts = []; + for (let i = 1; i <= n; i++) { + const t = i / n, m = 1 - t; + pts.push([ + m * m * m * x0 + 3 * m * m * t * cx1 + 3 * m * t * t * cx2 + t * t * t * x1, + m * m * m * y0 + 3 * m * m * t * cy1 + 3 * m * t * t * cy2 + t * t * t * y1, + ]); + } + return pts; +} + + // ---- 循环抽题: 只处理文字点选(题面含汉字), 其余重新出题 ---- + let cur = { x: 520 + Math.random() * 120, y: 380 + Math.random() * 120 }; + let vt = Date.now() / 1000; // 事件时间轴 (秒) + const moveTo = async (tx, ty) => { + const pts = humanPath(cur.x, cur.y, tx, ty, 12 + Math.floor(Math.random() * 14)); + for (const [px, py] of pts) { + vt += 0.008 + Math.random() * 0.018; // 8~26ms 一个采样点 + await cdp.send('Input.dispatchMouseEvent', + { type: 'mouseMoved', x: px, y: py, button: 'none', timestamp: vt }, sessionId); + } + cur = { x: tx, y: ty }; + }; + const clickAt = async (tx, ty) => { + await moveTo(tx, ty); + vt += 0.05 + Math.random() * 0.12; + await cdp.send('Input.dispatchMouseEvent', + { type: 'mousePressed', x: tx, y: ty, button: 'left', clickCount: 1, timestamp: vt }, sessionId); + vt += 0.04 + Math.random() * 0.07; + await cdp.send('Input.dispatchMouseEvent', + { type: 'mouseReleased', x: tx, y: ty, button: 'left', clickCount: 1, timestamp: vt }, sessionId); + vt += 0.3 + Math.random() * 0.6; // 两击之间的自然停顿 + }; + + for (let attempt = 0; attempt < 10; attempt++) { + imgReqId = null; verifyReqId = null; + await cdp.eval(`(async () => { + if (window.__cap && window.__cap.destroy) { try { window.__cap.destroy(); } catch(e){} } + window.__capcb = null; + if (typeof TencentCaptcha !== 'function') { + await new Promise((res, rej) => { + const s = document.createElement('script'); + s.src = 'https://turing.captcha.qcloud.com/TJCaptcha.js'; + s.onload = res; s.onerror = () => rej(new Error('sdk')); + document.head.appendChild(s); + }); + } + window.__cap = new TencentCaptcha(${JSON.stringify(AID)}, r => { + window.__capcb = JSON.stringify(r); }); + window.__cap.show(); + return 'ok'; + })()`, sessionId); + await sleep(5000); + if (!imgReqId) { process.stderr.write(`[${attempt}] 未捕获挑战图\n`); continue; } + + const meta = JSON.parse(await cdp.eval(`(() => { + const cand = [...document.querySelectorAll('img,canvas,div')] + .map(e => ({ e, r: e.getBoundingClientRect() })) + .filter(o => o.r.width > 200 && o.r.height > 150 && + (o.e.tagName !== 'DIV' || + getComputedStyle(o.e).backgroundImage.indexOf('url(') >= 0)); + cand.sort((a, b) => b.r.width * b.r.height - a.r.width * a.r.height); + // 优先用真实图片区域; 按尺寸猜会选中 tencent-captcha-dy__bg-placeholder + // (一张 7x5 的占位图, 被拉伸到 330x235, 位置与可视图片不一致) + const area = document.querySelector('.tencent-captcha-dy__image-area'); + const big = area ? { e: area, r: area.getBoundingClientRect() } : cand[0]; + const t = [...document.querySelectorAll('*')] + .filter(e => e.children.length === 0 && /请依次点击/.test(e.textContent || '')) + .map(e => e.textContent.trim()); + const path = []; + for (let e = big && big.e; e && e.tagName; e = e.parentElement) { + path.push(e.tagName + (e.id ? '#' + e.id : '') + + (e.className && typeof e.className === 'string' + ? '.' + e.className.trim().split(/\s+/).slice(0, 3).join('.') : '')); + if (path.length > 5) break; + } + const iframes = [...document.querySelectorAll('iframe')] + .map(f => ({ src: (f.src || '').slice(0, 70), + w: f.getBoundingClientRect().width, + h: f.getBoundingClientRect().height })); + return JSON.stringify({ + instruction: t[0] || '', + rect: big ? { x: big.r.left, y: big.r.top, w: big.r.width, h: big.r.height } : null, + tag: big ? big.e.tagName : null, + bg: big ? getComputedStyle(big.e).backgroundImage.slice(0, 90) : null, + src: big && big.e.src ? big.e.src.slice(0, 90) : null, + path, iframes, + }); + })()`, sessionId)); + process.stderr.write(' DOM: ' + JSON.stringify({ tag: meta.tag, bg: meta.bg, + src: meta.src, path: meta.path, iframes: meta.iframes }) + '\n'); + // 注意: 题面里汉字之间是有空格的 ("请依次点击:齿 奥 柏 "), + // 不能写成 [\u4e00-\u9fa5]{3} 连排, 否则永远匹配不上 (空转 bug)。 + const m2 = /请依次点击:\s*(.+?)\s*$/.exec(meta.instruction); + const toks = m2 ? m2[1].split(/\s+/).filter(Boolean) : []; + const isText = toks.length === 3 && toks.every(t => /^[\u4e00-\u9fa5]{1,2}$/.test(t)); + process.stderr.write(`[${attempt}] 题面=${JSON.stringify(meta.instruction)} 目标=${JSON.stringify(toks)} ` + + `文字点选=${isText} rect=${!!meta.rect}\n`); + if (!isText || !meta.rect) { process.stderr.write(' -> 非文字点选, 换题\n'); continue; } + + const body = await cdp.send('Network.getResponseBody', { requestId: imgReqId }, sessionId); + const buf = body.base64Encoded ? Buffer.from(body.body, 'base64') : Buffer.from(body.body, 'utf8'); + const imgPath = path.join(TMP, `cap_${attempt}.jpg`); + fs.writeFileSync(imgPath, buf); + const solved = JSON.parse(execFileSync(PY, ['-c', PY_SOLVE, imgPath, meta.instruction], + { encoding: 'utf8' })); + process.stderr.write(` 求解 ${JSON.stringify(solved.points)} post=${solved.posterior.toFixed(4)}\n`); + if (!solved.ok) continue; + + const shot = async (name) => { + try { + const r = await cdp.send('Page.captureScreenshot', { format: 'png' }, sessionId); + const f = path.join(OUT, name); + fs.writeFileSync(f, Buffer.from(r.data, 'base64')); + process.stderr.write(' [截图] ' + f + '\n'); + } catch (e) { process.stderr.write(' [截图失败] ' + e.message + '\n'); } + }; + await shot(`shot_${attempt}_before.png`); + const scale = meta.rect.w / 672.0; + await sleep(1200 + Math.random() * 2200); // 人读题需要时间 + vt = Date.now() / 1000; + for (const [x, y] of solved.points) { + await clickAt(meta.rect.x + x * scale, meta.rect.y + y * scale); + } + await sleep(800); + // 关键: 点选类验证码点完目标后必须再点「确定」, SDK 才会提交 verify + const btn = await cdp.eval(`(() => { + const all = [...document.querySelectorAll('div,button,a,span')]; + const b = all.filter(e => (e.textContent || '').trim() === '确定' && + e.getBoundingClientRect().width > 40)[0]; + if (!b) return null; + const r = b.getBoundingClientRect(); + return JSON.stringify({ x: r.left + r.width / 2, y: r.top + r.height / 2 }); + })()`, sessionId); + if (btn) { + const b = JSON.parse(btn); + await clickAt(b.x, b.y); + process.stderr.write(' 已沿类人轨迹点击「确定」按钮\n'); + } else { + process.stderr.write(' [warn] 未找到「确定」按钮\n'); + } + await sleep(1200); + await shot(`shot_${attempt}_after.png`); + process.stderr.write(' 三次点击 + 确定 已派发, 等 verify...\n'); + + const before = verifyResults.length; + for (let i = 0; i < 12; i++) { + await sleep(1000); + if (verifyResults.length > before) { + const r = verifyResults[verifyResults.length - 1]; + process.stderr.write(` [结果] 官方 SDK verify -> errorCode=${r.code}\n`); + console.log(JSON.stringify({ attempt, instruction: meta.instruction, + points: solved.points, errorCode: r.code, + response: r.txt.slice(0, 400) })); + return; + } + const cb = await cdp.eval('window.__capcb || ""', sessionId); + if (cb) { process.stderr.write(' [回调] ' + cb + '\n'); console.log(cb); return; } + } + process.stderr.write(' 本轮无 verify 响应, 换题重试\n'); + } + process.stderr.write('[x] 10 轮内未拿到文字点选的 verify 结果\n'); + } finally { + if (KEEP_OPEN) { + process.stderr.write('[i] --keep-open: 浏览器保持打开, 你可以在窗口里直接看\n'); + } else { + proc.kill('SIGKILL'); + try { fs.rmSync(profile, { recursive: true, force: true }); } catch (_) {} + } + try { fs.rmSync(TMP, { recursive: true, force: true }); } catch (_) {} + } +} + +main().catch(e => { console.error('ERR', e.message); process.exit(1); });