#!/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); });