From 990ab9dcd30b0810827ee8c57c4c0333345d97a6 Mon Sep 17 00:00:00 2001 From: Wraient Date: Sat, 22 Aug 2026 20:48:17 +0530 Subject: [PATCH 1/3] =?UTF-8?q?feat(captcha):=20pluggable=20solver=20backe?= =?UTF-8?q?nds=20=E2=80=94=20happy-dom=20happy=20path,=20playwright,=20nat?= =?UTF-8?q?ive=20reference?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds captcha_node/ — a standalone Node.js solver runtime driven by a daemon (bounded-concurrency worker pool), plus a backend switch in src/proxy/captcha.ts via ZCODE_CAPTCHA_BACKEND: - happy (happy-dom): production-proven happy path. ~815ms CPU/solve, ~260-330ms with CAPTCHA_WINDOW_REUSE=1. No browser install, more IP-tolerant than jsdom. Validated end-to-end live (verify tokens minted). - jsdom: unchanged default — the existing in-process path in captcha.ts still handles ZCODE_CAPTCHA_BACKEND=jsdom (or unset). - playwright: Chromium backend for last-resort compatibility debugging. - native (native_solve2.js): pure-HTTP solver (~600ms, no DOM). Currently rejected upstream with F001 from most IPs — included as a working protocol reference; not wired into the default flow. The daemon keeps DOM/Chromium work out of the Bun event loop and bounds memory by recycling workers (RSS cap + max-solves per worker, duplicate certifyId rejection). Full backend details and benchmarks: captcha_node/README.md --- captcha_node/README.md | 150 +++ captcha_node/daemon.js | 276 +++++ captcha_node/native_solve.js | 1372 ++++++++++++++++++++++ captcha_node/native_solve2.js | 651 +++++++++++ captcha_node/package.json | 20 + captcha_node/solve-core.js | 1733 ++++++++++++++++++++++++++++ captcha_node/solve-happy-lib.js | 1851 ++++++++++++++++++++++++++++++ captcha_node/solve-playwright.js | 276 +++++ captcha_node/solver.js | 22 + captcha_node/worker.js | 233 ++++ src/proxy/captcha-solver.ts | 298 +++++ src/proxy/captcha.ts | 46 +- 12 files changed, 6927 insertions(+), 1 deletion(-) create mode 100644 captcha_node/README.md create mode 100644 captcha_node/daemon.js create mode 100644 captcha_node/native_solve.js create mode 100644 captcha_node/native_solve2.js create mode 100644 captcha_node/package.json create mode 100644 captcha_node/solve-core.js create mode 100644 captcha_node/solve-happy-lib.js create mode 100644 captcha_node/solve-playwright.js create mode 100644 captcha_node/solver.js create mode 100644 captcha_node/worker.js create mode 100644 src/proxy/captcha-solver.ts diff --git a/captcha_node/README.md b/captcha_node/README.md new file mode 100644 index 0000000..2e301f5 --- /dev/null +++ b/captcha_node/README.md @@ -0,0 +1,150 @@ +# captcha_node — solver backends & benchmarks + +Aliyun NoCaptcha (traceless) solvers for the start-plan flow. The proxy (`src/proxy/captcha*.ts`) +drives these via `daemon.js`/`worker.js`; pick a backend with `ZCODE_CAPTCHA_BACKEND`. + +## Backends + +| Backend | `ZCODE_CAPTCHA_BACKEND` | Status | Use when | +|---|---|---|---| +| happy-dom | `happy` (or unset — see note) | **production-proven** | default choice | +| jsdom | `jsdom` (current code default) | working but slower; F001-prone on some IPs | local testing only | +| playwright | `playwright` | working; heaviest | last-resort compatibility | +| headless VM | `headless_solve.js` | **WIP — does not complete** | research only, see below | + +> **Note:** the fallback in code is `process.env.ZCODE_CAPTCHA_BACKEND || "jsdom"` in +> `worker.js` / `solver.js` / `captcha-jsdom.ts`. For production use, set `happy` +> explicitly. + +## Benchmarks (measured 2026-08-22, this machine + IP) + +Single token = one traceless solve. CPU = `process.cpuUsage` delta, RSS via `/proc`. +Network conditions and pe-roll luck vary run-to-run (~40-60% of pe versions stall and need +a retry; retry cost is included in the averages). + +### Backend comparison (fresh window per solve) + +| Backend | Wall time | CPU | Notes | +|---|---|---|---| +| happy-dom | ~1.5–3.0s | **~815ms** avg (426–1235 range) | 20/30 solve-rate here; `happy` lib ~1.8k lines, no browser needed | +| jsdom | — | ~1.6s+ | 100% "degraded result (76 chars)" from this IP during these tests; historically works | +| playwright | — | higher (Chromium launch per solve) | not re-benchmarked this session | +| headless VM | n/a (boots in **68ms/74ms CPU**) | n/a | SDK executes fully but the pe bytecode VM entry never starts — see WIP section | + +### happy-dom window reuse (`CAPTCHA_WINDOW_REUSE=1`) + +Reuses one happy-dom window across solves instead of booting a fresh DOM per token +(implemented in `solve-happy-lib.js`, opt-in). + +| Metric | Fresh window (old) | Window reuse | Delta | +|---|---|---|---| +| CPU per solve (warm) | ~815–878ms | **~260–330ms** | **~2.5–3x less** | +| Wall per solve (warm) | ~1.5–3.0s | **~0.5–0.7s** | ~2.5–4x faster | +| Success rate (8-solve A/B) | 2/8 | 7/8 | fewer wasted retries | +| Steady-state RSS (30 solves) | **~429MB avg** (320–503) | ~813MB avg (484–968) | **~1.9x more memory** | + +Knobs (also settable per-call via `solveTraceless({reuseWindow:true})`): + +`ZCODE_CAPTCHA_BACKEND=happy node solver.js # one-shot solve via any backend` + +On failure/stall the pooled window is destroyed and the retry boots a fresh one +(a fresh InitCaptchaV3 rolls a new pe version anyway). + +**Tuning rule of thumb:** +- CPU-bound box → `CAPTCHA_WINDOW_REUSE=1` + `ZCODE_CAPTCHA_WORKER_MAX_RSS_MB=1100` + (budget ~1GB per captcha worker; the 350MB default would recycle workers every solve + and negate the win). +- Memory-bound box → leave reuse off; behavior is byte-identical to the old path. +- `CAPTCHA_REUSE_MAX_SOLVES=10` was tested and is *worse* (avg 860MB, peak 1161MB): + frequent re-boots churn more than they free. + +### Cost profile of one happy solve (where the CPU goes) + +| Phase | Wall | CPU | +|---|---|---| +| DOM boot + SDK load + init RTT | 0–1.9s | ~800ms | +| pe bytecode VM (W mint) | 1.9–2.6s | ~770ms | +| Burst (Log3/Upload/Log2/Verify) + verify | 2.6–3.1s | ~30ms | + +Window reuse amortizes the first phase; the pe-VM phase is irreducible without the +headless-VM path below. + +## Which backend should an agent use? + +1. **Production / serving inference: `happy` + window reuse** (with the raised RSS cap). + It is the only configuration validated end-to-end through the proxy's inference path + (identity headers + system prompt → HTTP 200). +2. **Local dev / minimal deps: `jsdom`.** Keep the old default for local work — it needs + no Playwright/Chromium and still solves on residential IPs. (It 100% failed from this + datacenter IP during benchmarking — "degraded result" — while happy passed 2/3, so + happy is also more IP-tolerant.) +3. **Debugging DOM behavior: `playwright`.** +4. **Do not build on the pure-HTTP native solver** — server-side validation makes it + infeasible (see findings below). + +## Protocol findings (for future work; proven via a live field-mutation harness) + +The current wire protocol (verified against live passing sessions): + +``` +InitCaptchaV3 -> (cert + DeviceConfig: tokenKey, mid, ip) + ~60ms crypto window +Log3, UploadLog, Log2 (fired within ~2ms of each other, to three different hosts) +VerifyCaptchaV3 (~1-3ms after Log2) +NO Log1. Log2 host is now ap-southeast-1.device.saf.aliyuncs.com +(the old cloudauth Log2 host is only used by Log3) +``` + +Mutation-test results (each value = pass-rate of 3 live trials): + +| Artifact | Mutated to | Result | +|---|---|---| +| Log2 W fields [43]/[21]/[72]/[74] | random/synthetic | **pass 3/3** (server does not validate Log2's W) | +| verify `data` blob | fully synthetic (own h2d cipher + deflate) | **pass 3/3** (construction is reproducible) | +| UploadLog claims, Log3 track | random/emptied | **pass 3/3** | +| token W — any field ([43],[72],[74],[21],[32],[78], 93/94 tail) | changed | **fail 3/3 (F001)** | +| token W = SDK's Log2 W + `\|93-NaN\|94-NaN` | synced pair | **pass 3/3** | +| synthetic W pair (stamps changed consistently everywhere) | — | **fail** — the W contains VM-computed values with no offline derivation | + +Conclusion: pure-HTTP minting is blocked by design — the token W is bound to values the pe +bytecode VM computes (`INIT_MD5_SECRET_SALT` never even fires during traceless). The VM +must execute. `native_solve.js` / `native_solve2.js` are kept as protocol documentation +only. + +The token's `#0#` field is currently the literal string `daye,raolewoba!` — +not a hash (contrary to old comments in `native_solve.js`). + +## Headless VM solver (WIP — `headless_solve.js`) + +Goal: run the real SDK in a bare `node:vm` with a hand-built DOM shim (~70ms boot vs +happy-dom's ~1.9s → potential ~10x CPU cut) and zero captured constants (fully +rotation-resistant: new pe/feilin versions are fetched from CDN automatically). + +Progress: loader, feilin and pe all execute cleanly; the loader's iframe mount runs; no +errors surface anywhere (instrumented catches: 0). Blocker: the pe's generator-based +bytecode interpreter never starts pumping (0 dispatches), and no SDK globals +(`__AYF`, `detectIncognito`, …) ever get registered. Eliminated causes: element +`instanceof` validation, iframe `contentWindow` (was a real loader crash — fixed), +`setImmediate`/`MessageChannel` (fixed), font-fingerprint probe (offsetWidth now varies +per `style.fontFamily`), `postMessage`, canvas 2D, `document.write`, all missing globals. + +Next lead: instrument happy-dom's **sync** interceptor (`beforeSyncRequest` pe branch) to +log when `__AYF`/`initAliyunCaptcha` appear, then diff what pumps the generator. + + +## Benchmarking scripts + +The A/B numbers above come from scripts of the shape kept in this repo history: + +```bash +cd captcha_node +# CPU/wall A/B +CAPTCHA_WINDOW_REUSE=1 node -e '...' # 8 solves, measure cpuUsage/rss per solve +# steady-state RSS (30 solves, sample per solve) +node /tmp/mem_avg.js # re-create from the transcript of this session if needed +# per-backend latency +node scripts/bench-captcha-worker.mjs happy 5 +``` + +When re-benchmarking, always run both sides in the same hour — stall rates drift with +pe rotations and IP reputation. diff --git a/captcha_node/daemon.js b/captcha_node/daemon.js new file mode 100644 index 0000000..8f024f6 --- /dev/null +++ b/captcha_node/daemon.js @@ -0,0 +1,276 @@ +/** + * Captcha daemon — bounded concurrency queue. + * + * Modes (ZCODE_CAPTCHA_BROWSER_POOL): + * unset/1 — long-lived worker pool (one Chromium per worker, context per solve) [default] + * 0 — child-process per solve (full browser launch each time) + */ +const readline = require('node:readline'); +const { spawn } = require('node:child_process'); +const path = require('node:path'); + +const NODE_BIN = process.env.ZCODE_NODE_PATH?.trim() || 'node'; +const SOLVER_JS = path.join(__dirname, 'solver.js'); +const WORKER_JS = path.join(__dirname, 'worker.js'); +const USE_POOL = process.env.ZCODE_CAPTCHA_BROWSER_POOL !== '0'; +const MAX_CONCURRENT = Math.max( + 1, + Number( + process.env.CAPTCHA_DAEMON_CONCURRENCY ?? + (process.env.ZCODE_CAPTCHA_LOW_CPU === "1" ? 3 : 6), + ), +); +const SOLVE_TIMEOUT_MS = Number(process.env.ZCODE_CAPTCHA_TIMEOUT || 40) * 1000; +// Lazy pool: workers spawn on demand (no pre-spawn) and idle workers are +// reaped after IDLE_MS with nothing queued — RAM drops to ~1 worker at zero +// traffic instead of MAX_CONCURRENT permanent Chromium processes. +const WORKER_IDLE_REAP_MS = Number(process.env.ZCODE_CAPTCHA_WORKER_IDLE_MS || 120) * 1000; +const WORKER_WARM_FLOOR = Number(process.env.ZCODE_CAPTCHA_WORKER_WARM_FLOOR ?? 1); + +const queue = []; +let active = 0; + +/** @type {Array<{ proc: import('node:child_process').ChildProcess, busy: boolean, buffer: string, ready: boolean, reqId: number | null, timer: ReturnType | null }>} */ +const workers = []; + +function runSolveChild(req) { + const scene = req.scene || '11xygtvd'; + const region = req.region || 'sgp'; + const prefix = req.prefix || 'no8xfe'; + + return new Promise((resolve) => { + const proc = spawn(NODE_BIN, [SOLVER_JS, scene, region, prefix], { + cwd: __dirname, + env: { ...process.env, FONTCONFIG_PATH: '/dev/null' }, + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); + + let stdout = ''; + let stderr = ''; + proc.stdout?.on('data', (chunk) => { + stdout += chunk.toString('utf8'); + }); + proc.stderr?.on('data', (chunk) => { + stderr += chunk.toString('utf8'); + }); + + const timer = setTimeout(() => { + try { + proc.kill('SIGKILL'); + } catch (_) {} + resolve({ id: req.id, ok: false, error: `child solve timeout (${SOLVE_TIMEOUT_MS}ms)` }); + }, SOLVE_TIMEOUT_MS); + + proc.on('error', (err) => { + clearTimeout(timer); + resolve({ id: req.id, ok: false, error: err.message }); + }); + + proc.on('close', (code) => { + clearTimeout(timer); + if (code === 0) { + for (const line of stdout.split(/\r?\n/)) { + if (line.startsWith('VERIFY_PARAM=')) { + const param = line.slice('VERIFY_PARAM='.length).trim(); + if (param.length > 20) { + resolve({ id: req.id, ok: true, param }); + return; + } + } + } + } + const detail = stderr.trim() || stdout.trim() || `exit ${code ?? '?'}`; + resolve({ id: req.id, ok: false, error: detail.slice(0, 300) }); + }); + }); +} + +function spawnWorker() { + const entry = { + proc: null, + busy: false, + buffer: '', + ready: false, + reqId: null, + timer: null, + reaping: false, + }; + const proc = spawn(NODE_BIN, [WORKER_JS], { + cwd: __dirname, + env: { ...process.env, FONTCONFIG_PATH: '/dev/null' }, + stdio: ['pipe', 'pipe', 'pipe'], + windowsHide: true, + }); + entry.proc = proc; + + proc.stdout?.on('data', (chunk) => { + entry.buffer += chunk.toString('utf8'); + let idx; + while ((idx = entry.buffer.indexOf('\n')) >= 0) { + const line = entry.buffer.slice(0, idx).trim(); + entry.buffer = entry.buffer.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line); + if (msg.workerReady) { + entry.ready = true; + drainQueue(); + continue; + } + if (typeof msg.id === 'number' && entry.reqId === msg.id) { + if (entry.timer) clearTimeout(entry.timer); + entry.busy = false; + entry.reqId = null; + entry.timer = null; + process.stdout.write(JSON.stringify(msg) + '\n'); + active -= 1; + drainQueue(); + scheduleReap(); + } + } catch (_) {} + } + }); + + proc.stderr?.on('data', (chunk) => { + const text = chunk.toString('utf8').trim(); + if (text) process.stderr.write(`[worker] ${text}\n`); + }); + + proc.on('close', () => { + const i = workers.indexOf(entry); + if (i >= 0) workers.splice(i, 1); + if (entry.reqId != null) { + if (entry.timer) clearTimeout(entry.timer); + process.stdout.write( + JSON.stringify({ id: entry.reqId, ok: false, error: 'worker died mid-solve' }) + '\n', + ); + active -= 1; + } + // Respawn to keep pool size only if workers are needed: a death while + // requests are queued (or under the warm floor) refills; intentional + // idle reaps (entry.reaping, idle system) do not respawn. + if (USE_POOL && !entry.reaping) { + if (queue.length > 0 || active > 0 || workers.length < WORKER_WARM_FLOOR) { + workers.push(spawnWorker()); + } + } + drainQueue(); + }); + + workers.push(entry); + return entry; +} + +function initPool() { + // Lazy: no pre-spawn. Workers appear on demand from drainQueue(). + workers.length = 0; +} + +function reapIdleWorkers() { + if (queue.length > 0 || active > 0) return; + const idle = workers.filter( + (x) => x.ready && !x.busy && x.reqId == null && x.proc && !x.reaping, + ); + // Keep WORKER_WARM_FLOOR warm to avoid cold-start latency on the next token. + const excess = idle.slice(Math.max(0, WORKER_WARM_FLOOR)); + for (const w of excess) { + w.reaping = true; + try { + w.proc.kill('SIGTERM'); + } catch (_) {} + } +} + +let reapTimer = null; +function scheduleReap() { + if (reapTimer) return; + reapTimer = setTimeout(() => { + reapTimer = null; + reapIdleWorkers(); + if (workers.length > 0 || queue.length > 0) scheduleReap(); + }, WORKER_IDLE_REAP_MS); + reapTimer.unref?.(); +} + +function drainQueue() { + while (queue.length > 0 && active < MAX_CONCURRENT) { + const req = queue.shift(); + active += 1; + + if (USE_POOL) { + let w = workers.find((x) => x.ready && !x.busy && x.proc?.stdin?.writable); + if (!w && workers.length < MAX_CONCURRENT) { + // On-demand spawn: grow the pool only when requests are waiting. + w = spawnWorker(); + } + if (!w) { + queue.unshift(req); + active -= 1; + break; + } + w.busy = true; + w.reqId = req.id; + w.timer = setTimeout(() => { + try { + w.proc?.kill('SIGKILL'); + } catch (_) {} + w.busy = false; + w.reqId = null; + w.timer = null; + process.stdout.write( + JSON.stringify({ id: req.id, ok: false, error: `worker solve timeout (${SOLVE_TIMEOUT_MS}ms)` }) + '\n', + ); + active -= 1; + drainQueue(); + }, SOLVE_TIMEOUT_MS); + w.proc.stdin.write(JSON.stringify(req) + '\n'); + } else { + void runSolveChild(req) + .then((result) => { + process.stdout.write(JSON.stringify(result) + '\n'); + }) + .finally(() => { + active -= 1; + drainQueue(); + }); + } + } +} + +function enqueue(req) { + queue.push(req); + drainQueue(); +} + +function main() { + if (USE_POOL) initPool(); + + process.stdout.write( + JSON.stringify({ + ready: true, + concurrency: MAX_CONCURRENT, + mode: USE_POOL ? 'browser-pool' : 'child-process', + }) + '\n', + ); + + const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + rl.on('line', (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let req; + try { + req = JSON.parse(trimmed); + } catch { + process.stdout.write(JSON.stringify({ id: null, ok: false, error: 'invalid json' }) + '\n'); + return; + } + if (typeof req.id !== 'number') { + process.stdout.write(JSON.stringify({ id: req.id ?? null, ok: false, error: 'missing id' }) + '\n'); + return; + } + enqueue(req); + }); +} + +main(); diff --git a/captcha_node/native_solve.js b/captcha_node/native_solve.js new file mode 100644 index 0000000..bf6e81f --- /dev/null +++ b/captcha_node/native_solve.js @@ -0,0 +1,1372 @@ +/** + * Native Aliyun NoCaptcha solver — no browser, no JS VM, no Playwright. + * + * Reversed from wire captures (harvest8 session) and verified live + * (init -> Log1 -> Log2 -> verify -> T001 + securityToken). + * + * Flow (4 HTTPS POSTs, ~600 ms): + * 1. InitCaptchaV3 -> CertifyId + DeviceConfig (AES-128-CBC key 87f879f135f27da7) + * 2. Log1 (cloudauth-device, constant DeviceData) + * 3. Log2 (um DeviceData registering the session token) + * 4. VerifyCaptchaV3 -> securityToken + * + * The payload key + server mid are read from DeviceConfig per session; the + * midhash/trailing in the token and the prefix/arg in `data` are NOT + * server-validated (any 32-hex works). + */ +"use strict"; + +const crypto = require("crypto"); +const https = require("https"); +const zlib = require("zlib"); +const fs = require("fs"); +const path = require("path"); +if (process.env.NATIVE_PROXY) { + try { + const { ProxyAgent, setGlobalDispatcher } = require("undici"); + setGlobalDispatcher(new ProxyAgent(process.env.NATIVE_PROXY)); + } catch (_) {} +} + +const SCENE = "11xygtvd"; +const VERIFY_KEY = "222aiJodos2938JDdosko2djd82sf0&"; +const LOG_KEY = "DuanemHmyeE6LXCC46sJEDUw5DTlSZ&"; +const AADUANE = "111jdk439dJJIjd023823201"; +const LOG_AADUANE = "DuaneAprqkYsF3nt1yjK29Bf"; +const INIT_URL = "https://no8xfe.captcha-open-southeast.aliyuncs.com/"; +const VERIFY_URL = "https://no8xfe-verify.captcha-open-southeast.aliyuncs.com/"; +const LOG_URL = "https://cloudauth-device-dualstack.ap-southeast-1.aliyuncs.com/"; +const UPLOAD_URL = "https://upload.captcha-open-southeast.aliyuncs.com/"; +const IV = Buffer.from("0123456789ABCDEF"); +const CONF_KEY = Buffer.from("87f879f135f27da7"); +// Constant captcha DeviceData prefix (captured; the blob tail is re-minted +// fresh per session like the SDK — reusing a captured blob verbatim trips +// the verify risk engine (F001)). +const INIT_SK = "3795d28242a11619bc25f786f84e53d4"; + +// === h2d blob cipher (6-bit affine stream, KSA over 64-entry S-box) === +const _SBOX_RAW = [ + 1, 32, 50, 10, 51, 6, 44, 37, 16, 46, 11, 62, 19, 43, 25, 23, 30, 60, 53, 34, + 7, 26, 12, 48, 5, 2, 20, 4, 61, 13, 47, 49, 29, 27, 22, 17, 39, 56, 41, 38, + 55, 31, 15, 58, 52, 40, 8, 57, 45, 35, 59, 36, 42, 54, 63, 3, 24, 28, 14, 9, + 21, +]; +function buildSbox() { + return [ + ..._SBOX_RAW.slice(1, 18), 33, ..._SBOX_RAW.slice(18, 32), 18, + ..._SBOX_RAW.slice(32, 35), 1, ..._SBOX_RAW.slice(35, 60), 0, 21, + ]; +} +function ksa(keyStr) { + const S = buildSbox(); + let t = 0; + for (let o = 0; o < 64; o++) { + t = + (((o + t + S[o] + S[t]) >> 1) + keyStr.charCodeAt(o % keyStr.length)) & 63; + if (t !== o) { + const tmp = S[o]; + S[o] = S[t]; + S[t] = tmp; + } + } + return S; +} +function h2dCrypt(data, S, invert) { + // XOR/affine-4 stream cipher, byte-for-byte verified against the captured + // VerifyCaptchaV3 `data` blob (see h2d_cipher.py). NOT self-inverse. + S = [...S]; + let e = 0; + let a = 0; + const out = Buffer.alloc(data.length); + for (let n = 0; n < data.length; n++) { + a = ((e ^ a) + (S[e] ^ S[a])) & 63; + if (a !== e) { + const t = S[e]; + S[e] = S[a]; + S[a] = t; + } + const x = S[e] + S[a]; + const y = S[x & 63]; + let m = data[n]; + if (invert) m = ((m ^ x ^ y) + (S[a] + a) - e - S[e]) & 255; + else m = (((m + e + S[e]) - (S[a] + a)) ^ x ^ y) & 255; + out[n] = m; + e = (e + 1) & 63; + } + return out; +} + +// === helpers === +function pct(s) { + return encodeURIComponent(String(s)); +} +function sign(params, key) { + const canon = Object.keys(params) + .sort() + .map((k) => `${pct(k)}=${pct(params[k])}`) + .join("&"); + const sts = "POST&%2F&" + pct(canon); + return crypto.createHmac("sha1", key).update(sts).digest("base64"); +} +// Browser request layer: mirror what the passing jsdom path sends (Chrome 127 +// UA + client hints + Accept-Language + Origin/Referer), plus a host-scoped +// cookie jar so the captcha hosts' set-cookie (acw_tc etc.) flows back on +// subsequent calls exactly like jsdom's. +const BROWSER_HEADERS = { + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "Accept-Language": "en-US,en;q=0.9", +}; +const cookieJar = new Map(); // host -> Map(name, value) +function cookieHeader(host) { + const m = cookieJar.get(host); + if (!m || m.size === 0) return null; + return [...m.entries()].map(([k, v]) => `${k}=${v}`).join("; "); +} +const CDN_NAV_HEADERS = { + accept: "*/*", + "accept-encoding": "gzip, deflate", + "accept-language": "en", + referer: "https://zcode.z.ai/", + "user-agent": + "Mozilla/5.0 (linux) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/29.1.1", +}; +// The SDK's page-load asset fetches (the WAF sees them as the browser +// evidence at the session/IP level): AliyunCaptcha.js + FeiLin BEFORE log1, +// pe..js + main.css BETWEEN init and the Log3/Log2 burst (captured +// sdkctl14_now order: aliyunCaptcha, feilin140, then after init: pe.094, +// main.css, then Log3/Log2/Upload/Verify). NATIVE_CDN=1 mirrors this. +// NOTE (mitm capture, Aug 18): with a warm disk cache the CDN assets NEVER +// reach the network — the ONLY requests aliyun sees from a passing session +// are the zcode.z.ai page load (GET / -> 307 -> GET /en, 35.2KB HTML) and +// the 6 API POSTs. NATIVE_PAGE=1 mirrors the page load too. +const CDN_ASSETS = [ + { url: "https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js", early: true }, + { url: "https://g.alicdn.com/captcha-frontend/FeiLin/1.5.1/feilin140.e4ddf273062f47310fe754484bdd8cdb6534c3ca3cd6c0c23776fc9f42e24f7e.js", early: true }, + { url: "https://g.alicdn.com/captcha-frontend/dynamicJS/3.29.0/pe.094.f10b48a2d9910e6c.js", early: false }, + { url: "https://g.alicdn.com/captcha-frontend/dynamicJS/3.29.0/main.css", early: false }, +]; +async function loadCdnAssets(early) { + if (process.env.NATIVE_CDN !== "1") return; + for (const a of CDN_ASSETS) { + if (a.early !== early) continue; + try { + const res = await fetch(a.url, { method: "GET", headers: CDN_NAV_HEADERS }); + await res.arrayBuffer(); + } catch (_) {} + } +} +// NATIVE_PAGE=1: load the zcode.z.ai origin page (GET / -> 307 -> /en), the +// ONE network request the passing harness makes that the native never did +// (mitm capture: passing sessions show ONLY the page load + the 6 API POSTs +// on the wire — the CDN assets are disk-cache-served). The page registers +// the IP+UA+origin with the WAF before the captcha burst. NOTE: the harness's +// page load is a COOKIELESS pre-fetch (redirect: follow — jsdom itself never +// navigates; the jar cookies only ride an HTML-string navigation that never +// hits the network — mitm census: exactly 2 page GETs, no Cookie header). +async function loadOriginPage() { + if (process.env.NATIVE_PAGE !== "1") return; + try { + await fetch("https://zcode.z.ai/", { + method: "GET", + redirect: "follow", + headers: { + accept: "*/*", + "accept-encoding": "br, gzip, deflate", + "accept-language": "en-US,en;q=0.9", + "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "user-agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + }, + }).then((r) => r.arrayBuffer()); + } catch (_) {} +} +// Browser mirror: the SDK page load fetches assets from every captcha host +// before the flow, priming the WAF's acw_tc cookie on each. Prime all three +// hosts so Log1/init/Log3/Log2/verify all carry cookies like the SDK's do. +async function primeHosts() { + const hosts = [ + "cloudauth-device.captcha-open-southeast.aliyuncs.com", + "no8xfe.captcha-open-southeast.aliyuncs.com", + "no8xfe-verify.captcha-open-southeast.aliyuncs.com", + ]; + for (const host of hosts) { + try { + await new Promise((resolve, reject) => { + const req = https.request( + { + hostname: host, + path: "/", + method: "GET", + headers: { + Accept: "*/*", + Origin: "https://zcode.z.ai", + Referer: "https://zcode.z.ai/", + ...BROWSER_HEADERS, + }, + }, + (res) => { + res.resume(); + res.on("end", () => { + absorbCookies(host, res.headers["set-cookie"]); + resolve(); + }); + } + ); + req.on("error", reject); + req.end(); + }); + } catch (_) {} + } +} +function absorbCookies(host, setCookie) { + if (!setCookie) return; + const entries = Array.isArray(setCookie) ? setCookie : [setCookie]; + let m = cookieJar.get(host); + if (!m) { + m = new Map(); + cookieJar.set(host, m); + } + for (const raw of entries) { + const first = raw.split(";")[0].trim(); + const eq = first.indexOf("="); + if (eq > 0) m.set(first.slice(0, eq), first.slice(eq + 1)); + } +} +let _mutator = null; +function forwardMutator() { + if (_mutator !== null) return _mutator; + if (!process.env.NATIVE_MUTATE) return (_mutator = false); + try { + const mod = require(path.resolve(process.env.NATIVE_MUTATE)); + if (typeof mod !== "function") return (_mutator = false); + _mutator = mod; + } catch (_e) { + _mutator = false; + } + return _mutator; +} + +let _sched = null; +function loadSchedule() { + if (_sched !== null) return _sched; + if (!process.env.NATIVE_SCHEDULE) return (_sched = false); + try { + _sched = JSON.parse(fs.readFileSync(path.resolve(process.env.NATIVE_SCHEDULE), "utf8")); + } catch (_) { + _sched = false; + } + return _sched; +} +/** Dispatch gate: hold until log1DispatchAt + (rel[act] - rel.Log1) — the + * SDK's CLIENT-side rhythm (claims inside the re-anchored bodies stay + * mid-relative; dispatch deltas must mirror the captured log1-dispatch + * deltas, else claim-vs-arrival skew trips F001). */ +let _log1DispatchAt = 0; +function schedWait(action) { + const s = loadSchedule(); + if (!s || !s.rel || !s.rel[action] || !s.rel.Log1 || !_log1DispatchAt) return Promise.resolve(); + const target = _log1DispatchAt + (s.rel[action] - s.rel.Log1); + const wait = target - Date.now(); + if (process.env.NATIVE_DEBUG_BODIES) console.error(`[sched] ${action} target=${target - _log1DispatchAt}ms wait=${wait}ms now=${Date.now() - _log1DispatchAt}ms`); + if (wait <= 0) return Promise.resolve(); + return new Promise((r) => setTimeout(() => { + if (process.env.NATIVE_DEBUG_BODIES) console.error(`[sched] ${action} woke=${Date.now() - _log1DispatchAt}ms`); + r(); + }, wait)); +} + +async function postFetch(url, params, key) { + if (process.env.NATIVE_DEBUG_BODIES === "1") console.error(`[pf] enter=${Date.now()} ${params.Action || ""}`); + const p = { ...params, Signature: sign(params, key) }; + const body = Object.keys(p) + .map((k) => `${pct(k)}=${pct(p[k])}`) + .join("&"); + const mut = forwardMutator(); + const sendBody = mut ? mut(url, body) : body; + const u = new URL(url); + if (process.env.NATIVE_DEBUG_BODIES === "1") { + console.error(`\n===== NATIVE POST ${Date.now()} ${u.hostname}${u.pathname}\n${body}\n=====`); + } + const headers = { + accept: "*/*", + "accept-encoding": "gzip, deflate", + "accept-language": "en-US,en;q=0.9", + "content-type": "application/x-www-form-urlencoded; charset=UTF-8", + origin: "https://zcode.z.ai", + referer: "https://zcode.z.ai/", + "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "user-agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + }; + const ck = cookieHeader(u.hostname); + if (ck && process.env.NATIVE_SKIP_COOKIES !== "1") headers.cookie = ck; + // undici fetch() adds fetch-spec headers (sec-fetch-mode: cors) that jsdom's + // XHR dispatcher path does NOT send — the passing SDK's POSTs carry NO + // sec-fetch-* headers on the wire (mitm-verified). The raw dispatcher + // request() sends only the exact headers above, like jsdom's XHR. + let res; + try { + const { request } = require("undici"); + res = await request(u, { method: "POST", headers, body: sendBody }); + } catch (_e) { + res = await fetch(u, { method: "POST", headers, body: sendBody, redirect: "manual" }); + } + let raw; + try { + raw = Buffer.from(await res.body.arrayBuffer()); + } catch (_e) { + raw = Buffer.from(await res.text && (await res.text()).toString(), "utf8"); + } + const ce = String(res.headers["content-encoding"] || "").toLowerCase(); + if (ce === "gzip") raw = zlib.gunzipSync(raw); + else if (ce === "deflate") raw = zlib.inflateSync(raw); + const text = raw.toString("utf8"); + let json = null; + try { + json = JSON.parse(text); + } catch (e) { + throw new Error("bad json: " + text.slice(0, 200)); + } + const sc = Array.isArray(res.headers["set-cookie"]) + ? res.headers["set-cookie"].join("; ") + : res.headers["set-cookie"]; + if (sc) absorbCookies(u.hostname, [sc]); + if (mut) { + try { + mut.onResponse(u.href, text); + } catch (_) {} + } + if (process.env.NATIVE_DEBUG_BODIES === "1") { + const show = params.Action === "InitCaptchaV3" ? JSON.stringify(json).slice(0, 2000) : JSON.stringify(json).slice(0, 240); + console.error(`[native-resp] ${u.hostname} ${params.Action} -> ${show}`); + } + return json; +} +function postCurl(url, params, key) { + return new Promise((resolve, reject) => { + if (process.env.NATIVE_DEBUG_BODIES === "1") console.error(`[pf] curl enter=${Date.now()} ${params.Action || ""}`); + const p = { ...params, Signature: sign(params, key) }; + const body = Object.keys(p) + .map((k) => `${pct(k)}=${pct(p[k])}`) + .join("&"); + const mut = forwardMutator(); + const sendBody = mut ? mut(url, body) : body; + const u = new URL(url); + if (process.env.NATIVE_DEBUG_BODIES === "1") { + console.error(`\n===== NATIVE POST ${Date.now()} ${u.hostname}${u.pathname}\n${sendBody}\n=====`); + } + const tmpBody = path.join( + require("os").tmpdir(), + `zcap_body_${Date.now()}_${Math.random().toString(36).slice(2)}.bin`, + ); + fs.writeFileSync(tmpBody, sendBody); + const args = [ + "-sS", + "--compressed", + "-X", + "POST", + u.href, + "-H", + "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", + "-H", + "Origin: https://zcode.z.ai", + "-H", + "Referer: https://zcode.z.ai/", + "--data-binary", + "@" + tmpBody, + "-w", + "\n__ZCAP_STATUS:%{http_code}", + ]; + const ck = cookieHeader(u.hostname); + if (ck && process.env.NATIVE_SKIP_COOKIES !== "1") args.push("-b", ck); + const { execFile } = require("child_process"); + execFile( + process.env.NATIVE_CURL_BIN || "curl_chrome116", + args, + { maxBuffer: 20 * 1024 * 1024, timeout: 60000 }, + (err, stdout, stderr) => { + try { + fs.unlinkSync(tmpBody); + } catch (_) {} + if (process.env.NATIVE_DEBUG_BODIES === "1") + console.error(`[pf] curl done=${Date.now()} ${params.Action || ""} err=${err ? err.code : "ok"}`); + if (err && !stdout) return reject(new Error("curl: " + String(err.message).slice(0, 200))); + let text = stdout.toString("utf8"); + const m = text.match(/\n__ZCAP_STATUS:(\d+)\s*$/); + if (m) { + text = text.slice(0, m.index); + } + try { + const json = JSON.parse(text); + if (mut) { + try { + mut.onResponse(u.href, text); + } catch (_) {} + } + return resolve(json); + } catch (e) { + return reject(new Error("bad json from curl: " + text.slice(0, 200))); + } + }, + ); + }); +} + +function post(url, params, key) { + if (process.env.NATIVE_TRANSPORT === "curl") return postCurl(url, params, key); + if (process.env.NATIVE_FETCH === "1") return postFetch(url, params, key); + return new Promise((resolve, reject) => { + const p = { ...params, Signature: sign(params, key) }; + const body = Object.keys(p) + .map((k) => `${pct(k)}=${pct(p[k])}`) + .join("&"); + const mut = forwardMutator(); + const sendBody = mut ? mut(url, body) : body; + const u = new URL(url); + if (process.env.NATIVE_DEBUG_BODIES === "1") { + console.error(`\n===== NATIVE POST ${Date.now()} ${u.hostname}${u.pathname}\n${sendBody}\n=====`); + } + const headers = process.env.NATIVE_BARE_HEADERS === "1" + ? { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Content-Length": Buffer.byteLength(sendBody), + } + : { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Content-Length": Buffer.byteLength(sendBody), + Accept: "*/*", + "Accept-Encoding": "gzip, deflate", + Origin: "https://zcode.z.ai", + Referer: "https://zcode.z.ai/", + ...BROWSER_HEADERS, + }; + const ck = cookieHeader(u.hostname); + if (ck && process.env.NATIVE_SKIP_COOKIES !== "1") headers.Cookie = ck; + const req = https.request( + { + hostname: u.hostname, + path: u.pathname, + method: "POST", + headers, + }, + (res) => { + const chunks = []; + const ce = String(res.headers["content-encoding"] || "").toLowerCase(); + res.on("data", (c) => { + chunks.push(c); + }); + res.on("end", () => { + try { + absorbCookies(u.hostname, res.headers["set-cookie"]); + let raw = Buffer.concat(chunks); + if (ce === "gzip") raw = zlib.gunzipSync(raw); + else if (ce === "deflate") raw = zlib.inflateSync(raw); + const rawText = raw.toString("utf8"); + if (mut) { + try { + mut.onResponse(u.href, rawText); + } catch (_) {} + } + const json = JSON.parse(rawText); + if (process.env.NATIVE_DEBUG_BODIES === "1") { + const show = + params.Action === "InitCaptchaV3" + ? JSON.stringify(json).slice(0, 3000) + : JSON.stringify(json).slice(0, 240); + console.error(`[native-resp] ${u.hostname} ${params.Action} -> ${show}`); + } + resolve(json); + } catch (e) { + reject(new Error("bad json: " + raw.toString("utf8").slice(0, 200))); + } + }); + } + ); + req.on("error", reject); + req.write(body); + req.end(); + }); +} +function tsNow() { + return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); +} +function nonce() { + return crypto.randomUUID(); +} +function aesEnc(key, pt) { + const c = crypto.createCipheriv("aes-128-cbc", key, IV); + c.setAutoPadding(false); + return Buffer.concat([c.update(pt), c.final()]); +} +function pkcs7(buf) { + const pad = 16 - (buf.length % 16); + return Buffer.concat([buf, Buffer.alloc(pad, pad)]); +} +function unpad(buf) { + const pad = buf[buf.length - 1]; + return pad < 16 ? buf.slice(0, buf.length - pad) : buf; +} +function randomHex(len) { + return crypto.randomBytes(len / 2).toString("hex").slice(0, len); +} +function uuidV4Hex() { + const b = crypto.randomBytes(16); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + return b.toString("hex"); +} + +// === templates (embedded — no external files at runtime) === + +// Real-W value pool harvested from PASSING jsdom SDK sessions (Aug 19). +// The verify risk engine structurally validates [21]/[71]/[73]; crypto-random +// bytes F001. These exact values pass verbatim in fresh sessions (3/3, then +// 6/6 harvest runs); each session picks one set so [21]/[71]/[73] rotate the +// way the SDK's do. [32] stays the stable persistent device id (stableF32, +// seeded via NATIVE_F32 to the jsdom device 17ab549f...). +const W_VALUE_POOL = [ + // SDK [21] plaintext is 8 uppercase/lowercase alphanum ASCII chars + // (captured: "ff5cnj4h", "g655cc7d") — b64-coded in the W. The OLD pool + // used crypto.randomBytes(8) raw bytes, which the risk engine's structural + // validator rejects (F001). + { f21: Buffer.from("ff5cnj4h").toString("base64"), f71: "NqPwTsZj4slVuynnFdOGUA6AINAWiXLvlUwALZfo", f73: "S4Se6AGqFKH4l0yPMjaFw6YiLKj6ufZFLqLXDoeMB0" }, + { f21: Buffer.from("g655cc7d").toString("base64"), f71: "9IPoRp5D4y5pl6FBbLMrusPd0RbRNNhmoAUMbq5w", f73: "i6F5iyX0sOAVnEgqZu16QKQTTznOsOuBHgsXfHzOzj" }, + { f21: Buffer.from("k3qy9pa2").toString("base64"), f71: "7irJozxRxFMU30MicDhEXsLzR1l22CuJpBEvvyZ5", f73: "wBoVEHnrJmec5DNegnuFF5WuzuXYRiAOsva55RdHCA" }, + { f21: Buffer.from("m7vx4b6d").toString("base64"), f71: "qTySEgZ27yIxfA2MydGg8WJa7FL7oOFa2fdqnw2z", f73: "PYHkpO6u9GQwq28YqhTiJqH2RIJOmFAdtwowTpMel8" }, + { f21: Buffer.from("p2nz8h5e").toString("base64"), f71: "UxN0o2x0zQdhIK2jDksv8lyxY0sAzt4x9Nm5XTl8", f73: "rmSxU3chbWudf7oYMPA6IwL4u7oimY8tf2wMdV9PuS" }, + { f21: Buffer.from("w4te6d9f").toString("base64"), f71: "VAZnxWiHDaMgwmiMh7VUvER9QprJdGmxGoojV1Kf", f73: "5QxpK167kAWgl8qa9XPFpSF5wgkeTq46GZ6rLBecs9" }, +]; + +// Full-session replay rig (NATIVE_REPLAY_SESSION): re-emits a captured +// PASSING session's blocks so the synthetic parts can be bisected one at a +// time. NATIVE_REPLAY_USE is a bitmask: 1=data blob, 2=uploadlog claims, +// 4=behavior track, 8=W f21/71/73; default -1 = replay everything. +let _rpl = null; +function loadReplay() { + if (_rpl !== null || !process.env.NATIVE_REPLAY_SESSION) { + if (_rpl === null) _rpl = false; + return _rpl; + } + try { + _rpl = JSON.parse(fs.readFileSync(process.env.NATIVE_REPLAY_SESSION, "utf8")); + } catch (_) { + _rpl = false; + } + return _rpl; +} +function replayUse() { + const v = Number(process.env.NATIVE_REPLAY_USE || -1); + return (b) => v === -1 || (v & b) === b; +} + +// W.10054 payload template (111 fields, captured session, exact wire layout). +// Per-session replacements: [77]=certifyId, [87]=midTs+1, [72]=midTs-440, +// [74]=midTs+1121. field[43] = browser timing matrix; the token variant ends +// with |93-1536|94-1554, the earlier Log2 DD variant omits that tail. +const W_TEMPLATE = [ + "W.10054", "", "", "", "", "Linux x86_64", + "Chrome", "127.0.0.0", "", "", "", "", + "", "", "", "", "", "", + "", "", "", "amZmNmo4Nmw=", "8", "", + "", "", "", "", "", "", + "", "", "21373fa2b8a36869378c44aeae9f1ea0", "", "12", "", + "Linux", "x86_64", "", "", "", "", + "2409:40c2:129a:1604:9091:f22d:d759:af94", "10-0|11-480|20-482|23-582|30-585|40-598|90-1503|91-1520|92-1520|93-1536|94-1554", "true", "true", "", "720*1280", + "", "", "", "", "", "https://zcode.z.ai/", + "", "", "", "", "", "", + "", "", "", "127.0.0.0", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", "", + "", "saf-captcha", "1", "", "", "tmjlhS6odvjcSEK1F8KeCpjAAvjhNvWGxS68sFf5", + "1786985483752", "v82pqzcGx2GiSXe4VEHBXstA5ajrXKdoeClX0s3HVx", "1786985485272", "desktop", "", "s8FTQ8RmtM", + "5f4943d6d1d2ad38635e3f7b153b2946", "", "5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", "", "", "", + "", "0", "0", "1786985484179", "", "", + "", "", "", "", "", "", + "", "", "", "", "", "", + "", "", "", "", "", "", + "", "", "" +].join("#"); + + +// um DeviceData plaintext template (8-field form, matches the captured SDK +// wire shape exactly): +// sk#W#secret2#W20220202#CLOUD#0#501#b64(inner) +// secret2 = AES-CBC(payloadKey, "W.10054#saf-captcha#") — payloadKey is +// DeviceConfig field[0] (base64 text -> utf8 key). +// inner = mid#payload#AES@k("saf-captcha")#AES@k("W.10054")##AES@k(ts) +// (k = payloadKey; the outer Data layer is AES-CBC(WEB_FLAG_KEY)). +const DD_TEMPLATE_PREFIX = + "3795d28242a11619bc25f786f84e53d4#W#"; + +// data field: TrackList JSON — exact shape from the captured verify `data` +// (mu/te appear once; VerifyTime is a real wall-clock stamp ~10ms after +// TrackStartTime, arg = b64 of 10 random bytes WITH '==' padding). +const REAL_JSON = + '{"TrackList":{"mc":"","tc":"","mu":"","te":"","mp":"","tmv":"","ks":"","fi":"","startTime":%d},"TrackStartTime":%d,"VerifyTime":%d,"arg":"%s"}'; + +// The DC matrix (field 43) is a per-session browser event log in ms since +// [72] (init). Values from a passing pe.090-era wire capture (run7): +// 10-0|11-591|20-594|23-1328|30-1330|40-1344|90-3611|91-3622|92-3623 +// 93-3632|94-3644 (verify-token W only) +// 23/30/40 anchor on the behavior-track startTime (30 = S-2/S/S+14 where +// S = startTime - [72]), 90/91/92 are span-relative (span = [74]-[72]: +// 90 = span-12, 91 = span-1, 92 = span) and the 93/94 tail is minted at the +// verify-token W's build moment ([73]-style: 93 = now-[72]-13, 94 = now-[72]-1). +function buildDcMatrix(initTs, startTime, span, now2, withTail) { + const d30 = startTime - initTs; + // 11/20 = page-load events ~90-115ms BEFORE the interaction start (d30): + // the current-era SDK wires all carry 11 = d30-(92..115), 20 = 11+2..3 + // (jAB: -115/-112, jsdom: -113/-110, sdkw: -92/-91, sdkfull: -92/-90). + // run7's -739/-736 was one outlier; hardcoding it (native's old behavior) + // reads as d30-1123 on today's sessions — outside the SDK's band. + // CRITICAL: the base (10..92) must be drawn ONCE per session — the SDK reuses + // ONE W for the Log2 envelope and the verify token (verified byte-identical + // d11/d20/d30 across sdkctl14_now's Log2-inner W and token W); a fresh + // Math.random draw per buildW call makes the two Ws' [43] diverge -> F001. + if ( + !_dcCache || + _dcCache.initTs !== initTs || + _dcCache.startTime !== startTime || + _dcCache.span !== span + ) { + const r11 = 92 + Math.floor(Math.random() * 23); + const r20 = r11 - 1; + _dcCache = { + initTs, + startTime, + span, + base: `10-0|11-${d30 - r11}|20-${d30 - r20}|23-${d30 - 2}|30-${d30}|40-${d30 + 14}|90-${span - 12}|91-${span}|92-${span}`, + }; + } + return withTail && now2 ? _dcCache.base + `|93-${now2 - initTs - 13}|94-${now2 - initTs - 1}` : _dcCache.base; +} +let _dcCache = null; + +function machineIpv6() { + try { + const ifaces = require("os").networkInterfaces(); + for (const name of Object.keys(ifaces)) { + for (const a of ifaces[name] || []) { + if (a.family !== "IPv6" || a.internal) continue; + const addr = String(a.address); + if (!addr.startsWith("fe80") && !addr.startsWith("::")) return addr; + } + } + } catch (_) {} + return null; +} + +// [32] = persistent W device id, mimicking the SDK's localStorage (stable +// for the lifetime of a "browser install"). First call generates + persists +// (NATIVE_F32 overrides; the harness can seed the recently-observed value +// 17ab549fc615147ebfa8c87f5ae7712b for A/B bisection on this box). +function stableF32() { + if (process.env.NATIVE_F32) return process.env.NATIVE_F32; + const candidates = [ + path.join(process.env.HOME || "/tmp", ".zcode-proxy", "f32"), + "/tmp/zcode-proxy-f32", + ]; + for (const p of candidates) { + try { + if (fs.existsSync(p)) { + const v = fs.readFileSync(p, "utf8").trim(); + if (/^[0-9a-f]{32}$/.test(v)) return v; + } + } catch (_) {} + } + const v = crypto.randomBytes(16).toString("hex"); + for (const p of candidates) { + try { + fs.mkdirSync(path.dirname(p), { recursive: true }); + fs.writeFileSync(p, v); + return v; + } catch (_) {} + } + return v; +} + +// Public IPv4 for the UploadLog telemetry (the SDK reports its egress IP; +// the server cross-checks it against the request source IP). +let _publicIp = null; +function publicIpv4() { + return new Promise((resolve) => { + if (_publicIp) return resolve(_publicIp); + const req = https.request( + { hostname: "api.ipify.org", path: "/", method: "GET", headers: { Accept: "text/plain" } }, + (res) => { + let d = ""; + res.on("data", (c) => (d += c)); + res.on("end", () => { + const ip = d.trim(); + if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) _publicIp = ip; + resolve(ip || ""); + }); + } + ); + req.setTimeout(5000, () => req.destroy()); + req.on("error", () => resolve("")); + req.end(); + }); +} + +function buildW(cert, midTs, ddVariant, t, ipv6) { + // t = { initTs, wTs, startTime, session, now2? }: the SDK stamps [72]=init + // (solve start), [74]=W-mint-1ms (wall clock), [87]=midTs+1, [110] always + // present (run7 log2 W carries "[Chromium,Not)A;Brand]" too), and salts + // [21]/[71]/[73]/[32] with ONE fresh random set reused verbatim by BOTH the + // Log2 DD W and the verify token W (captured: byte-identical between them). + // Replaying a captured session's values trips the risk engine (F017). + const f = W_TEMPLATE.split("#"); + f[77] = cert; + f[87] = String(midTs + 1); + f[72] = String(t.initTs); + f[74] = String(t.wTs); + f[21] = t.session.f21; + f[71] = t.session.f71; + f[73] = t.session.f73; + f[32] = t.session.f32; + f[110] = "[Chromium,Not)A;Brand]"; + // [42] = THE IP THE SERVER OBSERVED — cfg[8] of the init DeviceConfig, + // echoed VERBATIM (IPv4 or IPv6; every passing SDK capture: W[42] === + // cfg[8] exactly). Never substitute the machine's local address family — + // the risk engine cross-checks the claimed IP against the request source. + const ip = String(ipv6 || ""); + if (ip) f[42] = ip; + const span = t.wTs - t.initTs; + f[43] = buildDcMatrix(t.initTs, t.startTime, span, t.now2, !ddVariant); + return f.join("#"); +} +function decryptConfig(b64) { + const pt = unpad(aesDec(CONF_KEY, Buffer.from(b64, "base64"))); + return pt.toString("utf8"); +} +function aesDec(key, ct) { + const c = crypto.createDecipheriv("aes-128-cbc", key, IV); + c.setAutoPadding(false); + return Buffer.concat([c.update(ct), c.final()]); +} +function buildToken(mid, payloadB64, trailing) { + return Buffer.from(`SG_WEB#${mid}#${payloadB64}#0#${trailing}`).toString("base64"); +} +// um DeviceData outer AES key (constant, extracted via the SDK's secret +// unwrap; decrypts the captured Log2 Data. The W payload + inner blobs use +// the per-session payloadKey from DeviceConfig field[0] instead). +const WEB_FLAG_KEY = Buffer.from("a549a55c60a39aa0"); + +// Log1 Data + init DeviceData are HARDCODED SDK constants — byte-identical +// across ALL captured sessions (sdkctl17/18/20, Aug 18) AND all 6 harvest +// runs (Aug 19): the current bundle ships static blobs (do NOT re-mint; the +// old "fresh per-session random tail" assumption was wrong for this era and +// trips F001 — the server has only ever seen these exact bytes from this +// AaduaneId/device). +const UMDD_KEY = Buffer.from("45f8ac1e1de14397"); +const LOG1_MAGIC = Buffer.from("574cf794cf3773f4e38d9bba1d67a0f7", "hex"); +const LOG1_SK = "3795d28242a11619bc25f786f84e53d4"; +const LOG1_DATA_CONST = + "TEQYvgJq1LrMqFaBybfIzPxz2ygFyAct7X/w+LacfXWd9rGSwE/x6ZCONucD1fehrtHiD2ADpEIEuf3/3x5GPEHZiSr3V6CUS5gq97FCPvfQRPQJb88/uidhHZLBaYMgHjb1hJo0dxcWqO7mL/meqWHPlzNxKfuvTWzLV5/j+Mg="; +const INIT_DEVICE_DATA_CONST = + "TEQYvgJq1LrMqFaBybfIzPxz2ygFyAct7X/w+LacfXWd9rGSwE/x6ZCONucD1fehuJdn/1eqeR0DPqjDoh7DP7j1GLPmBohgT/1vG1SmPu/2HiPf4AvsoZhKQpp33G7xYkh1oJKuT+89UgvvVdCOREp4SrT9vDuovvZSF7BhjrRLbVaYLPYzmgW/jReYy/RU"; + +function buildLog1Data() { + return LOG1_DATA_CONST; +} + +function buildInitData() { + return INIT_DEVICE_DATA_CONST; +} + +// The SDK's Log3 carries a behavior track: AES (with the PER-SESSION cfg0 +// key — the same key as the token W, NOT the constant) of a JSON like +// {"mousemove":[],"mouseclick":[],"keyup":[],"scrollTop":[],"scrollLeft":[], +// "pointerEvent":[],"clientType":"desktop","startTime":,"timestamp":""} +// (captured fresh from a passing live solve: all event lists are EMPTY — +// the "events" only appear on non-jsdom builds with a virtual mouse; the +// payload's presence + metadata is what the risk model reads). +function buildBehaviorJson(midTs) { + // Captured from the CURRENT bundle (pe.057, passing solve): timestamp must + // equal the session midTs (the W[87] stamp) EXACTLY, startTime = midTs+163 + // (pe.055 era: +370 — jitters 160-390). Event arrays may be empty (pe.055 + // passed with all-empty; pe.057 adds synthesized mousemove/click events). + const startTime = midTs + 163 + Math.floor(Math.random() * 80); + return JSON.stringify({ + mousemove: [], + mouseclick: [], + keyup: [], + scrollTop: [], + scrollLeft: [], + pointerEvent: [], + clientType: "desktop", + startTime, + timestamp: String(midTs), + }); +} + +function buildDeviceData(payloadKey, mid, payloadB64, innerTs, opts = {}) { + // 8-field um DeviceData (matches the captured SDK wire shape 1:1): + // sk#W#secret2#W20220202#CLOUD#0#501#b64(inner) + // secret2 and the inner blobs are AES-CBC with the session payloadKey; + // the outer Data layer is AES-CBC with the constant WEB_FLAG_KEY. + // opts.log3 emits the SDK's 7-field Log3 variant: + // sk#W#secret2#W20220202#CLOUD##b64("511#"+b64(inner-nopayload)+"-504#"+b64(full-inner)) + // where "-504" pre-declares the +504 ms ts delta of the Log2 that follows, + // and part1's inner ts is 1 ms before part2's (captured: 0560 vs 0561). + const secret2 = aesEnc(payloadKey, pkcs7(Buffer.from(`W.10054#saf-captcha#${SCENE}`, "utf8"))).toString("base64"); + const saf = aesEnc(payloadKey, pkcs7(Buffer.from("saf-captcha", "utf8"))).toString("base64"); + const wblob = aesEnc(payloadKey, pkcs7(Buffer.from("W.10054", "utf8"))).toString("base64"); + // opts.log3 emits the SDK's 7-field Log3 variant — TWO records joined by + // the constant "-504#" separator (captured verbatim in every passing wire): + // sk#W#secret2#W20220202#CLOUD##b64("511#"+b64(part1)+"-504#"+b64(part2)) + // part1 = mid##AES(saf)#AES(wblob)##AES(tsA) (W slot EMPTY) + // part2 = mid#AES(track)#AES(saf)#AES(wblob)##AES(tsA+1) + // where track = the behavior JSON (captured: all event lists EMPTY, with + // startTime = the W's behavior startTime and timestamp = midTs+1). + // Sending part1 alone trips the verify risk engine (F001): the track + // record in part2 is REQUIRED (pe.053 + pe.090 passing captures both + // carry it; the ts delta part1->part2 = +1ms, tsA = Log2.ts + 11). + if (opts.log3) { + const tsA = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs), "utf8"))).toString("base64"); + const tsB = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs + 1), "utf8"))).toString("base64"); + const part1 = [mid, "", saf, wblob, "", tsA].join("#"); + const trackB64 = opts.track ? aesEnc(payloadKey, pkcs7(Buffer.from(opts.track, "utf8"))).toString("base64") : ""; + const part2 = [mid, trackB64, saf, wblob, "", tsB].join("#"); + const field6 = Buffer.from(`511#${Buffer.from(part1, "utf8").toString("base64")}-504#${Buffer.from(part2, "utf8").toString("base64")}`, "utf8").toString("base64"); + const plain = `${DD_TEMPLATE_PREFIX}${secret2}#W20220202#CLOUD##${field6}`; + return aesEnc(WEB_FLAG_KEY, pkcs7(Buffer.from(plain, "utf8"))).toString("base64"); + } + const tsBlob = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs), "utf8"))).toString("base64"); + const inner = [mid, payloadB64, saf, wblob, "", tsBlob].join("#"); + const plain = `${DD_TEMPLATE_PREFIX}${secret2}#W20220202#CLOUD#0#501#${Buffer.from(inner, "utf8").toString("base64")}`; + return aesEnc(WEB_FLAG_KEY, pkcs7(Buffer.from(plain, "utf8"))).toString("base64"); +} +function buildData(ts, vt, prefix, arg) { + const json = + prefix + + REAL_JSON.replace("%d", ts) + .replace("%d", ts) + .replace("%d", vt) + .replace("%s", arg); + const deflated = zlib.deflateSync(Buffer.from(json, "utf8")); + const b64 = deflated.toString("base64"); + const blob = h2dCrypt(Buffer.from(b64, "utf8"), ksa("3e627e1b4c63f913"), false); + return blob.toString("base64"); +} + +let _cached = null; +let _cachedAt = 0; +let _retried = false; + +async function solveCaptcha(opts = {}) { + // Cache: the Aliyun server pins one certifyId/securityToken per device for + // extended periods; if the backend tolerates reuse, caching avoids the + // 4-call flow. CAPTCHA_CACHE_TTL_MS env or opts.cacheTtlMs. + const cacheTtlMs = + opts.cacheTtlMs ?? + (process.env.CAPTCHA_CACHE_TTL_MS ? Number(process.env.CAPTCHA_CACHE_TTL_MS) : 0); + if (cacheTtlMs > 0 && _cached && Date.now() - _cachedAt < cacheTtlMs) { + return { ..._cached }; + } + + // 0. Prime WAF cookies on all captcha hosts (mirrors the SDK's page/script + // loads so every POST below carries acw_tc). NATIVE_CDN=1 mirrors the + // SDK's ACTUAL page load (CDN assets, no API-host primes) instead. + if (process.env.NATIVE_CDN === "1") { + await loadCdnAssets(true); + } else if (process.env.NATIVE_PAGE !== "1" && process.env.NATIVE_NO_PRIME !== "1") { + await primeHosts(); + } + await loadOriginPage(); + + // 1. log1 (device registration) + init fired TOGETHER at page load — + // the SDK's own pattern: both XHRs depart ~simultaneously, the log1 mid + // is issued mid-flight, and the init response lands mid+632..1100ms + // (sdkfull/sdkw/sdkctl13/sdkctl14_now all show init COMPLETING in that + // band — the init REQUEST is ALREADY in flight when the mid exists; the + // native used to serialize init after log1's response, pushing every + // downstream arrival ~400-800ms late vs the SDK's pattern -> F001). + // The um cloudauth calls carry NO Timestamp/Mode/SceneId params. + // CAPTURED (Aug 18 pe.078/pe.088): the LOG1 RESPONSE also returns a + // DeviceConfig, and ITS mid — not the init response's — is the session + // mid the SDK echoes verbatim into Log2/Log3 and the verify token + // (verified: log1 mid ts 1787049327735 == token mid == log2 mid; the + // init-issued mid (ts +641ms) appears NOWHERE). The risk engine ties + // the session to the log1-registered mid; echoing the init mid -> F001. + const log1P = post( + LOG_URL, + { + AaduaneId: LOG_AADUANE, + Version: "2020-10-15", + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Action: "Log1", + Data: buildLog1Data(), + SignatureNonce: nonce(), + }, + LOG_KEY + ); + _log1DispatchAt = Date.now(); + // MEASURED (timed passing session, Aug 19): the SDK fires init ~1.4s + // AFTER log1 (log1 send -> init send = +1416ms, init response ~+1.5s) — + // NOT simultaneously. The old "both XHRs depart ~simultaneously" premise + // made the native's init arrive at log1+3ms; the arrival rhythm (init ~1.3 + // -1.6s after log1) is the observable the risk engine sees. + const log1 = await log1P; + const sched = loadSchedule(); + if (sched && sched.rel && sched.rel.InitCaptchaV3 && sched.rel.Log1) { + await schedWait("InitCaptchaV3"); + } else { + const initGapMs = process.env.NATIVE_INIT_GAP_MS + ? Number(process.env.NATIVE_INIT_GAP_MS) + : 500 + Math.floor(Math.random() * 250); + await new Promise((r) => setTimeout(r, initGapMs)); + } + const initSentAt = Date.now(); + const initP = post( + INIT_URL, + { + AaduaneId: AADUANE, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Timestamp: tsNow(), + Version: "2023-03-05", + Action: "InitCaptchaV3", + SceneId: SCENE, + Language: "en", + Mode: "popup", + DeviceData: buildInitData(), + SignatureNonce: nonce(), + }, + VERIFY_KEY + ); + const init = await initP; + if (process.env.NATIVE_DEBUG_BODIES) console.error(`[t] initResp=${Date.now()}`); + if (process.env.NATIVE_VERSION_REQUIRE) { + const got = String(init.StaticPath || "").match(/pe\.[0-9]+/); + const want = process.env.NATIVE_VERSION_REQUIRE; + if (!got || !want.includes(got[0])) { + throw new Error("version-mismatch got=" + (got && got[0]) + " want=" + want); + } + } + const cert = init.CertifyId; + if (!cert) throw new Error("init no cert: " + JSON.stringify(init).slice(0, 300)); + + // The LOG1 response's DeviceConfig (field[2]) issues THE session mid: + // -h-- — the SDK echoes THIS exact mid into Log2/ + // Log3 and the verify token (the init response issues a second, unused + // mid ~600ms later; any other tail -> Log2 "404 parameter formate + // error(data)" AND the risk engine's session lookup fails -> F001). + const log1Cfg = decryptConfig((log1.ResultObject && log1.ResultObject.DeviceConfig) || log1.DeviceConfig || "").split("#"); + const log1Mid = log1Cfg[2] || ""; + const log1MidTs = Number(log1Mid.split("-")[2]) || 0; + + // Solve entry: [72] = t0 = the CLAIMED session start, stamped BEFORE the + // log1 mid issue — every CURRENT passing wire keeps [87]=midTs+1 ahead of + // [72] by 458-514ms (pe.092 captures sdkctl17/18/20; the older run7-era + // 850-1200ms comments are superseded). Anchoring t0 on the server-issued + // mid ts (NOT on the local init clock) keeps the [87]-[72] delta identical + // regardless of RTT jitter. + const t0 = log1MidTs - (430 + Math.floor(Math.random() * 100)); + + // Resolve the egress IPv4 DURING the solve window so the tail burst has no + // ipify round trip in it (the uploadlog must land BEFORE the verify, like + // the SDK's asset-loaded telemetry). + const ipv4P = process.env.NATIVE_SKIP_UPLOAD === "1" ? Promise.resolve("") : publicIpv4(); + + const cfgFields = decryptConfig(init.DeviceConfig).split("#"); + // W-payload key: the PER-SESSION key derived from DeviceConfig field[0] + // (b64 -> utf8: "6e62d5c58115def6"). Verified against a fresh passing live + // capture: the CURRENT pe bundle uses this ONE key for every payload layer — + // Log2 secret2 + inner blobs, the Log2 DD W (inner[1]), the Log3 behavior + // JSON, and the verify deviceToken W. (An earlier pe build used the constant + // cc01323d7e7df0c7 instead — do not resurrect that; today's SDK decrypts + // fine only with the cfg0-derived key.) + const tokenKey = Buffer.from(Buffer.from(cfgFields[0], "base64").toString(), "utf8"); + const serverSk = cfgFields[2].split("-")[0]; + // field[7] of the log1 cfg = the mid's own midTs; field[8] = the client IP + // the server sees. + const serverMid = log1Mid || cfgFields[2]; + const serverMidTs = log1MidTs || Number(cfgFields[2].split("-")[2]) || Date.now(); + // W[42] = the IP the server saw at LOG1 time (log1 cfg field[8]); the + // fresh pe.090 capture's W carried log1's IPv6 while init's cfg said IPv4 — + // the SDK feeds log1's value, NOT init's. + const serverIpv6 = log1Cfg[8] || cfgFields[8] || ""; + if (process.env.NATIVE_DEBUG_BODIES === "1") { + console.error(`[native] log1MidTs=${log1MidTs || "none"} initMidTs=${cfgFields[2].split("-")[2]} used=${serverMidTs}`); + } + if (process.env.NATIVE_DEBUG_BODIES === "1") { + console.error("[native] cfg0:", cfgFields[0], "| sk:", serverSk, "| mid:", serverMid, "| ipv6:", serverIpv6, "| log1ip:", log1Cfg[8] || "", "| initip:", cfgFields[8] || "", "| cert:", cert); + } + + // pe.088 wire capture: the SDK uses the server-ISSUED session mid verbatim + // (config field[2]) in the Log2/Log3 skeletons AND the verify token — it + // does NOT re-mint (earlier pe.05x builds did; today any other tail -> + // Log2 "404 parameter formate error(data)"). NATIVE_MID=own forces the + // legacy minted mid for A/B bisection only. + const mint = process.env.NATIVE_MID === "own" ? serverMidTs - (700 + Math.floor(Math.random() * 250)) : serverMidTs - 850 - Math.floor(Math.random() * 180); + const ownMid = `${serverSk}-h-${mint}-${uuidV4Hex()}`; + const mid = process.env.NATIVE_MID === "own" ? ownMid : serverMid; + + // Everything anchors on the LOG1-issued mid's ts (pe.078/pe.088 capture): + // [87] = midTs+1, behavior-track startTime = midTs + 142..289 (TODAY's + // band; the pe.05x-era +700-820 profile no longer matches), [72] = the + // pre-init solve-start claim ~400-1200ms before [87]. + const midTs = Number(mid.split("-")[2]) || Date.now(); + // init round-trip measurement for the UploadLog claims (captured SDK: + // mInit.t = initDone+164, js.t = mInit.t+97, rt = initDone-t0+106) and + // resolve the egress IPv4 DURING the solve window so the exit burst has + // no ipify round trip in it. + const initDoneAt = Date.now(); + const initDurMs = initDoneAt - initSentAt; + + // One shared random set for BOTH W payloads (Log2 DD + verify token) — + // captured SDK sessions carry byte-identical [21]/[71]/[73]/[32] across + // them; per-call randoms trip the verify risk engine (F001). [21]/[71]/ + // [73] are FRESH per session (SDK: new random every run), but [32] is the + // SDK's PERSISTENT device id (localStorage): captured 21373fa2b8a3686937 + // 8c44aeae9f1ea0 for the Aug 17 harness sessions and 17ab549fc615147ebf + // a8c87f5ae7712b for the Aug 18 sessions — stable within each storage + // lifetime, never per-session random. A fresh [32] every session while + // [21]/[71]/[73] rotate trips the device-fingerprint correlation (F001). + const session = { + f21: Buffer.from(crypto.randomBytes(8).toString("hex").slice(0, 8)).toString("base64"), + f71: crypto.randomBytes(30).toString("base64").replace(/=+$/, ""), + f73: crypto.randomBytes(31).toString("base64").replace(/=+$/, ""), + f32: stableF32(), + }; + { + const rplv = loadReplay(); + if (rplv && replayUse()(8) && rplv.wv) { + session.f21 = rplv.wv.f21; + session.f71 = rplv.wv.f71; + session.f73 = rplv.wv.f73; + } else { + const v = W_VALUE_POOL[crypto.randomInt(W_VALUE_POOL.length)]; + session.f21 = v.f21; + session.f71 = v.f71; + session.f73 = v.f73; + } + } + + // 2. CLAIM TIMELINE (measured on 6/6 passing pe.079 sessions, Aug 19): + // mInit.t = midTs+610..715, rt = mInit+230..380 (the SDK's init spans a + // BROADER base than the mid — init start can predate the mid mint), + // js.t = mInit+90..105, js rt = 88..102, data start = js.t+4..6, + // VerifyTime = start+12..15, W[74] mint = VerifyTime+10..15 (so + // mint = js.t+26..36, and the wall-clock burst lands midTs+726..850, + // exactly the observed passing arrival band 745..843), uploadlog rt = + // js.t+240..260. The old anchor (mint = midTs+850..1030 with claims + // straggling ~500ms behind the js.t claim) trips F001 — the risk model + // reads the UPLOADLOG claim lattice, not the arrivals alone. + await loadCdnAssets(false); + const bWant = replayUse(); + const rpl = loadReplay(); + let mInitT = initSentAt; // REAL stamps (set at dispatch): the SDK's + let jsT = Date.now(); // upload log carries its true init/js times; + let dTs = 0; // cooked mid-anchored claims skew the + let dVt = 0; // claim-vs-arrival lattice -> F001. + let mintClaim = 0; + if (rpl && bWant(2) && rpl.uploadLog) { + const rel = (abs) => abs - rpl.midTs; + mInitT = midTs + rel(rpl.uploadLog.mInit.t); + jsT = midTs + rel(rpl.uploadLog.js.t); + dTs = midTs + rel(rpl.dataTs); + dVt = midTs + rel(rpl.dataVt); + mintClaim = midTs + rel(rpl.mint); + } + const mintAtTarget = mintClaim; + const waitMs = mintAtTarget - Date.now(); + if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs)); + + // 3. mint payload + token + data — all stamps wall-clock, mint-relative + // (verify data: TrackStartTime/startTime = wTs-26..-35, VerifyTime = + // wTs-14..-18; W [74] = mint-1; behavior-track startTime = mid+120..147). + // REAL stamps: with the flow sped up (init at mid+300..500, mint at + // init-done+155..285), the W span = mint-t0 lands ~1500..2450ms — inside + // every passing envelope (SDK13 1336 / SDKW 2590) with ZERO claim-vs- + // arrival skew, exactly like the SDK's own real-clock wires. + const mintAt = Date.now(); + const wTimes = { + initTs: t0, + wTs: mintAt - 1, + startTime: midTs + 120 + Math.floor(Math.random() * 28), + session, + }; + if (process.env.NATIVE_DEBUG_BODIES) console.error(`[t] preBuildW=${Date.now()}`); + const ddPayloadB64 = aesEnc(tokenKey, pkcs7(Buffer.from(buildW(cert, midTs, true, wTimes, serverIpv6), "utf8"))).toString("base64"); + if (process.env.NATIVE_DEBUG_BODIES) console.error(`[t] afterBuildW=${Date.now()}`); + const log2Ts = Date.now(); + const dd = buildDeviceData(tokenKey, mid, ddPayloadB64, log2Ts); + if (process.env.NATIVE_DEBUG_BODIES) console.error(`[t] afterDD=${Date.now()}`); + + // 3. log burst — FIRED, not awaited (the SDK's XHR deliveries are async: + // Log2/Log3 land ~8ms apart while the upload/verify burst departs + // ~210-220ms later — mitm-verified on a passing sdkctl17 session: + // log2 t+3.069, log3 t+3.077, upload t+3.296, verify t+3.297). + // WIRE ORDER: LOG2 -> LOG3 (+8ms) -> ~215ms processing -> UPLOAD (+1ms) + // -> VERIFY. (The older capture comment "LOG3 -> LOG2" is superseded — + // the passing sdkctl17 mitm flow shows Log2 departing first.) + + // Log2 (device data, carries the DD W) departs FIRST. + await schedWait("Log2"); + const pLog2 = post( + LOG_URL, + { + AaduaneId: LOG_AADUANE, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Version: "2020-10-15", + Action: "Log2", + Data: dd, + SignatureNonce: nonce(), + }, + LOG_KEY + ).catch(() => {}); + + // Log3 behavior track: the CURRENT SDK ships REAL collected events here. + // Fresh passing pe.090 capture (sdkctl13): 3-6 mousemove with t spaced + // ~20-40ms, TWO clicks at the FIRST move point's coords with t = p0.t+1 + // and p0.t+2, two keyups at p0.t+3, startTime = mid+100..230, timestamp = + // midTs+1. Absolutely populated — a missing track is a hard F001 today. + // NATIVE_TRACK_REPLAY = path to a JSON file: verbatim FeiLin/SDK track + // (A/B: synthetic vs REAL event data — the last unproven dimension). + const track = + process.env.NATIVE_TRACK_REPLAY + ? (() => { + const t = JSON.parse(fs.readFileSync(process.env.NATIVE_TRACK_REPLAY, "utf8")); + return { + ...t, + scrollTop: [], + scrollLeft: [], + pointerEvent: [], + clientType: "desktop", + startTime: wTimes.startTime, + timestamp: String(midTs + 1), + }; + })() + : loadReplay() && replayUse()(4) && loadReplay().track + ? (() => { + const t = loadReplay().track; + const delta = midTs - loadReplay().midTs; + return { + ...t, + scrollTop: [], + scrollLeft: [], + pointerEvent: [], + clientType: "desktop", + startTime: wTimes.startTime, + timestamp: String(midTs + 1), + }; + })() + : (() => { + const pts = []; + const n = 2 + (Math.random() < 0.5 ? 1 : 0); + // Current-era pe.092 captures (sdkctl17/20): the track STARTS at the click + // cluster (x 505-515, y 340-355), first event t = 425-504 (d11-23..-80), + // moves every ~22-27ms, and the LAST move jumps away (dx -45..-55, + // dy +30..+40). Older-era comments here (top-left trail start, 3-6 moves) + // are superseded by the fresh decrypts. + let x = 505 + Math.floor(Math.random() * 15); + let y = 340 + Math.floor(Math.random() * 18); + let t = 400 + Math.floor(Math.random() * 100); + const tMax = (mintAt - wTimes.startTime) - 150 + Math.floor(Math.random() * 60); + for (let i = 0; i < n && t < tMax; i++) { + if (i === n - 1) { + x -= 45 + Math.floor(Math.random() * 15); + y += 30 + Math.floor(Math.random() * 12); + } + pts.push({ x: Math.round(x), y: Math.round(y), t }); + t += 22 + Math.floor(Math.random() * 6); + } + if (pts.length === 0) pts.push({ x: 506, y: 349, t: 400 }); + const p0 = pts[0]; + const ct = p0.t + 1 + (Math.random() < 0.5 ? 0 : 1); + return { + mousemove: pts, + mouseclick: [ + { x: p0.x, y: p0.y, t: ct, it: false, ft: 0 }, + { x: p0.x, y: p0.y, t: ct, it: false, ft: 0 }, + ], + keyup: [{ t: ct + 1 }, { t: ct + 1 + (Math.random() < 0.5 ? 0 : 1) }], + scrollTop: [], + scrollLeft: [], + pointerEvent: [], + clientType: "desktop", + startTime: wTimes.startTime, + timestamp: String(midTs + 1), + }; + })(); + if (process.env.NATIVE_DEBUG_BODIES) console.error(`[t] afterTrack=${Date.now()}`); + + // Log3 departs ~8ms after Log2 (tsC = Log2.ts + 11, captured +11/+12). + await schedWait("Log3"); + const pLog3 = post( + LOG_URL, + { + AaduaneId: LOG_AADUANE, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Version: "2020-10-15", + Action: "Log3", + Data: buildDeviceData(tokenKey, mid, "", log2Ts + 11, { + log3: true, + track: JSON.stringify(track), + }), + SignatureNonce: nonce(), + }, + LOG_KEY + ).catch(() => {}); + + // UploadLog rides the ipv4 probe promise WITHOUT blocking the W mint — + // the SDK fires it async mid-burst and the verify token mints right after + // the logs, not after the probe RTT. + // NATIVE_SEQ_UPLOAD=1: send UploadLog BEFORE the verify, awaited, using the + // init-seen IPv4 (cfgFields[8], same value the SDK's upload log carries) — + // matches the SDK's arrival order Log2 -> UploadLog -> Verify. + const uploadBody = (remoteIp) => { + const r = loadReplay(); + if (r && replayUse()(2) && r.uploadLog) { + const delta = midTs - r.midTs; + const ul = JSON.parse(JSON.stringify(r.uploadLog)); + ul.mInit.t += delta; + ul.js.t += delta; + return { + AaduaneId: AADUANE, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Timestamp: tsNow(), + Version: "2023-03-05", + Action: "UploadLog", + log: JSON.stringify(ul), + SignatureNonce: nonce(), + }; + } + return { + AaduaneId: AADUANE, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Timestamp: tsNow(), + Version: "2023-03-05", + Action: "UploadLog", + log: JSON.stringify({ + sId: SCENE, + pfx: "no8xfe", + ip: remoteIp, + mInit: { + t: mInitT, + s: true, + msg: "INIT_SUCCESS", + rt: initDurMs + 480 + Math.floor(Math.random() * 150), + }, + hst: "captcha-open-southeast.aliyuncs.com", + cId: cert, + js: { t: jsT, s: true, msg: "DYNAMICJS_LOADED", rt: 85 + Math.floor(Math.random() * 12) }, + rt: initDurMs + 560 + Math.floor(Math.random() * 100), + }), + SignatureNonce: nonce(), + }; + }; + + // SDK arrival pattern (mitm sdkctl17, passing): log2 departs (DD mint) + // -> log3(+8ms) -> token W minted at [74]+21..23 (now2; the 93/94 [43] + // tail + verify data claims anchor here) -> ~195ms of bundle processing + // -> upload(+218ms) -> verify(+219ms, ~1-3ms after upload). + // now2 is PINNED to wTs+21..23 (the SDK's tight band) instead of being + // stamped after the log2/log3 dispatch latency + sleep, which previously + // landed 93/94 at span+27..40 (F001-relevant tail drift). The [43] 93/94 + // values are CLAIMS — pin them; the wall clock merely sleeps to the target. + const now2Target = mintAt + 21 + Math.floor(Math.random() * 3); + const now2 = now2Target; + await new Promise((r) => setTimeout(r, Math.max(0, now2Target - Date.now()))); + // Causality (measured on sdkctl17/20 + 6 passing pe.079 runs Aug 19): + // TrackStartTime = dTs must land ~25-34ms BEFORE the W's [74] (wTs), and + // must equal the behavior-track startTime +4..6 after js.t — buildData is + // called before the W mint, and a VerifyTime after wTs is an impossible + // timeline (all 39 F001 runs carried vt = wTs+6..+21). + const wTs = wTimes.wTs; + const data = (() => { + const r = loadReplay(); + if (r && replayUse()(1) && r.dataPlain) { + const delta = midTs - r.midTs; + const plain = r.dataPlain + .replace(/("startTime":)(\d+)/g, (m, p, v) => p + (Number(v) + delta)) + .replace(/("TrackStartTime":)(\d+)/g, (m, p, v) => p + (Number(v) + delta)) + .replace(/("VerifyTime":)(\d+)/g, (m, p, v) => p + (Number(v) + delta)); + const deflated = zlib.deflateSync(Buffer.from(plain, "utf8")); + return h2dCrypt(Buffer.from(deflated.toString("base64"), "utf8"), ksa("3e627e1b4c63f913"), false).toString("base64"); + } + return buildData( + mintAt - (26 + Math.floor(Math.random() * 9)), + mintAt - (26 + Math.floor(Math.random() * 9)) + 12 + Math.floor(Math.random() * 4), + randomHex(32), + Buffer.from(crypto.randomBytes(10)).toString("base64"), + ); + })(); + const Wstr = buildW(cert, midTs, false, { ...wTimes, now2 }, serverIpv6); + const payloadB64 = aesEnc(tokenKey, pkcs7(Buffer.from(Wstr, "utf8"))).toString("base64"); + const token = buildToken(mid, payloadB64, randomHex(32)); + if (process.env.NATIVE_DUMP === "1") { + try { + fs.writeFileSync("/tmp/opencode/harvest/native_dump.json", JSON.stringify({ mid, cert, midTs, w: Wstr.split("#"), data })); + } catch (_) {} + } + await schedWait("UploadLog"); + if (process.env.NATIVE_SKIP_UPLOAD !== "1") { + if (process.env.NATIVE_SEQ_UPLOAD === "1") { + await post(UPLOAD_URL, uploadBody(cfgFields[8] || "152.59.63.67"), VERIFY_KEY).catch(() => {}); + } else { + void post(UPLOAD_URL, uploadBody(cfgFields[8] || "152.59.63.67"), VERIFY_KEY).catch(() => {}); + } + } + await schedWait("VerifyCaptchaV3"); + const vr = await post( + VERIFY_URL, + { + AaduaneId: AADUANE, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Timestamp: tsNow(), + Version: "2023-03-05", + Action: "VerifyCaptchaV3", + SceneId: SCENE, + CertifyId: cert, + CaptchaVerifyParam: JSON.stringify({ sceneId: SCENE, certifyId: cert, deviceToken: token, data }), + SignatureNonce: nonce(), + }, + VERIFY_KEY + ); + await Promise.allSettled([pLog2, pLog3]); + const res = vr.Result || {}; + if (res.VerifyResult && res.securityToken) { + const out = { certifyId: cert, sceneId: SCENE, isSign: true, securityToken: res.securityToken }; + _cached = out; + _cachedAt = Date.now(); + return { ...out }; + } + // Consumed/replaced certifyId between init and verify: retry the flow once. + if ((res.VerifyCode === "F008" || res.VerifyCode === "F002") && !_retried) { + _retried = true; + try { + return await solveCaptcha(opts); + } finally { + _retried = false; + } + } + const err = new Error("verify failed: " + (res.VerifyCode || vr.Code) + " " + JSON.stringify(vr).slice(0, 300)); + err.verifyCode = res.VerifyCode; + err.response = vr; + throw err; +} + +module.exports = { solveCaptcha }; diff --git a/captcha_node/native_solve2.js b/captcha_node/native_solve2.js new file mode 100644 index 0000000..6290f63 --- /dev/null +++ b/captcha_node/native_solve2.js @@ -0,0 +1,651 @@ +/** + * native_solve2.js — profile-driven pure-HTTP Aliyun NoCaptcha solver. + * + * ROTATION-RESISTANT DESIGN: + * Everything the risk engine structurally validates is harvested from + * PASSING happy-dom sessions by capture_native_profile.js and stored in a + * profile JSON (see /tmp/native-profiles or NATIVE_PROFILE). The solver + * regenerates fresh sessions in that shape — nothing about the current + * bundle's expectations is hardcoded beyond the (so-far-stable) signing + * keys and the wire protocol itself. + * + * Today's protocol (verified from live passing captures, 2026-08-22): + * Init → (cert + DeviceConfig: tokenKey, mid, serverIp) + * ~150-450ms later: Log3, UploadLog, Log2 fired back-to-back (~2ms apart) + * Verify ~1-3ms after Log2. NO Log1. Log2 now goes to device.saf host. + * W[43] DC matrix carries literal NaN values and passes. + * + * Usage: + * node native_solve2.js # solve once, print JSON + * NATIVE_PROFILE=/path/profile.json # explicit profile (else newest in dir) + * NATIVE_PROFILE_DIR=/tmp/native-profiles + */ +"use strict"; + +const crypto = require("crypto"); +const https = require("https"); +const fs = require("fs"); +const path = require("path"); +const zlib = require("zlib"); +const { request: undiciRequest } = require("undici"); +const origFetch = global.fetch; + +const SCENE = "11xygtvd"; +const VERIFY_KEY = "222aiJodos2938JDdosko2djd82sf0&"; +const LOG_KEY = "DuanemHmyeE6LXCC46sJEDUw5DTlSZ&"; +const AADUANE = "111jdk439dJJIjd023823201"; +const LOG_AADUANE = "DuaneAprqkYsF3nt1yjK29Bf"; +const INIT_URL = "https://no8xfe.captcha-open-southeast.aliyuncs.com/"; +const VERIFY_URL = "https://no8xfe-verify.captcha-open-southeast.aliyuncs.com/"; +const UPLOAD_URL = "https://upload.captcha-open-southeast.aliyuncs.com/"; +// Current-era Log2 host (rotated away from cloudauth-device; Log3 stays there). +const LOG3_URL = "https://cloudauth-device-dualstack.ap-southeast-1.aliyuncs.com/"; +const LOG2_URL = "https://ap-southeast-1.device.saf.aliyuncs.com/"; +const IV = Buffer.from("0123456789ABCDEF"); +const CONF_KEY = Buffer.from("87f879f135f27da7"); +const WEB_FLAG_KEY = Buffer.from("a549a55c60a39aa0"); +const INIT_DEVICE_DATA_CONST = + "TEQYvgJq1LrMqFaBybfIzPxz2ygFyAct7X/w+LacfXWd9rGSwE/x6ZCONucD1fehuJdn/1eqeR0DPqjDoh7DP7j1GLPmBohgT/1vG1SmPu/2HiPf4AvsoZhKQpp33G7xYkh1oJKuT+89UgvvVdCOREp4SrT9vDuovvZSF7BhjrRLbVaYLPYzmgW/jReYy/RU"; +const DD_TEMPLATE_PREFIX = "3795d28242a11619bc25f786f84e53d4#W#"; + +// ── h2d blob cipher (same as native_solve.js) ────────────────────────────── +const _SBOX_RAW = [ + 1, 32, 50, 10, 51, 6, 44, 37, 16, 46, 11, 62, 19, 43, 25, 23, 30, 60, 53, 34, + 7, 26, 12, 48, 5, 2, 20, 4, 61, 13, 47, 49, 29, 27, 22, 17, 39, 56, 41, 38, + 55, 31, 15, 58, 52, 40, 8, 57, 45, 35, 59, 36, 42, 54, 63, 3, 24, 28, 14, 9, + 21, +]; +function buildSbox() { + return [ + ..._SBOX_RAW.slice(1, 18), 33, ..._SBOX_RAW.slice(18, 32), 18, + ..._SBOX_RAW.slice(32, 35), 1, ..._SBOX_RAW.slice(35, 60), 0, 21, + ]; +} +function ksa(keyStr) { + const S = buildSbox(); + let t = 0; + for (let o = 0; o < 64; o++) { + t = (((o + t + S[o] + S[t]) >> 1) + keyStr.charCodeAt(o % keyStr.length)) & 63; + if (t !== o) { const tmp = S[o]; S[o] = S[t]; S[t] = tmp; } + } + return S; +} +function h2dCrypt(data, S, invert) { + S = [...S]; + let e = 0, a = 0; + const out = Buffer.alloc(data.length); + for (let n = 0; n < data.length; n++) { + a = ((e ^ a) + (S[e] ^ S[a])) & 63; + if (a !== e) { const t = S[e]; S[e] = S[a]; S[a] = t; } + const x = S[e] + S[a]; + const y = S[x & 63]; + let m = data[n]; + if (invert) m = ((m ^ x ^ y) + (S[a] + a) - e - S[e]) & 255; + else m = (((m + e + S[e]) - (S[a] + a)) ^ x ^ y) & 255; + out[n] = m; + e = (e + 1) & 63; + } + return out; +} + +// ── crypto helpers ───────────────────────────────────────────────────────── +function aesEnc(key, pt) { + const c = crypto.createCipheriv("aes-128-cbc", key, IV); + c.setAutoPadding(false); + return Buffer.concat([c.update(pt), c.final()]); +} +function pkcs7(buf) { + const pad = 16 - (buf.length % 16); + return Buffer.concat([buf, Buffer.alloc(pad, pad)]); +} +function unpad(buf) { + const pad = buf[buf.length - 1]; + return pad >= 1 && pad <= 16 ? buf.slice(0, buf.length - pad) : buf; +} +function aesDec(key, ct) { + const c = crypto.createDecipheriv("aes-128-cbc", key, IV); + c.setAutoPadding(false); + return Buffer.concat([c.update(ct), c.final()]); +} +function decryptConfig(b64) { + return unpad(aesDec(CONF_KEY, Buffer.from(b64, "base64"))).toString("utf8"); +} +function randomHex(len) { + return crypto.randomBytes(len / 2).toString("hex").slice(0, len); +} +function uuidV4Hex() { + const b = crypto.randomBytes(16); + b[6] = (b[6] & 0x0f) | 0x40; + b[8] = (b[8] & 0x3f) | 0x80; + return b.toString("hex"); +} +function tsNow() { + return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); +} +function pct(s) { return encodeURIComponent(String(s)); } +function sign(params, key) { + const canon = Object.keys(params) + .sort() + .map((k) => `${pct(k)}=${pct(params[k])}`) + .join("&"); + const sts = "POST&%2F&" + pct(canon); + return crypto.createHmac("sha1", key).update(sts).digest("base64"); +} + +// ── profile loading (rotation resistance) ────────────────────────────────── +function loadProfiles() { + const dir = process.env.NATIVE_PROFILE_DIR || "/tmp/native-profiles"; + let files = []; + try { + files = fs.readdirSync(dir) + .filter((f) => /^profile-.*\.json$/.test(f) && !f.includes(".raw.")) + .map((f) => path.join(dir, f)) + .sort(); + } catch (_) {} + if (process.env.NATIVE_PROFILE) files.push(process.env.NATIVE_PROFILE); + const profs = files + .map((f) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch (_) { return null; } }) + .filter((p) => p && p.w && Array.isArray(p.w) && p.w.length >= 88); + if (profs.length === 0) { + throw new Error( + "no profiles found — run: cd captcha_node && node capture_native_profile.js 8 " + + "(the native solver needs harvested passing-session profiles)", + ); + } + return profs; +} + +// ── request layer (raw undici: no sec-fetch headers, like jsdom XHR) ─────── +const BROWSER_HEADERS = { + "user-agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "accept-language": "en-US,en;q=0.9", +}; +const cookieJar = new Map(); +function cookieHeader(host) { + const m = cookieJar.get(host); + if (!m || m.size === 0) return null; + return [...m.entries()].map(([k, v]) => `${k}=${v}`).join("; "); +} +function absorbCookies(host, setCookie) { + if (!setCookie) return; + const entries = Array.isArray(setCookie) ? setCookie : [setCookie]; + let m = cookieJar.get(host); + if (!m) { m = new Map(); cookieJar.set(host, m); } + for (const raw of entries) { + const first = raw.split(";")[0].trim(); + const eq = first.indexOf("="); + if (eq > 0) m.set(first.slice(0, eq), first.slice(eq + 1)); + } +} +let _httpDispatcher = null; +function dispatcher() { + if (_httpDispatcher) return _httpDispatcher; + // Native requests stay DIRECT (they are not WAF-blocked; the proxy only + // wraps the LLM POST). NATIVE_PROXY overrides for testing. + const proxyUrl = process.env.NATIVE_PROXY || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; + if (proxyUrl) { + try { + const { ProxyAgent } = require("undici"); + _httpDispatcher = new ProxyAgent(proxyUrl); + } catch (_) { _httpDispatcher = undefined; } + } + return _httpDispatcher; +} +async function post(url, params, key) { + const p = { ...params, Signature: sign(params, key) }; + const body = Object.keys(p).map((k) => `${pct(k)}=${pct(p[k])}`).join("&"); + const u = new URL(url); + // EXACTLY the headers the passing happy sessions send (captured on the + // undici fetch call): fetch-spec headers (sec-fetch-mode, accept, + // accept-encoding, content-length) are added by undici itself — do not + // set them manually or the HTTP/2 fingerprint diverges from the SDK's. + const headers = { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "user-agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "accept-language": "en-US,en;q=0.9", + referer: "https://zcode.z.ai/", + origin: "https://zcode.z.ai", + }; + const ck = cookieHeader(u.hostname); + if (ck && process.env.NATIVE_NO_COOKIES !== "1") headers.cookie = ck; + const opts = { method: "POST", headers, body }; + const d = dispatcher(); + if (d) opts.dispatcher = d; + // Note: the local undici 8.x ProxyAgent is incompatible with node's global + // fetch dispatcher protocol ("invalid onRequestStart"). When proxying, use + // the local undici's own request(); otherwise global fetch (happy parity). + let res; + if (d) { + const { request } = require("undici"); + res = await request(u, opts); + } else { + res = await origFetch(u, opts); + } + let raw; + if (res.body && typeof res.body.arrayBuffer === "function") { + raw = Buffer.from(await res.body.arrayBuffer()); + } else if (typeof res.arrayBuffer === "function") { + raw = Buffer.from(await res.arrayBuffer()); + } else { + raw = Buffer.from([]); + } + const ce = String( + (res.headers.get ? res.headers.get("content-encoding") : res.headers["content-encoding"]) || "", + ).toLowerCase(); + if (ce === "gzip") raw = zlib.gunzipSync(raw); + else if (ce === "deflate") raw = zlib.inflateSync(raw); + const scRaw = d + ? res.headers["set-cookie"] + : typeof res.headers.getSetCookie === "function" + ? res.headers.getSetCookie() + : []; + if (scRaw) absorbCookies(u.hostname, Array.isArray(scRaw) ? scRaw : [scRaw]); + const text = raw.toString("utf8"); + let json; + try { json = JSON.parse(text); } catch (e) { throw new Error("bad json: " + text.slice(0, 200)); } + if (process.env.NATIVE_DEBUG === "1") { + console.error(`[native2] ${u.hostname} ${params.Action} -> ${JSON.stringify(json).slice(0, 200)}`); + } + return json; +} +/** Fire-and-forget variant for the burst (Log3/UploadLog/Log2). */ +function fire(url, params, key) { + return post(url, params, key).catch(() => {}); +} + +// ── payload builders (profile-shaped) ────────────────────────────────────── +function rand(min, max) { + return min + Math.floor(Math.random() * (max - min + 1)); +} + +/** Build a W payload array from a harvested profile, re-minted for this + * session: cert, mid-anchored stamps, per-session DC matrix, this session's + * whole-profile salt set ([21]/[71]/[73]/[78]/[85]/[86] stay correlated). */ +function buildWFromProfile(profs, cert, midTs, salt, withTail, wTimes) { + const f = salt.w.slice(); f[77] = cert; + // [87] = cfg[7] EXACTLY (captured: W[87] === init DeviceConfig field[7] + // byte-for-byte, which is midTs+1 or +2) — never re-roll it. + f[87] = String(wTimes.w87 ?? midTs + rand(0, 2)); // = cfg[7] (midTs+1..2) + f[72] = String(wTimes.initTs); // = mid+69..83 + f[74] = String(wTimes.wTs); // mint-1 + // DC matrix [43]: today's shape is `20-a|23-b|30-0|40-c|90-NaN|91-NaN|92-NaN` + // (token W appends `|93-NaN|94-NaN`). a/b/c drawn from the observed bands. + const a = rand(240, 665); + const b = a + rand(31, 81); + const c = rand(1, 9); + f[43] = `20-${a}|23-${b}|30-0|40-${c}|90-NaN|91-NaN|92-NaN${withTail ? "|93-NaN|94-NaN" : ""}`; + f[42] = wTimes.serverIp || f[42]; + return f.join("#"); +} + +function buildDeviceData(payloadKey, mid, payloadB64, innerTs, opts = {}) { + const secret2 = aesEnc(payloadKey, pkcs7(Buffer.from(`W.10054#saf-captcha#${SCENE}`, "utf8"))).toString("base64"); + const saf = aesEnc(payloadKey, pkcs7(Buffer.from("saf-captcha", "utf8"))).toString("base64"); + const wblob = aesEnc(payloadKey, pkcs7(Buffer.from("W.10054", "utf8"))).toString("base64"); + const tsA = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs), "utf8"))).toString("base64"); + if (opts.log3) { + const tsB = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs + 1), "utf8"))).toString("base64"); + const part1 = [mid, "", saf, wblob, "", tsA].join("#"); + const trackB64 = opts.track + ? aesEnc(payloadKey, pkcs7(Buffer.from(opts.track, "utf8"))).toString("base64") + : ""; + const part2 = [mid, trackB64, saf, wblob, "", tsB].join("#"); + const field6 = Buffer.from( + `511#${Buffer.from(part1, "utf8").toString("base64")}-504#${Buffer.from(part2, "utf8").toString("base64")}`, + "utf8", + ).toString("base64"); + const plain = `${DD_TEMPLATE_PREFIX}${secret2}#W20220202#CLOUD##${field6}`; + return aesEnc(WEB_FLAG_KEY, pkcs7(Buffer.from(plain, "utf8"))).toString("base64"); + } + const tsBlob = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs), "utf8"))).toString("base64"); + const inner = [mid, payloadB64, saf, wblob, "", tsBlob].join("#"); + const plain = `${DD_TEMPLATE_PREFIX}${secret2}#W20220202#CLOUD#0#501#${Buffer.from(inner, "utf8").toString("base64")}`; + return aesEnc(WEB_FLAG_KEY, pkcs7(Buffer.from(plain, "utf8"))).toString("base64"); +} + +function buildData(prefix, ts, vt, arg) { + const REAL_JSON = + '{"TrackList":{"mc":"","tc":"","mu":"","te":"","mp":"","tmv":"","ks":"","fi":"","startTime":%d},"TrackStartTime":%d,"VerifyTime":%d,"arg":"%s"}'; + const json = prefix + REAL_JSON.replace("%d", ts).replace("%d", ts).replace("%d", vt).replace("%s", arg); + const b64 = zlib.deflateSync(Buffer.from(json, "utf8")).toString("base64"); + const blob = h2dCrypt(Buffer.from(b64, "utf8"), ksa("3e627e1b4c63f913"), false); + return blob.toString("base64"); +} + +function buildToken(mid, payloadB64, trailing) { + return Buffer.from(`SG_WEB#${mid}#${payloadB64}#0#${trailing}`).toString("base64"); +} + +// ── WAF cookie priming (mirrors the happy path's page load + host pings; the +// captured passing sessions carry acw_tc cookies on every captcha POST) ── +async function primeCookies() { + if (process.env.NATIVE_NO_PRIME === "1") return; + // 1. Origin page load (sets zcode WAF cookies; also registers IP+UA+origin) + try { + const res = await undiciRequest("https://zcode.z.ai/", { + method: "GET", + headers: { + accept: "*/*", + "accept-encoding": "gzip, deflate", + "accept-language": "en-US,en;q=0.9", + ...BROWSER_HEADERS, + }, + }); + absorbCookies("zcode.z.ai", res.headers["set-cookie"]); + await res.body.arrayBuffer().catch(() => {}); + } catch (_) {} + // 2. Prime each captcha API host so acw_tc flows on the burst POSTs. + for (const host of [ + "no8xfe.captcha-open-southeast.aliyuncs.com", + "no8xfe-verify.captcha-open-southeast.aliyuncs.com", + "cloudauth-device-dualstack.ap-southeast-1.aliyuncs.com", + "ap-southeast-1.device.saf.aliyuncs.com", + "upload.captcha-open-southeast.aliyuncs.com", + ]) { + try { + const res = await undiciRequest(`https://${host}/`, { + method: "GET", + headers: { accept: "*/*", origin: "https://zcode.z.ai", referer: "https://zcode.z.ai/", ...BROWSER_HEADERS }, + }); + absorbCookies(host, res.headers["set-cookie"]); + await res.body.arrayBuffer().catch(() => {}); + } catch (_) {} + } +} + +// ── the solve ────────────────────────────────────────────────────────────── +// (salt pool helper removed — sessions use one whole profile's salt set) + +async function solveCaptcha() { + await primeCookies(); + const profs = loadProfiles(); + // VERBATIM-REPLAY bisection (NATIVE_REPLAY_CAPTURE=1): after a fresh init, + // re-encrypt the captured passing session's W/track/uploadLog/data with the + // NEW tokenKey + mid + cert, shift all timestamps by (newMidTs - oldMidTs), + // and send. If this passes, the crypto + construction are correct and the + // bug is in my synthetic randomization; if it F001s, the diff is in the + // environment (IP/TLS/session correlation). + const replay = process.env.NATIVE_REPLAY_CAPTURE === "1" ? profs[profs.length - 1] : null; + // ONE whole profile's salt set per session — [21]/[71]/[73]/[78]/[85]/[86] + // are correlated in captures (e.g. f78 9d4568c0.. always pairs f85=1); + // mixing fields across profiles produces combinations the SDK never emits. + const prof = profs[crypto.randomInt(profs.length)]; + const salt = { w: prof.w }; + void salt; // salt set is carried via wTimes/prof; kept for clarity + + // 1. InitCaptchaV3 — DeviceData is the bundle's static constant (byte- + // identical across every captured era; verified Aug 22). + const initSentAt = Date.now(); + const init = await post( + INIT_URL, + { + AaduaneId: AADUANE, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Timestamp: tsNow(), + Version: "2023-03-05", + Action: "InitCaptchaV3", + SceneId: SCENE, + Language: "en", + Mode: "popup", + DeviceData: INIT_DEVICE_DATA_CONST, + SignatureNonce: crypto.randomUUID(), + }, + VERIFY_KEY, + ); + const cert = init.CertifyId; + if (!cert) throw new Error("init no cert: " + JSON.stringify(init).slice(0, 300)); + + const cfgFields = decryptConfig(init.DeviceConfig).split("#"); + const tokenKey = Buffer.from(Buffer.from(cfgFields[0], "base64").toString(), "utf8"); + const mid = cfgFields[2]; + const midTs = Number(mid.split("-")[2]) || Date.now(); + const serverIp = cfgFields[8] || ""; + const initDoneAt = Date.now(); + const initDurMs = initDoneAt - initSentAt; + + // 2. Wait into the observed mint band. Captured passing sessions: + // [72] = mid+68..81, [74] = mid+151..160, burst dispatch = mid+212..215 + // (the W is minted ~55-60ms BEFORE the burst departs). + // Mint target scales with the observed init response latency: fast init + // (mid+67..113) -> burst ~mid+212; slow init (mid+471..473) -> burst ~mid+469 + // (the SDK waits for its asset loading; captured bands: mint = mid+151..160 + // fast / mid+412..583 slow). Pick band by init RTT, then dispatch = mint+60. + const initRtt = initDoneAt - initSentAt; + const slowBand = initRtt > 300; + + // Mirror the SDK's per-solve CDN fetch: the happy path ALWAYS re-fetches + // the rotating pe..js from g.alicdn.com between init and the burst + // (bypassPeCache — network GET with UA only). The CDN/WAF correlates this + // fetch with the session; skipping it is a verified F001 trigger. + try { + const sp = String(init.StaticPath || ""); + if (sp) { + // StaticPath = "3.29.0/pe.081.a2ede546e341a1b9" (no .js) + const peUrl = `https://g.alicdn.com/captcha-frontend/dynamicJS/${sp}${sp.endsWith(".js") ? "" : ".js"}`; + const r = await origFetch(peUrl, { headers: { "user-agent": BROWSER_HEADERS["user-agent"] } }); + await r.arrayBuffer().catch(() => {}); + } + } catch (_) {} + + if (replay) { + // ── VERBATIM REPLAY BISECTION ────────────────────────────────────────── + // Re-encrypt the captured passing session's artifacts with the new + // session's key/mid/cert; shift all stamps by delta = newMidTs - oldMidTs; + // dispatch at the captured rhythm (delta-shifted). Nothing synthetic. + const delta = midTs - replay.midTs; + const shift = (s) => String(Number(s) + delta); + const capW = [...replay.w]; + capW[77] = cert; + capW[72] = shift(capW[72]); + capW[74] = shift(capW[74]); + capW[87] = shift(capW[87]); + const capW2 = [...replay.deviceTokenW || replay.w]; + capW2[77] = cert; + capW2[72] = shift(capW2[72]); + capW2[74] = shift(capW2[74]); + capW2[87] = shift(capW2[87]); + const capTrack = { ...replay.track, startTime: replay.track.startTime + delta, timestamp: shift(replay.track.timestamp) }; + const capUl = JSON.parse(JSON.stringify(replay.uploadLog)); + capUl.mInit.t += delta; + capUl.js.t += delta; + capUl.cId = cert; + const capDataPlain = replay.dataPlain + .replace(/("startTime":)(\d+)/g, (m, a, v) => a + (Number(v) + delta)) + .replace(/("TrackStartTime":)(\d+)/g, (m, a, v) => a + (Number(v) + delta)) + .replace(/("VerifyTime":)(\d+)/g, (m, a, v) => a + (Number(v) + delta)) + // The 32-hex prefix and b64 arg are per-session nonces — re-mint them. + .replace(/^[0-9a-f]{32}/, randomHex(32)) + .replace(/"arg":"[^"]*"/, `"arg":"${Buffer.from(crypto.randomBytes(10)).toString("base64")}"`); + const deflated = zlib.deflateSync(Buffer.from(capDataPlain, "utf8")); + const capData = h2dCrypt(Buffer.from(deflated.toString("base64"), "utf8"), ksa("3e627e1b4c63f913"), false).toString("base64"); + + const capDd = aesEnc(tokenKey, pkcs7(Buffer.from(capW.join("#"), "utf8"))).toString("base64"); + const capDdOuter = buildDeviceData(tokenKey, mid, capDd, Number(capW[74]) + 2); + const t = replay.timings; + const dispatchAt = midTs + (t.log2At ?? 213); + const w1 = dispatchAt - Date.now(); + if (w1 > 0) await new Promise((r) => setTimeout(r, w1)); + fire(LOG3_URL, { + AaduaneId: LOG_AADUANE, SignatureMethod: "HMAC-SHA1", SignatureVersion: "1.0", + Format: "JSON", Version: "2020-10-15", Action: "Log3", + Data: buildDeviceData(tokenKey, mid, "", Number(capW[74]) + 12, { log3: true, track: JSON.stringify(capTrack) }), + SignatureNonce: crypto.randomUUID(), + }, LOG_KEY); + fire(UPLOAD_URL, { + AaduaneId: AADUANE, SignatureMethod: "HMAC-SHA1", SignatureVersion: "1.0", + Format: "JSON", Timestamp: tsNow(), Version: "2023-03-05", Action: "UploadLog", + log: JSON.stringify(capUl), SignatureNonce: crypto.randomUUID(), + }, VERIFY_KEY); + fire(LOG2_URL, { + AaduaneId: LOG_AADUANE, SignatureMethod: "HMAC-SHA1", SignatureVersion: "1.0", + Format: "JSON", Version: "2020-10-15", Action: "Log2", + Data: capDdOuter, SignatureNonce: crypto.randomUUID(), + }, LOG_KEY); + await new Promise((r) => setTimeout(r, 1)); + const capToken = buildToken(mid, aesEnc(tokenKey, pkcs7(Buffer.from(capW2.join("#"), "utf8"))).toString("base64"), crypto.randomBytes(16).toString("hex")); + const vr2 = await post(VERIFY_URL, { + AaduaneId: AADUANE, SignatureMethod: "HMAC-SHA1", SignatureVersion: "1.0", + Format: "JSON", Timestamp: tsNow(), Version: "2023-03-05", Action: "VerifyCaptchaV3", + SceneId: SCENE, CertifyId: cert, + CaptchaVerifyParam: JSON.stringify({ sceneId: SCENE, certifyId: cert, deviceToken: capToken, data: capData }), + SignatureNonce: crypto.randomUUID(), + }, VERIFY_KEY); + const r2 = vr2.Result || {}; + if (r2.VerifyResult && r2.securityToken) { + return { certifyId: cert, sceneId: SCENE, isSign: true, securityToken: r2.securityToken }; + } + const e2 = new Error("REPLAY verify failed: " + (r2.VerifyCode || vr2.Code)); + e2.verifyCode = r2.VerifyCode; + throw e2; + } + + const mintTarget = midTs + (slowBand ? rand(410, 585) : rand(150, 160)); + const wait1 = mintTarget - Date.now(); + if (wait1 > 0) await new Promise((r) => setTimeout(r, wait1)); + + // 3. Claim lattice (mid-anchored, from captured bands): + // [72]=mid+68..81, [74]=mintTarget-1, behavior startTime=mid+120..150, + // data TrackStartTime=mid+130..145, VerifyTime=+8..12 after that, + // uploadLog mInit.t=mid+40..60 rt=initDur+40..120, js.t=mid+125..145. + const wTimes = { + initTs: midTs + rand(69, 83), + wTs: mintTarget - 1, + startTime: midTs + rand(80, 133), + serverIp, + w87: Number(cfgFields[7]) || midTs + 1, // W[87] = cfg[7] verbatim + }; + const track = { + mousemove: [], mouseclick: [], keyup: [], + scrollTop: [], scrollLeft: [], pointerEvent: [], + clientType: "desktop", + startTime: wTimes.startTime, + timestamp: String(Number(cfgFields[7]) || midTs + 1), // = W[87] + }; + // ONE W for both the Log2 DD and the verify token (captured: byte-identical + // between them except [43]'s 93/94 tail on the token variant). + const wStr = buildWFromProfile(profs, cert, midTs, salt, false, wTimes); + const ddPayloadB64 = aesEnc(tokenKey, pkcs7(Buffer.from(wStr, "utf8"))).toString("base64"); + const dd = buildDeviceData(tokenKey, mid, ddPayloadB64, mintTarget); + + const uploadParams = () => ({ + AaduaneId: AADUANE, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Timestamp: tsNow(), + Version: "2023-03-05", + Action: "UploadLog", + log: JSON.stringify({ + sId: SCENE, + pfx: "no8xfe", + ip: serverIp, + mInit: { t: midTs + 47, s: true, msg: "INIT_SUCCESS", rt: initDurMs + rand(100, 150) }, + hst: "captcha-open-southeast.aliyuncs.com", + cId: cert, + js: { t: midTs + rand(130, 140), s: true, msg: "DYNAMICJS_LOADED", rt: rand(80, 90) }, + rt: initDurMs + rand(200, 260), + }), + SignatureNonce: crypto.randomUUID(), + }); + + // Build all burst payloads FIRST (the SDK's ~60ms crypto window), then + // dispatch at the observed band mid+212..218 — the W claims [74]=mid+151 + // but the burst ARRIVES at mid+213 in every capture; dispatching at mint + // time creates claim-vs-arrival skew (F001). + const log3Params = { + AaduaneId: LOG_AADUANE, + Version: "2020-10-15", + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Action: "Log3", + Data: buildDeviceData(tokenKey, mid, "", mintTarget + 11, { + log3: true, + track: JSON.stringify(track), + }), + SignatureNonce: crypto.randomUUID(), + }; + const uploadP = uploadParams(); + const log2Params = { + AaduaneId: LOG_AADUANE, + Version: "2020-10-15", + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Action: "Log2", + Data: dd, + SignatureNonce: crypto.randomUUID(), + }; + + const dispatchWait = midTs + (slowBand ? rand(468, 472) : rand(212, 218)) - Date.now(); + if (dispatchWait > 0) await new Promise((r) => setTimeout(r, dispatchWait)); + + // 4. The burst: Log3 -> UploadLog -> Log2 (~1ms apart), then Verify ~1-3ms + // after Log2 (all fired async — arrival order matters, not completion). + fire(LOG3_URL, log3Params, LOG_KEY); + fire(UPLOAD_URL, uploadP, VERIFY_KEY); + const log2P = fire(LOG2_URL, log2Params, LOG_KEY); + // Verify departs ~1-3ms after Log2 (captured: same ms or +1). + await new Promise((r) => setTimeout(r, rand(0, 2))); + + // 5. Verify — token W is the SAME W with the 93/94 NaN tail appended to [43] + // (captured: only [43] differs between the Log2 W and token W). + const dTs = wTimes.startTime + rand(4, 10); + const data = buildData(randomHex(32), dTs, dTs + rand(8, 12), Buffer.from(crypto.randomBytes(10)).toString("base64")); + const tokenW = wStr.replace(/92-NaN$/, "92-NaN|93-NaN|94-NaN"); + const payloadB64 = aesEnc(tokenKey, pkcs7(Buffer.from(tokenW, "utf8"))).toString("base64"); + const token = buildToken(mid, payloadB64, randomHex(32)); + + const vr = await post( + VERIFY_URL, + { + AaduaneId: AADUANE, + SignatureMethod: "HMAC-SHA1", + SignatureVersion: "1.0", + Format: "JSON", + Timestamp: tsNow(), + Version: "2023-03-05", + Action: "VerifyCaptchaV3", + SceneId: SCENE, + CertifyId: cert, + CaptchaVerifyParam: JSON.stringify({ sceneId: SCENE, certifyId: cert, deviceToken: token, data }), + SignatureNonce: crypto.randomUUID(), + }, + VERIFY_KEY, + ); + await log2P; + const res = vr.Result || {}; + if (res.VerifyResult && res.securityToken) { + return { certifyId: cert, sceneId: SCENE, isSign: true, securityToken: res.securityToken }; + } + const err = new Error("verify failed: " + (res.VerifyCode || vr.Code) + " " + JSON.stringify(vr).slice(0, 300)); + err.verifyCode = res.VerifyCode; + err.response = vr; + throw err; +} + +module.exports = { solveCaptcha, loadProfiles }; + +// CLI: solve once, print a proxy-compatible captcha param (base64 JSON with +// certifyId — same shape solve-core's extractVerifyParam produces). +if (require.main === module) { + solveCaptcha() + .then((r) => { + const param = Buffer.from(JSON.stringify(r)).toString("base64"); + console.log(param); + }) + .catch((e) => { + console.error("native2 FAIL:", e.message); + process.exit(1); + }); +} diff --git a/captcha_node/package.json b/captcha_node/package.json new file mode 100644 index 0000000..b891570 --- /dev/null +++ b/captcha_node/package.json @@ -0,0 +1,20 @@ +{ + "name": "zcode-proxy-captcha-solver", + "version": "1.0.0", + "private": true, + "description": "Headless Aliyun traceless captcha solver (Playwright + Chromium)", + "type": "commonjs", + "main": "solver.js", + "dependencies": { + "canvas": "^3.2.3", + "jsdom": "^29.1.1", + "playwright": "^1.61.0", + "happy-dom": "^20.11.2", + "undici": "^8.10.0" + }, + "allowScripts": { + "canvas@3.2.3": true, + "happy-dom": true, + "undici": true + } +} diff --git a/captcha_node/solve-core.js b/captcha_node/solve-core.js new file mode 100644 index 0000000..990fbb4 --- /dev/null +++ b/captcha_node/solve-core.js @@ -0,0 +1,1733 @@ +process.env.FONTCONFIG_PATH = "/dev/null"; +const { ProxyAgent, setGlobalDispatcher } = require("undici"); +const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; +if (proxyUrl) { + try { + setGlobalDispatcher(new ProxyAgent(proxyUrl)); + } catch (_) {} +} +const { JSDOM, VirtualConsole, requestInterceptor } = require("jsdom"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const util = require("node:util"); + +const CDN_CACHE_DIR = path.join(os.homedir(), ".zcode-captcha-cdn-cache"); + +// ── Optimization: In-memory CDN cache ────────────────────────────────────── +// Avoids fs.readFileSync on every solve for the same SDK scripts (~1.2MB total). +const _memCdnCache = new Map(); + +// ── Optimization: Cookie cache ───────────────────────────────────────────── +// Session cookies (acw_tc, cdn_sec_tc) are valid for ~30-60min; re-fetching +// them on every solve wastes ~300ms. Cache with a 5-minute TTL. +let _cookieCache = { cookies: [], ts: 0 }; +const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000; + +// ── Optimization: DEBUG flag ─────────────────────────────────────────────── +// Only emit [loader-*] stderr lines when CAPTCHA_DEBUG is set. +const _DEBUG = /^(1|true|yes)$/i.test(process.env.CAPTCHA_DEBUG || process.env.CAPTCHA_DEBUG_BODIES || ""); + +const originalPrepareStackTrace = Error.prepareStackTrace; +Error.prepareStackTrace = (err, callsites) => { + if (!callsites || !Array.isArray(callsites)) { + return originalPrepareStackTrace + ? originalPrepareStackTrace(err, callsites) + : String(err); + } + const filtered = callsites.filter((callsite) => { + try { + const filename = callsite.getFileName(); + if (!filename) return true; + const lower = filename.toLowerCase(); + return ( + !lower.includes("solve-core.js") && + !lower.includes("jsdom") && + !lower.includes("node_modules") && + !lower.includes("node:") && + !lower.includes("internal/") + ); + } catch (_) { + return true; + } + }); + + return ( + err.toString() + + "\n" + + filtered + .map((callsite) => { + try { + return " at " + callsite.toString(); + } catch (_) { + return " at "; + } + }) + .join("\n") + ); +}; + +function generateFingerprint() { + // Fingerprint mirrors what a real headless Chromium actually reports on this + // host (matched empirically against a passing Playwright solve). FeiLin's risk + // model cross-checks these for internal consistency. + const userAgent = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"; + const uaMajor = "127"; + const uaFull = "127.0.0.0"; + const platform = "Linux x86_64"; + const screen = { w: 1280, h: 720, aw: 1280, ah: 720 }; + // SwiftShader is what headless Chromium actually exposes; claiming a real GPU + // while being unable to render GL is a strong bot tell. + const webglUnmaskedVendor = "Google Inc. (Google)"; + const webglUnmaskedRenderer = + "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)"; + const canvasImage = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + + return { + userAgent, + uaMajor, + uaFull, + platform, + screen, + webglUnmaskedVendor, + webglUnmaskedRenderer, + canvasImage, + }; +} + +const HTML = ` +
+ +`; + +function diskPathFor(url) { + return path.join( + CDN_CACHE_DIR, + crypto.createHash("sha1").update(String(url)).digest("hex"), + ); +} + +// jsdom 29 replaced ResourceLoader with requestInterceptor. The interceptor +// receives a WHATWG Request and may return a Response to short-circuit the +// fetch, or return undefined to let the request proceed via undici normally. +// We short-circuit cached bodies; for alicdn we fetch, persist, and respond. + +// HARVEST-ONLY: patch the pe.* bytecode VM interpreter so its opcode-55 (call) +// handler dumps the bytecode program + stack + locals to window.__DBT when the +// callee is btoa/atob. Version-agnostic via regex (minifier may name the +// locals var s or u). +const peVmCallRegex = + /55==A\?\(f=r\[n\+\+\],l=e\.pop\(\),h=e\.pop\(\),o=\[\],\w+\(f\)\.forEach\(function\(\)\{o\.unshift\(e\.pop\(\)\)\}\),p=null===h\?l\.apply\((\w+),o\):h\[l\]\.apply\(h,o\),r\[n\+\+\]&&e\.push\(p\)\):/; +function patchPeBundle(buf, url) { + if (process.env.PE_PATCH === "off") return buf; + if (!/dynamicJS\/[^/]*\/pe\.\d+\./.test(url)) return buf; + let src = buf.toString("utf8"); + if (src.includes("__DBT")) return buf; // already patched + const m = src.match(peVmCallRegex); + if (!m) return buf; + const locals = m[1]; + const hook = `55==A?(f=r[n++],l=e.pop(),h=e.pop(),o=[],v(f).forEach(function(){o.unshift(e.pop())}),p=null===h?l.apply(${locals},o):h[l].apply(h,o),r[n++]&&e.push(p),function(){try{if(l===window.btoa||l===window.atob){window.__DBT=window.__DBT||[];var __sav=[];for(var __i=0;__i 0) { + _memCdnCache.set(url, buf); // store in memory + try { + fs.mkdirSync(CDN_CACHE_DIR, { recursive: true }); + fs.writeFileSync(diskPath, buf); + } catch (_) {} + } + return new Response(patchPeBundle(buf, url), { + status: upstream.status, + headers: { + "content-type": upstream.headers.get("content-type") || sniffMime(url), + }, + }); + } catch (err) { + if (_DEBUG) process.stderr.write(`[loader-network-err] ${url}: ${err.message}\n`); + return undefined; + } +} + +function sniffMime(url) { + if (/\.js(\?|$)/i.test(url)) return "application/javascript"; + if (/\.css(\?|$)/i.test(url)) return "text/css"; + if (/\.png(\?|$)/i.test(url)) return "image/png"; + if (/\.(jpg|jpeg)(\?|$)/i.test(url)) return "image/jpeg"; + if (/\.json(\?|$)/i.test(url)) return "application/json"; + return "application/octet-stream"; +} + +// Wrap with a post-fetch cache: requestInterceptor only short-circuits on a +// returned Response, so to *populate* the cache we layer a fetch that writes +// the body to disk for alicdn resources, then returns the original response. +const resourceLoader = { + interceptors: [requestInterceptor(cachedCaptchaInterceptor)], +}; +const virtualConsole = new VirtualConsole(); +const formatConsoleArg = (arg) => { + if (arg && typeof arg === "object") { + const props = {}; + for (const name of Object.getOwnPropertyNames(arg)) { + props[name] = arg[name]; + } + try { + let proto = Object.getPrototypeOf(arg); + while (proto && proto !== Object.prototype) { + for (const name of Object.getOwnPropertyNames(proto)) { + if (!(name in props)) { + try { + props[name] = arg[name]; + } catch {} + } + } + proto = Object.getPrototypeOf(proto); + } + } catch (_) {} + return `[Object ${arg.constructor?.name || "Object"}]: ${util.inspect(props, { depth: 5 })}`; + } + return String(arg); +}; + +virtualConsole.on("error", (...args) => { + if (_DEBUG) { + const formatted = args.map(formatConsoleArg).join(" "); + process.stderr.write(`[jsdom-err] ${formatted}\n`); + } +}); +virtualConsole.on("warn", (...args) => { + if (_DEBUG) { + const formatted = args.map(formatConsoleArg).join(" "); + process.stderr.write(`[jsdom-warn] ${formatted}\n`); + } +}); +virtualConsole.on("log", (...args) => { + if (_DEBUG) { + const formatted = args.map(formatConsoleArg).join(" "); + process.stderr.write(`[jsdom-log] ${formatted}\n`); + } +}); +virtualConsole.on("jsdomError", (err) => { + if (_DEBUG) { + process.stderr.write( + `[jsdom-jsdomError] ${err.stack || err.message || String(err)}\n`, + ); + } +}); + +/** + * Make a polyfilled function masquerade as a native browser API: its toString() + * returns `function name() { [native code] }`. FeiLin (and most bot detectors) + * string-check methods for `[native code]`; polyfills that leak JS source are + * an instant tell. + */ +function nativeize(fn, name = "") { + if (typeof fn !== "function") return fn; + try { + const nativeRe = /\[native code\]/; + if (nativeRe.test(Function.prototype.toString.call(fn))) return fn; + const display = name || fn.name || ""; + const spoofed = function (...args) { + return fn.apply(this, args); + }; + const nativeStr = `function ${display}() { [native code] }`; + Object.defineProperty(spoofed, "toString", { + value: () => nativeStr, + configurable: true, + writable: true, + }); + Object.defineProperty(spoofed, "name", { + value: display || fn.name || "", + configurable: true, + }); + return spoofed; + } catch (_) { + return fn; + } +} + +/** + * Globally mask Function#toString so jsdom's JS-implemented platform APIs read + * as native. jsdom re-implements setTimeout, createElement, addEventListener, + * performance.now, etc. in JavaScript; their toString() leaks source, which + * fingerprinting SDKs (FeiLin) detect. We decide per-function whether to spoof: + * real native code and tiny page helpers are left honest; anything multi-line + * or carrying jsdom-internal markers is reported as `function name() { [native code] }`. + */ +function installNativeToString(window) { + // jsdom implements the platform in JavaScript. Node's vm/contextify bypasses + // a plain Function.prototype.toString override for host-bound functions, but + // it DOES honor an own `toString` property on each function. So we walk the + // prototype chains of window, navigator, document, and common platform + // objects, and attach a native-looking own toString to every method we find. + const realToString = Function.prototype.toString; + const nativeRe = /\[native code\]/; + + const mask = (fn) => { + if (typeof fn !== 'function') return; + try { + const real = realToString.call(fn); + if (nativeRe.test(real)) return; + const name = fn.name || ''; + // Only mask jsdom platform code (multi-line or internal vars); keep + // page-authored single-liners honest so we don't break the page's own code. + if (!real.includes('\n') && !/\besValue\b/.test(real) && !/\blocalName\b/.test(real)) return; + const nativeStr = `function ${name}() { [native code] }`; + Object.defineProperty(fn, 'toString', { + value: () => nativeStr, + configurable: true, + writable: true, + }); + } catch (_) {} + }; + + const seen = new window.Set(); + const maskObj = (obj, depth) => { + if (!obj || (typeof obj !== 'object' && typeof obj !== 'function') || depth > 4) return; + if (seen.has(obj)) return; + try { seen.add(obj); } catch (_) { return; } + let names = []; + try { names = Object.getOwnPropertyNames(obj); } catch (_) { return; } + for (const name of names) { + if (name === 'toString' || name === 'constructor') continue; + let desc; + try { desc = Object.getOwnPropertyDescriptor(obj, name); } catch (_) { continue; } + if (!desc) continue; + if (typeof desc.value === 'function') { + mask(desc.value); + } else if (typeof desc.get === 'function') { + mask(desc.get); + try { const v = desc.get.call(obj); if (typeof v === 'function') mask(v); } catch (_) {} + } + // Recurse into prototype-ish members (constructors) at shallow depth. + if (depth < 2) { + try { + const v = desc.value; + if (v && (typeof v === 'function' || typeof v === 'object')) maskObj(v, depth + 1); + } catch (_) {} + } + } + }; + + const targets = [window, window.navigator, window.document, window.Document.prototype, window.Element.prototype, window.HTMLElement.prototype, window.Node.prototype, window.EventTarget.prototype, window.HTMLCanvasElement.prototype, window.XMLHttpRequest.prototype]; + for (const t of targets) { + try { maskObj(t, 0); } catch (_) {} + } +} + +function rawTransportSend(xhr, url, body, fallback) { + // Isolation experiment: send the POST via raw https.request (the native's + // transport: distinct header spelling, no cookie, no undici) while keeping + // the page/GET/SDK-execution context intact. Feeds the response back into + // the XHR so the SDK flow continues normally. + const https = require("https"); + const zlib = require("zlib"); + process.stderr.write(`[rawtransport] ${Date.now()} POST ${url} $(body.length) chars\n`); + let u; + try { u = new URL(url); } catch (e) { return fallback.call(xhr, body); } + const headerSet = { + "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", + "Content-Length": Buffer.byteLength(body), + Accept: "*/*", + "Accept-Encoding": "gzip, deflate", + Origin: "https://zcode.z.ai", + Referer: "https://zcode.z.ai/", + "User-Agent": + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "Accept-Language": "en-US,en;q=0.9", + }; + const proxyUrl = process.env.NATIVE_XHR_PROXY || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || ""; + let req; + if (proxyUrl) { + let pu; + try { + pu = new URL(proxyUrl.startsWith("http") ? proxyUrl : "http://" + proxyUrl); + } catch (e) { return fallback.call(xhr, body); } + req = https.request({ + hostname: pu.hostname, + port: pu.port || 443, + method: "CONNECT", + path: `${u.hostname}:443`, + headers: { Host: `${u.hostname}:443` }, + }); + req.on("connect", (res, socket) => { + if (res.statusCode !== 200) { socket.destroy(); return fallback.call(xhr, body); } + req = https.request({ socket, hostname: u.hostname, path: u.path, method: "POST", headers: headerSet, servername: u.hostname }); + req.on("response", (r) => finish(r)); + req.on("error", () => { try { fallback.call(xhr, body); } catch (_) {} }); + req.end(body); + }); + req.on("error", () => { try { fallback.call(xhr, body); } catch (_) {} }); + req.end(); + } else { + req = https.request({ hostname: u.hostname, path: u.path, method: "POST", headers: headerSet, servername: u.hostname }); + req.on("response", (r) => finish(r)); + req.on("error", () => { try { fallback.call(xhr, body); } catch (_) {} }); + req.end(body); + } + function finish(r) { + const chunks = []; + r.on("data", (c) => chunks.push(c)); + r.on("end", () => { + let raw = Buffer.concat(chunks); + const ce = String(r.headers["content-encoding"] || "").toLowerCase(); + try { + if (ce === "gzip") raw = zlib.gunzipSync(raw); + else if (ce === "deflate") raw = zlib.inflateSync(raw); + } catch (_) {} + const text = raw.toString("utf8"); + try { + Object.defineProperty(xhr, "status", { configurable: true, enumerable: true, writable: true, value: r.statusCode || 200 }); + Object.defineProperty(xhr, "readyState", { configurable: true, enumerable: true, writable: true, value: 4 }); + Object.defineProperty(xhr, "statusText", { configurable: true, enumerable: true, writable: true, value: "OK" }); + Object.defineProperty(xhr, "responseText", { configurable: true, enumerable: true, writable: true, value: text }); + Object.defineProperty(xhr, "response", { configurable: true, enumerable: true, writable: true, value: text }); + } catch (_) {} + try { xhr.dispatchEvent(new window.Event("load")); } catch (_) {} + try { xhr.dispatchEvent(new window.Event("readystatechange")); } catch (_) {} + process.stderr.write(`\n===== XHR ${Date.now()} POST ${url} [rawtransport ${r.statusCode}]\n--- RESP (${text.length}b) ---\n${text.slice(0, 40000)}\n=====\n`); + }); + r.on("error", () => { try { fallback.call(xhr, body); } catch (_) {} }); + } +} + +function installTrafficLogger(window) { + const DEBUG_HOSTS = /(cloudauth-device|captcha-open|verify|upload)/i; + const origXHROpen = window.XMLHttpRequest.prototype.open; + const origXHRSend = window.XMLHttpRequest.prototype.send; + const origSetHdr = window.XMLHttpRequest.prototype.setRequestHeader; + window.XMLHttpRequest.prototype.open = function (method, url, ...rest) { + this.__capMethod = method; + this.__capUrl = url; + return origXHROpen.call(this, method, url, ...rest); + }; + window.XMLHttpRequest.prototype.setRequestHeader = function (name, value) { + const h = this.__capHdrs || (this.__capHdrs = {}); + h[String(name)] = String(value); + return origSetHdr && origSetHdr.call(this, name, value); + }; + window.XMLHttpRequest.prototype.send = function (body) { + const url = String(this.__capUrl || ""); + if (/no8xfe-verify/i.test(url)) { + try { + const st = new Error("verify-trace").stack || ""; + process.stderr.write(`\n===== VERIFY TRACE =====\n${st}\n=====\n`); + } catch (_) {} + } + if (DEBUG_HOSTS.test(url)) { + const reqPreview = + typeof body === "string" ? body.slice(0, 40000) : ""; + this.addEventListener("load", () => { + let respPreview = ""; + try { + respPreview = String(this.responseText || "").slice(0, 40000); + } catch (_) {} + process.stderr.write( + `\n===== XHR ${Date.now()} ${String(this.__capMethod || "?")} ${url}\n--- REQ (${typeof body === "string" ? body.length : "?"} chars) ---\n${reqPreview}\n--- RESP (${respPreview.length}b) ---\n${respPreview}\n=====\n`, + ); + }); + } + if (global.__captchaMutator) { + try { + const mutated = global.__captchaMutator(String(this.__capUrl || ""), body); + if (typeof mutated === "string" && mutated !== body) body = mutated; + } catch (_) {} + } + if (process.env.NATIVE_XHR_TRANSPORT === "1" && DEBUG_HOSTS.test(url) && !/^(GET|HEAD)$/i.test(String(this.__capMethod || "POST")) && typeof body === "string" && body.length > 0) { + rawTransportSend(this, url, body, origXHRSend); + return; + } + return origXHRSend.call(this, body); + }; + if (typeof window.fetch === "function") { + const origFetch = window.fetch; + window.fetch = function (input, init) { + const url = String(typeof input === "string" ? input : input?.url || ""); + const p = origFetch.call(this, input, init); + if (DEBUG_HOSTS.test(url)) { + const reqBody = + init && init.body && typeof init.body === "string" + ? init.body.slice(0, 40000) + : ""; + p.then((resp) => { + resp + .clone() + .text() + .then((t) => { + process.stderr.write( + `\n===== FETCH ${(init && init.method) || "GET"} ${url}\n--- REQ (${typeof reqBody === "string" ? reqBody.length : "?"} chars) ---\n${reqBody}\n--- RESP (${t.length}b) ---\n${t.slice(0, 40000)}\n=====\n`, + ); + }) + .catch(() => {}); + }).catch(() => {}); + } + return p; + }; + } +} + +function applyPolyfills(window, fp) { + if (process.env.CAPTCHA_DEBUG_BODIES === "1") { + installTrafficLogger(window); + } + + // jsdom implements browser APIs in JavaScript, so every method (even + // genuinely-native ones like setTimeout / createElement / addEventListener) + // leaks its JS source via Function#toString. A real browser returns + // `function name() { [native code] }`. Fingerprinting SDKs like FeiLin sweep + // toString across the platform, so we override it globally: any function not + // authored by the page itself is reported as native. + installNativeToString(window); + + // Global Observers and events stubs + window.IntersectionObserver = + window.IntersectionObserver || + class { + constructor(cb) { + this.cb = cb; + } + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + }; + + window.ResizeObserver = + window.ResizeObserver || + class { + constructor(cb) { + this.cb = cb; + } + observe() {} + unobserve() {} + disconnect() {} + }; + + window.DeviceOrientationEvent = + window.DeviceOrientationEvent || + class extends window.Event { + constructor(type, opts) { + super(type, opts); + } + alpha = null; + beta = null; + gamma = null; + absolute = false; + }; + + window.DeviceMotionEvent = + window.DeviceMotionEvent || + class extends window.Event { + constructor(type, opts) { + super(type, opts); + } + acceleration = null; + accelerationIncludingGravity = null; + rotationRate = null; + interval = 16; + }; + + window.requestIdleCallback = + window.requestIdleCallback || + ((cb) => + setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 10 }), 1)); + window.cancelIdleCallback = + window.cancelIdleCallback || ((id) => clearTimeout(id)); + + window.matchMedia = () => ({ + matches: false, + media: "", + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false; + }, + }); + + // HTML5 Viewport and storage stubs + if (!window.visualViewport) { + const VisualViewport = () => {}; + VisualViewport.prototype = { + width: fp.screen.w - 16, + height: fp.screen.h - 120, + scale: 1, + offsetLeft: 0, + offsetTop: 0, + pageLeft: 0, + pageTop: 0, + onresize: null, + onscroll: null, + onscrollend: null, + }; + window.VisualViewport = VisualViewport; + window.visualViewport = Object.create(window.VisualViewport.prototype); + } + + if (!window.indexedDB) { + const IDBFactory = () => {}; + IDBFactory.prototype = { + open: () => ({ onupgradeneeded: null, onsuccess: null, onerror: null }), + deleteDatabase: () => ({}), + databases: () => Promise.resolve([]), + }; + window.IDBFactory = IDBFactory; + window.indexedDB = Object.create(window.IDBFactory.prototype); + } + + if (!window.speechSynthesis) { + const SpeechSynthesis = () => {}; + SpeechSynthesis.prototype = { + speak() {}, + cancel() {}, + pause() {}, + resume() {}, + getVoices: () => [], + }; + window.SpeechSynthesis = SpeechSynthesis; + window.speechSynthesis = Object.create(window.SpeechSynthesis.prototype); + window.SpeechSynthesisUtterance = () => {}; + } + + // WebGL and Canvas stubs + const proto = window.HTMLCanvasElement.prototype; + // Preserve the native 2D context (provided by the optional `canvas` npm package) + // so FeiLin's canvas fingerprint is real, not a constant. We only mock WebGL, + // which the `canvas` package does not provide. + const nativeGetContext = proto.getContext; + proto.getContext = function (type, ...rest) { + if (/webgl/i.test(type)) { + return makeWebGLMock(this); + } + // 2D and other contexts: use the native implementation when available. + if (typeof nativeGetContext === "function") { + try { + const ctx = nativeGetContext.call(this, type, ...rest); + if (ctx) return ctx; + } catch (_) {} + } + return make2DStub(this); + }; + + function makeWebGLMock(canvas) { + return { + canvas, + getParameter(p) { + // WebGL constant parameters mock + if (p === 7936) return "WebKit"; // VENDOR + if (p === 7937) return "WebKit WebGL"; // RENDERER + if (p === 7938) return "WebGL 1.0 (OpenGL ES 2.0 Chromium)"; // VERSION + if (p === 35724) + return "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)"; // SHADING_LANGUAGE_VERSION + if (p === 0x9245) return fp.webglUnmaskedVendor; // UNMASKED_VENDOR_WEBGL + if (p === 0x9246) return fp.webglUnmaskedRenderer; // UNMASKED_RENDERER_WEBGL + return "Intel Inc."; + }, + getExtension(name) { + if (name === "WEBGL_debug_renderer_info") { + return { + UNMASKED_VENDOR_WEBGL: 0x9245, + UNMASKED_RENDERER_WEBGL: 0x9246, + }; + } + return null; + }, + getSupportedExtensions() { + return [ + "ANGLE_instanced_arrays", + "EXT_blend_minmax", + "EXT_color_buffer_half_float", + "EXT_disjoint_timer_query", + "EXT_float_blend", + "EXT_frag_depth", + "EXT_shader_texture_lod", + "EXT_texture_compression_bptc", + "EXT_texture_compression_rgtc", + "EXT_texture_filter_anisotropic", + "EXT_sRGB", + "KHR_parallel_shader_compile", + "OES_element_index_uint", + "OES_fbo_render_mipmap", + "OES_standard_derivatives", + "OES_texture_float", + "OES_texture_float_linear", + "OES_texture_half_float", + "OES_texture_half_float_linear", + "OES_vertex_array_object", + "WEBGL_color_buffer_float", + "WEBGL_compressed_texture_astc", + "WEBGL_compressed_texture_etc", + "WEBGL_compressed_texture_etc1", + "WEBGL_compressed_texture_s3tc", + "WEBGL_compressed_texture_s3tc_srgb", + "WEBGL_debug_renderer_info", + "WEBGL_debug_shaders", + "WEBGL_depth_texture", + "WEBGL_draw_buffers", + "WEBGL_lose_context", + "WEBGL_multi_draw", + ]; + }, + getContextAttributes() { + return { + alpha: true, + antialias: true, + depth: true, + failIfMajorPerformanceCaveat: false, + powerPreference: "default", + premultipliedAlpha: true, + preserveDrawingBuffer: false, + stencil: false, + desynchronized: false, + }; + }, + getShaderPrecisionFormat() { + return { precision: 23, rangeMin: 127, rangeMax: 127 }; + }, + }; + } + + function make2DStub(canvas) { + return { + canvas, + fillRect() {}, + clearRect() {}, + getImageData: (_x, _y, w = 1, h = 1) => new window.ImageData(w, h), + putImageData() {}, + createImageData: (w = 1, h = 1) => new window.ImageData(w, h), + setTransform() {}, + transform() {}, + drawImage() {}, + save() {}, + restore() {}, + beginPath() {}, + moveTo() {}, + lineTo() {}, + bezierCurveTo() {}, + quadraticCurveTo() {}, + closePath() {}, + clip() {}, + stroke() {}, + fill() {}, + arc() {}, + rect() {}, + ellipse() {}, + translate() {}, + scale() {}, + rotate() {}, + fillText() {}, + strokeText() {}, + measureText: (t) => ({ width: String(t).length * 8 }), + createLinearGradient: () => ({ addColorStop() {} }), + createRadialGradient: () => ({ addColorStop() {} }), + createPattern: () => ({}), + isPointInPath: () => false, + font: "10px sans-serif", + textBaseline: "alphabetic", + textAlign: "start", + fillStyle: "#000", + strokeStyle: "#000", + globalAlpha: 1, + lineWidth: 1, + shadowBlur: 0, + shadowColor: "", + }; + } + + // toDataURL/toBlob: prefer the native canvas implementation (real fingerprint) + // and only fall back to a stub if the native impl is absent. + if (typeof proto.toDataURL !== "function" || proto.toDataURL.length === 0) { + const nativeToDataURL = proto.toDataURL; + proto.toDataURL = function (...a) { + try { + if (typeof nativeToDataURL === "function") + return nativeToDataURL.apply(this, a); + } catch (_) {} + return fp.canvasImage; + }; + } + const nativeToBlob = proto.toBlob; + if (!nativeToBlob) { + proto.toBlob = (cb) => cb && cb(new window.Blob()); + } + + window.Worker = class { + postMessage() {} + terminate() {} + addEventListener() {} + removeEventListener() {} + }; + + window.OffscreenCanvas = + window.OffscreenCanvas || + class { + constructor(w, h) { + this.width = w; + this.height = h; + } + getContext() { + return proto.getContext.call(this); + } + }; + + // Audio API Mocks + const audioMock = class { + constructor() { + this.sampleRate = 44100; + this.currentTime = 0; + this.state = "suspended"; + } + createOscillator() { + return { + type: "sine", + frequency: { value: 440, setValueAtTime() {} }, + connect() {}, + start() {}, + stop() {}, + }; + } + createDynamicsCompressor() { + return { + threshold: { value: -24, setValueAtTime() {} }, + knee: { value: 30, setValueAtTime() {} }, + ratio: { value: 12, setValueAtTime() {} }, + attack: { value: 0.003, setValueAtTime() {} }, + release: { value: 0.25, setValueAtTime() {} }, + connect() {}, + }; + } + createAnalyser() { + return { + fftSize: 2048, + frequencyBinCount: 1024, + getByteFrequencyData() {}, + getByteTimeDomainData() {}, + connect() {}, + }; + } + createGain() { + return { + gain: { value: 1 }, + connect() {}, + }; + } + destination = {}; + resume() { + this.state = "running"; + return Promise.resolve(); + } + close() { + this.state = "closed"; + return Promise.resolve(); + } + }; + window.AudioContext = window.AudioContext || audioMock; + // Real Chrome 127+ does not expose webkitAudioContext + window.OfflineAudioContext = + window.OfflineAudioContext || + class extends audioMock { + constructor(_channels, length, sampleRate) { + super(); + this.length = length; + this.sampleRate = sampleRate; + } + startRendering() { + // Real Chromium renders a deterministic, non-zero waveform from the + // oscillator+DynamicsCompressor chain. An all-zero buffer is a classic + // bot tell, so synthesize a plausible decaying signal. + const len = this.length || 44100; + const sr = this.sampleRate || 44100; + const buf = new Float32Array(len); + for (let i = 0; i < len; i += 1) { + const t = i / sr; + // 1kHz tone + harmonics, exponentially decaying (compressor envelope) + buf[i] = + Math.sin(2 * Math.PI * 1000 * t) * Math.exp(-t * 1.2) * 0.6 + + Math.sin(2 * Math.PI * 3000 * t) * Math.exp(-t * 1.5) * 0.25 + + Math.sin(2 * Math.PI * 5000 * t) * Math.exp(-t * 2.0) * 0.12; + } + return Promise.resolve({ + numberOfChannels: 1, + length: len, + sampleRate: sr, + getChannelData: () => buf, + }); + } + }; + + // Animation Mocks + window.requestAnimationFrame = + window.requestAnimationFrame || + ((cb) => setTimeout(() => cb(Date.now()), 16)); + window.cancelAnimationFrame = + window.cancelAnimationFrame || ((id) => clearTimeout(id)); + + // Visibility stubs + try { + Object.defineProperty(window.document, "hidden", { + value: false, + configurable: true, + }); + Object.defineProperty(window.document, "visibilityState", { + value: "visible", + configurable: true, + }); + } catch (_) {} + + // Fonts and permissions + if (!window.document.fonts) { + window.document.fonts = { + ready: Promise.resolve(), + check: () => true, + addEventListener() {}, + removeEventListener() {}, + }; + } + + // Emulate Chrome constructors and prototype chains to pass instanceof checks + const NetworkInformation = () => {}; + NetworkInformation.prototype = { + onchange: null, + effectiveType: "4g", + rtt: 50, + downlink: 10, + saveData: false, + }; + window.NetworkInformation = NetworkInformation; + Object.defineProperty(NetworkInformation.prototype, Symbol.toStringTag, { + value: "NetworkInformation", + configurable: true + }); + + const NavigatorUAData = () => {}; + NavigatorUAData.prototype = { + brands: [ + { brand: "Chromium", version: fp.uaMajor }, + { brand: "Not)A;Brand", version: "24" }, + ], + mobile: false, + platform: "Linux", + getHighEntropyValues: (_hints) => { + const res = { + brands: [ + { brand: "Chromium", version: fp.uaMajor }, + { brand: "Not)A;Brand", version: "24" }, + ], + mobile: false, + platform: "Linux", + platformVersion: "6.5.0", + architecture: "x86", + model: "", + uaFullVersion: fp.uaFull, + fullVersionList: [ + { brand: "Chromium", version: fp.uaFull }, + { brand: "Not)A;Brand", version: "24.0.0.0" }, + ], + }; + return Promise.resolve(res); + }, + }; + window.NavigatorUAData = NavigatorUAData; + Object.defineProperty(NavigatorUAData.prototype, Symbol.toStringTag, { + value: "NavigatorUAData", + configurable: true + }); + + const Permissions = () => {}; + Permissions.prototype = { + query: (param) => { + return Promise.resolve({ + state: param.name === "notifications" ? "prompt" : "granted", + onchange: null, + }); + }, + }; + window.Permissions = Permissions; + Object.defineProperty(Permissions.prototype, Symbol.toStringTag, { + value: "Permissions", + configurable: true + }); + + const Clipboard = () => {}; + Clipboard.prototype = { + readText: () => Promise.resolve(""), + writeText: () => Promise.resolve(), + }; + window.Clipboard = Clipboard; + Object.defineProperty(Clipboard.prototype, Symbol.toStringTag, { + value: "Clipboard", + configurable: true + }); + + const Geolocation = () => {}; + Geolocation.prototype = { + getCurrentPosition: (success) => + success && + success({ coords: { latitude: 0, longitude: 0, accuracy: 1 } }), + watchPosition: () => 1, + clearWatch: () => {}, + }; + window.Geolocation = Geolocation; + Object.defineProperty(Geolocation.prototype, Symbol.toStringTag, { + value: "Geolocation", + configurable: true + }); + + const CredentialsContainer = () => {}; + CredentialsContainer.prototype = { + get: () => Promise.resolve(null), + create: () => Promise.resolve(null), + store: () => Promise.resolve(), + preventSilentAccess: () => Promise.resolve(), + }; + window.CredentialsContainer = CredentialsContainer; + Object.defineProperty(CredentialsContainer.prototype, Symbol.toStringTag, { + value: "CredentialsContainer", + configurable: true + }); + + const StorageManager = () => {}; + StorageManager.prototype = { + estimate: () => Promise.resolve({ quota: 100000000, usage: 0 }), + persisted: () => Promise.resolve(false), + persist: () => Promise.resolve(false), + }; + window.StorageManager = StorageManager; + Object.defineProperty(StorageManager.prototype, Symbol.toStringTag, { + value: "StorageManager", + configurable: true + }); + + const USB = () => {}; + USB.prototype = { + getDevices: () => Promise.resolve([]), + requestDevice: () => Promise.reject(new Error("no devices")), + }; + window.USB = USB; + Object.defineProperty(USB.prototype, Symbol.toStringTag, { + value: "USB", + configurable: true + }); + + const MediaDevices = () => {}; + MediaDevices.prototype = { + enumerateDevices: () => Promise.resolve([]), + getUserMedia: () => Promise.reject(new Error("NotAllowedError")), + }; + window.MediaDevices = MediaDevices; + Object.defineProperty(MediaDevices.prototype, Symbol.toStringTag, { + value: "MediaDevices", + configurable: true + }); + + const nav = window.navigator; + const { plugins, mimeTypes } = createNavigatorPlugins(window); + const navPatch = { + userAgent: fp.userAgent, + platform: fp.platform, + language: "en-US", + languages: ["en-US", "en"], + vendor: "Google Inc.", + webdriver: false, + hardwareConcurrency: 12, + deviceMemory: 8, + maxTouchPoints: 0, + cookieEnabled: true, + plugins, + mimeTypes, + connection: Object.create(window.NetworkInformation.prototype), + userAgentData: Object.create(window.NavigatorUAData.prototype), + permissions: Object.create(window.Permissions.prototype), + clipboard: Object.create(window.Clipboard.prototype), + geolocation: Object.create(window.Geolocation.prototype), + credentials: Object.create(window.CredentialsContainer.prototype), + storage: Object.create(window.StorageManager.prototype), + usb: Object.create(window.USB.prototype), + mediaDevices: Object.create(window.MediaDevices.prototype), + sendBeacon: (url, data) => { + try { + const xhr = new window.XMLHttpRequest(); + xhr.open("POST", url, true); + xhr.send(data); + return true; + } catch (_) { + return false; + } + }, + appVersion: fp.userAgent.replace(/^Mozilla\//, ""), + appName: "Netscape", + appCodeName: "Mozilla", + product: "Gecko", + productSub: "20030107", + vendorSub: "", + oscpu: undefined, + }; + for (const [k, v] of Object.entries(navPatch)) { + try { + Object.defineProperty(nav, k, { value: v, configurable: true }); + } catch (_) {} + } + + // Chrome runtime stubs + window.chrome = { + app: { + isInstalled: false, + InstallState: { DISABLED: "disabled", INSTALLED: "installed", NOT_INSTALLED: "not_installed" }, + RunningState: { CANNOT_RUN: "cannot_run", CAN_RUN: "can_run", RUNNING: "running" }, + getDetails() { return null; }, + getIsInstalled() { return false; }, + installState(cb) { if (cb) cb("not_installed"); }, + runningState(cb) { if (cb) cb("cannot_run"); } + }, + csi() { + const now = Date.now(); + return { startE: now - 100, onloadT: now, pageT: 100, tran: 15 }; + }, + loadTimes() { + const now = Date.now() / 1000; + return { + requestTime: now - 0.1, startLoadTime: now - 0.1, + commitLoadTime: now - 0.05, finishDocumentLoadTime: now, + finishLoadTime: now, firstPaintTime: now - 0.02, + firstPaintAfterLoadTime: 0, navigationType: "Other", + wasFetchedViaSpdy: true, wasNpnNegotiated: true, + npnNegotiatedProtocol: "h2", wasAlternateProtocolAvailable: false, + connectionInfo: "h2" + }; + } + }; + + const screenPatch = { + width: fp.screen.w, + height: fp.screen.h, + availWidth: fp.screen.w, + availHeight: fp.screen.ah, + availLeft: 0, + availTop: 0, + colorDepth: 24, + pixelDepth: 24, + orientation: { angle: 0, type: "landscape-primary", onchange: null } + }; + for (const [k, v] of Object.entries(screenPatch)) { + try { + Object.defineProperty(window.screen, k, { get() { return v; }, configurable: true }); + } catch (_) {} + } + + window.outerWidth = fp.screen.w; + window.outerHeight = fp.screen.h - 40; + window.innerWidth = fp.screen.w - 16; + window.innerHeight = fp.screen.h - 120; + window.devicePixelRatio = 1; + + // Masquerade every polyfilled method as a native browser API so that + // Function#toString checks (a staple of fingerprinting SDKs like FeiLin) + // see `[native code]` instead of leaked JS source. + nativeize(window.navigator.sendBeacon, "sendBeacon"); + if (window.navigator.permissions) + nativeize(window.navigator.permissions.query, "query"); + if (window.navigator.clipboard) { + nativeize(window.navigator.clipboard.readText, "readText"); + nativeize(window.navigator.clipboard.writeText, "writeText"); + } + if (window.navigator.credentials) { + nativeize(window.navigator.credentials.get, "get"); + nativeize(window.navigator.credentials.create, "create"); + nativeize(window.navigator.credentials.store, "store"); + } + if (window.navigator.storage) + nativeize(window.navigator.storage.estimate, "estimate"); + if (window.navigator.mediaDevices) + nativeize( + window.navigator.mediaDevices.enumerateDevices, + "enumerateDevices", + ); + nativeize(proto.toDataURL, "toDataURL"); + if (proto.toBlob) nativeize(proto.toBlob, "toBlob"); + nativeize(proto.getContext, "getContext"); +} + +function createNavigatorPlugins(window) { + // FeiLin 1.4.2 reads navigator.plugins[0].__proto__ to fingerprint the + // plugin prototype chain. A plain object with no indexed entries makes + // plugins[0] undefined and crashes that probe (-> F001 risk rejection). + // Build a real PluginArray on every platform, not just win32. + const indexed = [ + { + name: "PDF Viewer", + filename: "internal-pdf-viewer", + description: "Portable Document Format", + }, + { + name: "Chrome PDF Viewer", + filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", + description: "", + }, + { + name: "Chromium PDF Viewer", + filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", + description: "", + }, + ]; + const plugins = Object.create(window.PluginArray.prototype); + const mockIndexed = []; + for (let i = 0; i < indexed.length; i++) { + const p = Object.create(window.Plugin.prototype); + Object.defineProperty(p, "name", { + value: indexed[i].name, + configurable: true, + enumerable: true, + }); + Object.defineProperty(p, "filename", { + value: indexed[i].filename, + configurable: true, + enumerable: true, + }); + Object.defineProperty(p, "description", { + value: indexed[i].description, + configurable: true, + enumerable: true, + }); + Object.defineProperty(p, "length", { + value: 1, + configurable: true, + enumerable: true, + }); + Object.defineProperty(p, "0", { + value: p, + configurable: true, + enumerable: true, + }); + p.item = () => p; + p.namedItem = () => p; + plugins[i] = p; + mockIndexed.push(p); + } + Object.defineProperty(plugins, "length", { + value: indexed.length, + configurable: true, + enumerable: true, + }); + plugins.item = (i) => plugins[i] ?? null; + plugins.namedItem = (name) => + mockIndexed.find((p) => p.name === name) ?? null; + plugins.refresh = () => {}; + const mimeTypes = Object.create(window.MimeTypeArray.prototype); + Object.defineProperty(mimeTypes, "length", { + value: 0, + configurable: true, + enumerable: true, + }); + mimeTypes.item = () => null; + mimeTypes.namedItem = () => null; + return { plugins, mimeTypes }; +} + +function waitFor(cond, timeoutMs = 12_000, intervalMs = 50) { + return new Promise((res, rej) => { + const started = Date.now(); + const timer = setInterval(() => { + let ok = false; + try { + ok = cond(); + } catch (_) {} + if (ok) { + clearInterval(timer); + res(); + } else if (Date.now() - started > timeoutMs) { + clearInterval(timer); + rej(new Error("timeout")); + } + }, intervalMs); + }); +} + +function mapHostPrototypes(window) { + const HostFunctionProto = Function.prototype; + const GuestFunctionProto = window.Function.prototype; + + const seen = new Set(); + function walk(obj, depth = 0) { + // Optimization: reduced max depth from 5 to 3 — FeiLin doesn't probe + // deeper than 3 levels of nested constructors/prototypes. + if (!obj || depth > 3 || (typeof obj !== "object" && typeof obj !== "function")) return; + if (seen.has(obj)) return; + seen.add(obj); + + let keys = []; + try { keys = Object.getOwnPropertyNames(obj); } catch(_) { return; } + + for (const k of keys) { + try { + const desc = Object.getOwnPropertyDescriptor(obj, k); + if (!desc) continue; + + if (typeof desc.value === "function") { + if (Object.getPrototypeOf(desc.value) === HostFunctionProto) { + Object.setPrototypeOf(desc.value, GuestFunctionProto); + } + walk(desc.value, depth + 1); + } + + if (typeof desc.get === "function") { + if (Object.getPrototypeOf(desc.get) === HostFunctionProto) { + Object.setPrototypeOf(desc.get, GuestFunctionProto); + } + } + if (typeof desc.set === "function") { + if (Object.getPrototypeOf(desc.set) === HostFunctionProto) { + Object.setPrototypeOf(desc.set, GuestFunctionProto); + } + } + + if (desc.value && (typeof desc.value === "object" || typeof desc.value === "function")) { + walk(desc.value, depth + 1); + } + } catch (_) {} + } + + try { + const proto = Object.getPrototypeOf(obj); + if (proto && proto !== Object.prototype) { + walk(proto, depth + 1); + } + } catch (_) {} + } + + walk(window); +} + +// ── Optimization: Pre-compute guest-context eval string ──────────────────── +// This is a constant; building it once avoids repeated string concatenation. +const GUEST_EVAL_PATCH = ` +(function() { + Object.defineProperty(Event.prototype, "isTrusted", { + get() { return true; }, + configurable: true + }); + const OrigDocument = window.Document; + class HTMLDocument extends OrigDocument {} + Object.defineProperty(HTMLDocument, "name", { value: "HTMLDocument", configurable: true }); + Object.setPrototypeOf(HTMLDocument.prototype, OrigDocument.prototype); + Object.defineProperty(HTMLDocument.prototype, Symbol.toStringTag, { + value: "HTMLDocument", + configurable: true + }); + Object.defineProperty(window, "HTMLDocument", { + value: HTMLDocument, + writable: true, + configurable: true + }); + Object.setPrototypeOf(window.document, HTMLDocument.prototype); + const origGetOwnPropertyNames = Object.getOwnPropertyNames; + const origOwnKeys = Reflect.ownKeys; + const origGetOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; + const origGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; + const blacklist = new Set([ + "CSSStyleProperties", "XPathException", "SharedArrayBuffer" + ]); + const filterKeys = (keys) => { + return keys.filter(k => { + if (typeof k === "string") { + return !k.startsWith("_") && !blacklist.has(k); + } + return true; + }); + }; + Object.getOwnPropertyNames = function getOwnPropertyNames(obj) { + const keys = origGetOwnPropertyNames.call(this, obj); + return obj === window ? filterKeys(keys) : keys; + }; + Reflect.ownKeys = function ownKeys(target) { + const keys = origOwnKeys.call(this, target); + return target === window ? filterKeys(keys) : keys; + }; + Object.getOwnPropertyDescriptors = function getOwnPropertyDescriptors(obj) { + const descs = origGetOwnPropertyDescriptors.call(this, obj); + if (obj === window) { + for (const k of Object.keys(descs)) { + if (k.startsWith("_") || blacklist.has(k)) { + delete descs[k]; + } + } + } + return descs; + }; + Object.getOwnPropertyDescriptor = function getOwnPropertyDescriptor(obj, prop) { + if (obj === window && (String(prop).startsWith("_") || blacklist.has(prop))) { + return undefined; + } + return origGetOwnPropertyDescriptor.call(this, obj, prop); + }; + const allKeys = origGetOwnPropertyNames(window); + for (const k of allKeys) { + if (blacklist.has(k)) { + try { delete window[k]; } catch (_) {} + } else if (k.startsWith("_")) { + try { + const desc = origGetOwnPropertyDescriptor(window, k); + if (desc && desc.configurable) { + Object.defineProperty(window, k, { enumerable: false }); + } + } catch (_) {} + } + } +})(); +`; + +async function createDom(region, prefix) { + const fp = generateFingerprint(); + + // Optimization: Cached cookie fetch — reuses cookies for 5 minutes + let initialCookies = []; + const now = Date.now(); + if (_cookieCache.ts > 0 && (now - _cookieCache.ts) < COOKIE_CACHE_TTL_MS) { + initialCookies = _cookieCache.cookies; + } else { + try { + const res = await fetch("https://zcode.z.ai/", { + headers: { + "User-Agent": fp.userAgent, + "sec-ch-ua": '"Chromium";v="' + fp.uaMajor + '", "Not)A;Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "Accept-Language": "en-US,en;q=0.9" + } + }); + initialCookies = res.headers.getSetCookie?.() || []; + _cookieCache = { cookies: initialCookies, ts: Date.now() }; + } catch (_) {} + } + + const dom = new JSDOM(HTML, { + url: "https://zcode.z.ai/", + runScripts: "dangerously", + resources: resourceLoader, + pretendToBeVisual: true, + virtualConsole, + userAgent: fp.userAgent, + beforeParse(window) { + if (window.Error) { + window.Error.prepareStackTrace = Error.prepareStackTrace; + } + applyPolyfills(window, fp); + mapHostPrototypes(window); + + // Hook JSDOM dispatcher to inject Chromium client hints + if (window._dispatcher && typeof window._dispatcher.request === "function") { + const origRequest = window._dispatcher.request; + window._dispatcher.request = function(opts) { + const headers = opts.headers; + if (headers && typeof headers.set === "function") { + headers.set("sec-ch-ua", '"Chromium";v="' + fp.uaMajor + '", "Not)A;Brand";v="24"'); + headers.set("sec-ch-ua-mobile", "?0"); + headers.set("sec-ch-ua-platform", '"Linux"'); + headers.set("user-agent", fp.userAgent); + headers.set("accept-language", "en-US,en;q=0.9"); + } + return origRequest.call(this, opts); + }; + } + + // Guest-context patches (pre-computed string — avoids repeated concat) + window.eval(GUEST_EVAL_PATCH); + + window.AliyunCaptchaConfig = { region, prefix }; + }, + }); + + // Inject pre-fetched cookies + for (const cookieStr of initialCookies) { + try { dom.cookieJar.setCookieSync(cookieStr, "https://zcode.z.ai/"); } catch (_) {} + } + const visitorId = crypto.randomUUID(); + const deviceMid = crypto.randomUUID(); + dom.cookieJar.setCookieSync(`zcode_visitor_id=${visitorId}; path=/; domain=zcode.z.ai; Secure`, "https://zcode.z.ai/"); + dom.cookieJar.setCookieSync(`zcode_device_mid=${deviceMid}; path=/; domain=zcode.z.ai; Secure`, "https://zcode.z.ai/"); + dom.cookieJar.setCookieSync(`visitor_id=${visitorId}; path=/; domain=zcode.z.ai; HttpOnly`, "https://zcode.z.ai/"); + + return dom; +} + +function destroyDom(dom) { + if (!dom) return; + try { + const cap = dom.window.document.getElementById("cap"); + if (cap) cap.replaceChildren(); + dom.window.close(); + } catch (_) {} +} + +function extractVerifyParam(param) { + let verifyParam = param; + if (param && typeof param === "object") { + verifyParam = param.verifyParam || param.data || param.param; + } + if (!verifyParam || String(verifyParam).length < 20) { + throw new Error("solver returned empty param"); + } + // Strict validation (same rule as solve-happy-lib): a REAL Aliyun verify + // param is ~280 chars of base64 JSON carrying certifyId + a long + // securityToken. Degraded SDK success paths emit ~76-char params with no + // securityToken that always 3007 upstream — refuse them here so they can + // be retried instead of poisoning the token pool. + const str = String(verifyParam); + if (str.length < 200) { + throw new Error( + "verify param too short (" + str.length + " chars) — degraded result, refusing", + ); + } + try { + const decoded = JSON.parse(Buffer.from(str, "base64").toString("utf8")); + const secTok = decoded && (decoded.securityToken || decoded.SecurityToken); + if (!secTok || String(secTok).length < 50) { + throw new Error("verify param missing securityToken — degraded result, refusing"); + } + } catch (err) { + if (err instanceof SyntaxError) { + throw new Error("verify param not base64-JSON: " + str.slice(0, 80)); + } + throw err; + } + return str; +} + +function handleCaptchaResult(result) { + if (result && typeof result === "object" && result.verifyResult === false) { + throw new Error( + "verify rejected: " + + JSON.stringify({ + verifyCode: result.verifyCode, + certifyId: result.certifyId, + }), + ); + } + return result; +} + +/** + * Dispatch a realistic interaction trajectory so FeiLin's behavioral collectors + * record human-like input. Traceless verification grades this heavily — without + * it the server returns F001 ("suspected attack"). + * + * FeiLin subscribes (via EventTarget.addEventListener) to: mousemove, mousedown, + * mouseup, click, keyup, touchstart, touchmove, touchend, pointerdown, scroll, + * focusin, focusout. We synthesize a coherent mouse-down→move→up→click arc with + * a keystroke, dispatching on document (where the collectors are bound). + */ +function simulateBehavior(window, durationMs = 600) { + const { document, MouseEvent, KeyboardEvent, UIEvent } = window; + if (!document || !MouseEvent) return; + + const fire = (type, ctor, opts) => { + try { + const Ctor = ctor || UIEvent; + const ev = new Ctor(type, { + bubbles: true, + cancelable: true, + view: window, + ...opts, + }); + document.dispatchEvent(ev); + if (document.body) document.body.dispatchEvent(ev); + } catch (_) {} + }; + + let x = 140 + Math.random() * 30; + let y = 110 + Math.random() * 20; + const targetX = 540 + Math.random() * 40; + const targetY = 380 + Math.random() * 30; + const steps = 22; + let i = 0; + const start = Date.now(); + + const moveStep = () => { + if (i > steps) return; + // ease toward target with jitter + x += (targetX - x) * 0.16 + (Math.random() - 0.5) * 5; + y += (targetY - y) * 0.16 + (Math.random() - 0.5) * 4; + fire("mousemove", MouseEvent, { + screenX: Math.round(x), + screenY: Math.round(y), + clientX: Math.round(x), + clientY: Math.round(y), + button: 0, + buttons: 1, + }); + i += 1; + const done = Date.now() - start >= durationMs; + if (i <= steps && !done) { + setTimeout(moveStep, 26 + Math.floor(Math.random() * 32)); + } else { + // conclude with a click at the landing point + fire("mousedown", MouseEvent, { + clientX: Math.round(x), + clientY: Math.round(y), + button: 0, + buttons: 1, + }); + fire("mouseup", MouseEvent, { + clientX: Math.round(x), + clientY: Math.round(y), + button: 0, + buttons: 0, + }); + fire("click", MouseEvent, { + clientX: Math.round(x), + clientY: Math.round(y), + button: 0, + }); + // a keystroke (human presence signal) + try { + fire("keyup", KeyboardEvent, { + key: "a", + code: "KeyA", + keyCode: 65, + which: 65, + }); + } catch (_) {} + } + }; + moveStep(); +} + +/** + * One-shot solve: create jsdom → verify → destroy. Memory is freed after each token. + * @param {{ scene: string, region: string, prefix: string, timeoutMs?: number }} opts + */ +async function solveTraceless(opts) { + const scene = opts.scene || "11xygtvd"; + const region = opts.region || "sgp"; + const prefix = opts.prefix || "no8xfe"; + const timeoutMs = opts.timeoutMs ?? 25_000; + + const dom = await createDom(region, prefix); + try { + const { window } = dom; + await waitFor( + () => typeof window.initAliyunCaptcha === "function", + timeoutMs, + ); + + // Prime FeiLin's behavioral buffer with a human-like mouse trajectory before + // the traceless verification snapshot is taken. + // Optimization: reduced from 1500ms to 600ms — Playwright only needs + // ~500ms of mouse priming; 600ms gives margin without wasting time. + simulateBehavior(window, 600); + + const param = await new Promise((resolve, reject) => { + const timer = setTimeout( + () => reject(new Error("captcha solve timeout")), + timeoutMs, + ); + const finish = (fn) => (value) => { + clearTimeout(timer); + fn(value); + }; + + try { + window.initAliyunCaptcha({ + SceneId: scene, + mode: "popup", + region, + prefix, + language: "en", + element: "#cap", + button: "#btn", + captchaLogoImg: "", + showErrorTip: false, + getInstance: (inst) => { + try { + (inst.startTracelessVerification || inst.show).call(inst); + } catch (e) { + finish(reject)(new Error(`start: ${e.message}`)); + } + }, + success: (result) => { + try { + finish(resolve)(handleCaptchaResult(result)); + } catch (err) { + finish(reject)(err); + } + }, + fail: (err) => + finish(reject)(new Error(`fail: ${JSON.stringify(err)}`)), + onError: (err) => + finish(reject)(new Error(`onError: ${JSON.stringify(err)}`)), + }); + } catch (err) { + clearTimeout(timer); + reject(err); + } + }); + + return extractVerifyParam(param); + } finally { + destroyDom(dom); + } +} + +module.exports = { solveTraceless, createDom, destroyDom, generateFingerprint }; diff --git a/captcha_node/solve-happy-lib.js b/captcha_node/solve-happy-lib.js new file mode 100644 index 0000000..90c0654 --- /dev/null +++ b/captcha_node/solve-happy-lib.js @@ -0,0 +1,1851 @@ +#!/usr/bin/env node +// ============================================================================ +// solve-happy.js — happy-dom port of zcode-proxy's AliyunCaptcha solver +// ISOLATED EXPERIMENT: prove happy-dom can execute the real Aliyun captcha SDK +// and produce a REAL ~280-char verify param via startTracelessVerification(). +// +// Mechanics ported from +// /home/wraient/Projects/zcode-api-auto-captcha/captcha_node/solve-core.js +// 1. cookie priming of https://zcode.z.ai/ (5-min cache) +// 2. CDN disk cache at ~/.zcode-captcha-cdn-cache/ + in-mem cache +// 3. installNativeToString (mask JS-implemented platform APIs as native) +// 4. per-request client-hint / UA / origin / referer injection (interceptor) +// 5. guest-side patches (Event.isTrusted, HTMLDocument naming, btoa) +// 6. solve contract: initAliyunCaptcha + getInstance().startTracelessVerification() +// ============================================================================ +process.env.FONTCONFIG_PATH = "/dev/null"; + +const { Window, PropertySymbol } = require("happy-dom"); +const { ProxyAgent, setGlobalDispatcher } = require("undici"); +const crypto = require("node:crypto"); +const fs = require("node:fs"); +const os = require("node:os"); +const path = require("node:path"); +const { spawnSync } = require("node:child_process"); + +const CDN_CACHE_DIR = path.join(os.homedir(), ".zcode-captcha-cdn-cache"); +const _memCdnCache = new Map(); +let _cookieCache = { cookies: [], ts: 0 }; +const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000; +const _DEBUG = /^(1|true|yes)$/i.test( + process.env.CAPTCHA_DEBUG || process.env.CAPTCHA_DEBUG_BODIES || "", +); + +const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; +if (proxyUrl) { + try { + setGlobalDispatcher(new ProxyAgent(proxyUrl)); + } catch (_) {} +} + +// ── Globals shared across solves ──────────────────────────────────────────── +const _requestLog = []; +const solveTimes = []; +// Consecutive-stall tracker per pe bundle URL: the same cached pe version +// can stall every attempt (bad rotated VM variant / stale cache). After two +// stalls on one URL, evict its memory + disk cache entry so the next init +// fetches fresh bytes from the CDN instead of re-stalling on them. +const _stallCounts = new Map(); +// Set after a stall: the next solve in this process fetches dynamicJS fresh +// (bypassing mem+disk cache) instead of re-using the bytes that just stalled. +let _bypassPeCacheOnce = false; + +function noteStallAndMaybeEvict(peUrl) { + try { + if (!peUrl || !/dynamicJS\//.test(peUrl)) return; + _bypassPeCacheOnce = true; + const n = (_stallCounts.get(peUrl) || 0) + 1; + _stallCounts.set(peUrl, n); + if (n >= 2 && !_DEBUG) { + process.stderr.write(`[pe-cache-evict] ${peUrl.split("/").pop()} stalled ${n}x — evicting cache\n`); + } + if (n >= 2) { + _memCdnCache.delete(peUrl); + try { fs.unlinkSync(diskPathFor(peUrl)); } catch (_) {} + _stallCounts.delete(peUrl); + } + } catch (_) {} +} + +// ── Fingerprint ───────────────────────────────────────────────────────────── +function generateFingerprint() { + const userAgent = + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"; + const uaMajor = "127"; + const uaFull = "127.0.0.0"; + const platform = "Linux x86_64"; + const screen = { w: 1280, h: 720, aw: 1280, ah: 720 }; + const webglUnmaskedVendor = "Google Inc. (Google)"; + const webglUnmaskedRenderer = + "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)"; + const canvasImage = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; + return { userAgent, uaMajor, uaFull, platform, screen, webglUnmaskedVendor, webglUnmaskedRenderer, canvasImage }; +} + +const fp = generateFingerprint(); + +const HTML = ` +
+ +`; + +function diskPathFor(url) { + return path.join(CDN_CACHE_DIR, crypto.createHash("sha1").update(String(url)).digest("hex")); +} + +function sniffMime(url) { + if (/\.js(\?|$)/i.test(url)) return "application/javascript"; + if (/\.css(\?|$)/i.test(url)) return "text/css"; + if (/\.png(\?|$)/i.test(url)) return "image/png"; + if (/\.(jpg|jpeg)(\?|$)/i.test(url)) return "image/jpeg"; + if (/\.json(\?|$)/i.test(url)) return "application/json"; + return "application/octet-stream"; +} + +// ── pe.* bytecode VM harvest hook (same as solve-core) ────────────────────── +const peVmCallRegex = + /55==A\?\(f=r\[n\+\+\],l=e\.pop\(\),h=e\.pop\(\),o=\[\],\w+\(f\)\.forEach\(function\(\)\{o\.unshift\(e\.pop\(\)\)\}\),p=null===h\?l\.apply\((\w+),o\):h\[l\]\.apply\(h,o\),r\[n\+\+\]&&e\.push\(p\)\):/; +function patchPeBundle(buf, url) { + if (process.env.PE_PATCH === "off") return buf; + if (!/dynamicJS\/[^/]*\/pe\.\d+\./.test(url)) return buf; + let src = buf.toString("utf8"); + if (src.includes("__DBT")) return buf; + const m = src.match(peVmCallRegex); + if (!m) return buf; + const locals = m[1]; + const hook = `55==A?(f=r[n++],l=e.pop(),h=e.pop(),o=[],v(f).forEach(function(){o.unshift(e.pop())}),p=null===h?l.apply(${locals},o):h[l].apply(h,o),r[n++]&&e.push(p),function(){try{if(l===window.btoa||l===window.atob){window.__DBT=window.__DBT||[];var __sav=[];for(var __i=0;__i 0) { + _memCdnCache.set(url, buf); + try { + const p = diskPathFor(url); + fs.mkdirSync(CDN_CACHE_DIR, { recursive: true }); + fs.writeFileSync(p, buf); + // verify write completed (no partial file) + const stat = fs.statSync(p); + if (stat.size !== buf.length) { + process.stderr.write(`[cache-write-short] ${url} wrote ${stat.size}/${buf.length}b — rewrite\n`); + fs.writeFileSync(p, buf); + } + } catch (err) { + if (_DEBUG) process.stderr.write(`[cache-write-err] ${url}: ${err.message}\n`); + } + } + return buf; + } catch (err) { + if (_DEBUG) process.stderr.write(`[loader-fetch-err] ${url}: ${err.message}\n`); + return null; + } +} + +function fetchSyncExternal(url) { + const args = ["-sSL", "--connect-timeout", "15", "--max-time", "30", url]; + try { + const r = spawnSync("curl", args, { encoding: null, maxBuffer: 64 * 1024 * 1024 }); + if (!r.error && r.status === 0 && r.stdout && r.stdout.length > 0) { + const buf = Buffer.from(r.stdout); + _memCdnCache.set(url, buf); + try { + fs.mkdirSync(CDN_CACHE_DIR, { recursive: true }); + fs.writeFileSync(diskPathFor(url), buf); + } catch (_) {} + return buf; + } + } catch (_) {} + return null; +} + +// ── Request header injection (every frame request: XHR, fetch, scripts) ──── +function injectRequestHeaders(request) { + const h = request.headers; + try { + h.set("sec-ch-ua", '"Chromium";v="' + fp.uaMajor + '", "Not)A;Brand";v="24"'); + h.set("sec-ch-ua-mobile", "?0"); + h.set("sec-ch-ua-platform", '"Linux"'); + h.set("user-agent", fp.userAgent); + h.set("accept-language", "en-US,en;q=0.9"); + h.set("referer", "https://zcode.z.ai/"); + let origin = null; + try { + const u = new URL(request.url); + const method = String(request.method || "GET").toUpperCase(); + const crossOrigin = u.origin !== "https://zcode.z.ai"; + if (crossOrigin || (method !== "GET" && method !== "HEAD")) { + origin = "https://zcode.z.ai"; + } + } catch (_) {} + if (origin) h.set("origin", origin); + } catch (_) {} +} + +function cookieHeader(request, window, browserFrame) { + try { + const ctx = browserFrame.page.context; + const u = new URL(request.url); + if (request.credentials === "omit") return null; + const cookies = ctx.cookieContainer.getCookies(u, false); + if (cookies.length > 0) { + return cookies.map((c) => `${c.name}=${c.value}`).join("; "); + } + } catch (_) {} + return null; +} + +function storeSetCookies(res, url) { + try { + const list = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : []; + if (list.length) { + const cookieContainer = global.__cookieContainer; + if (cookieContainer) { + for (const raw of list) { + const u = new URL(url); + const parts = raw.split(";"); + const pair = parts[0].split("="); + const cookie = { + name: pair[0].trim(), + value: pair.slice(1).join("=").trim(), + url: u.origin, + domain: u.hostname, + path: "/", + }; + for (const p of parts.slice(1)) { + const kv = p.trim().split(/=(.*)/s); + const k = (kv[0] || "").toLowerCase(); + if (k === "domain" && kv[1]) cookie.domain = kv[1]; + if (k === "path" && kv[1]) cookie.path = kv[1]; + if (k === "expires") cookie.expires = new Date(kv[1]).getTime(); + if (k === "max-age") cookie.maxAge = parseInt(kv[1], 10); + if (k === "httponly") cookie.httpOnly = true; + if (k === "secure") cookie.secure = true; + if (k === "samesite") cookie.sameSite = kv[1]; + } + try { + cookieContainer.addCookies([cookie]); + } catch (_) {} + } + } + } + } catch (_) {} +} + +// ── The interceptor: replaces happy-dom's network layer completely ───────── +// All frame requests (scripts, XHR, fetch, images) funnel through here. +function makeInterceptor(bypassPeCache = false) { + const skipPeCache = (url) => bypassPeCache && /dynamicJS\/.*\/pe\.\d+\./.test(url); + return { + async beforeAsyncRequest({ request, window: w }) { + const url = request.url; + _requestLog.push({ at: Date.now(), method: request.method, url }); + injectRequestHeaders(request); + if (/\balicdn\.com/i.test(url)) { + let body = skipPeCache(url) ? null : getCachedBody(url); + if (body && /\.js(\?|$)/i.test(url)) { + try { + new Function(body.toString("utf8")); + } catch (parseErr) { + process.stderr.write(`[cache-bad-js] ${url} len=${body.length} ${parseErr.message} — refetch fresh\n`); + _memCdnCache.delete(url); + try { fs.unlinkSync(diskPathFor(url)); } catch (_) {} + body = null; + } + } + if (!body) { + body = await fetchAndStore(url); + } + if (body) { + if (/dynamicJS\/[^/]*\/pe\.\d+\./.test(url)) { + try { w.__lastPeUrl = url; } catch (_) {} + } + return new w.Response(patchPeBundle(Buffer.from(body), url), { + status: 200, + statusText: "OK", + headers: { "content-type": sniffMime(url) }, + }); + } + } + // Passthrough via global fetch (undici; honors global ProxyAgent). + try { + const init = { method: request.method, headers: {} }; + request.headers.forEach((value, key) => { + init.headers[key] = value; + }); + const bs = new URL(url); + const cookie = cookieHeader(request, w, global.__browserFrame); + if (cookie) init.headers.cookie = cookie; + let hasBody = false; + try { + if (request.body) { + const ab = await request.arrayBuffer(); + if (ab && ab.byteLength > 0) { + init.body = ab; + hasBody = true; + } + } + } catch (_) {} + const res = await fetch(url, init); + const buf = Buffer.from(await res.arrayBuffer()); + storeSetCookies(res, url); + if (_DEBUG && /captcha-open|verify\.|device\.saf|cloudauth-device|upload\./i.test(url) && buf.length && buf.length < 4096) { + try { + process.stderr.write(`[xhr-body] ${request.method} ${bs.hostname}${bs.pathname}-> ${res.status} ${buf.toString("utf8").slice(0, 1200)}\n`); + } catch (_) {} + } + const headers = {}; + const ct = res.headers.get("content-type"); + if (ct) headers["content-type"] = ct; + const logHost = bs.hostname; + if (_DEBUG) + process.stderr.write( + `[xhr] ${request.method} ${logHost}${bs.pathname} -> ${res.status} (${buf.length}b)\n`, + ); + return new w.Response(buf, { + status: res.status, + statusText: res.statusText || "", + headers, + }); + } catch (err) { + if (_DEBUG) process.stderr.write(`[xhr-err] ${url}: ${err.message}\n`); + return new w.Response("", { status: 503, statusText: "passthrough failed" }); + } + }, + beforeSyncRequest({ request, window: w }) { + const url = request.url; + _requestLog.push({ at: Date.now(), method: request.method, url, sync: true }); + injectRequestHeaders(request); + let body = null; + if (/\balicdn\.com/i.test(url)) { + body = skipPeCache(url) ? null : getCachedBody(url); + if (body && /\.js(\?|$)/i.test(url)) { + try { + new Function(body.toString("utf8")); + } catch (parseErr) { + process.stderr.write(`[cache-bad-js:sync] ${url} len=${body.length} ${parseErr.message} — refetch fresh\n`); + _memCdnCache.delete(url); + try { fs.unlinkSync(diskPathFor(url)); } catch (_) {} + body = null; + } + } + if (!body) body = fetchSyncExternal(url); + } + if (body) { + if (/dynamicJS\/[^/]*\/pe\.\d+\./.test(url)) { + try { w.__lastPeUrl = url; } catch (_) {} + } + return { + status: 200, + statusText: "OK", + ok: true, + url, + redirected: false, + headers: new w.Headers({ "content-type": sniffMime(url) }), + body: patchPeBundle(Buffer.from(body), url), + [PropertySymbol.virtualServerFile]: null, + }; + } + // Non-CDN sync request: let happy-dom's own child-process sync fetch handle it. + return undefined; + }, + }; +} + +// ── Parse-fail instrumentation (host side) ───────────────────────────────── +// Wraps happy-dom's VM eval funnel (window[PropertySymbol.evaluateScript]). +// Every script tag / compiled module / dynamic chunk that happy-dom parses +// passes through here with options.filename = source URL, so any SyntaxError +// is dumped with URL + length + head/tail + sha1, and the disk cache is +// re-validated against a fresh CDN fetch when the URL is an http(s) file. +function installEvalInstrumentation(w) { + let PropertySymbol; + try { + PropertySymbol = require("happy-dom/lib/PropertySymbol.js").default ?? require("happy-dom/lib/PropertySymbol.js"); + } catch (_) {} + const sym = PropertySymbol && PropertySymbol.evaluateScript; + if (!sym || typeof w[sym] !== "function") { + process.stderr.write("[instr] no evaluateScript symbol, host hook skipped\n"); + return; + } + const orig = w[sym]; + w[sym] = function (code, options) { + try { + return orig.call(this, code, options); + } catch (err) { + try { + const src = String(code || ""); + const filename = (options && options.filename) || "?"; + const sha1 = crypto.createHash("sha1").update(src).digest("hex"); + process.stderr.write( + `\n[EVAL-PARSE-FAIL] file=${filename} len=${src.length} sha1=${sha1}\n` + + ` head300: ${JSON.stringify(src.slice(0, 300))}\n` + + ` tail100: ${JSON.stringify(src.slice(-100))}\n` + + ` err: ${err && err.message}\n`, + ); + if (/^https?:/.test(filename)) { + (async () => { + try { + const res = await fetch(filename, { headers: { "user-agent": fp.userAgent } }); + const fresh = Buffer.from(await res.arrayBuffer()); + process.stderr.write( + `[EVAL-CACHE-COMPARE] cachedLen=${src.length} freshLen=${fresh.length} freshSha1=${crypto.createHash("sha1").update(fresh).digest("hex")} http=${res.status}\n`, + ); + if (fresh.length > 0 && fresh.length !== src.length) { + process.stderr.write(`[EVAL-CACHE-MISMATCH] deleting ${diskPathFor(filename)} (stale/truncated cache)\n`); + try { + fs.unlinkSync(diskPathFor(filename)); + } catch (_) {} + _memCdnCache.delete(filename); + } + } catch (fetchErr) { + process.stderr.write(`[EVAL-CACHE-COMPARE-ERR] ${fetchErr.message}\n`); + } + })(); + } + } catch (e2) {} + throw err; + } + }; +} + +// ── Mask JS-implemented platform APIs as native (FeiLin toString sweep) ───── +function installNativeToString(w) { + const realToString = Function.prototype.toString; + const nativeRe = /\[native code\]/; + const mask = (fn) => { + if (typeof fn !== "function") return; + try { + if (nativeRe.test(realToString.call(fn))) return; + const name = fn.name || ""; + const nativeStr = `function ${name}() { [native code] }`; + Object.defineProperty(fn, "toString", { + value: () => nativeStr, + configurable: true, + writable: true, + }); + } catch (_) {} + }; + const seen = new w.Set(); + const maskObj = (obj, depth) => { + if ( + !obj || + (typeof obj !== "object" && typeof obj !== "function") || + depth > 5 + ) + return; + if (seen.has(obj)) return; + try { + seen.add(obj); + } catch (_) { + return; + } + let names = []; + try { + names = Object.getOwnPropertyNames(obj); + } catch (_) { + return; + } + for (const name of names) { + if (name === "toString" || name === "constructor") continue; + let desc; + try { + desc = Object.getOwnPropertyDescriptor(obj, name); + } catch (_) { + continue; + } + if (!desc) continue; + if (typeof desc.value === "function") { + mask(desc.value); + } else if (typeof desc.get === "function") { + mask(desc.get); + try { + const v = desc.get.call(obj); + if (typeof v === "function") mask(v); + } catch (_) {} + } + if (depth < 3) { + try { + const v = desc.value; + if (v && (typeof v === "function" || typeof v === "object")) + maskObj(v, depth + 1); + } catch (_) {} + } + } + }; + const targets = [ + w, + w.navigator, + w.document, + w.Document && w.Document.prototype, + w.Element && w.Element.prototype, + w.HTMLElement && w.HTMLElement.prototype, + w.Node && w.Node.prototype, + w.EventTarget && w.EventTarget.prototype, + w.HTMLCanvasElement && w.HTMLCanvasElement.prototype, + w.XMLHttpRequest && w.XMLHttpRequest.prototype, + w.Event && w.Event.prototype, + w.Window && w.Window.prototype, + ].filter(Boolean); + for (const t of targets) { + try { + maskObj(t, 0); + } catch (_) {} + } +} + +// ── Guest-context patches (run via window.eval inside the VM realm) ───────── +const GUEST_EVAL_PATCH = ` +(function() { + try { + Object.defineProperty(Event.prototype, "isTrusted", { + get() { return true; }, + configurable: true + }); + } catch (e) {} + try { + if (window.HTMLDocument) { + Object.defineProperty(window.HTMLDocument, "name", { value: "HTMLDocument", configurable: true }); + Object.defineProperty(window.HTMLDocument.prototype, Symbol.toStringTag, { value: "HTMLDocument", configurable: true }); + } + } catch (e) {} + try { + Object.defineProperty(window.Document.prototype, Symbol.toStringTag, { value: "HTMLDocument", configurable: true }); + } catch (e) {} + try { + window.addEventListener("unhandledrejection", function(e) { + var r = e && e.reason; + try { + console.error("[UH-REASON]", typeof r, r && r.stack ? r.stack.split("\\n").slice(0,6).join(" | ") : (r && r.message), JSON.stringify(r)); + } catch (e2) {} + }); + } catch (e) {} + try { + window.addEventListener("error", function(e) { + try { + console.error("[WINDOW-ERROR]", e && e.message, e && e.error && e.error.stack ? e.error.stack.split("\\n").slice(0,6).join(" | ") : ""); + } catch (e2) {} + }); + } catch (e) {} + // ---- eval/Function parse-fail instrumentation (installed before pe chain) ---- + // Catches SyntaxError from guest-side eval()/new Function() (the dynamic pe.* + // chunk is evaluated this way in some SDK paths). Host-side twin: the + // PropertySymbol.evaluateScript wrapper in installEvalInstrumentation(). + function __capFailDump(kind, code) { + try { + var src = String(code || ""); + if (typeof window.__capDebugDump === "function") { + window.__capDebugDump(window.__lastPeUrl || "?", src, kind); + } else { + console.error("[" + kind + "] url=" + (window.__lastPeUrl || "?") + " len=" + src.length + " head=" + JSON.stringify(src.slice(0, 300)) + " tail=" + JSON.stringify(src.slice(-100))); + } + } catch (e2) {} + } + try { + var _origEval2 = window.eval; + if (_origEval2) { + window.eval = function(code) { + try { return _origEval2.call(window, code); } + catch (e) { + if (e && (/unexpected|invalid|parse|syntax/i.test(String((e && e.message) || e)))) { + __capFailDump("REALM-EVAL-FAIL", code); + } + throw e; + } + }; + } + } catch (e) {} + try { + var _of = window.Function; + if (_of) { + var _WF = function() { + var args = Array.prototype.slice.call(arguments); + var body = args.length ? String(args[args.length - 1]) : ""; + try { return _of.apply(this, args); } + catch (e) { + if (e && (/unexpected|invalid|parse|syntax/i.test(String((e && e.message) || e)))) { + __capFailDump("REALM-FN-FAIL", body); + } + throw e; + } + }; + _WF.prototype = _of.prototype; + try { Object.defineProperty(_WF, "name", { value: "Function", configurable: true }); } catch (e) {} + window.Function = _WF; + } + } catch (e) {} +})(); +`; + +// ── Browser-ish polyfills (ported from solve-core applyPolyfills) ─────────── +function applyPolyfills(w) { + if (process.env.CAPTCHA_DEBUG_BODIES === "1") { + installTrafficLogger(w); + } + + // happy-dom lacks alert/prompt/confirm/open/close (same stubs as + // solve-shim.js line ~559-561) + if (typeof w.alert !== "function") w.alert = () => {}; + if (typeof w.prompt !== "function") w.prompt = () => null; + if (typeof w.confirm !== "function") w.confirm = () => false; + if (typeof w.open !== "function") w.open = () => null; + if (typeof w.close !== "function") w.close = () => {}; + try { Object.defineProperty(w, "alert", { value: w.alert, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "prompt", { value: w.prompt, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "confirm", { value: w.confirm, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "open", { value: w.open, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "close", { value: w.close, configurable: true, writable: true }); } catch (_) {} + + // happy-dom lacks browser globals that FeiLin / the pe risk engine probe. + // A missing one throws ReferenceError inside the VM machine → breaks the + // collection chain. Ported from solve-shim.js's stub list. + const extraGlobals = { + print: () => {}, + stop: () => {}, + moveTo: () => {}, + moveBy: () => {}, + showModalDialog: () => null, + find: () => false, + }; + for (const [k, v] of Object.entries(extraGlobals)) { + try { Object.defineProperty(w, k, { value: v, configurable: true, writable: true }); } catch (_) {} + } + // happy-dom's own open()/close() are destructive (close() tears the window + // down); the risk engine probes them → neutralize. + try { Object.defineProperty(w, "open", { value: () => null, configurable: true, writable: true }); } catch (_) {} + try { Object.defineProperty(w, "close", { value: () => {}, configurable: true, writable: true }); } catch (_) {} + + if (!w.Option) { + w.Option = class { + constructor(text, value, defaultSelected, selected) { + this.text = text ?? ""; + this.value = value ?? ""; + this.selected = selected ?? defaultSelected ?? false; + this.defaultSelected = !!defaultSelected; + this.disabled = false; + this.label = this.text; + this.index = 0; + } + }; + } + + if (!w.EventSource) { + w.EventSource = class { + constructor() { + this.readyState = 2; + this.onopen = null; + this.onmessage = null; + this.onerror = null; + } + close() { + this.readyState = 2; + } + addEventListener() {} + removeEventListener() {} + }; + } + + if (!w.Beacon) w.Beacon = class {}; + + if (!w.RTCPeerConnection) { + w.RTCPeerConnection = class { + constructor() {} + createDataChannel() { return {}; } + close() {} + createOffer() { return Promise.resolve({}); } + setLocalDescription() { return Promise.resolve(); } + addEventListener() {} + removeEventListener() {} + }; + } + + if (!w.MessageChannel) { + w.MessageChannel = class { + constructor() { + this.port1 = { onmessage: null, postMessage() {}, start() {}, close() {}, addEventListener() {}, removeEventListener() {} }; + this.port2 = { onmessage: null, postMessage() {}, start() {}, close() {}, addEventListener() {}, removeEventListener() {} }; + } + }; + } + + w.IntersectionObserver = + w.IntersectionObserver || + class { + constructor(cb) { + this.cb = cb; + } + observe() {} + unobserve() {} + disconnect() {} + takeRecords() { + return []; + } + }; + + w.ResizeObserver = + w.ResizeObserver || + class { + observe() {} + unobserve() {} + disconnect() {} + }; + + w.DeviceOrientationEvent = + w.DeviceOrientationEvent || + class extends w.Event { + constructor(type, opts) { + super(type, opts); + } + alpha = null; + beta = null; + gamma = null; + absolute = false; + }; + + w.DeviceMotionEvent = + w.DeviceMotionEvent || + class extends w.Event { + constructor(type, opts) { + super(type, opts); + } + acceleration = null; + accelerationIncludingGravity = null; + rotationRate = null; + interval = 16; + }; + + w.requestIdleCallback = w.requestIdleCallback || ((cb) => setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 10 }), 1)); + w.cancelIdleCallback = w.cancelIdleCallback || ((id) => clearTimeout(id)); + + w.matchMedia = + w.matchMedia || + (() => ({ + matches: false, + media: "", + onchange: null, + addListener() {}, + removeListener() {}, + addEventListener() {}, + removeEventListener() {}, + dispatchEvent() { + return false; + }, + })); + + if (!w.visualViewport) { + const VisualViewport = function () {}; + VisualViewport.prototype = { + width: fp.screen.w - 16, + height: fp.screen.h - 120, + scale: 1, + offsetLeft: 0, + offsetTop: 0, + pageLeft: 0, + pageTop: 0, + onresize: null, + onscroll: null, + onscrollend: null, + }; + w.VisualViewport = VisualViewport; + w.visualViewport = Object.create(w.VisualViewport.prototype); + } + + if (!w.indexedDB) { + const IDBFactory = function () {}; + IDBFactory.prototype = { + open: () => ({ onupgradeneeded: null, onsuccess: null, onerror: null }), + deleteDatabase: () => ({}), + databases: () => Promise.resolve([]), + }; + w.IDBFactory = IDBFactory; + w.indexedDB = Object.create(w.IDBFactory.prototype); + } + + if (!w.speechSynthesis) { + const SpeechSynthesis = function () {}; + SpeechSynthesis.prototype = { + speak() {}, + cancel() {}, + pause() {}, + resume() {}, + getVoices: () => [], + }; + w.SpeechSynthesis = SpeechSynthesis; + w.speechSynthesis = Object.create(w.SpeechSynthesis.prototype); + w.SpeechSynthesisUtterance = function () {}; + } + + w.Worker = + w.Worker || + class { + postMessage() {} + terminate() {} + addEventListener() {} + removeEventListener() {} + }; + + w.Notification = + w.Notification || + class { + static permission = "default"; + static requestPermission() { + return Promise.resolve("default"); + } + close() {} + }; + + // Canvas / WebGL + const proto = w.HTMLCanvasElement.prototype; + const nativeGetContext = typeof proto.getContext === "function" ? proto.getContext : null; + proto.getContext = function (type, ...rest) { + if (/webgl/i.test(type)) { + return makeWebGLMock(this); + } + if (nativeGetContext) { + try { + const ctx = nativeGetContext.call(this, type, ...rest); + if (ctx) return ctx; + } catch (_) {} + } + return make2DStub(this); + }; + + function makeWebGLMock(canvas) { + return { + canvas, + getParameter(p) { + if (p === 7936) return "WebKit"; + if (p === 7937) return "WebKit WebGL"; + if (p === 7938) return "WebGL 1.0 (OpenGL ES 2.0 Chromium)"; + if (p === 35724) return "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)"; + if (p === 0x9245) return fp.webglUnmaskedVendor; + if (p === 0x9246) return fp.webglUnmaskedRenderer; + return "Intel Inc."; + }, + getExtension(name) { + if (name === "WEBGL_debug_renderer_info") { + return { UNMASKED_VENDOR_WEBGL: 0x9245, UNMASKED_RENDERER_WEBGL: 0x9246 }; + } + return null; + }, + getSupportedExtensions() { + return [ + "ANGLE_instanced_arrays", "EXT_blend_minmax", "EXT_color_buffer_half_float", + "EXT_disjoint_timer_query", "EXT_float_blend", "EXT_frag_depth", + "EXT_shader_texture_lod", "EXT_texture_compression_bptc", + "EXT_texture_compression_rgtc", "EXT_texture_filter_anisotropic", + "EXT_sRGB", "KHR_parallel_shader_compile", "OES_element_index_uint", + "OES_fbo_render_mipmap", "OES_standard_derivatives", + "OES_texture_float", "OES_texture_float_linear", + "OES_texture_half_float", "OES_texture_half_float_linear", + "OES_vertex_array_object", "WEBGL_color_buffer_float", + "WEBGL_compressed_texture_astc", "WEBGL_compressed_texture_etc", + "WEBGL_compressed_texture_etc1", "WEBGL_compressed_texture_s3tc", + "WEBGL_compressed_texture_s3tc_srgb", "WEBGL_debug_renderer_info", + "WEBGL_debug_shaders", "WEBGL_depth_texture", "WEBGL_draw_buffers", + "WEBGL_lose_context", "WEBGL_multi_draw", + ]; + }, + getContextAttributes() { + return { + alpha: true, antialias: true, depth: true, + failIfMajorPerformanceCaveat: false, powerPreference: "default", + premultipliedAlpha: true, preserveDrawingBuffer: false, + stencil: false, desynchronized: false, + }; + }, + getShaderPrecisionFormat() { + return { precision: 23, rangeMin: 127, rangeMax: 127 }; + }, + }; + } + + function make2DStub(canvas) { + return { + canvas, + fillRect() {}, + clearRect() {}, + getImageData: (_x, _y, w2 = 1, h2 = 1) => new w.ImageData(w2, h2), + putImageData() {}, + createImageData: (w2 = 1, h2 = 1) => new w.ImageData(w2, h2), + setTransform() {}, + transform() {}, + drawImage() {}, + save() {}, + restore() {}, + beginPath() {}, + moveTo() {}, + lineTo() {}, + bezierCurveTo() {}, + quadraticCurveTo() {}, + closePath() {}, + clip() {}, + stroke() {}, + fill() {}, + arc() {}, + rect() {}, + ellipse() {}, + translate() {}, + scale() {}, + rotate() {}, + fillText() {}, + strokeText() {}, + measureText: (t) => ({ width: String(t).length * 8 }), + createLinearGradient: () => ({ addColorStop() {} }), + createRadialGradient: () => ({ addColorStop() {} }), + createPattern: () => ({}), + isPointInPath: () => false, + font: "10px sans-serif", + textBaseline: "alphabetic", + textAlign: "start", + fillStyle: "#000", + strokeStyle: "#000", + globalAlpha: 1, + lineWidth: 1, + shadowBlur: 0, + shadowColor: "", + }; + } + + const nativeToDataURL = typeof proto.toDataURL === "function" ? proto.toDataURL : null; + proto.toDataURL = function (...a) { + try { + if (nativeToDataURL) return nativeToDataURL.apply(this, a); + } catch (_) {} + return fp.canvasImage; + }; + if (typeof proto.toBlob !== "function") { + proto.toBlob = (cb) => cb && cb(new w.Blob()); + } + + w.OffscreenCanvas = + w.OffscreenCanvas || + class { + constructor(width, height) { + this.width = width; + this.height = height; + } + getContext() { + return proto.getContext.call(this); + } + }; + + const audioMock = class { + constructor() { + this.sampleRate = 44100; + this.currentTime = 0; + this.state = "suspended"; + } + createOscillator() { + return { + type: "sine", + frequency: { value: 440, setValueAtTime() {} }, + connect() {}, + start() {}, + stop() {}, + }; + } + createDynamicsCompressor() { + return { + threshold: { value: -24, setValueAtTime() {} }, + knee: { value: 30, setValueAtTime() {} }, + ratio: { value: 12, setValueAtTime() {} }, + attack: { value: 0.003, setValueAtTime() {} }, + release: { value: 0.25, setValueAtTime() {} }, + connect() {}, + }; + } + createAnalyser() { + return { + fftSize: 2048, + frequencyBinCount: 1024, + getByteFrequencyData() {}, + getByteTimeDomainData() {}, + connect() {}, + }; + } + createGain() { + return { gain: { value: 1 }, connect() {} }; + } + destination = {}; + resume() { + this.state = "running"; + return Promise.resolve(); + } + close() { + this.state = "closed"; + return Promise.resolve(); + } + }; + w.AudioContext = w.AudioContext || audioMock; + w.OfflineAudioContext = + w.OfflineAudioContext || + class extends audioMock { + constructor(_channels, length, sampleRate) { + super(); + this.length = length; + this.sampleRate = sampleRate; + } + startRendering() { + const len = this.length || 44100; + const sr = this.sampleRate || 44100; + const buf = new Float32Array(len); + for (let i = 0; i < len; i += 1) { + const t = i / sr; + buf[i] = + Math.sin(2 * Math.PI * 1000 * t) * Math.exp(-t * 1.2) * 0.6 + + Math.sin(2 * Math.PI * 3000 * t) * Math.exp(-t * 1.5) * 0.25 + + Math.sin(2 * Math.PI * 5000 * t) * Math.exp(-t * 2.0) * 0.12; + } + return Promise.resolve({ + numberOfChannels: 1, + length: len, + sampleRate: sr, + getChannelData: () => buf, + }); + } + }; + + w.requestAnimationFrame = w.requestAnimationFrame || ((cb) => setTimeout(() => cb(Date.now()), 16)); + w.cancelAnimationFrame = w.cancelAnimationFrame || ((id) => clearTimeout(id)); + + try { + Object.defineProperty(w.document, "hidden", { value: false, configurable: true }); + Object.defineProperty(w.document, "visibilityState", { + value: "visible", + configurable: true, + }); + } catch (_) {} + + if (!w.document.fonts) { + w.document.fonts = { + ready: Promise.resolve(), + check: () => true, + addEventListener() {}, + removeEventListener() {}, + }; + } + + if (!w.chrome) { + w.chrome = { + app: { + isInstalled: false, + InstallState: { DISABLED: "disabled", INSTALLED: "installed", NOT_INSTALLED: "not_installed" }, + RunningState: { CANNOT_RUN: "cannot_run", CAN_RUN: "can_run", RUNNING: "running" }, + getDetails() { return null; }, + getIsInstalled() { return false; }, + installState(cb) { if (cb) cb("not_installed"); }, + runningState(cb) { if (cb) cb("cannot_run"); }, + }, + csi() { + const now = Date.now(); + return { startE: now - 100, onloadT: now, pageT: 100, tran: 15 }; + }, + loadTimes() { + const now = Date.now() / 1000; + return { + requestTime: now - 0.1, startLoadTime: now - 0.1, + commitLoadTime: now - 0.05, finishDocumentLoadTime: now, + finishLoadTime: now, firstPaintTime: now - 0.02, + firstPaintAfterLoadTime: 0, navigationType: "Other", + wasFetchedViaSpdy: true, wasNpnNegotiated: true, + npnNegotiatedProtocol: "h2", wasAlternateProtocolAvailable: false, + connectionInfo: "h2", + }; + }, + }; + } + + // navigator patch + const nav = w.navigator; + const plugins = createNavigatorPlugins(w); + const navPatch = { + userAgent: fp.userAgent, + platform: fp.platform, + language: "en-US", + languages: ["en-US", "en"], + vendor: "Google Inc.", + webdriver: false, + hardwareConcurrency: 12, + deviceMemory: 8, + maxTouchPoints: 0, + cookieEnabled: true, + plugins: plugins.plugins, + mimeTypes: plugins.mimeTypes, + appVersion: fp.userAgent.replace(/^Mozilla\//, ""), + appName: "Netscape", + appCodeName: "Mozilla", + product: "Gecko", + productSub: "20030107", + vendorSub: "", + oscpu: undefined, + doNotTrack: null, + sendBeacon: (url, data) => { + try { + const xhr = new w.XMLHttpRequest(); + xhr.open("POST", url, true); + xhr.send(data); + return true; + } catch (_) { + return false; + } + }, + }; + for (const [k, v] of Object.entries(navPatch)) { + try { + Object.defineProperty(nav, k, { value: v, configurable: true }); + } catch (_) {} + } + + // polyfill navigator sub-objects that happy-dom lacks + const makeNS = (protoObj) => { + const C = new w.Function(); + C.prototype = protoObj; + return new C(); + }; + + if (!nav.connection) { + const NetInfo = () => {}; + NetInfo.prototype = { onchange: null, effectiveType: "4g", rtt: 50, downlink: 10, saveData: false }; + w.NetworkInformation = NetInfo; + try { + Object.defineProperty(nav, "connection", { value: makeNS(NetInfo.prototype), configurable: true }); + } catch (_) {} + } + if (!nav.userAgentData) { + const UAData = function () {}; + UAData.prototype = { + brands: [ + { brand: "Chromium", version: fp.uaMajor }, + { brand: "Not)A;Brand", version: "24" }, + ], + mobile: false, + platform: "Linux", + getHighEntropyValues: () => + Promise.resolve({ + brands: [ + { brand: "Chromium", version: fp.uaMajor }, + { brand: "Not)A;Brand", version: "24" }, + ], + mobile: false, + platform: "Linux", + platformVersion: "6.5.0", + architecture: "x86", + model: "", + uaFullVersion: fp.uaFull, + fullVersionList: [ + { brand: "Chromium", version: fp.uaFull }, + { brand: "Not)A;Brand", version: "24.0.0.0" }, + ], + }), + }; + try { + Object.defineProperty(nav, "userAgentData", { value: makeNS(UAData.prototype), configurable: true }); + } catch (_) {} + } + if (!w.Permissions) { + const Perms = () => {}; + Perms.prototype = { + query: (param) => + Promise.resolve({ state: param.name === "notifications" ? "prompt" : "granted", onchange: null }), + }; + w.Permissions = Perms; + } + try { + if (!nav.permissions) Object.defineProperty(nav, "permissions", { value: makeNS(w.Permissions.prototype), configurable: true }); + } catch (_) {} + try { + if (!nav.clipboard) + Object.defineProperty(nav, "clipboard", { + value: makeNS({ readText: () => Promise.resolve(""), writeText: () => Promise.resolve() }), + configurable: true, + }); + } catch (_) {} + try { + if (!nav.geolocation) + Object.defineProperty(nav, "geolocation", { + value: makeNS({ + getCurrentPosition: (s) => s && s({ coords: { latitude: 0, longitude: 0, accuracy: 1 } }), + watchPosition: () => 1, + clearWatch: () => {}, + }), + configurable: true, + }); + } catch (_) {} + try { + if (!nav.credentials) + Object.defineProperty(nav, "credentials", { + value: makeNS({ get: () => Promise.resolve(null), create: () => Promise.resolve(null), store: () => Promise.resolve(), preventSilentAccess: () => Promise.resolve() }), + configurable: true, + }); + } catch (_) {} + try { + if (!nav.storage) + Object.defineProperty(nav, "storage", { + value: makeNS({ estimate: () => Promise.resolve({ quota: 1e8, usage: 0 }), persisted: () => Promise.resolve(false), persist: () => Promise.resolve(false) }), + configurable: true, + }); + } catch (_) {} + try { + if (!nav.usb) + Object.defineProperty(nav, "usb", { + value: makeNS({ getDevices: () => Promise.resolve([]), requestDevice: () => Promise.reject(new Error("no devices")) }), + configurable: true, + }); + } catch (_) {} + try { + if (!nav.mediaDevices) + Object.defineProperty(nav, "mediaDevices", { + value: makeNS({ enumerateDevices: () => Promise.resolve([]), getUserMedia: () => Promise.reject(new Error("NotAllowedError")) }), + configurable: true, + }); + } catch (_) {} + + // screen + const screenPatch = { + width: fp.screen.w, + height: fp.screen.h, + availWidth: fp.screen.w, + availHeight: fp.screen.ah, + availLeft: 0, + availTop: 0, + colorDepth: 24, + pixelDepth: 24, + orientation: { angle: 0, type: "landscape-primary", onchange: null }, + }; + for (const [k, v] of Object.entries(screenPatch)) { + try { + Object.defineProperty(w.screen, k, { get: () => v, configurable: true }); + } catch (_) {} + } + + w.outerWidth = fp.screen.w; + w.outerHeight = fp.screen.h - 40; + w.innerWidth = fp.screen.w - 16; + w.innerHeight = fp.screen.h - 120; + w.devicePixelRatio = 1; +} + +function createNavigatorPlugins(w) { + const indexed = [ + { name: "PDF Viewer", filename: "internal-pdf-viewer", description: "Portable Document Format" }, + { name: "Chrome PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", description: "" }, + { name: "Chromium PDF Viewer", filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", description: "" }, + ]; + const plugins = w.PluginArray ? Object.create(w.PluginArray.prototype) : {}; + const mockIndexed = []; + for (let i = 0; i < indexed.length; i++) { + const p = Object.create((w.Plugin && w.Plugin.prototype) || Object.prototype); + Object.defineProperty(p, "name", { value: indexed[i].name, configurable: true, enumerable: true }); + Object.defineProperty(p, "filename", { value: indexed[i].filename, configurable: true, enumerable: true }); + Object.defineProperty(p, "description", { value: indexed[i].description, configurable: true, enumerable: true }); + Object.defineProperty(p, "length", { value: 1, configurable: true, enumerable: true }); + Object.defineProperty(p, "0", { value: p, configurable: true, enumerable: true }); + p.item = () => p; + p.namedItem = () => p; + plugins[i] = p; + mockIndexed.push(p); + } + Object.defineProperty(plugins, "length", { value: indexed.length, configurable: true, enumerable: true }); + plugins.item = (i) => plugins[i] ?? null; + plugins.namedItem = (name) => mockIndexed.find((p) => p.name === name) ?? null; + plugins.refresh = () => {}; + const mimeTypes = + w.MimeTypeArray ? Object.create(w.MimeTypeArray.prototype) : {}; + Object.defineProperty(mimeTypes, "length", { value: 0, configurable: true, enumerable: true }); + mimeTypes.item = () => null; + mimeTypes.namedItem = () => null; + return { plugins, mimeTypes }; +} + +// ── Traffic logger (XHR/fetch URL capture per solve) ─────────────────────── +function installTrafficLogger(w) { + const origOpen = w.XMLHttpRequest.prototype.open; + const origSend = w.XMLHttpRequest.prototype.send; + w.XMLHttpRequest.prototype.open = function (method, url, ...rest) { + this.__capMethod = method; + this.__capUrl = url; + return origOpen.call(this, method, url, ...rest); + }; + w.XMLHttpRequest.prototype.send = function (body) { + const url = String(this.__capUrl || ""); + const DEBUG_HOSTS = /(cloudauth-device|captcha-open|verify|upload|nocaptcha|aliyuncs)/i; + if (DEBUG_HOSTS.test(url)) { + this.addEventListener("load", () => { + let respPreview = ""; + try { + respPreview = String(this.responseText || "").slice(0, 3000); + } catch (_) {} + try { + process.stderr.write( + `\n===== XHR ${String(this.__capMethod || "?")} ${url}\n--- RESP (${respPreview.length}b) ---\n${respPreview}\n=====\n`, + ); + } catch (_) {} + }); + } + return origSend.call(this, body); + }; +} + +function safeJson(x) { + try { + if (x instanceof Error) return `Error: ${x.message}\n${(x.stack || "").slice(0, 1500)}`; + const s = JSON.stringify(x); + return s !== undefined && s.length < 3000 ? s : String(x); + } catch (_) { + return String(x); + } +} + +// ── Behavioral priming (FeiLin human-motion buffer) ──────────────────────── +function simulateBehavior(w, durationMs = 600) { + const { document, MouseEvent, KeyboardEvent, UIEvent } = w; + if (!document || !MouseEvent) return; + const fire = (type, ctor, opts) => { + try { + const Ctor = ctor || UIEvent; + const ev = new Ctor(type, { bubbles: true, cancelable: true, view: w, ...opts }); + document.dispatchEvent(ev); + if (document.body) document.body.dispatchEvent(ev); + } catch (_) {} + }; + let x = 140 + Math.random() * 30; + let y = 110 + Math.random() * 20; + const targetX = 540 + Math.random() * 40; + const targetY = 380 + Math.random() * 30; + const steps = 22; + let i = 0; + const start = Date.now(); + const moveStep = () => { + if (i > steps) return; + x += (targetX - x) * 0.16 + (Math.random() - 0.5) * 5; + y += (targetY - y) * 0.16 + (Math.random() - 0.5) * 4; + fire("mousemove", MouseEvent, { + screenX: Math.round(x), + screenY: Math.round(y), + clientX: Math.round(x), + clientY: Math.round(y), + button: 0, + buttons: 1, + }); + i += 1; + const done = Date.now() - start >= durationMs; + if (i <= steps && !done) { + setTimeout(moveStep, 26 + Math.floor(Math.random() * 32)); + } else { + fire("mousedown", MouseEvent, { clientX: Math.round(x), clientY: Math.round(y), button: 0, buttons: 1 }); + fire("mouseup", MouseEvent, { clientX: Math.round(x), clientY: Math.round(y), button: 0, buttons: 0 }); + fire("click", MouseEvent, { clientX: Math.round(x), clientY: Math.round(y), button: 0 }); + try { + fire("keyup", KeyboardEvent, { key: "a", code: "KeyA", keyCode: 65, which: 65 }); + } catch (_) {} + } + }; + moveStep(); +} + +function waitFor(cond, timeoutMs = 15_000, intervalMs = 40) { + return new Promise((res, rej) => { + const started = Date.now(); + const timer = setInterval(() => { + let ok = false; + try { + ok = cond(); + } catch (_) {} + if (ok) { + clearInterval(timer); + res(); + } else if (Date.now() - started > timeoutMs) { + clearInterval(timer); + rej(new Error("timeout")); + } + }, intervalMs); + }); +} + +// ── createDom ────────────────────────────────────────────────────────────── +async function createDom(region, prefix) { + let cookies = []; + const now = Date.now(); + if (_cookieCache.ts > 0 && now - _cookieCache.ts < COOKIE_CACHE_TTL_MS) { + cookies = _cookieCache.cookies; + } else { + try { + const res = await fetch("https://zcode.z.ai/", { + headers: { + "User-Agent": fp.userAgent, + "sec-ch-ua": '"Chromium";v="' + fp.uaMajor + '", "Not)A;Brand";v="24"', + "sec-ch-ua-mobile": "?0", + "sec-ch-ua-platform": '"Linux"', + "Accept-Language": "en-US,en;q=0.9", + }, + }); + cookies = typeof res.headers.getSetCookie === "function" ? res.headers.getSetCookie() : []; + _cookieCache = { cookies, ts: Date.now() }; + } catch (_) {} + } + + const interceptor = makeInterceptor(_bypassPeCacheOnce); + _bypassPeCacheOnce = false; + // Registered once per process — adding it inside createDom leaked a new + // EventEmitter listener per solve (MaxListenersExceededWarning + growth). + if (!process.__capUnhandledRejectionHooked) { + process.__capUnhandledRejectionHooked = true; + process.on("unhandledRejection", (reason) => { + if (!_DEBUG) return; + try { + const r = reason && reason.stack ? reason.stack : String(reason); + process.stderr.write(`[host-unhandledRejection] ${typeof reason} ${JSON.stringify(reason).slice(0, 200)} ${r}\n`); + } catch (_) {} + }); + } + // Guest console is silent unless CAPTCHA_DEBUG — piping every SDK log to + // stderr spams journald and slows mints under systemd. + const noop = () => {}; + const guestConsole = _DEBUG + ? { + log: (...a) => process.stderr.write(`[guest-log] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + warn: (...a) => process.stderr.write(`[guest-warn] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + error: (...a) => process.stderr.write(`[guest-err] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + info: (...a) => process.stderr.write(`[guest-info] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + debug: (...a) => process.stderr.write(`[guest-debug] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + trace: (...a) => process.stderr.write(`[guest-trace] ${a.map((x) => (typeof x === "object" ? safeJson(x) : String(x))).join(" ")}\n`), + } + : { log: noop, warn: noop, error: noop, info: noop, debug: noop, trace: noop }; + const w = new Window({ + url: "https://zcode.z.ai/", + console: guestConsole, + settings: { + enableJavaScriptEvaluation: true, + enableImageFileLoading: true, + suppressInsecureJavaScriptEnvironmentWarning: true, + navigator: { userAgent: fp.userAgent }, + viewport: { width: fp.screen.w, height: fp.screen.h, devicePixelRatio: 1 }, + fetch: { + disableSameOriginPolicy: true, + interceptor, + }, + }, + }); + + // Reach into the frame for cookie container + frame ref (host side helpers). + const WindowBrowserContext = require("happy-dom/lib/window/WindowBrowserContext.js").default; + const browserFrame = new WindowBrowserContext(w).getBrowserFrame(); + global.__browserFrame = browserFrame; + global.__cookieContainer = browserFrame.page.context.cookieContainer; + + // Cookie priming + for (const raw of cookies) { + try { + const u = new URL("https://zcode.z.ai/"); + const parts = raw.split(";"); + const pair = parts[0].split("="); + const cookie = { + name: pair[0].trim(), + value: pair.slice(1).join("=").trim(), + url: u.origin, + domain: u.hostname, + path: "/", + }; + for (const p of parts.slice(1)) { + const kv = p.trim().split(/=(.*)/s); + const k = (kv[0] || "").toLowerCase(); + if (k === "domain" && kv[1]) cookie.domain = kv[1]; + if (k === "path" && kv[1]) cookie.path = kv[1]; + if (k === "expires") cookie.expires = new Date(kv[1]).getTime(); + if (k === "max-age") cookie.maxAge = parseInt(kv[1], 10); + if (k === "httponly") cookie.httpOnly = true; + if (k === "secure") cookie.secure = true; + if (k === "samesite") cookie.sameSite = kv[1]; + } + browserFrame.page.context.cookieContainer.addCookies([cookie]); + } catch (_) {} + } + + const visitorId = crypto.randomUUID(); + const deviceMid = crypto.randomUUID(); + const pre = [ + { name: "zcode_visitor_id", value: visitorId, domain: "zcode.z.ai" }, + { name: "zcode_device_mid", value: deviceMid, domain: "zcode.z.ai" }, + { name: "visitor_id", value: visitorId, domain: "zcode.z.ai", httpOnly: true }, + ]; + for (const c of pre) { + try { + browserFrame.page.context.cookieContainer.addCookies([{ ...c, url: "https://zcode.z.ai", path: "/" }]); + } catch (_) {} + } + + // Apply polyfills + masking BEFORE the SDK script runs. + if (w.Error) { + w.Error.prepareStackTrace = Error.prepareStackTrace; + } + applyPolyfills(w); + installNativeToString(w); + installEvalInstrumentation(w); + // Host-side recorder the guest dump helper calls: computes sha1 of the failing + // source (guest realm has no node crypto) and re-checks the pe disk cache. + w.__capDebugDump = (url, src, kind) => { + try { + const s = String(src || ""); + const sha1 = crypto.createHash("sha1").update(s).digest("hex"); + process.stderr.write( + `\n[${kind}] url=${url} len=${s.length} sha1=${sha1}\n` + + ` head300: ${JSON.stringify(s.slice(0, 300))}\n` + + ` tail100: ${JSON.stringify(s.slice(-100))}\n`, + ); + if (/^https?:/.test(String(url))) { + (async () => { + try { + const res = await fetch(url, { headers: { "user-agent": fp.userAgent } }); + const fresh = Buffer.from(await res.arrayBuffer()); + process.stderr.write( + `[${kind}-CACHE-COMPARE] cachedLen=${s.length} freshLen=${fresh.length} freshSha1=${crypto.createHash("sha1").update(fresh).digest("hex")} http=${res.status}\n`, + ); + if (fresh.length > 0 && fresh.length !== s.length) { + process.stderr.write(`[${kind}-MISMATCH] deleting ${diskPathFor(url)} (stale/truncated cache)\n`); + try { + fs.unlinkSync(diskPathFor(url)); + } catch (_) {} + _memCdnCache.delete(url); + } + } catch (fetchErr) { + process.stderr.write(`[${kind}-CACHE-COMPARE-ERR] ${fetchErr.message}\n`); + } + })(); + } + } catch (_) {} + }; + w.eval(GUEST_EVAL_PATCH); + + // Write the page HTML (loads the SDK script) + w.document.write(HTML); + + w.AliyunCaptchaConfig = { region, prefix }; + + return { window: w, browserFrame }; +} + +function destroyDom(win) { + try { + const cap = win.document.getElementById("cap"); + if (cap) cap.replaceChildren(); + win.happyDOM.close(); + } catch (_) {} + try { + global.__cookieContainer = null; + global.__browserFrame = null; + } catch (_) {} +} + +function extractVerifyParam(param) { + let verifyParam = param; + if (param && typeof param === "object") { + verifyParam = param.verifyParam || param.data || param.param; + } + if (!verifyParam || String(verifyParam).length < 20) { + throw new Error("solver returned empty param: " + JSON.stringify(param)); + } + const str = String(verifyParam); + // Strict validation: a REAL Aliyun verify param is ~280 chars of base64 + // JSON containing certifyId + sceneId + isSign + a long securityToken. + // Len-76 junk like {"certifyId":"70bdb",...,"isSign":true} (no securityToken) + // comes from a degraded SDK result path and WILL 3007 upstream — never let + // it out of the solver. + if (str.length < 200) { + throw new Error( + "verify param too short (" + str.length + " chars) — degraded result, refusing: " + str.slice(0, 80), + ); + } + try { + const decoded = JSON.parse(Buffer.from(str, "base64").toString("utf8")); + const secTok = decoded && (decoded.securityToken || decoded.SecurityToken); + if (!secTok || String(secTok).length < 50) { + throw new Error( + "verify param missing securityToken — refusing degraded result: " + str.slice(0, 80), + ); + } + } catch (err) { + if (err instanceof SyntaxError || /securityToken/.test(String(err.message))) { + throw err instanceof SyntaxError + ? new Error("verify param not base64-JSON: " + str.slice(0, 80)) + : err; + } + throw err; + } + return str; +} + +function handleCaptchaResult(result) { + if (result && typeof result === "object" && result.verifyResult === false) { + throw new Error( + "verify rejected: " + + JSON.stringify({ verifyCode: result.verifyCode, certifyId: result.certifyId }), + ); + } + return result; +} + +// ── Window reuse pool ────────────────────────────────────────────────────── +// Reusing one happy-dom window across solves cuts CPU ~48% (measured: 426ms vs +// 815ms per solve) by amortizing the DOM boot + SDK script load. Enabled via +// CAPTCHA_WINDOW_REUSE=1 (or solveTraceless({reuseWindow:true})). The window +// is discarded after `maxSolves` (memory growth), after any stall/failure +// (fresh InitCaptchaV3 rolls a new pe version), or after `maxIdleMs` idle. +const _reusePool = { window: null, browserFrame: null, solves: 0, lastUsedAt: 0 }; +const REUSE_MAX_SOLVES = Number(process.env.CAPTCHA_REUSE_MAX_SOLVES || 25); +const REUSE_MAX_IDLE_MS = Number(process.env.CAPTCHA_REUSE_MAX_IDLE_MS || 120_000); + +function takeReusableWindow() { + const p = _reusePool; + if (!p.window) return null; + if (p.solves >= REUSE_MAX_SOLVES) { discardReusableWindow(); return null; } + if (Date.now() - p.lastUsedAt > REUSE_MAX_IDLE_MS) { discardReusableWindow(); return null; } + return { window: p.window, browserFrame: p.browserFrame, reused: true }; +} +function stageReusableWindow(window, browserFrame) { + _reusePool.window = window; + _reusePool.browserFrame = browserFrame; + _reusePool.solves = 0; + _reusePool.lastUsedAt = Date.now(); +} +function discardReusableWindow() { + const p = _reusePool; + if (p.window) { + try { destroyDom(p.window); } catch (_) {} + } + p.window = null; + p.browserFrame = null; + p.solves = 0; +} +function noteWindowSolved() { + _reusePool.solves += 1; + _reusePool.lastUsedAt = Date.now(); +} + +async function solveTraceless(opts) { + const scene = opts.scene || "11xygtvd"; + const region = opts.region || "sgp"; + const prefix = opts.prefix || "no8xfe"; + const timeoutMs = opts.timeoutMs ?? 30_000; + + const wantReuse = opts.reuseWindow ?? process.env.CAPTCHA_WINDOW_REUSE === "1"; + let dom; + let reused = false; + if (wantReuse) { + dom = takeReusableWindow(); + if (dom) reused = true; + } + if (!dom) { + dom = await createDom(region, prefix); + } + const { window: w, browserFrame } = dom; + const solveStart = Date.now(); + let solveSucceeded = false; + let keepWindow = false; + try { + await waitFor(() => typeof w.initAliyunCaptcha === "function", timeoutMs, 50); + + simulateBehavior(w, 600); + + const param = await new Promise((resolve, reject) => { + const timer = setTimeout(() => { + const peUrl = (() => { try { return w.__lastPeUrl || "?"; } catch (_) { return "?"; } })(); + const reqs = _requestLog + .filter((r) => r.at >= solveStart) + .map((r) => `${(r.at - solveStart)}ms ${r.method} ${String(r.url).replace(/^https?:\/\//, "").slice(0, 60)}`) + .slice(-12); + reject(new Error(`captcha solve timeout pe=${peUrl.split("/").pop() || peUrl} reqs=${JSON.stringify(reqs)}`)); + }, timeoutMs); // Fail-fast stall detector: healthy solves keep firing XHRs until verify + // (~3s). If no XHR for stallMs and none pending, this pe-VM variant + // stalled (seen across rotated pe.0xx versions) — abort early so the + // caller can retry with a fresh InitCaptchaV3 (new pe version). + // Fail-fast stall detector: healthy solves keep firing XHRs until + // verify (~3s, gaps <2s). If no XHR for 6s, this pe-VM variant stalled + // (seen across rotated pe.0xx versions) — abort early so the caller + // can retry with a fresh InitCaptchaV3 (new pe version). + const stallMs = opts.stallMs ?? Number(process.env.CAPTCHA_STALL_MS || 6_000); + const stallTimer = setInterval(() => { + const last = _requestLog[_requestLog.length - 1]; + if (last && Date.now() - last.at > stallMs) { + const peUrl = (() => { try { return w.__lastPeUrl || "?"; } catch (_) { return "?"; } })(); + noteStallAndMaybeEvict(peUrl); + const reqs = _requestLog + .filter((r) => r.at >= solveStart) + .map((r) => `${(r.at - solveStart)}ms ${r.method} ${String(r.url).replace(/^https?:\/\//, "").slice(0, 60)}`) + .slice(-12); + clearTimeout(timer); + clearInterval(stallTimer); + reject(new Error(`captcha solve stall pe=${peUrl.split("/").pop() || peUrl} lastXhr=${(last.at - solveStart)}ms reqs=${JSON.stringify(reqs)}`)); + } + }, 500); + const finish = (fn) => (value) => { + clearTimeout(timer); + clearInterval(stallTimer); + fn(value); + }; + try { + w.initAliyunCaptcha({ + SceneId: scene, + mode: "popup", + region, + prefix, + language: "en", + element: "#cap", + button: "#btn", + captchaLogoImg: "", + showErrorTip: false, + getInstance: (inst) => { + try { + (inst.startTracelessVerification || inst.show).call(inst); + } catch (e) { + finish(reject)(new Error(`start: ${e.message}`)); + } + }, + success: (result) => { + try { + finish(resolve)(handleCaptchaResult(result)); + } catch (err) { + finish(reject)(err); + } + }, + fail: (err) => finish(reject)(new Error(`fail: ${JSON.stringify(err)}`)), + onError: (err) => finish(reject)(new Error(`onError: ${JSON.stringify(err)}`)), + }); + } catch (err) { + clearTimeout(timer); + reject(err); + } + }); + + // Success clears this pe's stall history so future isolated stalls can + // still trigger eviction after two genuine consecutive failures. + try { + const okPe = w.__lastPeUrl; + if (okPe) _stallCounts.delete(okPe); + } catch (_) {} + + // Dump the pe-VM btoa tracer if requested (rotation forensics). + if (process.env.CAPTCHA_DUMP_DBT === "1") { + try { + const dbt = w.__DBT || []; + require("node:fs").writeFileSync( + process.env.CAPTCHA_DBT_FILE || "/tmp/pe-dbt.json", + JSON.stringify({ count: dbt.length, last: dbt.slice(-8), all: dbt }, null, 1), + ); + } catch (_) {} + } + + solveSucceeded = true; + const out = extractVerifyParam(param); + if (wantReuse) { + if (reused) noteWindowSolved(); + else stageReusableWindow(w, browserFrame); + keepWindow = true; + } + return out; + } finally { + // Reuse mode: on success the window stays pooled (keepWindow) for the next + // solve — a ~48% CPU cut. On failure it is destroyed: a stalled window must + // not poison later solves, and the retry rolls a fresh pe anyway. + if (!keepWindow) { + if (_reusePool.window === w) _reusePool.window = null; + destroyDom(w); + } + } +} + +async function main() { + const count = parseInt(process.env.SOLVES || "3", 10); + const results = []; + let consecutiveFails = 0; + for (let i = 1; i <= count; i++) { + const beforeMem = process.memoryUsage().rss; + const t0 = Date.now(); + let label = "?"; + _requestLog.length = 0; + try { + const token = await solveTraceless({}); + const dt = Date.now() - t0; + const rss = process.memoryUsage().rss; + label = token.length >= 100 ? `REAL token len=${token.length}` : `SHORT token len=${token.length}`; + console.log(`solve#${i}: ${label} wall=${dt}ms rss=${(rss / 1024 / 1024).toFixed(1)}MB rssDelta=${((rss - beforeMem) / 1024 / 1024).toFixed(1)}MB`); + console.log(` token[0:60]: ${token.slice(0, 60)}`); + const tokPath = `/tmp/opencode/happy-test/last-token.txt`; + require("fs").writeFileSync(tokPath, token); + results.push({ ok: true, i, wall: dt, token, rss }); + consecutiveFails = 0; + } catch (err) { + const dt = Date.now() - t0; + const rss = process.memoryUsage().rss; + console.log(`solve#${i}: FAIL wall=${dt}ms rss=${(rss / 1024 / 1024).toFixed(1)}MB err=${err.message}`); + console.log(` requests (this solve):`); + for (const r of _requestLog) { + let short = r.url; + try { const u = new URL(r.url); short = u.hostname + u.pathname; } catch (_) {} + console.log(` ${r.sync ? "sync" : " "} ${r.method} ${short}`); + } + results.push({ ok: false, i, wall: dt, err: err.message, rss }); + consecutiveFails++; + if (consecutiveFails >= 3) break; + } + } + + console.log("\n--- request log (interesting hosts) ---"); + const seen = new Set(); + for (const r of _requestLog) { + let short = r.url; + try { + const u = new URL(r.url); + short = u.hostname + u.pathname; + } catch (_) {} + if (!seen.has(short) || r.sync) { + seen.add(short); + console.log(`${r.sync ? "sync " : " "} ${r.method} ${short}`); + } + } + + const tok = results.find((r) => r.ok && r.token.length >= 100); + if (tok) { + console.log(`\n✅ got real token (len=${tok.token.length}) — running GLM validation`); + await validateToken(tok.token); + } else { + console.log("\n❌ no real token produced"); + process.exit(1); + } +} + +async function validateToken(token) { + const tokPath = "/tmp/opencode/happy-test/last-token.txt"; + require("fs").writeFileSync(tokPath, token); + const r = spawnSync("node", ["/tmp/opencode/happy-test/verify-token.mjs", tokPath, "glm-5.3"], { + encoding: "utf8", + timeout: 60_000, + }); + console.log((r.stdout || "") + (r.stderr || "")); + if (r.status === 0) { + process.exit(0); + } + process.exit(r.status ?? 1); +} + +module.exports = { solveTraceless }; \ No newline at end of file diff --git a/captcha_node/solve-playwright.js b/captcha_node/solve-playwright.js new file mode 100644 index 0000000..077ac15 --- /dev/null +++ b/captcha_node/solve-playwright.js @@ -0,0 +1,276 @@ +/** + * Aliyun traceless captcha via real Chromium on https://zcode.z.ai/ + * (Vanszs/xscope0 pattern — do not intercept or replace the page origin.) + * + * Env toggles for isolated benchmarks: + * ZCODE_CAPTCHA_FAST_WAITS=1 — event-driven waits, shorter mouse priming + * ZCODE_CAPTCHA_BLOCK_RESOURCES=1 — abort images/fonts/css/media + * ZCODE_CAPTCHA_LIGHT_CHROME=1 — extra memory-saving launch flags + * ZCODE_CAPTCHA_LOW_CPU=1 — 4 vCPU profile: smaller viewport, block images, lean Chrome + */ +const fs = require("fs"); +const { chromium } = require("playwright"); + +const ZCODE_ORIGIN = "https://zcode.z.ai/"; +const SDK_URL = + "https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js"; +const SOLVE_TIMEOUT_MS = Number(process.env.ZCODE_CAPTCHA_TIMEOUT || 40) * 1000; + +const FAST_WAITS = process.env.ZCODE_CAPTCHA_FAST_WAITS === "1"; +const BLOCK_RESOURCES = process.env.ZCODE_CAPTCHA_BLOCK_RESOURCES === "1"; +const LIGHT_CHROME = process.env.ZCODE_CAPTCHA_LIGHT_CHROME === "1"; +const LOW_CPU = process.env.ZCODE_CAPTCHA_LOW_CPU === "1"; + +const VIEWPORT = + LOW_CPU || BLOCK_RESOURCES + ? { width: 640, height: 480 } + : { width: 1280, height: 720 }; + +function resolveChromePath() { + const env = + process.env.ZCODE_CHROME_PATH?.trim() || + process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH?.trim(); + if (env && fs.existsSync(env)) return env; + try { + const exe = chromium.executablePath(); + if (exe && fs.existsSync(exe)) return exe; + } catch {} + return undefined; +} + +function launchOptions() { + const args = [ + "--no-sandbox", + "--disable-dev-shm-usage", + "--disable-blink-features=AutomationControlled", + "--disable-features=IsolateOrigins,site-per-process", + `--window-size=${VIEWPORT.width},${VIEWPORT.height}`, + ]; + if (LIGHT_CHROME || LOW_CPU) { + args.push( + "--disable-gpu", + "--disable-extensions", + "--disable-background-networking", + "--disable-default-apps", + "--disable-sync", + "--disable-translate", + "--mute-audio", + "--no-first-run", + "--disable-component-update", + `--renderer-process-limit=${LOW_CPU ? 2 : 4}`, + ); + } + if (LOW_CPU) { + args.push( + "--disable-accelerated-2d-canvas", + "--disable-smooth-scrolling", + "--disable-threaded-animation", + "--disable-threaded-scrolling", + ); + } + const opts = { + headless: !/^(1|true|yes)$/i.test(String(process.env.PW_HEADED || "")), + args, + ignoreDefaultArgs: ["--enable-automation"], + }; + if (process.env.HTTP_PROXY) { + opts.proxy = { + server: process.env.HTTP_PROXY, + }; + } + const exe = resolveChromePath(); + if (exe) opts.executablePath = exe; + return opts; +} + +function contextOptions(region) { + return { + userAgent: + "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", + viewport: VIEWPORT, + locale: "en-US", + timezoneId: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", + extraHTTPHeaders: { "Accept-Language": "en-US,en;q=0.9" }, + serviceWorkers: "block", + }; +} + +function initScriptPayload(region, prefix) { + return { + captchaRegion: region, + captchaPrefix: prefix, + }; +} + +const INIT_SCRIPT = ({ captchaRegion, captchaPrefix }) => { + Object.defineProperty(navigator, "webdriver", { get: () => undefined }); + Object.defineProperty(navigator, "plugins", { + get: () => [1, 2, 3, 4, 5], + }); + Object.defineProperty(navigator, "languages", { + get: () => ["en-US", "en"], + }); + window.AliyunCaptchaConfig = { + region: captchaRegion, + prefix: captchaPrefix, + }; +}; + +async function setupPage(page, region, prefix) { + await page.route("**/*", (route) => { + const req = route.request(); + if (req.resourceType() === "document") { + return route.continue({ + headers: { + ...req.headers(), + "cache-control": "no-cache", + pragma: "no-cache", + }, + }); + } + if (BLOCK_RESOURCES) { + const type = req.resourceType(); + if (["image", "font", "media", "stylesheet"].includes(type)) { + return route.abort(); + } + } else if (LOW_CPU) { + const type = req.resourceType(); + if (["image", "font", "media"].includes(type)) { + return route.abort(); + } + } + return route.continue(); + }); + + await page.goto(ZCODE_ORIGIN, { + waitUntil: "domcontentloaded", + timeout: 20_000, + }); + + if (FAST_WAITS) { + await page + .waitForFunction(() => typeof window.initAliyunCaptcha === "function", { + timeout: 10_000, + }) + .catch(() => {}); + await page.mouse.move(100, 100); + await page.mouse.move(300, 200); + await page.waitForTimeout(150); + } else if (LOW_CPU) { + await page.waitForTimeout(1200); + await page.mouse.move(100, 100); + await page.mouse.move(300, 200); + await page.waitForTimeout(200); + } else { + await page.waitForTimeout(2000); + await page.mouse.move(100, 100); + await page.mouse.move(300, 200); + await page.mouse.move(500, 300); + await page.waitForTimeout(500); + } +} + +async function runCaptchaOnPage(page, scene, region, prefix) { + const param = await page.evaluate( + async ({ sceneId, captchaRegion, captchaPrefix, sdkUrl, timeoutMs }) => { + if (!window.initAliyunCaptcha) { + await new Promise((res, rej) => { + const s = document.createElement("script"); + s.src = sdkUrl; + s.onload = res; + s.onerror = () => rej(new Error("SDK script load error")); + document.head.appendChild(s); + }); + } + return new Promise((resolve, reject) => { + const timeout = setTimeout( + () => reject(new Error(`captcha solve timeout ${timeoutMs}ms`)), + timeoutMs, + ); + window.initAliyunCaptcha({ + SceneId: sceneId, + mode: "popup", + region: captchaRegion, + prefix: captchaPrefix, + element: "#cap", + button: "#btn", + getInstance: (inst) => { + if ( + inst && + typeof inst.startTracelessVerification === "function" + ) { + inst.startTracelessVerification(); + } + }, + success: (p) => { + clearTimeout(timeout); + resolve(p); + }, + fail: (e) => { + clearTimeout(timeout); + reject(new Error(`fail: ${JSON.stringify(e).slice(0, 300)}`)); + }, + onError: (e) => { + clearTimeout(timeout); + reject(new Error(`onError: ${JSON.stringify(e).slice(0, 300)}`)); + }, + }); + }); + }, + { + sceneId: scene, + captchaRegion: region, + captchaPrefix: prefix, + sdkUrl: SDK_URL, + timeoutMs: SOLVE_TIMEOUT_MS, + }, + ); + + const value = + param && typeof param === "object" + ? param.verifyParam || param.param || param.data + : param; + const out = String(value ?? param ?? ""); + if (out.length < 20) { + throw new Error( + `empty verify param: ${JSON.stringify(param).slice(0, 200)}`, + ); + } + return out; +} + +/** + * Solve using an existing browser (worker pool). Creates and closes a context per solve. + * @param {import('playwright').Browser} browser + */ +async function solveWithBrowser(browser, { scene, region, prefix }) { + const ctx = await browser.newContext(contextOptions(region)); + try { + await ctx.addInitScript(INIT_SCRIPT, initScriptPayload(region, prefix)); + const page = await ctx.newPage(); + await setupPage(page, region, prefix); + return await runCaptchaOnPage(page, scene, region, prefix); + } finally { + await ctx.close().catch(() => {}); + } +} + +/** + * One-shot solve: launch browser → verify → close. + * @param {{ scene: string, region: string, prefix: string }} opts + * @returns {Promise} + */ +async function solveTraceless(opts) { + const browser = await chromium.launch(launchOptions()); + try { + return await solveWithBrowser(browser, opts); + } finally { + await browser.close().catch(() => {}); + } +} + +module.exports = { + solveTraceless, + solveWithBrowser, + launchOptions, +}; diff --git a/captcha_node/solver.js b/captcha_node/solver.js new file mode 100644 index 0000000..4165e21 --- /dev/null +++ b/captcha_node/solver.js @@ -0,0 +1,22 @@ +process.env.FONTCONFIG_PATH = "/dev/null"; +const backend = process.env.ZCODE_CAPTCHA_BACKEND || "jsdom"; +const solve = + backend === "jsdom" + ? require("./solve-core").solveTraceless + : backend === "happy" + ? require("./solve-happy-lib").solveTraceless + : require("./solve-playwright").solveTraceless; + +const SCENE = process.argv[2] || "11xygtvd"; +const REGION = process.argv[3] || "sgp"; +const PREFIX = process.argv[4] || "no8xfe"; + +solve({ scene: SCENE, region: REGION, prefix: PREFIX }) + .then((param) => { + console.log("VERIFY_PARAM=" + param); + process.exit(0); + }) + .catch((err) => { + process.stderr.write(String(err?.message || err) + "\n"); + process.exit(1); + }); diff --git a/captcha_node/worker.js b/captcha_node/worker.js new file mode 100644 index 0000000..4c705ef --- /dev/null +++ b/captcha_node/worker.js @@ -0,0 +1,233 @@ +/** + * Long-lived browser worker — one Chromium per process, context per solve. + * Reads JSON lines on stdin: { id, scene?, region?, prefix? } + * Writes JSON lines on stdout: { id, ok, param? | error? } + */ +const readline = require("node:readline"); + +process.env.FONTCONFIG_PATH = "/dev/null"; +const backend = process.env.ZCODE_CAPTCHA_BACKEND || "jsdom"; +const isJsdom = backend === "jsdom"; +const isPlaywright = backend === "playwright"; + +let playwright = null; +let solvePlaywright = null; +let solveImpl = null; + +if (isJsdom) { + solveImpl = require("./solve-core").solveTraceless; +} else if (isPlaywright) { + playwright = require("playwright"); + solvePlaywright = require("./solve-playwright"); +} else { + // happy (default alternative): happy-dom solver — no browser needed. + solveImpl = require("./solve-happy-lib").solveTraceless; +} + +const SOLVE_TIMEOUT_MS = Number(process.env.ZCODE_CAPTCHA_TIMEOUT || 40) * 1000; +const MAX_SOLVE_ATTEMPTS = Math.max(1, Number(process.env.ZCODE_CAPTCHA_RETRIES || 4)); + +let browser = null; +let busy = false; +const pending = []; +/** certifyIds produced by this worker — reject duplicates before returning. */ +const recentCertifyIds = new Map(); + +let solvesCount = 0; +// Recycle workers to bound per-process memory growth (DOM/DOM-tree leaks). +// Measured leak ≈12MB/solve on happy-dom — recycle at 25 solves / 320MB. +const MAX_SOLVES_PER_WORKER = Number( + process.env.ZCODE_CAPTCHA_MAX_SOLVES || (isJsdom ? 30 : isPlaywright ? 100 : 25), +); +// Hard RSS ceiling: recycle a worker the moment it crosses this, regardless of +// solve count (default 350MB; 0 disables). +const WORKER_MAX_RSS_MB = Number(process.env.ZCODE_CAPTCHA_WORKER_MAX_RSS_MB ?? 350); +// Playwright mode: close the idle Chromium after this long to free RAM; +// relaunched on demand by ensureBrowser(). +const BROWSER_IDLE_CLOSE_MS = Number(process.env.ZCODE_CAPTCHA_BROWSER_IDLE_MS || 120) * 1000; +let browserIdleTimer = null; + +function workerRssMb() { + try { + const status = require("node:fs").readFileSync("/proc/self/status", "utf8"); + const m = status.match(/VmRSS:\s*(\d+)/); + return m ? Number(m[1]) / 1024 : 0; + } catch (_) { + return process.memoryUsage().rss / 1024 / 1024; + } +} + +function scheduleBrowserIdleClose() { + if (!isPlaywright) return; + if (browserIdleTimer) clearTimeout(browserIdleTimer); + browserIdleTimer = setTimeout(() => { + browserIdleTimer = null; + if (busy) { + scheduleBrowserIdleClose(); + return; + } + void resetBrowser(); + }, BROWSER_IDLE_CLOSE_MS); + browserIdleTimer.unref?.(); +} + +function parseCertifyId(param) { + try { + const json = JSON.parse(Buffer.from(param, "base64").toString("utf8")); + return typeof json.certifyId === "string" && json.certifyId ? json.certifyId : null; + } catch { + return null; + } +} + +function isDuplicateError(msg) { + return /F008|"verifyCode"\s*:\s*"F008"/i.test(msg); +} + +function rememberCertifyId(id) { + recentCertifyIds.set(id, Date.now()); + const cutoff = Date.now() - 120_000; + for (const [k, at] of recentCertifyIds) { + if (at < cutoff) recentCertifyIds.delete(k); + } +} + +async function resetBrowser() { + if (browserIdleTimer) { + clearTimeout(browserIdleTimer); + browserIdleTimer = null; + } + if (!isPlaywright) return; + try { + await browser?.close().catch(() => {}); + } catch (_) {} + browser = null; +} + +async function ensureBrowser() { + if (isJsdom) return null; + if (browser?.isConnected()) return browser; + browser = await playwright.chromium.launch(solvePlaywright.launchOptions()); + return browser; +} + +async function processOne(req) { + const scene = req.scene || "11xygtvd"; + const region = req.region || "sgp"; + const prefix = req.prefix || "no8xfe"; + + let lastErr = "unknown"; + for (let attempt = 1; attempt <= MAX_SOLVE_ATTEMPTS; attempt += 1) { + const timer = setTimeout(() => {}, SOLVE_TIMEOUT_MS); + try { + let param; + if (isPlaywright) { + const b = await ensureBrowser(); + param = await solvePlaywright.solveWithBrowser(b, { scene, region, prefix }); + } else { + param = await solveImpl({ scene, region, prefix }); + } + clearTimeout(timer); + + const certifyId = parseCertifyId(param); + if (certifyId && recentCertifyIds.has(certifyId)) { + lastErr = `duplicate certifyId ${certifyId}`; + await resetBrowser(); + continue; + } + if (certifyId) rememberCertifyId(certifyId); + + process.stdout.write( + JSON.stringify({ id: req.id, ok: true, param }) + "\n", + ); + + // Recycle the worker after max solves OR when RSS crosses the + // ceiling — either bound keeps memory strictly bounded per worker. + solvesCount++; + if (solvesCount >= MAX_SOLVES_PER_WORKER || (WORKER_MAX_RSS_MB > 0 && workerRssMb() > WORKER_MAX_RSS_MB)) { + setTimeout(() => process.exit(0), 10); + } + return; + } catch (err) { + clearTimeout(timer); + lastErr = err?.message || String(err); + if ( + isDuplicateError(lastErr) || + (isPlaywright && /Target closed|Browser closed|crashed/i.test(lastErr)) + ) { + await resetBrowser(); + continue; + } + break; + } + } + + process.stdout.write( + JSON.stringify({ id: req.id, ok: false, error: lastErr.slice(0, 300) }) + "\n", + ); +} + +async function drain() { + if (busy || pending.length === 0) return; + busy = true; + const req = pending.shift(); + try { + await processOne(req); + } finally { + busy = false; + scheduleBrowserIdleClose(); + drain(); + } +} + +function enqueue(req) { + pending.push(req); + drain(); +} + +async function main() { + if (isPlaywright) { + // Warm launch only for playwright mode; jsdom/happy workers are ready + // immediately (no browser). Keep this — the first solve needs the + // browser, and warm floor workers exist for exactly that. + await ensureBrowser(); + } + process.stderr.write("worker ready\n"); + process.stdout.write(JSON.stringify({ workerReady: true }) + "\n"); + + const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); + rl.on("line", (line) => { + const trimmed = line.trim(); + if (!trimmed) return; + let req; + try { + req = JSON.parse(trimmed); + } catch { + process.stdout.write( + JSON.stringify({ id: null, ok: false, error: "invalid json" }) + "\n", + ); + return; + } + if (typeof req.id !== "number") { + process.stdout.write( + JSON.stringify({ + id: req.id ?? null, + ok: false, + error: "missing id", + }) + "\n", + ); + return; + } + enqueue(req); + }); + + process.on("SIGTERM", async () => { + await resetBrowser(); + process.exit(0); + }); +} + +main().catch((err) => { + process.stderr.write(String(err?.message || err) + "\n"); + process.exit(1); +}); diff --git a/src/proxy/captcha-solver.ts b/src/proxy/captcha-solver.ts new file mode 100644 index 0000000..8b54147 --- /dev/null +++ b/src/proxy/captcha-solver.ts @@ -0,0 +1,298 @@ +import { spawn, type ChildProcess } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); +const CAPTCHA_NODE_DIR = path.join(ROOT, "captcha_node"); +const DAEMON_JS = path.join(CAPTCHA_NODE_DIR, "daemon.js"); +const SOLVER_JS = path.join(CAPTCHA_NODE_DIR, "solver.js"); + +const NODE_BIN = process.env.ZCODE_NODE_PATH?.trim() || "node"; +const SOLVE_TIMEOUT_MS = Number(process.env.ZCODE_CAPTCHA_TIMEOUT || 40) * 1000; +const USE_DAEMON = process.env.ZCODE_CAPTCHA_DAEMON !== "0"; +const DEFAULT_CONCURRENCY = Number( + process.env.CAPTCHA_DAEMON_CONCURRENCY ?? + (process.env.ZCODE_CAPTCHA_LOW_CPU === "1" ? 3 : 6), +); + +type PendingSolve = { + resolve: (param: string) => void; + reject: (err: Error) => void; + timer: ReturnType; +}; + +/** Single Node daemon — one-shot Playwright solve per child, bounded concurrency queue. */ +class SolverDaemon { + private proc: ChildProcess | null = null; + private ready: Promise | null = null; + private buffer = ""; + private nextId = 1; + private pending = new Map(); + private disabled = false; + private daemonFailures = 0; + private concurrency = DEFAULT_CONCURRENCY; + private shuttingDown = false; + private restarting: Promise | null = null; + + async solve(scene: string, region: string, prefix: string): Promise { + if (!USE_DAEMON || this.disabled) { + return runOneShotSolver(scene, region, prefix); + } + try { + await this.ensureReady(); + return await this.requestSolve(scene, region, prefix); + } catch (err) { + const msg = err instanceof Error ? err.message : String(err); + if (!this.shuttingDown && (msg.includes("daemon") || msg.includes("spawn"))) { + // Transient spawn failures shouldn't permanently degrade to + // browser-launch-per-solve (much higher CPU). Retry the daemon a few + // times; only fall back permanently after repeated failures. + this.daemonFailures += 1; + if (this.daemonFailures >= 3) { + this.disabled = true; + this.shutdown(); + } else { + await this.restart(); + } + return runOneShotSolver(scene, region, prefix); + } + throw err; + } + } + + setConcurrency(n: number): void { + const next = Math.max(1, Math.floor(n)); + if (next === this.concurrency) return; + this.concurrency = next; + process.env.CAPTCHA_DAEMON_CONCURRENCY = String(next); + this.shutdown(); + } + + shutdown(): void { + this.shuttingDown = true; + for (const [, p] of this.pending) { + clearTimeout(p.timer); + p.reject(new Error("solver daemon shutting down")); + } + this.pending.clear(); + if (this.proc) { + try { + this.proc.kill("SIGTERM"); + } catch {} + this.proc = null; + } + this.ready = null; + this.buffer = ""; + this.shuttingDown = false; + } + + private async ensureReady(): Promise { + if (this.ready) return this.ready; + this.ready = this.spawnDaemon(); + return this.ready; + } + + /** Kill the daemon (draining nothing) and let ensureReady respawn it on + * next use. In-flight requests are already rejected by shutdown(). */ + private async restart(): Promise { + if (this.restarting) return this.restarting; + this.restarting = (async () => { + this.shutdown(); + try { + await this.ensureReady(); + } catch { + // next solve() call retries via ensureReady + } + })(); + try { + await this.restarting; + } finally { + this.restarting = null; + } + } + + private spawnDaemon(): Promise { + if (!fs.existsSync(DAEMON_JS)) { + return Promise.reject(new Error(`Missing ${DAEMON_JS}`)); + } + + return new Promise((resolve, reject) => { + const proc = spawn(NODE_BIN, [DAEMON_JS], { + cwd: CAPTCHA_NODE_DIR, + env: { + ...process.env, + CAPTCHA_DAEMON_CONCURRENCY: String(this.concurrency), + FONTCONFIG_PATH: "/dev/null", + }, + stdio: ["pipe", "pipe", "pipe"], + windowsHide: true, + }); + this.proc = proc; + + let settled = false; + const fail = (err: Error) => { + if (settled) return; + settled = true; + this.ready = null; + this.proc = null; + reject(err); + }; + + proc.on("error", (err) => fail(new Error(`cannot spawn daemon: ${err.message}`))); + proc.on("close", (code) => { + for (const [, p] of this.pending) { + clearTimeout(p.timer); + p.reject(new Error(`daemon exited (${code ?? "?"})`)); + } + this.pending.clear(); + this.proc = null; + this.ready = null; + }); + + proc.stdout?.on("data", (chunk: Buffer) => { + this.buffer += chunk.toString("utf8"); + if (!settled) { + const ready = this.buffer.includes('"ready"'); + this.drainBuffer(); + if (ready) { + settled = true; + resolve(); + } + } else { + this.drainBuffer(); + } + }); + + proc.stderr?.on("data", (chunk: Buffer) => { + const text = chunk.toString("utf8").trim(); + if (text) process.stderr.write(`[captcha-daemon] ${text}\n`); + }); + + setTimeout(() => { + if (!settled) fail(new Error("daemon ready timeout")); + }, 30_000); + }); + } + + private drainBuffer(): void { + let idx: number; + while ((idx = this.buffer.indexOf("\n")) >= 0) { + const line = this.buffer.slice(0, idx).trim(); + this.buffer = this.buffer.slice(idx + 1); + if (!line) continue; + try { + const msg = JSON.parse(line) as { + id?: number; + ok?: boolean; + param?: string; + error?: string; + ready?: boolean; + }; + if (msg.ready) continue; + if (typeof msg.id !== "number") continue; + const pending = this.pending.get(msg.id); + if (!pending) continue; + this.pending.delete(msg.id); + clearTimeout(pending.timer); + if (msg.ok && msg.param && msg.param.length > 20) { + pending.resolve(msg.param); + } else { + pending.reject(new Error(msg.error || "daemon solve failed")); + } + } catch { + // ignore malformed lines + } + } + } + + private requestSolve(scene: string, region: string, prefix: string): Promise { + const proc = this.proc; + if (!proc?.stdin?.writable) { + return Promise.reject(new Error("daemon stdin unavailable")); + } + + const id = this.nextId++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.pending.delete(id); + reject(new Error(`daemon solve timeout (${SOLVE_TIMEOUT_MS}ms)`)); + }, SOLVE_TIMEOUT_MS); + + this.pending.set(id, { resolve, reject, timer }); + proc.stdin!.write(JSON.stringify({ id, scene, region, prefix }) + "\n"); + }); + } +} + +const daemon = new SolverDaemon(); + +export function setCaptchaSolverConcurrency(n: number): void { + daemon.setConcurrency(n); +} + +export function captchaSolverConcurrency(): number { + return Number(process.env.CAPTCHA_DAEMON_CONCURRENCY || DEFAULT_CONCURRENCY); +} + +export function shutdownCaptchaSolver(): void { + daemon.shutdown(); +} + +export async function runCaptchaSolve(scene: string, region: string, prefix: string): Promise { + return daemon.solve(scene, region, prefix); +} + +function runOneShotSolver(scene: string, region: string, prefix: string): Promise { + if (!fs.existsSync(SOLVER_JS)) { + return Promise.reject(new Error(`Missing ${SOLVER_JS}`)); + } + + return new Promise((resolve, reject) => { + const proc = spawn(NODE_BIN, [SOLVER_JS, scene, region, prefix], { + cwd: CAPTCHA_NODE_DIR, + env: { ...process.env, FONTCONFIG_PATH: "/dev/null" }, + stdio: ["ignore", "pipe", "pipe"], + windowsHide: true, + }); + + let stdout = ""; + let stderr = ""; + proc.stdout?.on("data", (chunk: Buffer) => { + stdout += chunk.toString("utf8"); + }); + proc.stderr?.on("data", (chunk: Buffer) => { + stderr += chunk.toString("utf8"); + }); + + const timer = setTimeout(() => { + proc.kill("SIGKILL"); + reject(new Error(`captcha solve timeout (${SOLVE_TIMEOUT_MS}ms)`)); + }, SOLVE_TIMEOUT_MS); + + proc.on("error", (err) => { + clearTimeout(timer); + reject(new Error(`cannot spawn ${NODE_BIN}: ${err.message}`)); + }); + + proc.on("close", (code) => { + clearTimeout(timer); + for (const line of stdout.split(/\r?\n/)) { + if (line.startsWith("VERIFY_PARAM=")) { + const param = line.slice("VERIFY_PARAM=".length).trim(); + if (param.length > 20) { + resolve(param); + return; + } + } + } + reject( + new Error( + `captcha solve exit ${code ?? "?"}: ${stderr.slice(0, 200) || stdout.slice(0, 200) || "no output"}`, + ), + ); + }); + }); +} + +export { CAPTCHA_NODE_DIR }; diff --git a/src/proxy/captcha.ts b/src/proxy/captcha.ts index beff4ac..73304b0 100644 --- a/src/proxy/captcha.ts +++ b/src/proxy/captcha.ts @@ -54,6 +54,8 @@ export function detectCaptchaChallenge(resp: Response): string | null { export function invalidateCaptchaToken(): void { cachedToken = null; } + + async function fetchCaptchaConfig(appVersion: string): Promise { if (cachedConfig.value && cachedConfig.expiresAt > Date.now()) return cachedConfig.value; try { @@ -65,15 +67,57 @@ async function fetchCaptchaConfig(appVersion: string): Promise { if (cachedToken && cachedToken.expiresAt > Date.now()) return { verifyParam: cachedToken.verifyParam, region: cachedToken.region }; const cfg = await fetchCaptchaConfig(appVersion); if (!cfg || !cfg.enabled || !cfg.prefix || !cfg.sceneId) throw new Error("Captcha config unavailable"); - const verifyParam = await solveInJsdomWithRetry(cfg); + const verifyParam = await solveCaptchaWithRetry(cfg); cachedToken = { verifyParam, region: cfg.region, expiresAt: Date.now() + TOKEN_TTL_MS }; return { verifyParam, region: cfg.region }; } +async function solveCaptchaWithRetry(cfg: FetchedCaptchaConfig): Promise { + if (DAEMON_BACKENDS.has(CAPTCHA_BACKEND)) { + // The worker process retries internally (ZCODE_CAPTCHA_RETRIES attempts); + // this outer loop is a thin respawn guard for daemon-level failures. + let lastErr: Error | null = null; + for (let attempt = 1; attempt <= SOLVE_RETRIES; attempt++) { + try { + return await runCaptchaSolve(cfg.sceneId, cfg.region, cfg.prefix); + } catch (err) { + lastErr = err as Error; + console.error(`[captcha:${CAPTCHA_BACKEND}] solve attempt ${attempt}/${SOLVE_RETRIES} failed: ${lastErr.message}`); + } + } + throw new Error(`captcha solve failed after ${SOLVE_RETRIES} attempts: ${lastErr?.message ?? "unknown"}`); + } + return solveInJsdomWithRetry(cfg); +} + +export function shutdownCaptcha(): void { + try { shutdownCaptchaSolver(); } catch {} +} + async function solveInJsdomWithRetry(cfg: FetchedCaptchaConfig): Promise { let lastErr: Error | null = null; for (let attempt = 1; attempt <= SOLVE_RETRIES; attempt++) { From 2ed03b8b24178ab02db1df339ba6a9d4e6093039 Mon Sep 17 00:00:00 2001 From: Wraient Date: Sun, 23 Aug 2026 01:32:00 +0530 Subject: [PATCH 2/3] feat(captcha): request-scaling pre-solved token pool Adds a CaptchaTokenPool that pre-solves tokens in the background and serves them in ~8-12ms on the request hot path, instead of paying a full ~1.5-10s solve per request. For daemon backends (ZCODE_CAPTCHA_BACKEND=happy|playwright), getCaptchaToken() now takes from the pool; the jsdom default keeps the existing single-token cache. Pool behavior (src/proxy/captcha-jsdom.ts): - demand-driven sizing: target grows with take rate (up to poolSizeMax), decays after idle, deep-idles to zero traffic after 15 min - empty-pool takes race parallel solves (cap via CAPTCHA_SOLVE_RACE_DEADLINE_MS, default 25s) - per-token TTL with expiry pruning; duplicate certifyId rejection (Aliyun F008 guard) across pool and issued tokens - CPU governor bounds solve concurrency to the host CPU budget (CAPTCHA_CPU_LIMIT_PCT, default 100) - IP-block detection hook (onCaptchaIpBlock) for the Aliyun 'too many captcha requests' family Proxy startup (start-plan) warms the pool in the background (CAPTCHA_POOL_MIN, default 20). env-only configuration; no config.yaml schema changes in this PR. Verified live: prefill 2 -> two takes in 8ms/12ms with distinct certifyIds and automatic background refill; 500 bun tests pass (23 new); upstream compile build OK. --- src/index.ts | 8 + src/proxy/captcha-cpu-governor.test.ts | 79 +++ src/proxy/captcha-cpu-governor.ts | 206 +++++++ src/proxy/captcha-jsdom.ts | 808 +++++++++++++++++++++++++ src/proxy/captcha-pool.test.ts | 327 ++++++++++ src/proxy/captcha-token.test.ts | 37 ++ src/proxy/captcha-token.ts | 32 + src/proxy/captcha.ts | 51 +- 8 files changed, 1546 insertions(+), 2 deletions(-) create mode 100644 src/proxy/captcha-cpu-governor.test.ts create mode 100644 src/proxy/captcha-cpu-governor.ts create mode 100644 src/proxy/captcha-jsdom.ts create mode 100644 src/proxy/captcha-pool.test.ts create mode 100644 src/proxy/captcha-token.test.ts create mode 100644 src/proxy/captcha-token.ts diff --git a/src/index.ts b/src/index.ts index 6a86f12..cb631b5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -138,6 +138,14 @@ async function serve(configPath: string | undefined, debug: boolean): Promise m.startCaptchaPool(config.identity.appVersion)) + .catch((err) => console.error(`[captcha] pool warmup failed: ${(err as Error).message}`)); + } console.log(` provider: ${config.provider}`); console.log(` plan: ${config.plan}`); console.log(` auth mode: ${config.auth.mode}`); diff --git a/src/proxy/captcha-cpu-governor.test.ts b/src/proxy/captcha-cpu-governor.test.ts new file mode 100644 index 0000000..409ba41 --- /dev/null +++ b/src/proxy/captcha-cpu-governor.test.ts @@ -0,0 +1,79 @@ +import { describe, expect, it } from "bun:test"; +import { CaptchaCpuGovernor } from "./captcha-cpu-governor.js"; + +function makeGovernor(overrides: Partial[0]> = {}) { + return new CaptchaCpuGovernor({ + cpuLimitPercent: 100, + hysteresisPercent: 5, + poolSizeMin: 40, + poolSizeMax: 120, + maxSolveConcurrency: 4, + intervalMs: 3_000, + ...overrides, + }); +} + +describe("CaptchaCpuGovernor", () => { + it("keeps background concurrency at 0 when warm and CPU is at limit", () => { + const gov = makeGovernor(); + (gov as unknown as { lastCpuPercent: number }).lastCpuPercent = 100; + (gov as unknown as { concurrency: number }).concurrency = 2; + + expect(gov.backgroundConcurrency(40)).toBe(0); + expect(gov.backgroundConcurrency(50)).toBe(0); + }); + + it("allows at least 1 background solve when below pool minimum", () => { + const gov = makeGovernor(); + (gov as unknown as { lastCpuPercent: number }).lastCpuPercent = 95; + (gov as unknown as { concurrency: number }).concurrency = 0; + + expect(gov.backgroundConcurrency(39)).toBe(1); + expect(gov.backgroundConcurrency(10)).toBe(1); + }); + + it("ramps concurrency and target when CPU is low", async () => { + const gov = makeGovernor(); + const sample = gov as unknown as { + sampleCpuPercent: () => Promise; + tick: () => Promise; + }; + + sample.sampleCpuPercent = async () => 30; + await sample.tick(); + + const snap = gov.snapshot(); + expect(snap.concurrency).toBeGreaterThan(1); + expect(snap.maxEffectiveTarget).toBeGreaterThan(40); + expect(snap.throttled).toBe(false); + }); + + it("throttles toward pool minimum when CPU is high", async () => { + const gov = makeGovernor(); + const sample = gov as unknown as { + sampleCpuPercent: () => Promise; + tick: () => Promise; + }; + + sample.sampleCpuPercent = async () => 98; + await sample.tick(); + + const snap = gov.snapshot(); + expect(snap.maxEffectiveTarget).toBe(40); + expect(snap.throttled).toBe(true); + }); + + it("soft-throttles to 1 worker near the limit", () => { + const gov = makeGovernor(); + (gov as unknown as { lastCpuPercent: number }).lastCpuPercent = 97; + (gov as unknown as { concurrency: number }).concurrency = 3; + + expect(gov.backgroundConcurrency(50)).toBe(1); + }); + + it("is disabled when cpuLimitPercent is 0", () => { + const gov = makeGovernor({ cpuLimitPercent: 0 }); + expect(gov.enabled).toBe(false); + expect(gov.backgroundConcurrency(100)).toBe(4); + }); +}); diff --git a/src/proxy/captcha-cpu-governor.ts b/src/proxy/captcha-cpu-governor.ts new file mode 100644 index 0000000..4961800 --- /dev/null +++ b/src/proxy/captcha-cpu-governor.ts @@ -0,0 +1,206 @@ +import fs from "node:fs/promises"; +import os from "node:os"; + +export type CpuGovernorConfig = { + /** Host CPU ceiling — 100 = all vCPUs saturated (0 disables governor). */ + cpuLimitPercent: number; + /** Hysteresis band below limit before ramping up again. */ + hysteresisPercent: number; + poolSizeMin: number; + poolSizeMax: number; + maxSolveConcurrency: number; + intervalMs: number; +}; + +export type CpuGovernorSnapshot = { + cpuPercent: number; + limitPercent: number; + concurrency: number; + maxEffectiveTarget: number; + throttled: boolean; +}; + +/** 100 = entire host (all vCPUs) may be used; governor prevents exceeding that. */ +const DEFAULT_CPU_LIMIT = Number(process.env.CAPTCHA_CPU_LIMIT_PCT || 100); +const DEFAULT_INTERVAL_MS = Number(process.env.CAPTCHA_CPU_GOVERNOR_INTERVAL_MS || 2_000); +const TARGET_STEP = 15; + +type CpuCounters = { idle: number; total: number }; + +async function readCpuCounters(): Promise { + try { + const stat = await fs.readFile("/proc/stat", "utf8"); + const line = stat.split("\n")[0] ?? ""; + const parts = line.split(/\s+/).slice(1).map((v) => Number(v) || 0); + const idle = (parts[3] ?? 0) + (parts[4] ?? 0); + const total = parts.reduce((sum, n) => sum + n, 0); + return { idle, total }; + } catch { + const load = os.loadavg()[0] ?? 0; + const cpus = Math.max(1, os.cpus().length); + const pct = (load / cpus) * 100; + return { idle: 100 - pct, total: 100 }; + } +} + +/** Samples host-wide CPU via /proc/stat (100% = all cores busy). */ +export class CaptchaCpuGovernor { + private prevCpu: CpuCounters | null = null; + private lastCpuPercent = 0; + private concurrency: number; + private maxEffectiveTarget: number; + private timer: ReturnType | null = null; + + constructor(private cfg: CpuGovernorConfig) { + this.concurrency = 1; + this.maxEffectiveTarget = cfg.poolSizeMin; + } + + get enabled(): boolean { + return this.cfg.cpuLimitPercent > 0; + } + + configure(partial: Partial): void { + this.cfg = { ...this.cfg, ...partial }; + this.maxEffectiveTarget = Math.max( + this.cfg.poolSizeMin, + Math.min(this.maxEffectiveTarget, this.cfg.poolSizeMax), + ); + this.concurrency = Math.min(this.concurrency, this.cfg.maxSolveConcurrency); + } + + start(): void { + if (!this.enabled || this.timer) return; + void this.tick(); + this.timer = setInterval(() => void this.tick(), this.cfg.intervalMs); + } + + stop(): void { + if (this.timer) { + clearInterval(this.timer); + this.timer = null; + } + } + + snapshot(): CpuGovernorSnapshot { + const limit = this.cfg.cpuLimitPercent; + return { + cpuPercent: Math.round(this.lastCpuPercent * 10) / 10, + limitPercent: limit, + concurrency: this.concurrency, + maxEffectiveTarget: this.maxEffectiveTarget, + throttled: this.lastCpuPercent >= limit - 3, + }; + } + + /** Parallel background solves allowed right now (may be 0 when pool is warm). */ + backgroundConcurrency(readyTokens: number): number { + if (!this.enabled) return this.cfg.maxSolveConcurrency; + + const cpu = this.lastCpuPercent; + const limit = this.cfg.cpuLimitPercent; + let allowed = this.concurrency; + + if (cpu >= limit) { + allowed = 0; + } else if (cpu >= limit - 4) { + allowed = Math.min(allowed, 1); + } else if (cpu >= limit - 10) { + allowed = Math.min(allowed, 2); + } + + if (readyTokens < this.cfg.poolSizeMin) { + return Math.max(1, allowed); + } + + return allowed; + } + + maxPoolTarget(): number { + return this.maxEffectiveTarget; + } + + private async tick(): Promise { + const cpu = await this.sampleCpuPercent(); + this.lastCpuPercent = cpu; + + const limit = this.cfg.cpuLimitPercent; + const rampThreshold = limit - this.cfg.hysteresisPercent; + + if (cpu >= limit) { + this.concurrency = 1; + this.maxEffectiveTarget = this.cfg.poolSizeMin; + } else if (cpu >= limit - 4) { + if (this.concurrency > 1) this.concurrency -= 1; + this.maxEffectiveTarget = this.cfg.poolSizeMin; + } else if (cpu >= limit - 10) { + if (this.concurrency > 2) this.concurrency -= 1; + this.maxEffectiveTarget = Math.max( + this.cfg.poolSizeMin, + this.maxEffectiveTarget - TARGET_STEP, + ); + } else if (cpu <= rampThreshold) { + if (this.concurrency < this.cfg.maxSolveConcurrency) { + this.concurrency += 1; + } + if (this.maxEffectiveTarget < this.cfg.poolSizeMax) { + this.maxEffectiveTarget = Math.min( + this.cfg.poolSizeMax, + this.maxEffectiveTarget + TARGET_STEP, + ); + } + } + } + + private async sampleCpuPercent(): Promise { + const cur = await readCpuCounters(); + if (!this.prevCpu) { + this.prevCpu = cur; + return this.lastCpuPercent; + } + const idleDelta = cur.idle - this.prevCpu.idle; + const totalDelta = cur.total - this.prevCpu.total; + this.prevCpu = cur; + if (totalDelta <= 0) return this.lastCpuPercent; + const used = ((totalDelta - idleDelta) / totalDelta) * 100; + return Math.max(0, Math.min(100, used)); + } +} + +export function resolveCpuGovernorConfig(opts: { + poolSizeMin?: number; + poolSizeMax?: number; + solveConcurrency?: number; + cpuLimitPercent?: number; + cpuGovernorIntervalSec?: number; +}): CpuGovernorConfig { + const poolSizeMin = opts.poolSizeMin ?? Number(process.env.CAPTCHA_POOL_MIN || 40); + const poolSizeMax = Math.max( + poolSizeMin, + opts.poolSizeMax ?? Number(process.env.CAPTCHA_POOL_MAX || 120), + ); + const maxSolveConcurrency = + opts.solveConcurrency ?? + Number( + process.env.CAPTCHA_SOLVE_CONCURRENCY || + (process.env.ZCODE_CAPTCHA_LOW_CPU === "1" ? 3 : 6), + ); + + const cpuLimitPercent = + opts.cpuLimitPercent ?? + (process.env.CAPTCHA_CPU_GOVERNOR === "0" ? 0 : DEFAULT_CPU_LIMIT); + + const intervalMs = + opts.cpuGovernorIntervalSec != null + ? opts.cpuGovernorIntervalSec * 1000 + : DEFAULT_INTERVAL_MS; + + return { + cpuLimitPercent, + hysteresisPercent: 5, + poolSizeMin, + poolSizeMax, + maxSolveConcurrency, + intervalMs, + }; +} diff --git a/src/proxy/captcha-jsdom.ts b/src/proxy/captcha-jsdom.ts new file mode 100644 index 0000000..0bae42f --- /dev/null +++ b/src/proxy/captcha-jsdom.ts @@ -0,0 +1,808 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { spawn } from "node:child_process"; +import { + CaptchaCpuGovernor, + resolveCpuGovernorConfig, + type CpuGovernorSnapshot, +} from "./captcha-cpu-governor.js"; +import { + CAPTCHA_NODE_DIR, + captchaSolverConcurrency, + runCaptchaSolve, + setCaptchaSolverConcurrency, + shutdownCaptchaSolver, +} from "./captcha-solver.js"; +import { isCaptchaDuplicateError, isCaptchaIpBlockError, parseCertifyId } from "./captcha-token.js"; + +export interface CaptchaPoolOptions { + /** @deprecated Use poolSizeMax — kept as max cap alias. */ + poolSize?: number; + poolSizeMin?: number; + poolSizeMax?: number; + /** Idle seconds before shrinking toward min (default 120). */ + scaleDownIdleMs?: number; + /** Deep-idle floor: pool target decays to this after sustained zero traffic + * (default 1). First token take restores the full poolSizeMin. */ + idleFloor?: number; + /** Zero-traffic seconds after which background solving stops entirely — + * target floor decays to 0 (default 900). One banked token is still kept + * for an instant serve on wake-up. */ + deepIdleAfterMs?: number; + tokenTtlMs?: number; + refillIntervalMs?: number; + solveRetries?: number; + staggerMs?: number; + solveConcurrency?: number; + /** Parallel solves raced when a take finds the pool empty (default 2). */ + emptyTakeRace?: number; + /** Host CPU ceiling for captcha workers (default 100, 0 = off). */ + cpuLimitPercent?: number; + cpuGovernorIntervalSec?: number; + /** + * Called when minting fails with an IP-level block ("too many captcha + * requests" family). The proxy wires this to the Telegram network reset so + * an Aliyun IP block self-heals instead of stranding the token pool. + */ + onCaptchaIpBlock?: (reason: string) => void; +} + +type TokenEntry = { param: string; cachedAt: number; certifyId: string | null }; + +/** Tracks certifyIds already handed out or queued — prevents F008 duplicate usage. */ +class CertifyIdRegistry { + private seen = new Map(); + + constructor(private ttlMs: number) {} + + setTtlMs(ttlMs: number): void { + this.ttlMs = ttlMs; + } + + prune(): void { + const cutoff = Date.now() - this.ttlMs; + for (const [id, at] of this.seen) { + if (at < cutoff) this.seen.delete(id); + } + } + + has(id: string): boolean { + this.prune(); + return this.seen.has(id); + } + + add(id: string): void { + this.prune(); + this.seen.set(id, Date.now()); + } + + clear(): void { + this.seen.clear(); + } +} + +const DEFAULT_POOL_MIN = Number(process.env.CAPTCHA_POOL_MIN || 40); +const DEFAULT_POOL_MAX = Number(process.env.CAPTCHA_POOL_MAX || 120); +const DEFAULT_TOKEN_TTL_MS = Number(process.env.CAPTCHA_CACHE_TTL_MS || 95_000); +const DEFAULT_REFILL_INTERVAL_MS = Number(process.env.CAPTCHA_REFILL_INTERVAL_MS || 1_000); +const DEFAULT_STAGGER_MS = Number(process.env.CAPTCHA_SOLVE_STAGGER_MS || 0); +const DEFAULT_SOLVE_CONCURRENCY = Number( + process.env.CAPTCHA_SOLVE_CONCURRENCY || + (process.env.ZCODE_CAPTCHA_LOW_CPU === "1" ? 3 : 8), +); +const DEFAULT_SCALE_DOWN_IDLE_MS = Number(process.env.CAPTCHA_POOL_SCALE_DOWN_IDLE_MS || 120_000); +const DEFAULT_IDLE_FLOOR = Number(process.env.CAPTCHA_POOL_IDLE_FLOOR || 1); +// Zero-traffic beyond this stops background solving entirely (floor 0): no +// mint churn, near-zero CPU. The next token take restores poolSizeMin. +const DEFAULT_DEEP_IDLE_AFTER_MS = Number(process.env.CAPTCHA_DEEP_IDLE_AFTER_MS || 900_000); +// Parallel solves raced on an empty-pool take — first success serves the +// client, the twins top up the pool. Cuts worst-case cold TTFB and widens +// odds against a bad rotated pe bundle stalling one racer. +const EMPTY_TAKE_RACE = Math.max(1, Number(process.env.CAPTCHA_EMPTY_TAKE_RACE || 3)); +const SOLVE_RETRIES = Number(process.env.ZCODE_CAPTCHA_RETRIES || 4); +const SCALE_UP_STEP = 20; +const TAKE_RATE_WINDOW_MS = 120_000; + +type ResolvedPoolOpts = { + poolSizeMin: number; + poolSizeMax: number; + idleFloor: number; + deepIdleAfterMs: number; + scaleDownIdleMs: number; + tokenTtlMs: number; + refillIntervalMs: number; + solveRetries: number; + staggerMs: number; + solveConcurrency: number; + emptyTakeRace: number; + onCaptchaIpBlock?: (reason: string) => void; +}; + +function resolvePoolOpts(opts: CaptchaPoolOptions): ResolvedPoolOpts { + const max = opts.poolSizeMax ?? opts.poolSize ?? DEFAULT_POOL_MAX; + const min = Math.min(opts.poolSizeMin ?? DEFAULT_POOL_MIN, max); + return { + poolSizeMin: min, + poolSizeMax: max, + idleFloor: Math.min(opts.idleFloor ?? DEFAULT_IDLE_FLOOR, min), + deepIdleAfterMs: Math.max( + opts.deepIdleAfterMs ?? DEFAULT_DEEP_IDLE_AFTER_MS, + opts.scaleDownIdleMs ?? DEFAULT_SCALE_DOWN_IDLE_MS, + ), + scaleDownIdleMs: opts.scaleDownIdleMs ?? DEFAULT_SCALE_DOWN_IDLE_MS, + tokenTtlMs: opts.tokenTtlMs ?? DEFAULT_TOKEN_TTL_MS, + refillIntervalMs: opts.refillIntervalMs ?? DEFAULT_REFILL_INTERVAL_MS, + solveRetries: opts.solveRetries ?? SOLVE_RETRIES, + staggerMs: opts.staggerMs ?? DEFAULT_STAGGER_MS, + solveConcurrency: opts.solveConcurrency ?? DEFAULT_SOLVE_CONCURRENCY, + emptyTakeRace: Math.max(1, opts.emptyTakeRace ?? EMPTY_TAKE_RACE), + onCaptchaIpBlock: opts.onCaptchaIpBlock, + }; +} + +export class CaptchaTokenPool { + private tokens: TokenEntry[] = []; + private opts: ResolvedPoolOpts; + private effectiveTarget: number; + private refillTimer: ReturnType | null = null; + private refillInFlight = false; + private lastSolveAt = 0; + private activeSolves = 0; + private cfg: CaptchaConfig | null = null; + private pausedUntil = 0; + private lastTakeAt: number; + private takeTimestamps: number[] = []; + private certifyIds: CertifyIdRegistry; + private governor: CaptchaCpuGovernor | null = null; + // Mint-storm detection: sliding windows of failed vs successful mint + // attempts. A burst of failures with zero successes means velocity/IP + // flagging — fire the telegram IP reset automatically. + private mintFailures: number[] = []; + private mintSuccesses: number[] = []; + private lastStormResetAt = 0; + + constructor(opts: CaptchaPoolOptions = {}) { + // The module-level singleton constructs with no opts before config load; + // resolvePoolOpts would fall back to env/global defaults there (e.g. + // poolSizeMin 40) and configure()'s clamp would preserve that as the + // starting target — minting a storm of soon-expired tokens at every + // boot. With no explicit opts, defer sizing to the first configure(). + const hasOpts = Object.keys(opts).length > 0; + this.opts = resolvePoolOpts(opts); + this.effectiveTarget = hasOpts ? this.opts.poolSizeMin : 0; + // Seed idle clock at startup so a no-traffic proxy decays to idleFloor + // instead of solving 40-60 tokens forever (lastTakeAt=0 disabled decay). + this.lastTakeAt = Date.now(); + this.certifyIds = new CertifyIdRegistry(this.opts.tokenTtlMs); + this.initGovernor(opts); + setCaptchaSolverConcurrency(this.opts.solveConcurrency); + } + + configure(opts: CaptchaPoolOptions): void { + this.opts = resolvePoolOpts({ ...this.opts, ...opts }); + this.certifyIds.setTtlMs(this.opts.tokenTtlMs); + this.effectiveTarget = Math.max( + this.opts.poolSizeMin, + Math.min(this.effectiveTarget, this.opts.poolSizeMax), + ); + this.initGovernor(opts); + // Propagate the resolved concurrency to the solver daemon — the + // import-time constructor runs before config load and would otherwise + // leave the daemon at its fallback (8) instead of the configured value. + setCaptchaSolverConcurrency(this.opts.solveConcurrency); + this.trimToTarget(); + } + + stats(): { + ready: number; + target: number; + min: number; + max: number; + activeSolves: number; + solverWorkers: number; + cpu?: CpuGovernorSnapshot; + } { + this.pruneExpired(); + return { + ready: this.tokens.length, + target: this.effectiveTarget, + min: this.opts.poolSizeMin, + max: this.opts.poolSizeMax, + activeSolves: this.activeSolves, + solverWorkers: captchaSolverConcurrency(), + cpu: this.governor?.enabled ? this.governor.snapshot() : undefined, + }; + } + + invalidate(): void { + this.tokens = []; + this.certifyIds.clear(); + this.pausedUntil = Date.now() + this.opts.staggerMs; + } + + startBackgroundRefill(cfg: CaptchaConfig): void { + this.cfg = cfg; + this.stopBackgroundRefill(); + this.governor?.start(); + void this.refill({ urgent: false }); + this.refillTimer = setInterval(() => { + void this.refill({ urgent: false }); + }, this.opts.refillIntervalMs); + } + + stopBackgroundRefill(): void { + this.governor?.stop(); + if (this.refillTimer) { + clearInterval(this.refillTimer); + this.refillTimer = null; + } + } + + async takeToken(cfg: CaptchaConfig): Promise { + this.cfg = cfg; + await ensureCaptchaDeps(); + + const readyBefore = this.tokens.length; + const token = this.popFresh(); + if (token) { + this.onTokenTaken(false, readyBefore - 1); + void this.refill({ urgent: true }); + return token; + } + + this.onTokenTaken(true, 0); + // Re-size immediately from current demand (this take is already in the + // window) so an empty-pool arrival widens the buffer right away. + this.effectiveTarget = Math.max(this.effectiveTarget, this.computeActiveTarget()); + let param: string; + try { + // Overall deadline for the racing chain: during mint storms a racer can + // grind through its retry budget (~30-45s) — cap the client-facing wait + // and let the background waves finish the job instead. + const raceDeadlineMs = Number(process.env.CAPTCHA_SOLVE_RACE_DEADLINE_MS || 25_000); + param = await Promise.race([ + this.solveRaced(cfg), + new Promise((_, rej) => + setTimeout( + () => rej(new Error(`captcha take deadline (${raceDeadlineMs}ms)`)), + Math.max(1_000, raceDeadlineMs), + ), + ), + ]); + } catch (err) { + // Mints fail in clusters (pe-stall storms, F008 velocity). Background + // refill waves keep retrying — give them a short window to land a + // token before surfacing a failure to the client. This converts most + // storm-window 503s into slower successes. + const graceMs = Number(process.env.CAPTCHA_TAKE_GRACE_MS || 10_000); + const deadline = Date.now() + Math.max(0, graceMs); + while (Date.now() < deadline) { + await new Promise((r) => setTimeout(r, 400)); + const token = this.popFresh(); + if (token) { + this.markParamIssued(token); + void this.refill({ urgent: true }); + return token; + } + } + throw err; + } + this.markParamIssued(param); + void this.refill({ urgent: true }); + return param; + } + + async prefill(cfg: CaptchaConfig, count?: number): Promise { + this.cfg = cfg; + await ensureCaptchaDeps(); + const target = Math.min(count ?? this.effectiveTarget, this.effectiveTarget); + while (this.tokens.length + this.activeSolves < target) { + const need = target - this.tokens.length - this.activeSolves; + if (need <= 0) break; + const concurrency = this.governor?.enabled + ? this.governor.backgroundConcurrency(this.tokens.length) + : this.opts.solveConcurrency; + await this.solveBatch(cfg, need, Math.max(1, concurrency)); + } + } + + requestUrgentRefill(): void { + void this.refill({ urgent: true }); + } + + private poolHasCertifyId(certifyId: string): boolean { + return this.tokens.some((t) => t.certifyId === certifyId); + } + + private isDuplicateParam(param: string): boolean { + const certifyId = parseCertifyId(param); + if (!certifyId) return false; + return this.certifyIds.has(certifyId) || this.poolHasCertifyId(certifyId); + } + + private markParamIssued(param: string): void { + const certifyId = parseCertifyId(param); + if (certifyId) this.certifyIds.add(certifyId); + } + + private popFresh(): string | null { + this.pruneExpired(); + while (this.tokens.length > 0) { + const entry = this.tokens.pop()!; + if (entry.certifyId && this.certifyIds.has(entry.certifyId)) continue; + this.markParamIssued(entry.param); + return entry.param; + } + return null; + } + + private pushToken(param: string): void { + this.pruneExpired(); + if (this.tokens.length >= this.effectiveTarget) return; + const certifyId = parseCertifyId(param); + if (certifyId && (this.certifyIds.has(certifyId) || this.poolHasCertifyId(certifyId))) { + return; + } + this.tokens.push({ param, cachedAt: Date.now(), certifyId }); + } + + private pruneExpired(): void { + const now = Date.now(); + this.tokens = this.tokens.filter((t) => now - t.cachedAt < this.opts.tokenTtlMs); + } + + private deficit(): number { + this.pruneExpired(); + return Math.max(0, this.effectiveTarget - this.tokens.length - this.activeSolves); + } + + private pruneTakeWindow(): void { + const cutoff = Date.now() - TAKE_RATE_WINDOW_MS; + this.takeTimestamps = this.takeTimestamps.filter((t) => t > cutoff); + } + + private demandTarget(): number { + this.pruneTakeWindow(); + if (this.takeTimestamps.length === 0) return this.opts.poolSizeMin; + const ratePerSec = this.takeTimestamps.length / (TAKE_RATE_WINDOW_MS / 1000); + const ttlSec = this.opts.tokenTtlMs / 1000; + return Math.ceil(ratePerSec * ttlSec * 1.25); + } + + /** + * Target while traffic is recent: measured demand (take rate × TTL × + * margin) plus one token of arrival-jitter headroom, floored at + * poolSizeMin, capped by governor + poolSizeMax. This is the ONLY sizing + * authority while active — bursts raise demand, quiet periods drain it. + */ + private computeActiveTarget(): number { + const demand = Math.max(this.demandTarget(), this.opts.poolSizeMin); + const raw = Math.ceil(demand * 1.25) + 1; + const governorMax = this.governor?.maxPoolTarget() ?? this.opts.poolSizeMax; + return Math.max( + this.currentFloor(), + Math.min(this.opts.poolSizeMax, governorMax, raw), + ); + } + + private onTokenTaken(wasEmpty: boolean, readyAfter: number): void { + this.lastTakeAt = Date.now(); + this.takeTimestamps.push(this.lastTakeAt); + this.pruneTakeWindow(); + this.wakeFromIdle(); + // Sizing is demand-driven via computeActiveTarget() on the next refill + // tick; takes only need to restore the active floor instantly. + } + + private maybeScaleDown(): void { + if (this.lastTakeAt === 0) return; + const idleMs = Date.now() - this.lastTakeAt; + if (idleMs < this.opts.scaleDownIdleMs) return; + + this.pruneTakeWindow(); + const recentTakes = this.takeTimestamps.filter((t) => t > Date.now() - 60_000).length; + if (recentTakes > 1) return; + + // Deep idle: decay the effective floor itself so Chromium workers and + // solve churn wind down — first toward idleFloor, then to zero after + // deepIdleAfterMs (no background mints at all). Any token take snaps the + // floor back to full poolSizeMin. + const floor = this.currentFloor(); + if (this.effectiveTarget > floor) { + this.effectiveTarget = Math.max(floor, this.effectiveTarget - SCALE_UP_STEP); + this.trimToTarget(); + } + } + + /** + * Active traffic → poolSizeMin. After scaleDownIdleMs of no takes → + * idleFloor (keeper churn ≈ 1 mint/TTL). After deepIdleAfterMs → 0: + * background solving stops; the banked token still serves a wake-up take. + */ + private currentFloor(): number { + if (this.lastTakeAt === 0) return this.opts.poolSizeMin; + const idleMs = Date.now() - this.lastTakeAt; + if (idleMs >= this.opts.deepIdleAfterMs) return 0; + if (idleMs >= this.opts.scaleDownIdleMs) { + return Math.min(this.opts.idleFloor, this.opts.poolSizeMin); + } + return this.opts.poolSizeMin; + } + + /** Traffic returned: restore the full configured floor immediately. */ + private wakeFromIdle(): void { + if (this.effectiveTarget < this.opts.poolSizeMin) { + this.effectiveTarget = this.opts.poolSizeMin; + } + } + + private trimToTarget(): void { + this.pruneExpired(); + // Never trim the last banked token: it serves a wake-up take instantly + // (free TTFB) and expires naturally via pruneExpired. + const floor = Math.max(this.effectiveTarget, 1); + while (this.tokens.length > floor) { + this.tokens.shift(); + } + } + + private applyGovernorCaps(): void { + if (!this.governor?.enabled) return; + const governorMax = this.governor.maxPoolTarget(); + // Floor is idle-aware (idleFloor → 0 past deepIdleAfterMs) — don't clamp + // it back up (that would refill forever during idle). + const floor = this.currentFloor(); + this.effectiveTarget = Math.max( + floor, + Math.min(this.effectiveTarget, governorMax, this.opts.poolSizeMax), + ); + } + + private async refill(opts: { urgent: boolean }): Promise { + if (!this.cfg) return; + // Refresh sizing BEFORE the in-flight guard: a fill batch with slow or + // stalling solves can hold refillInFlight for minutes, and freezing the + // target that whole time starves the buffer exactly when demand exists. + const idleDecay = + this.lastTakeAt > 0 && Date.now() - this.lastTakeAt >= this.opts.scaleDownIdleMs; + if (!idleDecay) { + this.effectiveTarget = this.computeActiveTarget(); + } else { + this.maybeScaleDown(); + } + + // Idle decay: drop the target toward the current floor (idleFloor → 0 + // past deepIdleAfterMs) and skip background solves while enough banked + // tokens remain. This is what stops mint churn at zero traffic. + if (!opts.urgent && idleDecay) { + if (this.tokens.length >= Math.max(this.currentFloor(), 1)) return; + } + + if (this.refillInFlight || Date.now() < this.pausedUntil) return; + + this.applyGovernorCaps(); + + const ready = this.tokens.length; + const concurrency = this.governor?.enabled + ? opts.urgent + ? Math.max(1, this.governor.backgroundConcurrency(ready) || this.governor.snapshot().concurrency) + : this.governor.backgroundConcurrency(ready) + : this.opts.solveConcurrency; + + if (!opts.urgent && concurrency === 0 && ready >= this.opts.poolSizeMin) { + return; + } + + const need = this.deficit(); + if (need <= 0) return; + + this.refillInFlight = true; + try { + await this.solveBatch(this.cfg, need, Math.max(1, concurrency)); + } finally { + this.refillInFlight = false; + } + } + + /** Fire up to `need` solves in parallel waves of solveConcurrency. */ + private async solveBatch(cfg: CaptchaConfig, need: number, concurrency = this.opts.solveConcurrency): Promise { + let remaining = need; + while (remaining > 0 && this.tokens.length + this.activeSolves < this.effectiveTarget) { + const wave = Math.min(remaining, concurrency, this.deficit()); + if (wave <= 0) break; + if (this.opts.staggerMs > 0) await this.waitForStagger(); + const results = await Promise.allSettled( + Array.from({ length: wave }, () => this.solveOne(cfg)), + ); + for (const r of results) { + if (r.status === "fulfilled") { + this.pushToken(r.value); + } else { + console.warn("[captcha-pool] parallel solve failed:", r.reason); + } + } + remaining -= wave; + if (results.every((r) => r.status === "rejected")) break; + } + } + + private async waitForStagger(): Promise { + const elapsed = Date.now() - this.lastSolveAt; + const wait = this.opts.staggerMs - elapsed; + if (wait > 0) { + await new Promise((r) => setTimeout(r, wait)); + } + } + + private noteMintFailure(reason: string): void { + const now = Date.now(); + this.mintFailures.push(now); + const cutoff = now - 360_000; + this.mintFailures = this.mintFailures.filter((t) => t > cutoff); + this.maybeFireMintStormReset(reason); + } + + private noteMintSuccess(): void { + const now = Date.now(); + this.mintSuccesses.push(now); + const cutoff = now - 360_000; + this.mintSuccesses = this.mintSuccesses.filter((t) => t > cutoff); + } + + /** Velocity/IP flagging signature: many failed mints in the last 5 min + * while nothing succeeded in the last 3. Fires the telegram IP reset at + * most once per cooldown — requestCaptchaIpResetSync dedupes further. */ + private maybeFireMintStormReset(latestReason: string): void { + const now = Date.now(); + const fails = this.mintFailures.filter((t) => t > now - 300_000).length; + const succs = this.mintSuccesses.filter((t) => t > now - 180_000).length; + if (fails < 8 || succs > 0) return; + if (now - this.lastStormResetAt < 12 * 60_000) return; + this.lastStormResetAt = now; + console.warn( + `[captcha] mint storm detected (${fails} failures/5min, ${succs} successes/3min; latest: ${latestReason.slice(0, 120)}) -> requesting IP reset`, + ); + try { + this.opts.onCaptchaIpBlock?.(`mint storm: ${fails} failed mints in 5min`); + } catch { + // callback is best-effort; never break the pool + } + } + + private async solveFresh(cfg: CaptchaConfig): Promise { + this.activeSolves += 1; + this.lastSolveAt = Date.now(); + try { + let lastErr: string | null = null; + let sawIpBlock = false; + for (let attempt = 1; attempt <= this.opts.solveRetries; attempt += 1) { + try { + const param = await runCaptchaSolve(cfg.sceneId, cfg.region, cfg.prefix); + if (!param) { + lastErr = "solver returned empty"; + continue; + } + if (this.isDuplicateParam(param)) { + lastErr = `duplicate certifyId ${parseCertifyId(param) ?? "?"}`; + continue; + } + this.noteMintSuccess(); + return param; + } catch (err) { + lastErr = err instanceof Error ? err.message : String(err); + if (isCaptchaDuplicateError(lastErr)) { + lastErr = `duplicate certifyId (F008)`; + } else if (isCaptchaIpBlockError(lastErr)) { + sawIpBlock = true; + } + this.noteMintFailure(lastErr); + } + } + // All retries failed. If any failure was an IP-level block, ask the + // caller to rotate the egress (Telegram network reset) so the next + // mint has a fresh IP instead of being stuck unable to mint. + if (sawIpBlock && this.opts.onCaptchaIpBlock) { + try { + this.opts.onCaptchaIpBlock(lastErr ?? "captcha ip block"); + } catch (_) { + // callback is best-effort; never let it break the pool + } + } + throw new Error(`captcha failed after ${this.opts.solveRetries} attempts: ${lastErr ?? "unknown"}`); + } finally { + this.activeSolves -= 1; + } + } + + private async solveOne(cfg: CaptchaConfig): Promise { + return this.solveFresh(cfg); + } + + /** + * Empty-pool serve: race up to `emptyTakeRace` solves in parallel and + * resolve with the first success so the client waits for one mint, not a + * sequential retry chain. Extra successes are banked into the pool instead + * of being wasted; the race only rejects when every racer fails. + */ + private solveRaced(cfg: CaptchaConfig): Promise { + const racers = Math.max(1, Math.min(this.opts.emptyTakeRace, this.opts.solveConcurrency)); + if (racers <= 1) return this.solveFresh(cfg); + + return new Promise((resolve, reject) => { + let pending = racers; + let served = false; + let lastErr: unknown = null; + for (let i = 0; i < racers; i += 1) { + this.solveFresh(cfg).then( + (param) => { + if (served) { + // Lost the race but minted a valid token — bank it. + this.pushToken(param); + } else { + served = true; + resolve(param); + } + }, + (err) => { + lastErr = err ?? lastErr; + }, + ).finally(() => { + pending -= 1; + if (pending === 0 && !served) { + reject( + lastErr instanceof Error + ? lastErr + : new Error(String(lastErr ?? "captcha race failed")), + ); + } + }); + } + }); + } + + private initGovernor(opts: CaptchaPoolOptions): void { + const cfg = resolveCpuGovernorConfig({ + poolSizeMin: this.opts.poolSizeMin, + poolSizeMax: this.opts.poolSizeMax, + solveConcurrency: this.opts.solveConcurrency, + cpuLimitPercent: opts.cpuLimitPercent, + cpuGovernorIntervalSec: opts.cpuGovernorIntervalSec, + }); + if (!this.governor) { + this.governor = new CaptchaCpuGovernor(cfg); + } else { + this.governor.configure(cfg); + } + } +} + +export interface CaptchaConfig { + enabled: boolean; + prefix: string; + sceneId: string; + region: string; +} + +const pool = new CaptchaTokenPool(); + +export function configureCaptchaPool(opts: CaptchaPoolOptions): void { + pool.configure(opts); +} + +export function getCaptchaPoolStats(): { + ready: number; + target: number; + min?: number; + max?: number; + activeSolves: number; + solverWorkers?: number; +} { + return pool.stats(); +} + +export function invalidateJsdomCaptcha(): void { + pool.invalidate(); +} + +export function stopCaptchaPool(): void { + pool.stopBackgroundRefill(); + shutdownCaptchaSolver(); +} + +export async function solveCaptchaJsdom(cfg: CaptchaConfig): Promise { + if (!cfg.enabled || !cfg.prefix || !cfg.sceneId) { + throw new Error("Captcha config unavailable from ZCode API"); + } + return pool.takeToken(cfg); +} + +export function startCaptchaPoolRefill(cfg: CaptchaConfig): void { + if (!cfg.enabled || !cfg.prefix || !cfg.sceneId) return; + pool.startBackgroundRefill(cfg); +} + +export async function prefillCaptchaPool(cfg: CaptchaConfig, count?: number): Promise { + if (!cfg.enabled || !cfg.prefix || !cfg.sceneId) return; + await pool.prefill(cfg, count); +} + +export function urgentCaptchaRefill(): void { + pool.requestUrgentRefill(); +} + +/** Lazy install Playwright + Chromium in captcha_node (avoids postinstall on every bun install). */ +let depsReady: Promise | null = null; + +function playwrightBrowserInstalled(): boolean { + const cache = + process.env.PLAYWRIGHT_BROWSERS_PATH?.trim() || + path.join(os.homedir(), ".cache", "ms-playwright"); + try { + return fs.readdirSync(cache).some((name) => name.startsWith("chromium-")); + } catch { + return false; + } +} + +function runCommand( + cmd: string, + args: string[], + cwd: string, + label: string, +): Promise { + return new Promise((resolve, reject) => { + const isWin = process.platform === "win32"; + const proc = spawn(isWin && cmd === "npm" ? "npm.cmd" : cmd, args, { + cwd, + stdio: ["ignore", "pipe", "pipe"], + shell: isWin && cmd === "npx", + }); + let stderr = ""; + proc.stderr?.on("data", (c: Buffer) => { + stderr += c.toString("utf8"); + }); + proc.on("error", (err) => reject(new Error(`${label} failed: ${err.message}`))); + proc.on("close", (code) => { + if (code === 0) resolve(); + else reject(new Error(`${label} failed (${code}): ${stderr.slice(0, 300)}`)); + }); + }); +} + +async function ensureCaptchaDeps(): Promise { + if (process.env.ZCODE_CAPTCHA_SKIP_DEPS === "1") return; + // Only the "playwright" backend needs the npm package + a Chromium binary; + // jsdom and happy run fully in-process, so skip the ~150MB browser install + // for them entirely. + if ((process.env.ZCODE_CAPTCHA_BACKEND || "jsdom") !== "playwright") return; + + const playwrightPkg = path.join(CAPTCHA_NODE_DIR, "node_modules", "playwright"); + const needsInstall = !fs.existsSync(playwrightPkg); + const needsBrowser = !playwrightBrowserInstalled(); + + if (!needsInstall && !needsBrowser) return; + if (depsReady) return depsReady; + + depsReady = (async () => { + if (needsInstall) { + console.log("[captcha] installing Playwright in captcha_node…"); + await runCommand("npm", ["install", "--omit=dev"], CAPTCHA_NODE_DIR, "npm install"); + } + if (!playwrightBrowserInstalled()) { + console.log("[captcha] installing Chromium for Playwright (one-time download)…"); + await runCommand( + "npx", + ["playwright", "install", "chromium"], + CAPTCHA_NODE_DIR, + "playwright install chromium", + ); + } + })(); + + try { + await depsReady; + } finally { + depsReady = null; + } +} diff --git a/src/proxy/captcha-pool.test.ts b/src/proxy/captcha-pool.test.ts new file mode 100644 index 0000000..8e38d3f --- /dev/null +++ b/src/proxy/captcha-pool.test.ts @@ -0,0 +1,327 @@ +import { afterEach, beforeEach, describe, expect, it, mock } from "bun:test"; + +const solveMock = mock(async (_scene: string, _region: string, _prefix: string) => { + return "x".repeat(64); +}); + +mock.module("./captcha-solver.js", () => ({ + runCaptchaSolve: solveMock, + shutdownCaptchaSolver: () => {}, + setCaptchaSolverConcurrency: () => {}, + captchaSolverConcurrency: () => 2, + CAPTCHA_NODE_DIR: "/tmp", +})); + +const { CaptchaTokenPool } = await import("./captcha-jsdom.js"); + +const CFG = { + enabled: true, + prefix: "no8xfe", + sceneId: "11xygtvd", + region: "sgp", +}; + +let pool: InstanceType; + +describe("CaptchaTokenPool", () => { + beforeEach(() => { + process.env.ZCODE_CAPTCHA_SKIP_DEPS = "1"; + solveMock.mockClear(); + pool = new CaptchaTokenPool({ + poolSizeMin: 3, + poolSizeMax: 3, + tokenTtlMs: 60_000, + refillIntervalMs: 60_000, + staggerMs: 0, + solveRetries: 1, + solveConcurrency: 2, + scaleDownIdleMs: 60_000, + }); + }); + + afterEach(() => { + delete process.env.ZCODE_CAPTCHA_SKIP_DEPS; + pool.stopBackgroundRefill(); + }); + + it("prefill adds tokens without consuming on take", async () => { + await pool.prefill(CFG, 2); + expect(pool.stats().ready).toBe(2); + expect(solveMock).toHaveBeenCalledTimes(2); + }); + + it("takeToken uses prefetched token and triggers background refill", async () => { + await pool.prefill(CFG, 2); + solveMock.mockClear(); + + const param = await pool.takeToken(CFG); + expect(param.length).toBeGreaterThan(20); + expect(pool.stats().ready).toBeGreaterThanOrEqual(1); + await new Promise((r) => setTimeout(r, 10)); + expect(solveMock.mock.calls.length).toBeGreaterThanOrEqual(1); + }); + + it("concurrent takes use parallel solves when pool is empty", async () => { + const [a, b] = await Promise.all([pool.takeToken(CFG), pool.takeToken(CFG)]); + expect(a.length).toBeGreaterThan(20); + expect(b.length).toBeGreaterThan(20); + expect(solveMock.mock.calls.length).toBeGreaterThanOrEqual(2); + }); + + it("invalidate clears the pool", async () => { + await pool.prefill(CFG, 2); + pool.invalidate(); + expect(pool.stats().ready).toBe(0); + }); + + it("pushToken rejects duplicate certifyId", async () => { + const payload = Buffer.from( + JSON.stringify({ certifyId: "dup-test-id", sceneId: "11xygtvd", isSign: true, securityToken: "x" }), + ).toString("base64"); + const poolAny = pool as unknown as { + pushToken: (p: string) => void; + stats: () => { ready: number }; + }; + poolAny.pushToken(payload); + poolAny.pushToken(payload); + expect(poolAny.stats().ready).toBe(1); + }); + + it("takeToken prefers newest token (LIFO)", async () => { + let n = 0; + solveMock.mockImplementation(async () => { + n += 1; + return `token-${n}:${"x".repeat(48)}`; + }); + await pool.prefill(CFG, 3); + solveMock.mockClear(); + + const taken = await pool.takeToken(CFG); + expect(taken.startsWith("token-3:")).toBe(true); + }); +}); + +describe("CaptchaTokenPool deep idle", () => { + beforeEach(() => { + process.env.ZCODE_CAPTCHA_SKIP_DEPS = "1"; + solveMock.mockClear(); + }); + afterEach(() => { + delete process.env.ZCODE_CAPTCHA_SKIP_DEPS; + pool?.stopBackgroundRefill?.(); + }); + + it("decays target to idleFloor after sustained zero traffic", async () => { + pool = new CaptchaTokenPool({ + poolSizeMin: 5, + poolSizeMax: 10, + idleFloor: 1, + tokenTtlMs: 60_000, + refillIntervalMs: 60_000, + solveRetries: 1, + solveConcurrency: 1, + scaleDownIdleMs: 60_000, + }); + await pool.prefill(CFG, 5); + expect(pool.stats().target).toBe(5); + // Simulate 61s of idle: lastTakeAt far in the past. + const p = pool as unknown as { lastTakeAt: number }; + p.lastTakeAt = Date.now() - 61_000; + const decayed = pool as unknown as { maybeScaleDown: () => void; applyGovernorCaps: () => void }; + decayed.maybeScaleDown(); + decayed.applyGovernorCaps(); + expect(pool.stats().target).toBeLessThanOrEqual(1); + expect(pool.stats().ready).toBeLessThanOrEqual(1); + }); + + it("restores full floor on the next token take", async () => { + pool = new CaptchaTokenPool({ + poolSizeMin: 5, + poolSizeMax: 10, + idleFloor: 1, + tokenTtlMs: 60_000, + refillIntervalMs: 60_000, + solveRetries: 1, + solveConcurrency: 1, + scaleDownIdleMs: 60_000, + }); + await pool.prefill(CFG, 5); + const p = pool as unknown as { lastTakeAt: number }; + p.lastTakeAt = Date.now() - 61_000; + const decayed = pool as unknown as { maybeScaleDown: () => void }; + decayed.maybeScaleDown(); + expect(pool.stats().target).toBeLessThanOrEqual(1); + await pool.takeToken(CFG); + expect(pool.stats().target).toBe(5); + }); + + it("decays floor to 0 after deepIdleAfterMs (no background mints)", async () => { + pool = new CaptchaTokenPool({ + poolSizeMin: 5, + poolSizeMax: 10, + idleFloor: 1, + deepIdleAfterMs: 120_000, + tokenTtlMs: 60_000, + refillIntervalMs: 60_000, + solveRetries: 1, + solveConcurrency: 1, + scaleDownIdleMs: 60_000, + }); + await pool.prefill(CFG, 5); + const p = pool as unknown as { lastTakeAt: number }; + // Past scaleDownIdleMs but before deepIdleAfterMs → keeper floor. + p.lastTakeAt = Date.now() - 61_000; + let decayed = pool as unknown as { maybeScaleDown: () => void }; + decayed.maybeScaleDown(); + expect(pool.stats().target).toBe(1); + // Past deepIdleAfterMs → floor 0. + p.lastTakeAt = Date.now() - 121_000; + decayed.maybeScaleDown(); + expect(pool.stats().target).toBe(0); + }); + + it("keeps one banked token through decay for instant wake-up serves", async () => { + pool = new CaptchaTokenPool({ + poolSizeMin: 5, + poolSizeMax: 10, + idleFloor: 0, + deepIdleAfterMs: 120_000, + tokenTtlMs: 60_000, + refillIntervalMs: 60_000, + solveRetries: 1, + solveConcurrency: 1, + scaleDownIdleMs: 60_000, + }); + await pool.prefill(CFG, 5); + const p = pool as unknown as { lastTakeAt: number }; + p.lastTakeAt = Date.now() - 121_000; + const decayed = pool as unknown as { maybeScaleDown: () => void }; + decayed.maybeScaleDown(); + // Target is 0 but the last banked token survives trimming. + expect(pool.stats().target).toBe(0); + expect(pool.stats().ready).toBe(1); + // …and a take right after deep idle still gets served instantly. + solveMock.mockClear(); + const param = await pool.takeToken(CFG); + expect(param.length).toBeGreaterThan(20); + }); + + it("races parallel solves on an empty-pool take and banks extras", async () => { + pool = new CaptchaTokenPool({ + poolSizeMin: 2, + poolSizeMax: 10, + emptyTakeRace: 3, + tokenTtlMs: 60_000, + refillIntervalMs: 60_000, + staggerMs: 0, + solveRetries: 1, + solveConcurrency: 4, + scaleDownIdleMs: 60_000, + }); + let n = 0; + let active = 0; + let peak = 0; + solveMock.mockImplementation(async () => { + active += 1; + peak = Math.max(peak, active); + await new Promise((r) => setTimeout(r, 20)); + active -= 1; + n += 1; + return `race-token-${n}:${"y".repeat(48)}`; + }); + const param = await pool.takeToken(CFG); + expect(param).toContain("race-token-"); + // Racers overlap: peak concurrent solves equals the race width instead + // of stacking sequentially. + expect(peak).toBe(3); + // Extra successes are banked into the pool once they settle. + await new Promise((r) => setTimeout(r, 30)); + expect(pool.stats().ready).toBeGreaterThanOrEqual(2); + }); + + it("grace-waits for refill when all raced solves fail before surfacing the error", async () => { + process.env.CAPTCHA_TAKE_GRACE_MS = "1500"; + try { + pool = new CaptchaTokenPool({ + poolSizeMin: 1, + poolSizeMax: 10, + emptyTakeRace: 2, + tokenTtlMs: 60_000, + refillIntervalMs: 60_000, + staggerMs: 0, + solveRetries: 1, + solveConcurrency: 4, + scaleDownIdleMs: 600_000, + deepIdleAfterMs: 600_000, + }); + // Every solve fails at first — a full storm. + solveMock.mockImplementation(async () => { + throw new Error("captcha solve stall pe=pe.099.storm.js"); + }); + const takePromise = pool.takeToken(CFG); + // Mid-storm, a refill wave succeeds and banks a token. + const pushTimer = setTimeout(() => { + const p = pool as unknown as { pushToken: (s: string) => void }; + p.pushToken(`storm-rescue:${"g".repeat(48)}`); + }, 400); + const param = await takePromise; + clearTimeout(pushTimer); + expect(param).toContain("storm-rescue"); + } finally { + delete process.env.CAPTCHA_TAKE_GRACE_MS; + } + }); + + it("fires the IP reset once when a mint storm is detected (no successes)", async () => { + pool = new CaptchaTokenPool({ + poolSizeMin: 1, + poolSizeMax: 10, + tokenTtlMs: 60_000, + refillIntervalMs: 600_000, + staggerMs: 0, + solveRetries: 1, + solveConcurrency: 4, + scaleDownIdleMs: 600_000, + }); + const fired: string[] = []; + (pool as unknown as { opts: { onCaptchaIpBlock?: (r: string) => void } }).opts.onCaptchaIpBlock = + (reason) => fired.push(reason); + + const p = pool as unknown as { + noteMintFailure: (r: string) => void; + noteMintSuccess: () => void; + }; + // 7 failures: below threshold — no fire. + for (let i = 0; i < 7; i++) p.noteMintFailure("captcha solve stall pe=x.js"); + expect(fired.length).toBe(0); + // 8th failure crosses the threshold with zero successes — fires. + p.noteMintFailure("captcha solve stall pe=x.js"); + expect(fired.length).toBe(1); + expect(fired[0]).toContain("mint storm"); + // Cooldown: more failures do not re-fire immediately. + for (let i = 0; i < 5; i++) p.noteMintFailure("captcha solve stall pe=x.js"); + expect(fired.length).toBe(1); + + // A success inside the window suppresses the trigger entirely. + const poolB = new CaptchaTokenPool({ + poolSizeMin: 1, + poolSizeMax: 10, + tokenTtlMs: 60_000, + refillIntervalMs: 600_000, + staggerMs: 0, + solveRetries: 1, + solveConcurrency: 4, + scaleDownIdleMs: 600_000, + }); + const fired2: string[] = []; + (poolB as unknown as { opts: { onCaptchaIpBlock?: (r: string) => void } }).opts.onCaptchaIpBlock = + (reason) => fired2.push(reason); + const p2 = poolB as unknown as { + noteMintFailure: (r: string) => void; + noteMintSuccess: () => void; + }; + p2.noteMintSuccess(); + for (let i = 0; i < 10; i++) p2.noteMintFailure("stall"); + expect(fired2.length).toBe(0); + }); +}); diff --git a/src/proxy/captcha-token.test.ts b/src/proxy/captcha-token.test.ts new file mode 100644 index 0000000..7dd2875 --- /dev/null +++ b/src/proxy/captcha-token.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "bun:test"; +import { isCaptchaIpBlockError } from "./captcha-token.js"; + +describe("isCaptchaIpBlockError", () => { + it("detects the 'too many captcha requests' family", () => { + expect( + isCaptchaIpBlockError( + 'verify rejected: {"verifyCode":"F005","Message":"too many captcha requests"}', + ), + ).toBe(true); + expect( + isCaptchaIpBlockError('Request was denied due to risk control. retry later'), + ).toBe(true); + expect(isCaptchaIpBlockError("rate limit exceeded")).toBe(true); + expect(isCaptchaIpBlockError("frequent requests detected")).toBe(true); + }); + + it("does not flag F008 duplicates (local retry, not an IP block)", () => { + expect( + isCaptchaIpBlockError('duplicate certifyId "F008"'), + ).toBe(false); + expect( + isCaptchaIpBlockError('{"verifyCode":"F008"}'), + ).toBe(false); + }); + + it("does not flag stalls/timeouts (retryable without an IP reset)", () => { + expect(isCaptchaIpBlockError("captcha solve stall pe=pe.062.abc.js")).toBe(false); + expect(isCaptchaIpBlockError("captcha solve timeout pe=pe.089")).toBe(false); + expect(isCaptchaIpBlockError("solver returned empty")).toBe(false); + }); + + it("ignores happy-path / non-captcha messages", () => { + expect(isCaptchaIpBlockError("")).toBe(false); + expect(isCaptchaIpBlockError("captcha failed after 4 attempts: duplicate certifyId ?")).toBe(false); + }); +}); \ No newline at end of file diff --git a/src/proxy/captcha-token.ts b/src/proxy/captcha-token.ts new file mode 100644 index 0000000..450dcc3 --- /dev/null +++ b/src/proxy/captcha-token.ts @@ -0,0 +1,32 @@ +/** Decode certifyId from a base64 Aliyun verify-param blob. */ +export function parseCertifyId(param: string): string | null { + try { + const json = JSON.parse(Buffer.from(param, "base64").toString("utf8")) as { + certifyId?: unknown; + }; + return typeof json.certifyId === "string" && json.certifyId.length > 0 + ? json.certifyId + : null; + } catch { + return null; + } +} + +/** Aliyun F008 — certifyId / verify token already consumed or duplicated. */ +export function isCaptchaDuplicateError(message: string): boolean { + return /F008|"verifyCode"\s*:\s*"F008"/i.test(message); +} + +/** + * Aliyun IP-block family: no-captcha possible because the mint IP has been + * flagged ("too many captcha requests"). Distinct from F008 (duplicate + * certifyId) and from pe-VM stalls/timeouts, which are local-side issues that + * a retry can fix without rotating the IP. + */ +const CAPTCHA_IP_BLOCK_RE = + /too many|request was denied|risk control|frequent|rate\s*limit|exceeded.*(?:request|limit)|ip\s*suspicious|denied due to|retry later/i; + +export function isCaptchaIpBlockError(message: string): boolean { + if (isCaptchaDuplicateError(message)) return false; + return CAPTCHA_IP_BLOCK_RE.test(message); +} diff --git a/src/proxy/captcha.ts b/src/proxy/captcha.ts index 73304b0..166fdab 100644 --- a/src/proxy/captcha.ts +++ b/src/proxy/captcha.ts @@ -83,15 +83,31 @@ async function fetchCaptchaConfig(appVersion: string): Promise { - if (cachedToken && cachedToken.expiresAt > Date.now()) return { verifyParam: cachedToken.verifyParam, region: cachedToken.region }; const cfg = await fetchCaptchaConfig(appVersion); if (!cfg || !cfg.enabled || !cfg.prefix || !cfg.sceneId) throw new Error("Captcha config unavailable"); - const verifyParam = await solveCaptchaWithRetry(cfg); + if (DAEMON_BACKENDS.has(CAPTCHA_BACKEND)) { + // Pre-solved token pool: requests take an already-minted token (sub-ms) + // while background workers refill — the hot path never waits on a solve. + const verifyParam = await solveCaptchaJsdom(cfg); + return { verifyParam, region: cfg.region }; + } + if (cachedToken && cachedToken.expiresAt > Date.now()) return { verifyParam: cachedToken.verifyParam, region: cachedToken.region }; + const verifyParam = await solveInJsdomWithRetry(cfg); cachedToken = { verifyParam, region: cfg.region, expiresAt: Date.now() + TOKEN_TTL_MS }; return { verifyParam, region: cfg.region }; } @@ -116,6 +132,37 @@ async function solveCaptchaWithRetry(cfg: FetchedCaptchaConfig): Promise export function shutdownCaptcha(): void { try { shutdownCaptchaSolver(); } catch {} + try { stopCaptchaPool(); } catch {} +} + +/** + * Start background pre-solving of the token pool (daemon backends only). + * Warms only the idle minimum; the pool grows on demand with traffic. + */ +export async function startCaptchaPool(appVersion: string): Promise { + const cfg = await fetchCaptchaConfig(appVersion); + if (!cfg || !cfg.enabled) return; + // Size the pool before prefill: the module-level pool defers sizing to the + // first configure() so a cold boot doesn't mint a storm of soon-expired + // tokens. CAPTCHA_POOL_MIN/CAPTCHA_POOL_MAX env vars override the defaults. + const min = Number(process.env.CAPTCHA_POOL_MIN || 20); + const max = Number(process.env.CAPTCHA_POOL_MAX || Math.max(min * 6, 120)); + configureCaptchaPool({ poolSizeMin: min, poolSizeMax: max }); + startCaptchaPoolRefill(cfg as CaptchaConfig); + await prefillCaptchaPool(cfg as CaptchaConfig, min); +} + +/** Request an urgent refill burst (e.g. after a challenge/retry). */ +export function urgentCaptcha(): void { + if (DAEMON_BACKENDS.has(CAPTCHA_BACKEND)) urgentCaptchaRefill(); +} + +export function captchaPoolStats(): { ready: number; target: number; activeSolves: number } { + return getCaptchaPoolStats(); +} + +export function configureCaptchaSolving(opts: Parameters[0]): void { + configureCaptchaPool(opts); } async function solveInJsdomWithRetry(cfg: FetchedCaptchaConfig): Promise { From a52554f2fdf88a31493ff2646ad1f9e7be5e675f Mon Sep 17 00:00:00 2001 From: Wraient Date: Mon, 24 Aug 2026 06:53:30 +0530 Subject: [PATCH 3/3] =?UTF-8?q?feat(captcha):=20fully=20self-contained=20s?= =?UTF-8?q?olver=20=E2=80=94=20in-process=20happy-dom,=20no=20external=20N?= =?UTF-8?q?ode?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The happy-dom solver now runs INSIDE the Bun process (src/proxy/ captcha-happy.ts), so the release binaries stay self-contained: no external Node.js, no captcha_node/ daemon, no canvas/ playwright/Chromium. happy is the default backend. Bun-compat work in the port: - GlobalWindow instead of VM Window (Bun drops happy-dom's VMGlobalPropertyScript assignments; class-field globals work) - globalThis window aliasing (ref-counted across concurrent solves) so guest scripts can resolve bare window/document/XMLHttpRequest/ Range/... identifiers — under Bun, script tags execute in the host realm; host-critical globals (process, Bun, intrinsics, timers registry is window-bound) are never shadowed - guest timers bound to the window's own registry so pe-VM callbacks die with the window instead of firing after destroyDom - sync XHR served by a SharedArrayBuffer + Atomics blocking fetch on a worker thread — happy-dom's built-in sync fetch spawns 'process.argv[0] -e', which breaks compiled binaries - Option/Video element-constructor polyfills (FeiLin references them) - uncaughtException handler: a bad rotated pe bundle fails one solve, never the proxy process Verified: bun test 500/0; in-process solves 6/6 one-shot (Node solver parity in the same window); compiled binary (no Node on PATH, fresh HOME, cold CDN cache) boots start-plan, warms the pool, and mints real T001-verified tokens; pool prefill+take e2e ~0-1ms hot path. --- bun.lock | 19 +- captcha_node/README.md | 150 -- captcha_node/daemon.js | 276 --- captcha_node/native_solve.js | 1372 ------------- captcha_node/native_solve2.js | 651 ------- captcha_node/package.json | 20 - captcha_node/solve-core.js | 1733 ----------------- captcha_node/solve-playwright.js | 276 --- captcha_node/solver.js | 22 - captcha_node/worker.js | 233 --- package.json | 1 + .../proxy/captcha-happy.ts | 494 +++-- src/proxy/captcha-jsdom.ts | 83 - src/proxy/captcha-solver.ts | 316 +-- src/proxy/captcha.ts | 38 +- 15 files changed, 424 insertions(+), 5260 deletions(-) delete mode 100644 captcha_node/README.md delete mode 100644 captcha_node/daemon.js delete mode 100644 captcha_node/native_solve.js delete mode 100644 captcha_node/native_solve2.js delete mode 100644 captcha_node/package.json delete mode 100644 captcha_node/solve-core.js delete mode 100644 captcha_node/solve-playwright.js delete mode 100644 captcha_node/solver.js delete mode 100644 captcha_node/worker.js rename captcha_node/solve-happy-lib.js => src/proxy/captcha-happy.ts (79%) diff --git a/bun.lock b/bun.lock index bb4e898..21eff76 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "zcode-proxy", "dependencies": { + "happy-dom": "^20.11.6", "jsdom": "^25.0.0", "undici": "^8.10.0", "yaml": "^2.5.0", @@ -89,10 +90,16 @@ "@types/tough-cookie": ["@types/tough-cookie@4.0.5", "", {}, "sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA=="], + "@types/whatwg-mimetype": ["@types/whatwg-mimetype@3.0.2", "", {}, "sha512-c2AKvDT8ToxLIOUlN51gTiHXflsfIFisS4pO7pDPoKouJCESkhZnEy623gwP9laCy5lnLDAw1vAzu2vM2YLOrA=="], + + "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="], + "agent-base": ["agent-base@7.1.4", "", {}, "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ=="], "asynckit": ["asynckit@0.4.0", "", {}, "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="], + "buffer-image-size": ["buffer-image-size@0.6.4", "", { "dependencies": { "@types/node": "*" } }, "sha512-nEh+kZOPY1w+gcCMobZ6ETUp9WfibndnosbpwB1iJk/8Gt5ZF2bhS6+B6bPYz424KtwsR6Rflc3tCz1/ghX2dQ=="], + "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "call-bind-apply-helpers": ["call-bind-apply-helpers@1.0.2", "", { "dependencies": { "es-errors": "^1.3.0", "function-bind": "^1.1.2" } }, "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ=="], @@ -111,7 +118,7 @@ "dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="], - "entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], + "entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "es-define-property": ["es-define-property@1.0.1", "", {}, "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="], @@ -133,6 +140,8 @@ "gopd": ["gopd@1.2.0", "", {}, "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="], + "happy-dom": ["happy-dom@20.11.6", "", { "dependencies": { "@types/node": ">=20.0.0", "@types/whatwg-mimetype": "^3.0.2", "@types/ws": "^8.18.1", "buffer-image-size": "^0.6.4", "entities": "^7.0.1", "whatwg-mimetype": "^3.0.0", "ws": "^8.21.0" } }, "sha512-Hldbg8AdAa5a5oDcZpjqnGitp7JB0hqWmfv/8qr+kft4vzSD8BHsbdRfzYvL/0QcbKcURC/yyoygbeDQarPvYg=="], + "has-symbols": ["has-symbols@1.1.0", "", {}, "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="], "has-tostringtag": ["has-tostringtag@1.0.2", "", { "dependencies": { "has-symbols": "^1.0.3" } }, "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw=="], @@ -193,7 +202,7 @@ "whatwg-encoding": ["whatwg-encoding@3.1.1", "", { "dependencies": { "iconv-lite": "0.6.3" } }, "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ=="], - "whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + "whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="], "whatwg-url": ["whatwg-url@14.2.0", "", { "dependencies": { "tr46": "^5.1.0", "webidl-conversions": "^7.0.0" } }, "sha512-De72GdQZzNTUBBChsXueQUnPKDkg/5A5zp7pFDuQAj5UFoENpiACU0wlCvzpAGnTkj++ihpKwKyYewn/XNUbKw=="], @@ -206,5 +215,11 @@ "yaml": ["yaml@2.9.0", "", { "bin": { "yaml": "bin.mjs" } }, "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA=="], "cssstyle/rrweb-cssom": ["rrweb-cssom@0.8.0", "", {}, "sha512-guoltQEx+9aMf2gDZ0s62EcV8lsXR+0w8915TC3ITdn2YueuNjdAYh/levpU9nFaoChh9RUS5ZdQMrKfVEN9tw=="], + + "data-urls/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "jsdom/whatwg-mimetype": ["whatwg-mimetype@4.0.0", "", {}, "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg=="], + + "parse5/entities": ["entities@6.0.1", "", {}, "sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g=="], } } diff --git a/captcha_node/README.md b/captcha_node/README.md deleted file mode 100644 index 2e301f5..0000000 --- a/captcha_node/README.md +++ /dev/null @@ -1,150 +0,0 @@ -# captcha_node — solver backends & benchmarks - -Aliyun NoCaptcha (traceless) solvers for the start-plan flow. The proxy (`src/proxy/captcha*.ts`) -drives these via `daemon.js`/`worker.js`; pick a backend with `ZCODE_CAPTCHA_BACKEND`. - -## Backends - -| Backend | `ZCODE_CAPTCHA_BACKEND` | Status | Use when | -|---|---|---|---| -| happy-dom | `happy` (or unset — see note) | **production-proven** | default choice | -| jsdom | `jsdom` (current code default) | working but slower; F001-prone on some IPs | local testing only | -| playwright | `playwright` | working; heaviest | last-resort compatibility | -| headless VM | `headless_solve.js` | **WIP — does not complete** | research only, see below | - -> **Note:** the fallback in code is `process.env.ZCODE_CAPTCHA_BACKEND || "jsdom"` in -> `worker.js` / `solver.js` / `captcha-jsdom.ts`. For production use, set `happy` -> explicitly. - -## Benchmarks (measured 2026-08-22, this machine + IP) - -Single token = one traceless solve. CPU = `process.cpuUsage` delta, RSS via `/proc`. -Network conditions and pe-roll luck vary run-to-run (~40-60% of pe versions stall and need -a retry; retry cost is included in the averages). - -### Backend comparison (fresh window per solve) - -| Backend | Wall time | CPU | Notes | -|---|---|---|---| -| happy-dom | ~1.5–3.0s | **~815ms** avg (426–1235 range) | 20/30 solve-rate here; `happy` lib ~1.8k lines, no browser needed | -| jsdom | — | ~1.6s+ | 100% "degraded result (76 chars)" from this IP during these tests; historically works | -| playwright | — | higher (Chromium launch per solve) | not re-benchmarked this session | -| headless VM | n/a (boots in **68ms/74ms CPU**) | n/a | SDK executes fully but the pe bytecode VM entry never starts — see WIP section | - -### happy-dom window reuse (`CAPTCHA_WINDOW_REUSE=1`) - -Reuses one happy-dom window across solves instead of booting a fresh DOM per token -(implemented in `solve-happy-lib.js`, opt-in). - -| Metric | Fresh window (old) | Window reuse | Delta | -|---|---|---|---| -| CPU per solve (warm) | ~815–878ms | **~260–330ms** | **~2.5–3x less** | -| Wall per solve (warm) | ~1.5–3.0s | **~0.5–0.7s** | ~2.5–4x faster | -| Success rate (8-solve A/B) | 2/8 | 7/8 | fewer wasted retries | -| Steady-state RSS (30 solves) | **~429MB avg** (320–503) | ~813MB avg (484–968) | **~1.9x more memory** | - -Knobs (also settable per-call via `solveTraceless({reuseWindow:true})`): - -`ZCODE_CAPTCHA_BACKEND=happy node solver.js # one-shot solve via any backend` - -On failure/stall the pooled window is destroyed and the retry boots a fresh one -(a fresh InitCaptchaV3 rolls a new pe version anyway). - -**Tuning rule of thumb:** -- CPU-bound box → `CAPTCHA_WINDOW_REUSE=1` + `ZCODE_CAPTCHA_WORKER_MAX_RSS_MB=1100` - (budget ~1GB per captcha worker; the 350MB default would recycle workers every solve - and negate the win). -- Memory-bound box → leave reuse off; behavior is byte-identical to the old path. -- `CAPTCHA_REUSE_MAX_SOLVES=10` was tested and is *worse* (avg 860MB, peak 1161MB): - frequent re-boots churn more than they free. - -### Cost profile of one happy solve (where the CPU goes) - -| Phase | Wall | CPU | -|---|---|---| -| DOM boot + SDK load + init RTT | 0–1.9s | ~800ms | -| pe bytecode VM (W mint) | 1.9–2.6s | ~770ms | -| Burst (Log3/Upload/Log2/Verify) + verify | 2.6–3.1s | ~30ms | - -Window reuse amortizes the first phase; the pe-VM phase is irreducible without the -headless-VM path below. - -## Which backend should an agent use? - -1. **Production / serving inference: `happy` + window reuse** (with the raised RSS cap). - It is the only configuration validated end-to-end through the proxy's inference path - (identity headers + system prompt → HTTP 200). -2. **Local dev / minimal deps: `jsdom`.** Keep the old default for local work — it needs - no Playwright/Chromium and still solves on residential IPs. (It 100% failed from this - datacenter IP during benchmarking — "degraded result" — while happy passed 2/3, so - happy is also more IP-tolerant.) -3. **Debugging DOM behavior: `playwright`.** -4. **Do not build on the pure-HTTP native solver** — server-side validation makes it - infeasible (see findings below). - -## Protocol findings (for future work; proven via a live field-mutation harness) - -The current wire protocol (verified against live passing sessions): - -``` -InitCaptchaV3 -> (cert + DeviceConfig: tokenKey, mid, ip) - ~60ms crypto window -Log3, UploadLog, Log2 (fired within ~2ms of each other, to three different hosts) -VerifyCaptchaV3 (~1-3ms after Log2) -NO Log1. Log2 host is now ap-southeast-1.device.saf.aliyuncs.com -(the old cloudauth Log2 host is only used by Log3) -``` - -Mutation-test results (each value = pass-rate of 3 live trials): - -| Artifact | Mutated to | Result | -|---|---|---| -| Log2 W fields [43]/[21]/[72]/[74] | random/synthetic | **pass 3/3** (server does not validate Log2's W) | -| verify `data` blob | fully synthetic (own h2d cipher + deflate) | **pass 3/3** (construction is reproducible) | -| UploadLog claims, Log3 track | random/emptied | **pass 3/3** | -| token W — any field ([43],[72],[74],[21],[32],[78], 93/94 tail) | changed | **fail 3/3 (F001)** | -| token W = SDK's Log2 W + `\|93-NaN\|94-NaN` | synced pair | **pass 3/3** | -| synthetic W pair (stamps changed consistently everywhere) | — | **fail** — the W contains VM-computed values with no offline derivation | - -Conclusion: pure-HTTP minting is blocked by design — the token W is bound to values the pe -bytecode VM computes (`INIT_MD5_SECRET_SALT` never even fires during traceless). The VM -must execute. `native_solve.js` / `native_solve2.js` are kept as protocol documentation -only. - -The token's `#0#` field is currently the literal string `daye,raolewoba!` — -not a hash (contrary to old comments in `native_solve.js`). - -## Headless VM solver (WIP — `headless_solve.js`) - -Goal: run the real SDK in a bare `node:vm` with a hand-built DOM shim (~70ms boot vs -happy-dom's ~1.9s → potential ~10x CPU cut) and zero captured constants (fully -rotation-resistant: new pe/feilin versions are fetched from CDN automatically). - -Progress: loader, feilin and pe all execute cleanly; the loader's iframe mount runs; no -errors surface anywhere (instrumented catches: 0). Blocker: the pe's generator-based -bytecode interpreter never starts pumping (0 dispatches), and no SDK globals -(`__AYF`, `detectIncognito`, …) ever get registered. Eliminated causes: element -`instanceof` validation, iframe `contentWindow` (was a real loader crash — fixed), -`setImmediate`/`MessageChannel` (fixed), font-fingerprint probe (offsetWidth now varies -per `style.fontFamily`), `postMessage`, canvas 2D, `document.write`, all missing globals. - -Next lead: instrument happy-dom's **sync** interceptor (`beforeSyncRequest` pe branch) to -log when `__AYF`/`initAliyunCaptcha` appear, then diff what pumps the generator. - - -## Benchmarking scripts - -The A/B numbers above come from scripts of the shape kept in this repo history: - -```bash -cd captcha_node -# CPU/wall A/B -CAPTCHA_WINDOW_REUSE=1 node -e '...' # 8 solves, measure cpuUsage/rss per solve -# steady-state RSS (30 solves, sample per solve) -node /tmp/mem_avg.js # re-create from the transcript of this session if needed -# per-backend latency -node scripts/bench-captcha-worker.mjs happy 5 -``` - -When re-benchmarking, always run both sides in the same hour — stall rates drift with -pe rotations and IP reputation. diff --git a/captcha_node/daemon.js b/captcha_node/daemon.js deleted file mode 100644 index 8f024f6..0000000 --- a/captcha_node/daemon.js +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Captcha daemon — bounded concurrency queue. - * - * Modes (ZCODE_CAPTCHA_BROWSER_POOL): - * unset/1 — long-lived worker pool (one Chromium per worker, context per solve) [default] - * 0 — child-process per solve (full browser launch each time) - */ -const readline = require('node:readline'); -const { spawn } = require('node:child_process'); -const path = require('node:path'); - -const NODE_BIN = process.env.ZCODE_NODE_PATH?.trim() || 'node'; -const SOLVER_JS = path.join(__dirname, 'solver.js'); -const WORKER_JS = path.join(__dirname, 'worker.js'); -const USE_POOL = process.env.ZCODE_CAPTCHA_BROWSER_POOL !== '0'; -const MAX_CONCURRENT = Math.max( - 1, - Number( - process.env.CAPTCHA_DAEMON_CONCURRENCY ?? - (process.env.ZCODE_CAPTCHA_LOW_CPU === "1" ? 3 : 6), - ), -); -const SOLVE_TIMEOUT_MS = Number(process.env.ZCODE_CAPTCHA_TIMEOUT || 40) * 1000; -// Lazy pool: workers spawn on demand (no pre-spawn) and idle workers are -// reaped after IDLE_MS with nothing queued — RAM drops to ~1 worker at zero -// traffic instead of MAX_CONCURRENT permanent Chromium processes. -const WORKER_IDLE_REAP_MS = Number(process.env.ZCODE_CAPTCHA_WORKER_IDLE_MS || 120) * 1000; -const WORKER_WARM_FLOOR = Number(process.env.ZCODE_CAPTCHA_WORKER_WARM_FLOOR ?? 1); - -const queue = []; -let active = 0; - -/** @type {Array<{ proc: import('node:child_process').ChildProcess, busy: boolean, buffer: string, ready: boolean, reqId: number | null, timer: ReturnType | null }>} */ -const workers = []; - -function runSolveChild(req) { - const scene = req.scene || '11xygtvd'; - const region = req.region || 'sgp'; - const prefix = req.prefix || 'no8xfe'; - - return new Promise((resolve) => { - const proc = spawn(NODE_BIN, [SOLVER_JS, scene, region, prefix], { - cwd: __dirname, - env: { ...process.env, FONTCONFIG_PATH: '/dev/null' }, - stdio: ['ignore', 'pipe', 'pipe'], - windowsHide: true, - }); - - let stdout = ''; - let stderr = ''; - proc.stdout?.on('data', (chunk) => { - stdout += chunk.toString('utf8'); - }); - proc.stderr?.on('data', (chunk) => { - stderr += chunk.toString('utf8'); - }); - - const timer = setTimeout(() => { - try { - proc.kill('SIGKILL'); - } catch (_) {} - resolve({ id: req.id, ok: false, error: `child solve timeout (${SOLVE_TIMEOUT_MS}ms)` }); - }, SOLVE_TIMEOUT_MS); - - proc.on('error', (err) => { - clearTimeout(timer); - resolve({ id: req.id, ok: false, error: err.message }); - }); - - proc.on('close', (code) => { - clearTimeout(timer); - if (code === 0) { - for (const line of stdout.split(/\r?\n/)) { - if (line.startsWith('VERIFY_PARAM=')) { - const param = line.slice('VERIFY_PARAM='.length).trim(); - if (param.length > 20) { - resolve({ id: req.id, ok: true, param }); - return; - } - } - } - } - const detail = stderr.trim() || stdout.trim() || `exit ${code ?? '?'}`; - resolve({ id: req.id, ok: false, error: detail.slice(0, 300) }); - }); - }); -} - -function spawnWorker() { - const entry = { - proc: null, - busy: false, - buffer: '', - ready: false, - reqId: null, - timer: null, - reaping: false, - }; - const proc = spawn(NODE_BIN, [WORKER_JS], { - cwd: __dirname, - env: { ...process.env, FONTCONFIG_PATH: '/dev/null' }, - stdio: ['pipe', 'pipe', 'pipe'], - windowsHide: true, - }); - entry.proc = proc; - - proc.stdout?.on('data', (chunk) => { - entry.buffer += chunk.toString('utf8'); - let idx; - while ((idx = entry.buffer.indexOf('\n')) >= 0) { - const line = entry.buffer.slice(0, idx).trim(); - entry.buffer = entry.buffer.slice(idx + 1); - if (!line) continue; - try { - const msg = JSON.parse(line); - if (msg.workerReady) { - entry.ready = true; - drainQueue(); - continue; - } - if (typeof msg.id === 'number' && entry.reqId === msg.id) { - if (entry.timer) clearTimeout(entry.timer); - entry.busy = false; - entry.reqId = null; - entry.timer = null; - process.stdout.write(JSON.stringify(msg) + '\n'); - active -= 1; - drainQueue(); - scheduleReap(); - } - } catch (_) {} - } - }); - - proc.stderr?.on('data', (chunk) => { - const text = chunk.toString('utf8').trim(); - if (text) process.stderr.write(`[worker] ${text}\n`); - }); - - proc.on('close', () => { - const i = workers.indexOf(entry); - if (i >= 0) workers.splice(i, 1); - if (entry.reqId != null) { - if (entry.timer) clearTimeout(entry.timer); - process.stdout.write( - JSON.stringify({ id: entry.reqId, ok: false, error: 'worker died mid-solve' }) + '\n', - ); - active -= 1; - } - // Respawn to keep pool size only if workers are needed: a death while - // requests are queued (or under the warm floor) refills; intentional - // idle reaps (entry.reaping, idle system) do not respawn. - if (USE_POOL && !entry.reaping) { - if (queue.length > 0 || active > 0 || workers.length < WORKER_WARM_FLOOR) { - workers.push(spawnWorker()); - } - } - drainQueue(); - }); - - workers.push(entry); - return entry; -} - -function initPool() { - // Lazy: no pre-spawn. Workers appear on demand from drainQueue(). - workers.length = 0; -} - -function reapIdleWorkers() { - if (queue.length > 0 || active > 0) return; - const idle = workers.filter( - (x) => x.ready && !x.busy && x.reqId == null && x.proc && !x.reaping, - ); - // Keep WORKER_WARM_FLOOR warm to avoid cold-start latency on the next token. - const excess = idle.slice(Math.max(0, WORKER_WARM_FLOOR)); - for (const w of excess) { - w.reaping = true; - try { - w.proc.kill('SIGTERM'); - } catch (_) {} - } -} - -let reapTimer = null; -function scheduleReap() { - if (reapTimer) return; - reapTimer = setTimeout(() => { - reapTimer = null; - reapIdleWorkers(); - if (workers.length > 0 || queue.length > 0) scheduleReap(); - }, WORKER_IDLE_REAP_MS); - reapTimer.unref?.(); -} - -function drainQueue() { - while (queue.length > 0 && active < MAX_CONCURRENT) { - const req = queue.shift(); - active += 1; - - if (USE_POOL) { - let w = workers.find((x) => x.ready && !x.busy && x.proc?.stdin?.writable); - if (!w && workers.length < MAX_CONCURRENT) { - // On-demand spawn: grow the pool only when requests are waiting. - w = spawnWorker(); - } - if (!w) { - queue.unshift(req); - active -= 1; - break; - } - w.busy = true; - w.reqId = req.id; - w.timer = setTimeout(() => { - try { - w.proc?.kill('SIGKILL'); - } catch (_) {} - w.busy = false; - w.reqId = null; - w.timer = null; - process.stdout.write( - JSON.stringify({ id: req.id, ok: false, error: `worker solve timeout (${SOLVE_TIMEOUT_MS}ms)` }) + '\n', - ); - active -= 1; - drainQueue(); - }, SOLVE_TIMEOUT_MS); - w.proc.stdin.write(JSON.stringify(req) + '\n'); - } else { - void runSolveChild(req) - .then((result) => { - process.stdout.write(JSON.stringify(result) + '\n'); - }) - .finally(() => { - active -= 1; - drainQueue(); - }); - } - } -} - -function enqueue(req) { - queue.push(req); - drainQueue(); -} - -function main() { - if (USE_POOL) initPool(); - - process.stdout.write( - JSON.stringify({ - ready: true, - concurrency: MAX_CONCURRENT, - mode: USE_POOL ? 'browser-pool' : 'child-process', - }) + '\n', - ); - - const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); - rl.on('line', (line) => { - const trimmed = line.trim(); - if (!trimmed) return; - let req; - try { - req = JSON.parse(trimmed); - } catch { - process.stdout.write(JSON.stringify({ id: null, ok: false, error: 'invalid json' }) + '\n'); - return; - } - if (typeof req.id !== 'number') { - process.stdout.write(JSON.stringify({ id: req.id ?? null, ok: false, error: 'missing id' }) + '\n'); - return; - } - enqueue(req); - }); -} - -main(); diff --git a/captcha_node/native_solve.js b/captcha_node/native_solve.js deleted file mode 100644 index bf6e81f..0000000 --- a/captcha_node/native_solve.js +++ /dev/null @@ -1,1372 +0,0 @@ -/** - * Native Aliyun NoCaptcha solver — no browser, no JS VM, no Playwright. - * - * Reversed from wire captures (harvest8 session) and verified live - * (init -> Log1 -> Log2 -> verify -> T001 + securityToken). - * - * Flow (4 HTTPS POSTs, ~600 ms): - * 1. InitCaptchaV3 -> CertifyId + DeviceConfig (AES-128-CBC key 87f879f135f27da7) - * 2. Log1 (cloudauth-device, constant DeviceData) - * 3. Log2 (um DeviceData registering the session token) - * 4. VerifyCaptchaV3 -> securityToken - * - * The payload key + server mid are read from DeviceConfig per session; the - * midhash/trailing in the token and the prefix/arg in `data` are NOT - * server-validated (any 32-hex works). - */ -"use strict"; - -const crypto = require("crypto"); -const https = require("https"); -const zlib = require("zlib"); -const fs = require("fs"); -const path = require("path"); -if (process.env.NATIVE_PROXY) { - try { - const { ProxyAgent, setGlobalDispatcher } = require("undici"); - setGlobalDispatcher(new ProxyAgent(process.env.NATIVE_PROXY)); - } catch (_) {} -} - -const SCENE = "11xygtvd"; -const VERIFY_KEY = "222aiJodos2938JDdosko2djd82sf0&"; -const LOG_KEY = "DuanemHmyeE6LXCC46sJEDUw5DTlSZ&"; -const AADUANE = "111jdk439dJJIjd023823201"; -const LOG_AADUANE = "DuaneAprqkYsF3nt1yjK29Bf"; -const INIT_URL = "https://no8xfe.captcha-open-southeast.aliyuncs.com/"; -const VERIFY_URL = "https://no8xfe-verify.captcha-open-southeast.aliyuncs.com/"; -const LOG_URL = "https://cloudauth-device-dualstack.ap-southeast-1.aliyuncs.com/"; -const UPLOAD_URL = "https://upload.captcha-open-southeast.aliyuncs.com/"; -const IV = Buffer.from("0123456789ABCDEF"); -const CONF_KEY = Buffer.from("87f879f135f27da7"); -// Constant captcha DeviceData prefix (captured; the blob tail is re-minted -// fresh per session like the SDK — reusing a captured blob verbatim trips -// the verify risk engine (F001)). -const INIT_SK = "3795d28242a11619bc25f786f84e53d4"; - -// === h2d blob cipher (6-bit affine stream, KSA over 64-entry S-box) === -const _SBOX_RAW = [ - 1, 32, 50, 10, 51, 6, 44, 37, 16, 46, 11, 62, 19, 43, 25, 23, 30, 60, 53, 34, - 7, 26, 12, 48, 5, 2, 20, 4, 61, 13, 47, 49, 29, 27, 22, 17, 39, 56, 41, 38, - 55, 31, 15, 58, 52, 40, 8, 57, 45, 35, 59, 36, 42, 54, 63, 3, 24, 28, 14, 9, - 21, -]; -function buildSbox() { - return [ - ..._SBOX_RAW.slice(1, 18), 33, ..._SBOX_RAW.slice(18, 32), 18, - ..._SBOX_RAW.slice(32, 35), 1, ..._SBOX_RAW.slice(35, 60), 0, 21, - ]; -} -function ksa(keyStr) { - const S = buildSbox(); - let t = 0; - for (let o = 0; o < 64; o++) { - t = - (((o + t + S[o] + S[t]) >> 1) + keyStr.charCodeAt(o % keyStr.length)) & 63; - if (t !== o) { - const tmp = S[o]; - S[o] = S[t]; - S[t] = tmp; - } - } - return S; -} -function h2dCrypt(data, S, invert) { - // XOR/affine-4 stream cipher, byte-for-byte verified against the captured - // VerifyCaptchaV3 `data` blob (see h2d_cipher.py). NOT self-inverse. - S = [...S]; - let e = 0; - let a = 0; - const out = Buffer.alloc(data.length); - for (let n = 0; n < data.length; n++) { - a = ((e ^ a) + (S[e] ^ S[a])) & 63; - if (a !== e) { - const t = S[e]; - S[e] = S[a]; - S[a] = t; - } - const x = S[e] + S[a]; - const y = S[x & 63]; - let m = data[n]; - if (invert) m = ((m ^ x ^ y) + (S[a] + a) - e - S[e]) & 255; - else m = (((m + e + S[e]) - (S[a] + a)) ^ x ^ y) & 255; - out[n] = m; - e = (e + 1) & 63; - } - return out; -} - -// === helpers === -function pct(s) { - return encodeURIComponent(String(s)); -} -function sign(params, key) { - const canon = Object.keys(params) - .sort() - .map((k) => `${pct(k)}=${pct(params[k])}`) - .join("&"); - const sts = "POST&%2F&" + pct(canon); - return crypto.createHmac("sha1", key).update(sts).digest("base64"); -} -// Browser request layer: mirror what the passing jsdom path sends (Chrome 127 -// UA + client hints + Accept-Language + Origin/Referer), plus a host-scoped -// cookie jar so the captcha hosts' set-cookie (acw_tc etc.) flows back on -// subsequent calls exactly like jsdom's. -const BROWSER_HEADERS = { - "User-Agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Linux"', - "Accept-Language": "en-US,en;q=0.9", -}; -const cookieJar = new Map(); // host -> Map(name, value) -function cookieHeader(host) { - const m = cookieJar.get(host); - if (!m || m.size === 0) return null; - return [...m.entries()].map(([k, v]) => `${k}=${v}`).join("; "); -} -const CDN_NAV_HEADERS = { - accept: "*/*", - "accept-encoding": "gzip, deflate", - "accept-language": "en", - referer: "https://zcode.z.ai/", - "user-agent": - "Mozilla/5.0 (linux) AppleWebKit/537.36 (KHTML, like Gecko) jsdom/29.1.1", -}; -// The SDK's page-load asset fetches (the WAF sees them as the browser -// evidence at the session/IP level): AliyunCaptcha.js + FeiLin BEFORE log1, -// pe..js + main.css BETWEEN init and the Log3/Log2 burst (captured -// sdkctl14_now order: aliyunCaptcha, feilin140, then after init: pe.094, -// main.css, then Log3/Log2/Upload/Verify). NATIVE_CDN=1 mirrors this. -// NOTE (mitm capture, Aug 18): with a warm disk cache the CDN assets NEVER -// reach the network — the ONLY requests aliyun sees from a passing session -// are the zcode.z.ai page load (GET / -> 307 -> GET /en, 35.2KB HTML) and -// the 6 API POSTs. NATIVE_PAGE=1 mirrors the page load too. -const CDN_ASSETS = [ - { url: "https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js", early: true }, - { url: "https://g.alicdn.com/captcha-frontend/FeiLin/1.5.1/feilin140.e4ddf273062f47310fe754484bdd8cdb6534c3ca3cd6c0c23776fc9f42e24f7e.js", early: true }, - { url: "https://g.alicdn.com/captcha-frontend/dynamicJS/3.29.0/pe.094.f10b48a2d9910e6c.js", early: false }, - { url: "https://g.alicdn.com/captcha-frontend/dynamicJS/3.29.0/main.css", early: false }, -]; -async function loadCdnAssets(early) { - if (process.env.NATIVE_CDN !== "1") return; - for (const a of CDN_ASSETS) { - if (a.early !== early) continue; - try { - const res = await fetch(a.url, { method: "GET", headers: CDN_NAV_HEADERS }); - await res.arrayBuffer(); - } catch (_) {} - } -} -// NATIVE_PAGE=1: load the zcode.z.ai origin page (GET / -> 307 -> /en), the -// ONE network request the passing harness makes that the native never did -// (mitm capture: passing sessions show ONLY the page load + the 6 API POSTs -// on the wire — the CDN assets are disk-cache-served). The page registers -// the IP+UA+origin with the WAF before the captcha burst. NOTE: the harness's -// page load is a COOKIELESS pre-fetch (redirect: follow — jsdom itself never -// navigates; the jar cookies only ride an HTML-string navigation that never -// hits the network — mitm census: exactly 2 page GETs, no Cookie header). -async function loadOriginPage() { - if (process.env.NATIVE_PAGE !== "1") return; - try { - await fetch("https://zcode.z.ai/", { - method: "GET", - redirect: "follow", - headers: { - accept: "*/*", - "accept-encoding": "br, gzip, deflate", - "accept-language": "en-US,en;q=0.9", - "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Linux"', - "user-agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - }, - }).then((r) => r.arrayBuffer()); - } catch (_) {} -} -// Browser mirror: the SDK page load fetches assets from every captcha host -// before the flow, priming the WAF's acw_tc cookie on each. Prime all three -// hosts so Log1/init/Log3/Log2/verify all carry cookies like the SDK's do. -async function primeHosts() { - const hosts = [ - "cloudauth-device.captcha-open-southeast.aliyuncs.com", - "no8xfe.captcha-open-southeast.aliyuncs.com", - "no8xfe-verify.captcha-open-southeast.aliyuncs.com", - ]; - for (const host of hosts) { - try { - await new Promise((resolve, reject) => { - const req = https.request( - { - hostname: host, - path: "/", - method: "GET", - headers: { - Accept: "*/*", - Origin: "https://zcode.z.ai", - Referer: "https://zcode.z.ai/", - ...BROWSER_HEADERS, - }, - }, - (res) => { - res.resume(); - res.on("end", () => { - absorbCookies(host, res.headers["set-cookie"]); - resolve(); - }); - } - ); - req.on("error", reject); - req.end(); - }); - } catch (_) {} - } -} -function absorbCookies(host, setCookie) { - if (!setCookie) return; - const entries = Array.isArray(setCookie) ? setCookie : [setCookie]; - let m = cookieJar.get(host); - if (!m) { - m = new Map(); - cookieJar.set(host, m); - } - for (const raw of entries) { - const first = raw.split(";")[0].trim(); - const eq = first.indexOf("="); - if (eq > 0) m.set(first.slice(0, eq), first.slice(eq + 1)); - } -} -let _mutator = null; -function forwardMutator() { - if (_mutator !== null) return _mutator; - if (!process.env.NATIVE_MUTATE) return (_mutator = false); - try { - const mod = require(path.resolve(process.env.NATIVE_MUTATE)); - if (typeof mod !== "function") return (_mutator = false); - _mutator = mod; - } catch (_e) { - _mutator = false; - } - return _mutator; -} - -let _sched = null; -function loadSchedule() { - if (_sched !== null) return _sched; - if (!process.env.NATIVE_SCHEDULE) return (_sched = false); - try { - _sched = JSON.parse(fs.readFileSync(path.resolve(process.env.NATIVE_SCHEDULE), "utf8")); - } catch (_) { - _sched = false; - } - return _sched; -} -/** Dispatch gate: hold until log1DispatchAt + (rel[act] - rel.Log1) — the - * SDK's CLIENT-side rhythm (claims inside the re-anchored bodies stay - * mid-relative; dispatch deltas must mirror the captured log1-dispatch - * deltas, else claim-vs-arrival skew trips F001). */ -let _log1DispatchAt = 0; -function schedWait(action) { - const s = loadSchedule(); - if (!s || !s.rel || !s.rel[action] || !s.rel.Log1 || !_log1DispatchAt) return Promise.resolve(); - const target = _log1DispatchAt + (s.rel[action] - s.rel.Log1); - const wait = target - Date.now(); - if (process.env.NATIVE_DEBUG_BODIES) console.error(`[sched] ${action} target=${target - _log1DispatchAt}ms wait=${wait}ms now=${Date.now() - _log1DispatchAt}ms`); - if (wait <= 0) return Promise.resolve(); - return new Promise((r) => setTimeout(() => { - if (process.env.NATIVE_DEBUG_BODIES) console.error(`[sched] ${action} woke=${Date.now() - _log1DispatchAt}ms`); - r(); - }, wait)); -} - -async function postFetch(url, params, key) { - if (process.env.NATIVE_DEBUG_BODIES === "1") console.error(`[pf] enter=${Date.now()} ${params.Action || ""}`); - const p = { ...params, Signature: sign(params, key) }; - const body = Object.keys(p) - .map((k) => `${pct(k)}=${pct(p[k])}`) - .join("&"); - const mut = forwardMutator(); - const sendBody = mut ? mut(url, body) : body; - const u = new URL(url); - if (process.env.NATIVE_DEBUG_BODIES === "1") { - console.error(`\n===== NATIVE POST ${Date.now()} ${u.hostname}${u.pathname}\n${body}\n=====`); - } - const headers = { - accept: "*/*", - "accept-encoding": "gzip, deflate", - "accept-language": "en-US,en;q=0.9", - "content-type": "application/x-www-form-urlencoded; charset=UTF-8", - origin: "https://zcode.z.ai", - referer: "https://zcode.z.ai/", - "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Linux"', - "user-agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - }; - const ck = cookieHeader(u.hostname); - if (ck && process.env.NATIVE_SKIP_COOKIES !== "1") headers.cookie = ck; - // undici fetch() adds fetch-spec headers (sec-fetch-mode: cors) that jsdom's - // XHR dispatcher path does NOT send — the passing SDK's POSTs carry NO - // sec-fetch-* headers on the wire (mitm-verified). The raw dispatcher - // request() sends only the exact headers above, like jsdom's XHR. - let res; - try { - const { request } = require("undici"); - res = await request(u, { method: "POST", headers, body: sendBody }); - } catch (_e) { - res = await fetch(u, { method: "POST", headers, body: sendBody, redirect: "manual" }); - } - let raw; - try { - raw = Buffer.from(await res.body.arrayBuffer()); - } catch (_e) { - raw = Buffer.from(await res.text && (await res.text()).toString(), "utf8"); - } - const ce = String(res.headers["content-encoding"] || "").toLowerCase(); - if (ce === "gzip") raw = zlib.gunzipSync(raw); - else if (ce === "deflate") raw = zlib.inflateSync(raw); - const text = raw.toString("utf8"); - let json = null; - try { - json = JSON.parse(text); - } catch (e) { - throw new Error("bad json: " + text.slice(0, 200)); - } - const sc = Array.isArray(res.headers["set-cookie"]) - ? res.headers["set-cookie"].join("; ") - : res.headers["set-cookie"]; - if (sc) absorbCookies(u.hostname, [sc]); - if (mut) { - try { - mut.onResponse(u.href, text); - } catch (_) {} - } - if (process.env.NATIVE_DEBUG_BODIES === "1") { - const show = params.Action === "InitCaptchaV3" ? JSON.stringify(json).slice(0, 2000) : JSON.stringify(json).slice(0, 240); - console.error(`[native-resp] ${u.hostname} ${params.Action} -> ${show}`); - } - return json; -} -function postCurl(url, params, key) { - return new Promise((resolve, reject) => { - if (process.env.NATIVE_DEBUG_BODIES === "1") console.error(`[pf] curl enter=${Date.now()} ${params.Action || ""}`); - const p = { ...params, Signature: sign(params, key) }; - const body = Object.keys(p) - .map((k) => `${pct(k)}=${pct(p[k])}`) - .join("&"); - const mut = forwardMutator(); - const sendBody = mut ? mut(url, body) : body; - const u = new URL(url); - if (process.env.NATIVE_DEBUG_BODIES === "1") { - console.error(`\n===== NATIVE POST ${Date.now()} ${u.hostname}${u.pathname}\n${sendBody}\n=====`); - } - const tmpBody = path.join( - require("os").tmpdir(), - `zcap_body_${Date.now()}_${Math.random().toString(36).slice(2)}.bin`, - ); - fs.writeFileSync(tmpBody, sendBody); - const args = [ - "-sS", - "--compressed", - "-X", - "POST", - u.href, - "-H", - "Content-Type: application/x-www-form-urlencoded; charset=UTF-8", - "-H", - "Origin: https://zcode.z.ai", - "-H", - "Referer: https://zcode.z.ai/", - "--data-binary", - "@" + tmpBody, - "-w", - "\n__ZCAP_STATUS:%{http_code}", - ]; - const ck = cookieHeader(u.hostname); - if (ck && process.env.NATIVE_SKIP_COOKIES !== "1") args.push("-b", ck); - const { execFile } = require("child_process"); - execFile( - process.env.NATIVE_CURL_BIN || "curl_chrome116", - args, - { maxBuffer: 20 * 1024 * 1024, timeout: 60000 }, - (err, stdout, stderr) => { - try { - fs.unlinkSync(tmpBody); - } catch (_) {} - if (process.env.NATIVE_DEBUG_BODIES === "1") - console.error(`[pf] curl done=${Date.now()} ${params.Action || ""} err=${err ? err.code : "ok"}`); - if (err && !stdout) return reject(new Error("curl: " + String(err.message).slice(0, 200))); - let text = stdout.toString("utf8"); - const m = text.match(/\n__ZCAP_STATUS:(\d+)\s*$/); - if (m) { - text = text.slice(0, m.index); - } - try { - const json = JSON.parse(text); - if (mut) { - try { - mut.onResponse(u.href, text); - } catch (_) {} - } - return resolve(json); - } catch (e) { - return reject(new Error("bad json from curl: " + text.slice(0, 200))); - } - }, - ); - }); -} - -function post(url, params, key) { - if (process.env.NATIVE_TRANSPORT === "curl") return postCurl(url, params, key); - if (process.env.NATIVE_FETCH === "1") return postFetch(url, params, key); - return new Promise((resolve, reject) => { - const p = { ...params, Signature: sign(params, key) }; - const body = Object.keys(p) - .map((k) => `${pct(k)}=${pct(p[k])}`) - .join("&"); - const mut = forwardMutator(); - const sendBody = mut ? mut(url, body) : body; - const u = new URL(url); - if (process.env.NATIVE_DEBUG_BODIES === "1") { - console.error(`\n===== NATIVE POST ${Date.now()} ${u.hostname}${u.pathname}\n${sendBody}\n=====`); - } - const headers = process.env.NATIVE_BARE_HEADERS === "1" - ? { - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "Content-Length": Buffer.byteLength(sendBody), - } - : { - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "Content-Length": Buffer.byteLength(sendBody), - Accept: "*/*", - "Accept-Encoding": "gzip, deflate", - Origin: "https://zcode.z.ai", - Referer: "https://zcode.z.ai/", - ...BROWSER_HEADERS, - }; - const ck = cookieHeader(u.hostname); - if (ck && process.env.NATIVE_SKIP_COOKIES !== "1") headers.Cookie = ck; - const req = https.request( - { - hostname: u.hostname, - path: u.pathname, - method: "POST", - headers, - }, - (res) => { - const chunks = []; - const ce = String(res.headers["content-encoding"] || "").toLowerCase(); - res.on("data", (c) => { - chunks.push(c); - }); - res.on("end", () => { - try { - absorbCookies(u.hostname, res.headers["set-cookie"]); - let raw = Buffer.concat(chunks); - if (ce === "gzip") raw = zlib.gunzipSync(raw); - else if (ce === "deflate") raw = zlib.inflateSync(raw); - const rawText = raw.toString("utf8"); - if (mut) { - try { - mut.onResponse(u.href, rawText); - } catch (_) {} - } - const json = JSON.parse(rawText); - if (process.env.NATIVE_DEBUG_BODIES === "1") { - const show = - params.Action === "InitCaptchaV3" - ? JSON.stringify(json).slice(0, 3000) - : JSON.stringify(json).slice(0, 240); - console.error(`[native-resp] ${u.hostname} ${params.Action} -> ${show}`); - } - resolve(json); - } catch (e) { - reject(new Error("bad json: " + raw.toString("utf8").slice(0, 200))); - } - }); - } - ); - req.on("error", reject); - req.write(body); - req.end(); - }); -} -function tsNow() { - return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); -} -function nonce() { - return crypto.randomUUID(); -} -function aesEnc(key, pt) { - const c = crypto.createCipheriv("aes-128-cbc", key, IV); - c.setAutoPadding(false); - return Buffer.concat([c.update(pt), c.final()]); -} -function pkcs7(buf) { - const pad = 16 - (buf.length % 16); - return Buffer.concat([buf, Buffer.alloc(pad, pad)]); -} -function unpad(buf) { - const pad = buf[buf.length - 1]; - return pad < 16 ? buf.slice(0, buf.length - pad) : buf; -} -function randomHex(len) { - return crypto.randomBytes(len / 2).toString("hex").slice(0, len); -} -function uuidV4Hex() { - const b = crypto.randomBytes(16); - b[6] = (b[6] & 0x0f) | 0x40; - b[8] = (b[8] & 0x3f) | 0x80; - return b.toString("hex"); -} - -// === templates (embedded — no external files at runtime) === - -// Real-W value pool harvested from PASSING jsdom SDK sessions (Aug 19). -// The verify risk engine structurally validates [21]/[71]/[73]; crypto-random -// bytes F001. These exact values pass verbatim in fresh sessions (3/3, then -// 6/6 harvest runs); each session picks one set so [21]/[71]/[73] rotate the -// way the SDK's do. [32] stays the stable persistent device id (stableF32, -// seeded via NATIVE_F32 to the jsdom device 17ab549f...). -const W_VALUE_POOL = [ - // SDK [21] plaintext is 8 uppercase/lowercase alphanum ASCII chars - // (captured: "ff5cnj4h", "g655cc7d") — b64-coded in the W. The OLD pool - // used crypto.randomBytes(8) raw bytes, which the risk engine's structural - // validator rejects (F001). - { f21: Buffer.from("ff5cnj4h").toString("base64"), f71: "NqPwTsZj4slVuynnFdOGUA6AINAWiXLvlUwALZfo", f73: "S4Se6AGqFKH4l0yPMjaFw6YiLKj6ufZFLqLXDoeMB0" }, - { f21: Buffer.from("g655cc7d").toString("base64"), f71: "9IPoRp5D4y5pl6FBbLMrusPd0RbRNNhmoAUMbq5w", f73: "i6F5iyX0sOAVnEgqZu16QKQTTznOsOuBHgsXfHzOzj" }, - { f21: Buffer.from("k3qy9pa2").toString("base64"), f71: "7irJozxRxFMU30MicDhEXsLzR1l22CuJpBEvvyZ5", f73: "wBoVEHnrJmec5DNegnuFF5WuzuXYRiAOsva55RdHCA" }, - { f21: Buffer.from("m7vx4b6d").toString("base64"), f71: "qTySEgZ27yIxfA2MydGg8WJa7FL7oOFa2fdqnw2z", f73: "PYHkpO6u9GQwq28YqhTiJqH2RIJOmFAdtwowTpMel8" }, - { f21: Buffer.from("p2nz8h5e").toString("base64"), f71: "UxN0o2x0zQdhIK2jDksv8lyxY0sAzt4x9Nm5XTl8", f73: "rmSxU3chbWudf7oYMPA6IwL4u7oimY8tf2wMdV9PuS" }, - { f21: Buffer.from("w4te6d9f").toString("base64"), f71: "VAZnxWiHDaMgwmiMh7VUvER9QprJdGmxGoojV1Kf", f73: "5QxpK167kAWgl8qa9XPFpSF5wgkeTq46GZ6rLBecs9" }, -]; - -// Full-session replay rig (NATIVE_REPLAY_SESSION): re-emits a captured -// PASSING session's blocks so the synthetic parts can be bisected one at a -// time. NATIVE_REPLAY_USE is a bitmask: 1=data blob, 2=uploadlog claims, -// 4=behavior track, 8=W f21/71/73; default -1 = replay everything. -let _rpl = null; -function loadReplay() { - if (_rpl !== null || !process.env.NATIVE_REPLAY_SESSION) { - if (_rpl === null) _rpl = false; - return _rpl; - } - try { - _rpl = JSON.parse(fs.readFileSync(process.env.NATIVE_REPLAY_SESSION, "utf8")); - } catch (_) { - _rpl = false; - } - return _rpl; -} -function replayUse() { - const v = Number(process.env.NATIVE_REPLAY_USE || -1); - return (b) => v === -1 || (v & b) === b; -} - -// W.10054 payload template (111 fields, captured session, exact wire layout). -// Per-session replacements: [77]=certifyId, [87]=midTs+1, [72]=midTs-440, -// [74]=midTs+1121. field[43] = browser timing matrix; the token variant ends -// with |93-1536|94-1554, the earlier Log2 DD variant omits that tail. -const W_TEMPLATE = [ - "W.10054", "", "", "", "", "Linux x86_64", - "Chrome", "127.0.0.0", "", "", "", "", - "", "", "", "", "", "", - "", "", "", "amZmNmo4Nmw=", "8", "", - "", "", "", "", "", "", - "", "", "21373fa2b8a36869378c44aeae9f1ea0", "", "12", "", - "Linux", "x86_64", "", "", "", "", - "2409:40c2:129a:1604:9091:f22d:d759:af94", "10-0|11-480|20-482|23-582|30-585|40-598|90-1503|91-1520|92-1520|93-1536|94-1554", "true", "true", "", "720*1280", - "", "", "", "", "", "https://zcode.z.ai/", - "", "", "", "", "", "", - "", "", "", "127.0.0.0", "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", "", - "", "saf-captcha", "1", "", "", "tmjlhS6odvjcSEK1F8KeCpjAAvjhNvWGxS68sFf5", - "1786985483752", "v82pqzcGx2GiSXe4VEHBXstA5ajrXKdoeClX0s3HVx", "1786985485272", "desktop", "", "s8FTQ8RmtM", - "5f4943d6d1d2ad38635e3f7b153b2946", "", "5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", "", "", "", - "", "0", "0", "1786985484179", "", "", - "", "", "", "", "", "", - "", "", "", "", "", "", - "", "", "", "", "", "", - "", "", "" -].join("#"); - - -// um DeviceData plaintext template (8-field form, matches the captured SDK -// wire shape exactly): -// sk#W#secret2#W20220202#CLOUD#0#501#b64(inner) -// secret2 = AES-CBC(payloadKey, "W.10054#saf-captcha#") — payloadKey is -// DeviceConfig field[0] (base64 text -> utf8 key). -// inner = mid#payload#AES@k("saf-captcha")#AES@k("W.10054")##AES@k(ts) -// (k = payloadKey; the outer Data layer is AES-CBC(WEB_FLAG_KEY)). -const DD_TEMPLATE_PREFIX = - "3795d28242a11619bc25f786f84e53d4#W#"; - -// data field: TrackList JSON — exact shape from the captured verify `data` -// (mu/te appear once; VerifyTime is a real wall-clock stamp ~10ms after -// TrackStartTime, arg = b64 of 10 random bytes WITH '==' padding). -const REAL_JSON = - '{"TrackList":{"mc":"","tc":"","mu":"","te":"","mp":"","tmv":"","ks":"","fi":"","startTime":%d},"TrackStartTime":%d,"VerifyTime":%d,"arg":"%s"}'; - -// The DC matrix (field 43) is a per-session browser event log in ms since -// [72] (init). Values from a passing pe.090-era wire capture (run7): -// 10-0|11-591|20-594|23-1328|30-1330|40-1344|90-3611|91-3622|92-3623 -// 93-3632|94-3644 (verify-token W only) -// 23/30/40 anchor on the behavior-track startTime (30 = S-2/S/S+14 where -// S = startTime - [72]), 90/91/92 are span-relative (span = [74]-[72]: -// 90 = span-12, 91 = span-1, 92 = span) and the 93/94 tail is minted at the -// verify-token W's build moment ([73]-style: 93 = now-[72]-13, 94 = now-[72]-1). -function buildDcMatrix(initTs, startTime, span, now2, withTail) { - const d30 = startTime - initTs; - // 11/20 = page-load events ~90-115ms BEFORE the interaction start (d30): - // the current-era SDK wires all carry 11 = d30-(92..115), 20 = 11+2..3 - // (jAB: -115/-112, jsdom: -113/-110, sdkw: -92/-91, sdkfull: -92/-90). - // run7's -739/-736 was one outlier; hardcoding it (native's old behavior) - // reads as d30-1123 on today's sessions — outside the SDK's band. - // CRITICAL: the base (10..92) must be drawn ONCE per session — the SDK reuses - // ONE W for the Log2 envelope and the verify token (verified byte-identical - // d11/d20/d30 across sdkctl14_now's Log2-inner W and token W); a fresh - // Math.random draw per buildW call makes the two Ws' [43] diverge -> F001. - if ( - !_dcCache || - _dcCache.initTs !== initTs || - _dcCache.startTime !== startTime || - _dcCache.span !== span - ) { - const r11 = 92 + Math.floor(Math.random() * 23); - const r20 = r11 - 1; - _dcCache = { - initTs, - startTime, - span, - base: `10-0|11-${d30 - r11}|20-${d30 - r20}|23-${d30 - 2}|30-${d30}|40-${d30 + 14}|90-${span - 12}|91-${span}|92-${span}`, - }; - } - return withTail && now2 ? _dcCache.base + `|93-${now2 - initTs - 13}|94-${now2 - initTs - 1}` : _dcCache.base; -} -let _dcCache = null; - -function machineIpv6() { - try { - const ifaces = require("os").networkInterfaces(); - for (const name of Object.keys(ifaces)) { - for (const a of ifaces[name] || []) { - if (a.family !== "IPv6" || a.internal) continue; - const addr = String(a.address); - if (!addr.startsWith("fe80") && !addr.startsWith("::")) return addr; - } - } - } catch (_) {} - return null; -} - -// [32] = persistent W device id, mimicking the SDK's localStorage (stable -// for the lifetime of a "browser install"). First call generates + persists -// (NATIVE_F32 overrides; the harness can seed the recently-observed value -// 17ab549fc615147ebfa8c87f5ae7712b for A/B bisection on this box). -function stableF32() { - if (process.env.NATIVE_F32) return process.env.NATIVE_F32; - const candidates = [ - path.join(process.env.HOME || "/tmp", ".zcode-proxy", "f32"), - "/tmp/zcode-proxy-f32", - ]; - for (const p of candidates) { - try { - if (fs.existsSync(p)) { - const v = fs.readFileSync(p, "utf8").trim(); - if (/^[0-9a-f]{32}$/.test(v)) return v; - } - } catch (_) {} - } - const v = crypto.randomBytes(16).toString("hex"); - for (const p of candidates) { - try { - fs.mkdirSync(path.dirname(p), { recursive: true }); - fs.writeFileSync(p, v); - return v; - } catch (_) {} - } - return v; -} - -// Public IPv4 for the UploadLog telemetry (the SDK reports its egress IP; -// the server cross-checks it against the request source IP). -let _publicIp = null; -function publicIpv4() { - return new Promise((resolve) => { - if (_publicIp) return resolve(_publicIp); - const req = https.request( - { hostname: "api.ipify.org", path: "/", method: "GET", headers: { Accept: "text/plain" } }, - (res) => { - let d = ""; - res.on("data", (c) => (d += c)); - res.on("end", () => { - const ip = d.trim(); - if (/^\d+\.\d+\.\d+\.\d+$/.test(ip)) _publicIp = ip; - resolve(ip || ""); - }); - } - ); - req.setTimeout(5000, () => req.destroy()); - req.on("error", () => resolve("")); - req.end(); - }); -} - -function buildW(cert, midTs, ddVariant, t, ipv6) { - // t = { initTs, wTs, startTime, session, now2? }: the SDK stamps [72]=init - // (solve start), [74]=W-mint-1ms (wall clock), [87]=midTs+1, [110] always - // present (run7 log2 W carries "[Chromium,Not)A;Brand]" too), and salts - // [21]/[71]/[73]/[32] with ONE fresh random set reused verbatim by BOTH the - // Log2 DD W and the verify token W (captured: byte-identical between them). - // Replaying a captured session's values trips the risk engine (F017). - const f = W_TEMPLATE.split("#"); - f[77] = cert; - f[87] = String(midTs + 1); - f[72] = String(t.initTs); - f[74] = String(t.wTs); - f[21] = t.session.f21; - f[71] = t.session.f71; - f[73] = t.session.f73; - f[32] = t.session.f32; - f[110] = "[Chromium,Not)A;Brand]"; - // [42] = THE IP THE SERVER OBSERVED — cfg[8] of the init DeviceConfig, - // echoed VERBATIM (IPv4 or IPv6; every passing SDK capture: W[42] === - // cfg[8] exactly). Never substitute the machine's local address family — - // the risk engine cross-checks the claimed IP against the request source. - const ip = String(ipv6 || ""); - if (ip) f[42] = ip; - const span = t.wTs - t.initTs; - f[43] = buildDcMatrix(t.initTs, t.startTime, span, t.now2, !ddVariant); - return f.join("#"); -} -function decryptConfig(b64) { - const pt = unpad(aesDec(CONF_KEY, Buffer.from(b64, "base64"))); - return pt.toString("utf8"); -} -function aesDec(key, ct) { - const c = crypto.createDecipheriv("aes-128-cbc", key, IV); - c.setAutoPadding(false); - return Buffer.concat([c.update(ct), c.final()]); -} -function buildToken(mid, payloadB64, trailing) { - return Buffer.from(`SG_WEB#${mid}#${payloadB64}#0#${trailing}`).toString("base64"); -} -// um DeviceData outer AES key (constant, extracted via the SDK's secret -// unwrap; decrypts the captured Log2 Data. The W payload + inner blobs use -// the per-session payloadKey from DeviceConfig field[0] instead). -const WEB_FLAG_KEY = Buffer.from("a549a55c60a39aa0"); - -// Log1 Data + init DeviceData are HARDCODED SDK constants — byte-identical -// across ALL captured sessions (sdkctl17/18/20, Aug 18) AND all 6 harvest -// runs (Aug 19): the current bundle ships static blobs (do NOT re-mint; the -// old "fresh per-session random tail" assumption was wrong for this era and -// trips F001 — the server has only ever seen these exact bytes from this -// AaduaneId/device). -const UMDD_KEY = Buffer.from("45f8ac1e1de14397"); -const LOG1_MAGIC = Buffer.from("574cf794cf3773f4e38d9bba1d67a0f7", "hex"); -const LOG1_SK = "3795d28242a11619bc25f786f84e53d4"; -const LOG1_DATA_CONST = - "TEQYvgJq1LrMqFaBybfIzPxz2ygFyAct7X/w+LacfXWd9rGSwE/x6ZCONucD1fehrtHiD2ADpEIEuf3/3x5GPEHZiSr3V6CUS5gq97FCPvfQRPQJb88/uidhHZLBaYMgHjb1hJo0dxcWqO7mL/meqWHPlzNxKfuvTWzLV5/j+Mg="; -const INIT_DEVICE_DATA_CONST = - "TEQYvgJq1LrMqFaBybfIzPxz2ygFyAct7X/w+LacfXWd9rGSwE/x6ZCONucD1fehuJdn/1eqeR0DPqjDoh7DP7j1GLPmBohgT/1vG1SmPu/2HiPf4AvsoZhKQpp33G7xYkh1oJKuT+89UgvvVdCOREp4SrT9vDuovvZSF7BhjrRLbVaYLPYzmgW/jReYy/RU"; - -function buildLog1Data() { - return LOG1_DATA_CONST; -} - -function buildInitData() { - return INIT_DEVICE_DATA_CONST; -} - -// The SDK's Log3 carries a behavior track: AES (with the PER-SESSION cfg0 -// key — the same key as the token W, NOT the constant) of a JSON like -// {"mousemove":[],"mouseclick":[],"keyup":[],"scrollTop":[],"scrollLeft":[], -// "pointerEvent":[],"clientType":"desktop","startTime":,"timestamp":""} -// (captured fresh from a passing live solve: all event lists are EMPTY — -// the "events" only appear on non-jsdom builds with a virtual mouse; the -// payload's presence + metadata is what the risk model reads). -function buildBehaviorJson(midTs) { - // Captured from the CURRENT bundle (pe.057, passing solve): timestamp must - // equal the session midTs (the W[87] stamp) EXACTLY, startTime = midTs+163 - // (pe.055 era: +370 — jitters 160-390). Event arrays may be empty (pe.055 - // passed with all-empty; pe.057 adds synthesized mousemove/click events). - const startTime = midTs + 163 + Math.floor(Math.random() * 80); - return JSON.stringify({ - mousemove: [], - mouseclick: [], - keyup: [], - scrollTop: [], - scrollLeft: [], - pointerEvent: [], - clientType: "desktop", - startTime, - timestamp: String(midTs), - }); -} - -function buildDeviceData(payloadKey, mid, payloadB64, innerTs, opts = {}) { - // 8-field um DeviceData (matches the captured SDK wire shape 1:1): - // sk#W#secret2#W20220202#CLOUD#0#501#b64(inner) - // secret2 and the inner blobs are AES-CBC with the session payloadKey; - // the outer Data layer is AES-CBC with the constant WEB_FLAG_KEY. - // opts.log3 emits the SDK's 7-field Log3 variant: - // sk#W#secret2#W20220202#CLOUD##b64("511#"+b64(inner-nopayload)+"-504#"+b64(full-inner)) - // where "-504" pre-declares the +504 ms ts delta of the Log2 that follows, - // and part1's inner ts is 1 ms before part2's (captured: 0560 vs 0561). - const secret2 = aesEnc(payloadKey, pkcs7(Buffer.from(`W.10054#saf-captcha#${SCENE}`, "utf8"))).toString("base64"); - const saf = aesEnc(payloadKey, pkcs7(Buffer.from("saf-captcha", "utf8"))).toString("base64"); - const wblob = aesEnc(payloadKey, pkcs7(Buffer.from("W.10054", "utf8"))).toString("base64"); - // opts.log3 emits the SDK's 7-field Log3 variant — TWO records joined by - // the constant "-504#" separator (captured verbatim in every passing wire): - // sk#W#secret2#W20220202#CLOUD##b64("511#"+b64(part1)+"-504#"+b64(part2)) - // part1 = mid##AES(saf)#AES(wblob)##AES(tsA) (W slot EMPTY) - // part2 = mid#AES(track)#AES(saf)#AES(wblob)##AES(tsA+1) - // where track = the behavior JSON (captured: all event lists EMPTY, with - // startTime = the W's behavior startTime and timestamp = midTs+1). - // Sending part1 alone trips the verify risk engine (F001): the track - // record in part2 is REQUIRED (pe.053 + pe.090 passing captures both - // carry it; the ts delta part1->part2 = +1ms, tsA = Log2.ts + 11). - if (opts.log3) { - const tsA = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs), "utf8"))).toString("base64"); - const tsB = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs + 1), "utf8"))).toString("base64"); - const part1 = [mid, "", saf, wblob, "", tsA].join("#"); - const trackB64 = opts.track ? aesEnc(payloadKey, pkcs7(Buffer.from(opts.track, "utf8"))).toString("base64") : ""; - const part2 = [mid, trackB64, saf, wblob, "", tsB].join("#"); - const field6 = Buffer.from(`511#${Buffer.from(part1, "utf8").toString("base64")}-504#${Buffer.from(part2, "utf8").toString("base64")}`, "utf8").toString("base64"); - const plain = `${DD_TEMPLATE_PREFIX}${secret2}#W20220202#CLOUD##${field6}`; - return aesEnc(WEB_FLAG_KEY, pkcs7(Buffer.from(plain, "utf8"))).toString("base64"); - } - const tsBlob = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs), "utf8"))).toString("base64"); - const inner = [mid, payloadB64, saf, wblob, "", tsBlob].join("#"); - const plain = `${DD_TEMPLATE_PREFIX}${secret2}#W20220202#CLOUD#0#501#${Buffer.from(inner, "utf8").toString("base64")}`; - return aesEnc(WEB_FLAG_KEY, pkcs7(Buffer.from(plain, "utf8"))).toString("base64"); -} -function buildData(ts, vt, prefix, arg) { - const json = - prefix + - REAL_JSON.replace("%d", ts) - .replace("%d", ts) - .replace("%d", vt) - .replace("%s", arg); - const deflated = zlib.deflateSync(Buffer.from(json, "utf8")); - const b64 = deflated.toString("base64"); - const blob = h2dCrypt(Buffer.from(b64, "utf8"), ksa("3e627e1b4c63f913"), false); - return blob.toString("base64"); -} - -let _cached = null; -let _cachedAt = 0; -let _retried = false; - -async function solveCaptcha(opts = {}) { - // Cache: the Aliyun server pins one certifyId/securityToken per device for - // extended periods; if the backend tolerates reuse, caching avoids the - // 4-call flow. CAPTCHA_CACHE_TTL_MS env or opts.cacheTtlMs. - const cacheTtlMs = - opts.cacheTtlMs ?? - (process.env.CAPTCHA_CACHE_TTL_MS ? Number(process.env.CAPTCHA_CACHE_TTL_MS) : 0); - if (cacheTtlMs > 0 && _cached && Date.now() - _cachedAt < cacheTtlMs) { - return { ..._cached }; - } - - // 0. Prime WAF cookies on all captcha hosts (mirrors the SDK's page/script - // loads so every POST below carries acw_tc). NATIVE_CDN=1 mirrors the - // SDK's ACTUAL page load (CDN assets, no API-host primes) instead. - if (process.env.NATIVE_CDN === "1") { - await loadCdnAssets(true); - } else if (process.env.NATIVE_PAGE !== "1" && process.env.NATIVE_NO_PRIME !== "1") { - await primeHosts(); - } - await loadOriginPage(); - - // 1. log1 (device registration) + init fired TOGETHER at page load — - // the SDK's own pattern: both XHRs depart ~simultaneously, the log1 mid - // is issued mid-flight, and the init response lands mid+632..1100ms - // (sdkfull/sdkw/sdkctl13/sdkctl14_now all show init COMPLETING in that - // band — the init REQUEST is ALREADY in flight when the mid exists; the - // native used to serialize init after log1's response, pushing every - // downstream arrival ~400-800ms late vs the SDK's pattern -> F001). - // The um cloudauth calls carry NO Timestamp/Mode/SceneId params. - // CAPTURED (Aug 18 pe.078/pe.088): the LOG1 RESPONSE also returns a - // DeviceConfig, and ITS mid — not the init response's — is the session - // mid the SDK echoes verbatim into Log2/Log3 and the verify token - // (verified: log1 mid ts 1787049327735 == token mid == log2 mid; the - // init-issued mid (ts +641ms) appears NOWHERE). The risk engine ties - // the session to the log1-registered mid; echoing the init mid -> F001. - const log1P = post( - LOG_URL, - { - AaduaneId: LOG_AADUANE, - Version: "2020-10-15", - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Action: "Log1", - Data: buildLog1Data(), - SignatureNonce: nonce(), - }, - LOG_KEY - ); - _log1DispatchAt = Date.now(); - // MEASURED (timed passing session, Aug 19): the SDK fires init ~1.4s - // AFTER log1 (log1 send -> init send = +1416ms, init response ~+1.5s) — - // NOT simultaneously. The old "both XHRs depart ~simultaneously" premise - // made the native's init arrive at log1+3ms; the arrival rhythm (init ~1.3 - // -1.6s after log1) is the observable the risk engine sees. - const log1 = await log1P; - const sched = loadSchedule(); - if (sched && sched.rel && sched.rel.InitCaptchaV3 && sched.rel.Log1) { - await schedWait("InitCaptchaV3"); - } else { - const initGapMs = process.env.NATIVE_INIT_GAP_MS - ? Number(process.env.NATIVE_INIT_GAP_MS) - : 500 + Math.floor(Math.random() * 250); - await new Promise((r) => setTimeout(r, initGapMs)); - } - const initSentAt = Date.now(); - const initP = post( - INIT_URL, - { - AaduaneId: AADUANE, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Timestamp: tsNow(), - Version: "2023-03-05", - Action: "InitCaptchaV3", - SceneId: SCENE, - Language: "en", - Mode: "popup", - DeviceData: buildInitData(), - SignatureNonce: nonce(), - }, - VERIFY_KEY - ); - const init = await initP; - if (process.env.NATIVE_DEBUG_BODIES) console.error(`[t] initResp=${Date.now()}`); - if (process.env.NATIVE_VERSION_REQUIRE) { - const got = String(init.StaticPath || "").match(/pe\.[0-9]+/); - const want = process.env.NATIVE_VERSION_REQUIRE; - if (!got || !want.includes(got[0])) { - throw new Error("version-mismatch got=" + (got && got[0]) + " want=" + want); - } - } - const cert = init.CertifyId; - if (!cert) throw new Error("init no cert: " + JSON.stringify(init).slice(0, 300)); - - // The LOG1 response's DeviceConfig (field[2]) issues THE session mid: - // -h-- — the SDK echoes THIS exact mid into Log2/ - // Log3 and the verify token (the init response issues a second, unused - // mid ~600ms later; any other tail -> Log2 "404 parameter formate - // error(data)" AND the risk engine's session lookup fails -> F001). - const log1Cfg = decryptConfig((log1.ResultObject && log1.ResultObject.DeviceConfig) || log1.DeviceConfig || "").split("#"); - const log1Mid = log1Cfg[2] || ""; - const log1MidTs = Number(log1Mid.split("-")[2]) || 0; - - // Solve entry: [72] = t0 = the CLAIMED session start, stamped BEFORE the - // log1 mid issue — every CURRENT passing wire keeps [87]=midTs+1 ahead of - // [72] by 458-514ms (pe.092 captures sdkctl17/18/20; the older run7-era - // 850-1200ms comments are superseded). Anchoring t0 on the server-issued - // mid ts (NOT on the local init clock) keeps the [87]-[72] delta identical - // regardless of RTT jitter. - const t0 = log1MidTs - (430 + Math.floor(Math.random() * 100)); - - // Resolve the egress IPv4 DURING the solve window so the tail burst has no - // ipify round trip in it (the uploadlog must land BEFORE the verify, like - // the SDK's asset-loaded telemetry). - const ipv4P = process.env.NATIVE_SKIP_UPLOAD === "1" ? Promise.resolve("") : publicIpv4(); - - const cfgFields = decryptConfig(init.DeviceConfig).split("#"); - // W-payload key: the PER-SESSION key derived from DeviceConfig field[0] - // (b64 -> utf8: "6e62d5c58115def6"). Verified against a fresh passing live - // capture: the CURRENT pe bundle uses this ONE key for every payload layer — - // Log2 secret2 + inner blobs, the Log2 DD W (inner[1]), the Log3 behavior - // JSON, and the verify deviceToken W. (An earlier pe build used the constant - // cc01323d7e7df0c7 instead — do not resurrect that; today's SDK decrypts - // fine only with the cfg0-derived key.) - const tokenKey = Buffer.from(Buffer.from(cfgFields[0], "base64").toString(), "utf8"); - const serverSk = cfgFields[2].split("-")[0]; - // field[7] of the log1 cfg = the mid's own midTs; field[8] = the client IP - // the server sees. - const serverMid = log1Mid || cfgFields[2]; - const serverMidTs = log1MidTs || Number(cfgFields[2].split("-")[2]) || Date.now(); - // W[42] = the IP the server saw at LOG1 time (log1 cfg field[8]); the - // fresh pe.090 capture's W carried log1's IPv6 while init's cfg said IPv4 — - // the SDK feeds log1's value, NOT init's. - const serverIpv6 = log1Cfg[8] || cfgFields[8] || ""; - if (process.env.NATIVE_DEBUG_BODIES === "1") { - console.error(`[native] log1MidTs=${log1MidTs || "none"} initMidTs=${cfgFields[2].split("-")[2]} used=${serverMidTs}`); - } - if (process.env.NATIVE_DEBUG_BODIES === "1") { - console.error("[native] cfg0:", cfgFields[0], "| sk:", serverSk, "| mid:", serverMid, "| ipv6:", serverIpv6, "| log1ip:", log1Cfg[8] || "", "| initip:", cfgFields[8] || "", "| cert:", cert); - } - - // pe.088 wire capture: the SDK uses the server-ISSUED session mid verbatim - // (config field[2]) in the Log2/Log3 skeletons AND the verify token — it - // does NOT re-mint (earlier pe.05x builds did; today any other tail -> - // Log2 "404 parameter formate error(data)"). NATIVE_MID=own forces the - // legacy minted mid for A/B bisection only. - const mint = process.env.NATIVE_MID === "own" ? serverMidTs - (700 + Math.floor(Math.random() * 250)) : serverMidTs - 850 - Math.floor(Math.random() * 180); - const ownMid = `${serverSk}-h-${mint}-${uuidV4Hex()}`; - const mid = process.env.NATIVE_MID === "own" ? ownMid : serverMid; - - // Everything anchors on the LOG1-issued mid's ts (pe.078/pe.088 capture): - // [87] = midTs+1, behavior-track startTime = midTs + 142..289 (TODAY's - // band; the pe.05x-era +700-820 profile no longer matches), [72] = the - // pre-init solve-start claim ~400-1200ms before [87]. - const midTs = Number(mid.split("-")[2]) || Date.now(); - // init round-trip measurement for the UploadLog claims (captured SDK: - // mInit.t = initDone+164, js.t = mInit.t+97, rt = initDone-t0+106) and - // resolve the egress IPv4 DURING the solve window so the exit burst has - // no ipify round trip in it. - const initDoneAt = Date.now(); - const initDurMs = initDoneAt - initSentAt; - - // One shared random set for BOTH W payloads (Log2 DD + verify token) — - // captured SDK sessions carry byte-identical [21]/[71]/[73]/[32] across - // them; per-call randoms trip the verify risk engine (F001). [21]/[71]/ - // [73] are FRESH per session (SDK: new random every run), but [32] is the - // SDK's PERSISTENT device id (localStorage): captured 21373fa2b8a3686937 - // 8c44aeae9f1ea0 for the Aug 17 harness sessions and 17ab549fc615147ebf - // a8c87f5ae7712b for the Aug 18 sessions — stable within each storage - // lifetime, never per-session random. A fresh [32] every session while - // [21]/[71]/[73] rotate trips the device-fingerprint correlation (F001). - const session = { - f21: Buffer.from(crypto.randomBytes(8).toString("hex").slice(0, 8)).toString("base64"), - f71: crypto.randomBytes(30).toString("base64").replace(/=+$/, ""), - f73: crypto.randomBytes(31).toString("base64").replace(/=+$/, ""), - f32: stableF32(), - }; - { - const rplv = loadReplay(); - if (rplv && replayUse()(8) && rplv.wv) { - session.f21 = rplv.wv.f21; - session.f71 = rplv.wv.f71; - session.f73 = rplv.wv.f73; - } else { - const v = W_VALUE_POOL[crypto.randomInt(W_VALUE_POOL.length)]; - session.f21 = v.f21; - session.f71 = v.f71; - session.f73 = v.f73; - } - } - - // 2. CLAIM TIMELINE (measured on 6/6 passing pe.079 sessions, Aug 19): - // mInit.t = midTs+610..715, rt = mInit+230..380 (the SDK's init spans a - // BROADER base than the mid — init start can predate the mid mint), - // js.t = mInit+90..105, js rt = 88..102, data start = js.t+4..6, - // VerifyTime = start+12..15, W[74] mint = VerifyTime+10..15 (so - // mint = js.t+26..36, and the wall-clock burst lands midTs+726..850, - // exactly the observed passing arrival band 745..843), uploadlog rt = - // js.t+240..260. The old anchor (mint = midTs+850..1030 with claims - // straggling ~500ms behind the js.t claim) trips F001 — the risk model - // reads the UPLOADLOG claim lattice, not the arrivals alone. - await loadCdnAssets(false); - const bWant = replayUse(); - const rpl = loadReplay(); - let mInitT = initSentAt; // REAL stamps (set at dispatch): the SDK's - let jsT = Date.now(); // upload log carries its true init/js times; - let dTs = 0; // cooked mid-anchored claims skew the - let dVt = 0; // claim-vs-arrival lattice -> F001. - let mintClaim = 0; - if (rpl && bWant(2) && rpl.uploadLog) { - const rel = (abs) => abs - rpl.midTs; - mInitT = midTs + rel(rpl.uploadLog.mInit.t); - jsT = midTs + rel(rpl.uploadLog.js.t); - dTs = midTs + rel(rpl.dataTs); - dVt = midTs + rel(rpl.dataVt); - mintClaim = midTs + rel(rpl.mint); - } - const mintAtTarget = mintClaim; - const waitMs = mintAtTarget - Date.now(); - if (waitMs > 0) await new Promise((r) => setTimeout(r, waitMs)); - - // 3. mint payload + token + data — all stamps wall-clock, mint-relative - // (verify data: TrackStartTime/startTime = wTs-26..-35, VerifyTime = - // wTs-14..-18; W [74] = mint-1; behavior-track startTime = mid+120..147). - // REAL stamps: with the flow sped up (init at mid+300..500, mint at - // init-done+155..285), the W span = mint-t0 lands ~1500..2450ms — inside - // every passing envelope (SDK13 1336 / SDKW 2590) with ZERO claim-vs- - // arrival skew, exactly like the SDK's own real-clock wires. - const mintAt = Date.now(); - const wTimes = { - initTs: t0, - wTs: mintAt - 1, - startTime: midTs + 120 + Math.floor(Math.random() * 28), - session, - }; - if (process.env.NATIVE_DEBUG_BODIES) console.error(`[t] preBuildW=${Date.now()}`); - const ddPayloadB64 = aesEnc(tokenKey, pkcs7(Buffer.from(buildW(cert, midTs, true, wTimes, serverIpv6), "utf8"))).toString("base64"); - if (process.env.NATIVE_DEBUG_BODIES) console.error(`[t] afterBuildW=${Date.now()}`); - const log2Ts = Date.now(); - const dd = buildDeviceData(tokenKey, mid, ddPayloadB64, log2Ts); - if (process.env.NATIVE_DEBUG_BODIES) console.error(`[t] afterDD=${Date.now()}`); - - // 3. log burst — FIRED, not awaited (the SDK's XHR deliveries are async: - // Log2/Log3 land ~8ms apart while the upload/verify burst departs - // ~210-220ms later — mitm-verified on a passing sdkctl17 session: - // log2 t+3.069, log3 t+3.077, upload t+3.296, verify t+3.297). - // WIRE ORDER: LOG2 -> LOG3 (+8ms) -> ~215ms processing -> UPLOAD (+1ms) - // -> VERIFY. (The older capture comment "LOG3 -> LOG2" is superseded — - // the passing sdkctl17 mitm flow shows Log2 departing first.) - - // Log2 (device data, carries the DD W) departs FIRST. - await schedWait("Log2"); - const pLog2 = post( - LOG_URL, - { - AaduaneId: LOG_AADUANE, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Version: "2020-10-15", - Action: "Log2", - Data: dd, - SignatureNonce: nonce(), - }, - LOG_KEY - ).catch(() => {}); - - // Log3 behavior track: the CURRENT SDK ships REAL collected events here. - // Fresh passing pe.090 capture (sdkctl13): 3-6 mousemove with t spaced - // ~20-40ms, TWO clicks at the FIRST move point's coords with t = p0.t+1 - // and p0.t+2, two keyups at p0.t+3, startTime = mid+100..230, timestamp = - // midTs+1. Absolutely populated — a missing track is a hard F001 today. - // NATIVE_TRACK_REPLAY = path to a JSON file: verbatim FeiLin/SDK track - // (A/B: synthetic vs REAL event data — the last unproven dimension). - const track = - process.env.NATIVE_TRACK_REPLAY - ? (() => { - const t = JSON.parse(fs.readFileSync(process.env.NATIVE_TRACK_REPLAY, "utf8")); - return { - ...t, - scrollTop: [], - scrollLeft: [], - pointerEvent: [], - clientType: "desktop", - startTime: wTimes.startTime, - timestamp: String(midTs + 1), - }; - })() - : loadReplay() && replayUse()(4) && loadReplay().track - ? (() => { - const t = loadReplay().track; - const delta = midTs - loadReplay().midTs; - return { - ...t, - scrollTop: [], - scrollLeft: [], - pointerEvent: [], - clientType: "desktop", - startTime: wTimes.startTime, - timestamp: String(midTs + 1), - }; - })() - : (() => { - const pts = []; - const n = 2 + (Math.random() < 0.5 ? 1 : 0); - // Current-era pe.092 captures (sdkctl17/20): the track STARTS at the click - // cluster (x 505-515, y 340-355), first event t = 425-504 (d11-23..-80), - // moves every ~22-27ms, and the LAST move jumps away (dx -45..-55, - // dy +30..+40). Older-era comments here (top-left trail start, 3-6 moves) - // are superseded by the fresh decrypts. - let x = 505 + Math.floor(Math.random() * 15); - let y = 340 + Math.floor(Math.random() * 18); - let t = 400 + Math.floor(Math.random() * 100); - const tMax = (mintAt - wTimes.startTime) - 150 + Math.floor(Math.random() * 60); - for (let i = 0; i < n && t < tMax; i++) { - if (i === n - 1) { - x -= 45 + Math.floor(Math.random() * 15); - y += 30 + Math.floor(Math.random() * 12); - } - pts.push({ x: Math.round(x), y: Math.round(y), t }); - t += 22 + Math.floor(Math.random() * 6); - } - if (pts.length === 0) pts.push({ x: 506, y: 349, t: 400 }); - const p0 = pts[0]; - const ct = p0.t + 1 + (Math.random() < 0.5 ? 0 : 1); - return { - mousemove: pts, - mouseclick: [ - { x: p0.x, y: p0.y, t: ct, it: false, ft: 0 }, - { x: p0.x, y: p0.y, t: ct, it: false, ft: 0 }, - ], - keyup: [{ t: ct + 1 }, { t: ct + 1 + (Math.random() < 0.5 ? 0 : 1) }], - scrollTop: [], - scrollLeft: [], - pointerEvent: [], - clientType: "desktop", - startTime: wTimes.startTime, - timestamp: String(midTs + 1), - }; - })(); - if (process.env.NATIVE_DEBUG_BODIES) console.error(`[t] afterTrack=${Date.now()}`); - - // Log3 departs ~8ms after Log2 (tsC = Log2.ts + 11, captured +11/+12). - await schedWait("Log3"); - const pLog3 = post( - LOG_URL, - { - AaduaneId: LOG_AADUANE, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Version: "2020-10-15", - Action: "Log3", - Data: buildDeviceData(tokenKey, mid, "", log2Ts + 11, { - log3: true, - track: JSON.stringify(track), - }), - SignatureNonce: nonce(), - }, - LOG_KEY - ).catch(() => {}); - - // UploadLog rides the ipv4 probe promise WITHOUT blocking the W mint — - // the SDK fires it async mid-burst and the verify token mints right after - // the logs, not after the probe RTT. - // NATIVE_SEQ_UPLOAD=1: send UploadLog BEFORE the verify, awaited, using the - // init-seen IPv4 (cfgFields[8], same value the SDK's upload log carries) — - // matches the SDK's arrival order Log2 -> UploadLog -> Verify. - const uploadBody = (remoteIp) => { - const r = loadReplay(); - if (r && replayUse()(2) && r.uploadLog) { - const delta = midTs - r.midTs; - const ul = JSON.parse(JSON.stringify(r.uploadLog)); - ul.mInit.t += delta; - ul.js.t += delta; - return { - AaduaneId: AADUANE, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Timestamp: tsNow(), - Version: "2023-03-05", - Action: "UploadLog", - log: JSON.stringify(ul), - SignatureNonce: nonce(), - }; - } - return { - AaduaneId: AADUANE, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Timestamp: tsNow(), - Version: "2023-03-05", - Action: "UploadLog", - log: JSON.stringify({ - sId: SCENE, - pfx: "no8xfe", - ip: remoteIp, - mInit: { - t: mInitT, - s: true, - msg: "INIT_SUCCESS", - rt: initDurMs + 480 + Math.floor(Math.random() * 150), - }, - hst: "captcha-open-southeast.aliyuncs.com", - cId: cert, - js: { t: jsT, s: true, msg: "DYNAMICJS_LOADED", rt: 85 + Math.floor(Math.random() * 12) }, - rt: initDurMs + 560 + Math.floor(Math.random() * 100), - }), - SignatureNonce: nonce(), - }; - }; - - // SDK arrival pattern (mitm sdkctl17, passing): log2 departs (DD mint) - // -> log3(+8ms) -> token W minted at [74]+21..23 (now2; the 93/94 [43] - // tail + verify data claims anchor here) -> ~195ms of bundle processing - // -> upload(+218ms) -> verify(+219ms, ~1-3ms after upload). - // now2 is PINNED to wTs+21..23 (the SDK's tight band) instead of being - // stamped after the log2/log3 dispatch latency + sleep, which previously - // landed 93/94 at span+27..40 (F001-relevant tail drift). The [43] 93/94 - // values are CLAIMS — pin them; the wall clock merely sleeps to the target. - const now2Target = mintAt + 21 + Math.floor(Math.random() * 3); - const now2 = now2Target; - await new Promise((r) => setTimeout(r, Math.max(0, now2Target - Date.now()))); - // Causality (measured on sdkctl17/20 + 6 passing pe.079 runs Aug 19): - // TrackStartTime = dTs must land ~25-34ms BEFORE the W's [74] (wTs), and - // must equal the behavior-track startTime +4..6 after js.t — buildData is - // called before the W mint, and a VerifyTime after wTs is an impossible - // timeline (all 39 F001 runs carried vt = wTs+6..+21). - const wTs = wTimes.wTs; - const data = (() => { - const r = loadReplay(); - if (r && replayUse()(1) && r.dataPlain) { - const delta = midTs - r.midTs; - const plain = r.dataPlain - .replace(/("startTime":)(\d+)/g, (m, p, v) => p + (Number(v) + delta)) - .replace(/("TrackStartTime":)(\d+)/g, (m, p, v) => p + (Number(v) + delta)) - .replace(/("VerifyTime":)(\d+)/g, (m, p, v) => p + (Number(v) + delta)); - const deflated = zlib.deflateSync(Buffer.from(plain, "utf8")); - return h2dCrypt(Buffer.from(deflated.toString("base64"), "utf8"), ksa("3e627e1b4c63f913"), false).toString("base64"); - } - return buildData( - mintAt - (26 + Math.floor(Math.random() * 9)), - mintAt - (26 + Math.floor(Math.random() * 9)) + 12 + Math.floor(Math.random() * 4), - randomHex(32), - Buffer.from(crypto.randomBytes(10)).toString("base64"), - ); - })(); - const Wstr = buildW(cert, midTs, false, { ...wTimes, now2 }, serverIpv6); - const payloadB64 = aesEnc(tokenKey, pkcs7(Buffer.from(Wstr, "utf8"))).toString("base64"); - const token = buildToken(mid, payloadB64, randomHex(32)); - if (process.env.NATIVE_DUMP === "1") { - try { - fs.writeFileSync("/tmp/opencode/harvest/native_dump.json", JSON.stringify({ mid, cert, midTs, w: Wstr.split("#"), data })); - } catch (_) {} - } - await schedWait("UploadLog"); - if (process.env.NATIVE_SKIP_UPLOAD !== "1") { - if (process.env.NATIVE_SEQ_UPLOAD === "1") { - await post(UPLOAD_URL, uploadBody(cfgFields[8] || "152.59.63.67"), VERIFY_KEY).catch(() => {}); - } else { - void post(UPLOAD_URL, uploadBody(cfgFields[8] || "152.59.63.67"), VERIFY_KEY).catch(() => {}); - } - } - await schedWait("VerifyCaptchaV3"); - const vr = await post( - VERIFY_URL, - { - AaduaneId: AADUANE, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Timestamp: tsNow(), - Version: "2023-03-05", - Action: "VerifyCaptchaV3", - SceneId: SCENE, - CertifyId: cert, - CaptchaVerifyParam: JSON.stringify({ sceneId: SCENE, certifyId: cert, deviceToken: token, data }), - SignatureNonce: nonce(), - }, - VERIFY_KEY - ); - await Promise.allSettled([pLog2, pLog3]); - const res = vr.Result || {}; - if (res.VerifyResult && res.securityToken) { - const out = { certifyId: cert, sceneId: SCENE, isSign: true, securityToken: res.securityToken }; - _cached = out; - _cachedAt = Date.now(); - return { ...out }; - } - // Consumed/replaced certifyId between init and verify: retry the flow once. - if ((res.VerifyCode === "F008" || res.VerifyCode === "F002") && !_retried) { - _retried = true; - try { - return await solveCaptcha(opts); - } finally { - _retried = false; - } - } - const err = new Error("verify failed: " + (res.VerifyCode || vr.Code) + " " + JSON.stringify(vr).slice(0, 300)); - err.verifyCode = res.VerifyCode; - err.response = vr; - throw err; -} - -module.exports = { solveCaptcha }; diff --git a/captcha_node/native_solve2.js b/captcha_node/native_solve2.js deleted file mode 100644 index 6290f63..0000000 --- a/captcha_node/native_solve2.js +++ /dev/null @@ -1,651 +0,0 @@ -/** - * native_solve2.js — profile-driven pure-HTTP Aliyun NoCaptcha solver. - * - * ROTATION-RESISTANT DESIGN: - * Everything the risk engine structurally validates is harvested from - * PASSING happy-dom sessions by capture_native_profile.js and stored in a - * profile JSON (see /tmp/native-profiles or NATIVE_PROFILE). The solver - * regenerates fresh sessions in that shape — nothing about the current - * bundle's expectations is hardcoded beyond the (so-far-stable) signing - * keys and the wire protocol itself. - * - * Today's protocol (verified from live passing captures, 2026-08-22): - * Init → (cert + DeviceConfig: tokenKey, mid, serverIp) - * ~150-450ms later: Log3, UploadLog, Log2 fired back-to-back (~2ms apart) - * Verify ~1-3ms after Log2. NO Log1. Log2 now goes to device.saf host. - * W[43] DC matrix carries literal NaN values and passes. - * - * Usage: - * node native_solve2.js # solve once, print JSON - * NATIVE_PROFILE=/path/profile.json # explicit profile (else newest in dir) - * NATIVE_PROFILE_DIR=/tmp/native-profiles - */ -"use strict"; - -const crypto = require("crypto"); -const https = require("https"); -const fs = require("fs"); -const path = require("path"); -const zlib = require("zlib"); -const { request: undiciRequest } = require("undici"); -const origFetch = global.fetch; - -const SCENE = "11xygtvd"; -const VERIFY_KEY = "222aiJodos2938JDdosko2djd82sf0&"; -const LOG_KEY = "DuanemHmyeE6LXCC46sJEDUw5DTlSZ&"; -const AADUANE = "111jdk439dJJIjd023823201"; -const LOG_AADUANE = "DuaneAprqkYsF3nt1yjK29Bf"; -const INIT_URL = "https://no8xfe.captcha-open-southeast.aliyuncs.com/"; -const VERIFY_URL = "https://no8xfe-verify.captcha-open-southeast.aliyuncs.com/"; -const UPLOAD_URL = "https://upload.captcha-open-southeast.aliyuncs.com/"; -// Current-era Log2 host (rotated away from cloudauth-device; Log3 stays there). -const LOG3_URL = "https://cloudauth-device-dualstack.ap-southeast-1.aliyuncs.com/"; -const LOG2_URL = "https://ap-southeast-1.device.saf.aliyuncs.com/"; -const IV = Buffer.from("0123456789ABCDEF"); -const CONF_KEY = Buffer.from("87f879f135f27da7"); -const WEB_FLAG_KEY = Buffer.from("a549a55c60a39aa0"); -const INIT_DEVICE_DATA_CONST = - "TEQYvgJq1LrMqFaBybfIzPxz2ygFyAct7X/w+LacfXWd9rGSwE/x6ZCONucD1fehuJdn/1eqeR0DPqjDoh7DP7j1GLPmBohgT/1vG1SmPu/2HiPf4AvsoZhKQpp33G7xYkh1oJKuT+89UgvvVdCOREp4SrT9vDuovvZSF7BhjrRLbVaYLPYzmgW/jReYy/RU"; -const DD_TEMPLATE_PREFIX = "3795d28242a11619bc25f786f84e53d4#W#"; - -// ── h2d blob cipher (same as native_solve.js) ────────────────────────────── -const _SBOX_RAW = [ - 1, 32, 50, 10, 51, 6, 44, 37, 16, 46, 11, 62, 19, 43, 25, 23, 30, 60, 53, 34, - 7, 26, 12, 48, 5, 2, 20, 4, 61, 13, 47, 49, 29, 27, 22, 17, 39, 56, 41, 38, - 55, 31, 15, 58, 52, 40, 8, 57, 45, 35, 59, 36, 42, 54, 63, 3, 24, 28, 14, 9, - 21, -]; -function buildSbox() { - return [ - ..._SBOX_RAW.slice(1, 18), 33, ..._SBOX_RAW.slice(18, 32), 18, - ..._SBOX_RAW.slice(32, 35), 1, ..._SBOX_RAW.slice(35, 60), 0, 21, - ]; -} -function ksa(keyStr) { - const S = buildSbox(); - let t = 0; - for (let o = 0; o < 64; o++) { - t = (((o + t + S[o] + S[t]) >> 1) + keyStr.charCodeAt(o % keyStr.length)) & 63; - if (t !== o) { const tmp = S[o]; S[o] = S[t]; S[t] = tmp; } - } - return S; -} -function h2dCrypt(data, S, invert) { - S = [...S]; - let e = 0, a = 0; - const out = Buffer.alloc(data.length); - for (let n = 0; n < data.length; n++) { - a = ((e ^ a) + (S[e] ^ S[a])) & 63; - if (a !== e) { const t = S[e]; S[e] = S[a]; S[a] = t; } - const x = S[e] + S[a]; - const y = S[x & 63]; - let m = data[n]; - if (invert) m = ((m ^ x ^ y) + (S[a] + a) - e - S[e]) & 255; - else m = (((m + e + S[e]) - (S[a] + a)) ^ x ^ y) & 255; - out[n] = m; - e = (e + 1) & 63; - } - return out; -} - -// ── crypto helpers ───────────────────────────────────────────────────────── -function aesEnc(key, pt) { - const c = crypto.createCipheriv("aes-128-cbc", key, IV); - c.setAutoPadding(false); - return Buffer.concat([c.update(pt), c.final()]); -} -function pkcs7(buf) { - const pad = 16 - (buf.length % 16); - return Buffer.concat([buf, Buffer.alloc(pad, pad)]); -} -function unpad(buf) { - const pad = buf[buf.length - 1]; - return pad >= 1 && pad <= 16 ? buf.slice(0, buf.length - pad) : buf; -} -function aesDec(key, ct) { - const c = crypto.createDecipheriv("aes-128-cbc", key, IV); - c.setAutoPadding(false); - return Buffer.concat([c.update(ct), c.final()]); -} -function decryptConfig(b64) { - return unpad(aesDec(CONF_KEY, Buffer.from(b64, "base64"))).toString("utf8"); -} -function randomHex(len) { - return crypto.randomBytes(len / 2).toString("hex").slice(0, len); -} -function uuidV4Hex() { - const b = crypto.randomBytes(16); - b[6] = (b[6] & 0x0f) | 0x40; - b[8] = (b[8] & 0x3f) | 0x80; - return b.toString("hex"); -} -function tsNow() { - return new Date().toISOString().replace(/\.\d{3}Z$/, "Z"); -} -function pct(s) { return encodeURIComponent(String(s)); } -function sign(params, key) { - const canon = Object.keys(params) - .sort() - .map((k) => `${pct(k)}=${pct(params[k])}`) - .join("&"); - const sts = "POST&%2F&" + pct(canon); - return crypto.createHmac("sha1", key).update(sts).digest("base64"); -} - -// ── profile loading (rotation resistance) ────────────────────────────────── -function loadProfiles() { - const dir = process.env.NATIVE_PROFILE_DIR || "/tmp/native-profiles"; - let files = []; - try { - files = fs.readdirSync(dir) - .filter((f) => /^profile-.*\.json$/.test(f) && !f.includes(".raw.")) - .map((f) => path.join(dir, f)) - .sort(); - } catch (_) {} - if (process.env.NATIVE_PROFILE) files.push(process.env.NATIVE_PROFILE); - const profs = files - .map((f) => { try { return JSON.parse(fs.readFileSync(f, "utf8")); } catch (_) { return null; } }) - .filter((p) => p && p.w && Array.isArray(p.w) && p.w.length >= 88); - if (profs.length === 0) { - throw new Error( - "no profiles found — run: cd captcha_node && node capture_native_profile.js 8 " + - "(the native solver needs harvested passing-session profiles)", - ); - } - return profs; -} - -// ── request layer (raw undici: no sec-fetch headers, like jsdom XHR) ─────── -const BROWSER_HEADERS = { - "user-agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Linux"', - "accept-language": "en-US,en;q=0.9", -}; -const cookieJar = new Map(); -function cookieHeader(host) { - const m = cookieJar.get(host); - if (!m || m.size === 0) return null; - return [...m.entries()].map(([k, v]) => `${k}=${v}`).join("; "); -} -function absorbCookies(host, setCookie) { - if (!setCookie) return; - const entries = Array.isArray(setCookie) ? setCookie : [setCookie]; - let m = cookieJar.get(host); - if (!m) { m = new Map(); cookieJar.set(host, m); } - for (const raw of entries) { - const first = raw.split(";")[0].trim(); - const eq = first.indexOf("="); - if (eq > 0) m.set(first.slice(0, eq), first.slice(eq + 1)); - } -} -let _httpDispatcher = null; -function dispatcher() { - if (_httpDispatcher) return _httpDispatcher; - // Native requests stay DIRECT (they are not WAF-blocked; the proxy only - // wraps the LLM POST). NATIVE_PROXY overrides for testing. - const proxyUrl = process.env.NATIVE_PROXY || process.env.HTTPS_PROXY || process.env.HTTP_PROXY; - if (proxyUrl) { - try { - const { ProxyAgent } = require("undici"); - _httpDispatcher = new ProxyAgent(proxyUrl); - } catch (_) { _httpDispatcher = undefined; } - } - return _httpDispatcher; -} -async function post(url, params, key) { - const p = { ...params, Signature: sign(params, key) }; - const body = Object.keys(p).map((k) => `${pct(k)}=${pct(p[k])}`).join("&"); - const u = new URL(url); - // EXACTLY the headers the passing happy sessions send (captured on the - // undici fetch call): fetch-spec headers (sec-fetch-mode, accept, - // accept-encoding, content-length) are added by undici itself — do not - // set them manually or the HTTP/2 fingerprint diverges from the SDK's. - const headers = { - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Linux"', - "user-agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - "accept-language": "en-US,en;q=0.9", - referer: "https://zcode.z.ai/", - origin: "https://zcode.z.ai", - }; - const ck = cookieHeader(u.hostname); - if (ck && process.env.NATIVE_NO_COOKIES !== "1") headers.cookie = ck; - const opts = { method: "POST", headers, body }; - const d = dispatcher(); - if (d) opts.dispatcher = d; - // Note: the local undici 8.x ProxyAgent is incompatible with node's global - // fetch dispatcher protocol ("invalid onRequestStart"). When proxying, use - // the local undici's own request(); otherwise global fetch (happy parity). - let res; - if (d) { - const { request } = require("undici"); - res = await request(u, opts); - } else { - res = await origFetch(u, opts); - } - let raw; - if (res.body && typeof res.body.arrayBuffer === "function") { - raw = Buffer.from(await res.body.arrayBuffer()); - } else if (typeof res.arrayBuffer === "function") { - raw = Buffer.from(await res.arrayBuffer()); - } else { - raw = Buffer.from([]); - } - const ce = String( - (res.headers.get ? res.headers.get("content-encoding") : res.headers["content-encoding"]) || "", - ).toLowerCase(); - if (ce === "gzip") raw = zlib.gunzipSync(raw); - else if (ce === "deflate") raw = zlib.inflateSync(raw); - const scRaw = d - ? res.headers["set-cookie"] - : typeof res.headers.getSetCookie === "function" - ? res.headers.getSetCookie() - : []; - if (scRaw) absorbCookies(u.hostname, Array.isArray(scRaw) ? scRaw : [scRaw]); - const text = raw.toString("utf8"); - let json; - try { json = JSON.parse(text); } catch (e) { throw new Error("bad json: " + text.slice(0, 200)); } - if (process.env.NATIVE_DEBUG === "1") { - console.error(`[native2] ${u.hostname} ${params.Action} -> ${JSON.stringify(json).slice(0, 200)}`); - } - return json; -} -/** Fire-and-forget variant for the burst (Log3/UploadLog/Log2). */ -function fire(url, params, key) { - return post(url, params, key).catch(() => {}); -} - -// ── payload builders (profile-shaped) ────────────────────────────────────── -function rand(min, max) { - return min + Math.floor(Math.random() * (max - min + 1)); -} - -/** Build a W payload array from a harvested profile, re-minted for this - * session: cert, mid-anchored stamps, per-session DC matrix, this session's - * whole-profile salt set ([21]/[71]/[73]/[78]/[85]/[86] stay correlated). */ -function buildWFromProfile(profs, cert, midTs, salt, withTail, wTimes) { - const f = salt.w.slice(); f[77] = cert; - // [87] = cfg[7] EXACTLY (captured: W[87] === init DeviceConfig field[7] - // byte-for-byte, which is midTs+1 or +2) — never re-roll it. - f[87] = String(wTimes.w87 ?? midTs + rand(0, 2)); // = cfg[7] (midTs+1..2) - f[72] = String(wTimes.initTs); // = mid+69..83 - f[74] = String(wTimes.wTs); // mint-1 - // DC matrix [43]: today's shape is `20-a|23-b|30-0|40-c|90-NaN|91-NaN|92-NaN` - // (token W appends `|93-NaN|94-NaN`). a/b/c drawn from the observed bands. - const a = rand(240, 665); - const b = a + rand(31, 81); - const c = rand(1, 9); - f[43] = `20-${a}|23-${b}|30-0|40-${c}|90-NaN|91-NaN|92-NaN${withTail ? "|93-NaN|94-NaN" : ""}`; - f[42] = wTimes.serverIp || f[42]; - return f.join("#"); -} - -function buildDeviceData(payloadKey, mid, payloadB64, innerTs, opts = {}) { - const secret2 = aesEnc(payloadKey, pkcs7(Buffer.from(`W.10054#saf-captcha#${SCENE}`, "utf8"))).toString("base64"); - const saf = aesEnc(payloadKey, pkcs7(Buffer.from("saf-captcha", "utf8"))).toString("base64"); - const wblob = aesEnc(payloadKey, pkcs7(Buffer.from("W.10054", "utf8"))).toString("base64"); - const tsA = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs), "utf8"))).toString("base64"); - if (opts.log3) { - const tsB = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs + 1), "utf8"))).toString("base64"); - const part1 = [mid, "", saf, wblob, "", tsA].join("#"); - const trackB64 = opts.track - ? aesEnc(payloadKey, pkcs7(Buffer.from(opts.track, "utf8"))).toString("base64") - : ""; - const part2 = [mid, trackB64, saf, wblob, "", tsB].join("#"); - const field6 = Buffer.from( - `511#${Buffer.from(part1, "utf8").toString("base64")}-504#${Buffer.from(part2, "utf8").toString("base64")}`, - "utf8", - ).toString("base64"); - const plain = `${DD_TEMPLATE_PREFIX}${secret2}#W20220202#CLOUD##${field6}`; - return aesEnc(WEB_FLAG_KEY, pkcs7(Buffer.from(plain, "utf8"))).toString("base64"); - } - const tsBlob = aesEnc(payloadKey, pkcs7(Buffer.from(String(innerTs), "utf8"))).toString("base64"); - const inner = [mid, payloadB64, saf, wblob, "", tsBlob].join("#"); - const plain = `${DD_TEMPLATE_PREFIX}${secret2}#W20220202#CLOUD#0#501#${Buffer.from(inner, "utf8").toString("base64")}`; - return aesEnc(WEB_FLAG_KEY, pkcs7(Buffer.from(plain, "utf8"))).toString("base64"); -} - -function buildData(prefix, ts, vt, arg) { - const REAL_JSON = - '{"TrackList":{"mc":"","tc":"","mu":"","te":"","mp":"","tmv":"","ks":"","fi":"","startTime":%d},"TrackStartTime":%d,"VerifyTime":%d,"arg":"%s"}'; - const json = prefix + REAL_JSON.replace("%d", ts).replace("%d", ts).replace("%d", vt).replace("%s", arg); - const b64 = zlib.deflateSync(Buffer.from(json, "utf8")).toString("base64"); - const blob = h2dCrypt(Buffer.from(b64, "utf8"), ksa("3e627e1b4c63f913"), false); - return blob.toString("base64"); -} - -function buildToken(mid, payloadB64, trailing) { - return Buffer.from(`SG_WEB#${mid}#${payloadB64}#0#${trailing}`).toString("base64"); -} - -// ── WAF cookie priming (mirrors the happy path's page load + host pings; the -// captured passing sessions carry acw_tc cookies on every captcha POST) ── -async function primeCookies() { - if (process.env.NATIVE_NO_PRIME === "1") return; - // 1. Origin page load (sets zcode WAF cookies; also registers IP+UA+origin) - try { - const res = await undiciRequest("https://zcode.z.ai/", { - method: "GET", - headers: { - accept: "*/*", - "accept-encoding": "gzip, deflate", - "accept-language": "en-US,en;q=0.9", - ...BROWSER_HEADERS, - }, - }); - absorbCookies("zcode.z.ai", res.headers["set-cookie"]); - await res.body.arrayBuffer().catch(() => {}); - } catch (_) {} - // 2. Prime each captcha API host so acw_tc flows on the burst POSTs. - for (const host of [ - "no8xfe.captcha-open-southeast.aliyuncs.com", - "no8xfe-verify.captcha-open-southeast.aliyuncs.com", - "cloudauth-device-dualstack.ap-southeast-1.aliyuncs.com", - "ap-southeast-1.device.saf.aliyuncs.com", - "upload.captcha-open-southeast.aliyuncs.com", - ]) { - try { - const res = await undiciRequest(`https://${host}/`, { - method: "GET", - headers: { accept: "*/*", origin: "https://zcode.z.ai", referer: "https://zcode.z.ai/", ...BROWSER_HEADERS }, - }); - absorbCookies(host, res.headers["set-cookie"]); - await res.body.arrayBuffer().catch(() => {}); - } catch (_) {} - } -} - -// ── the solve ────────────────────────────────────────────────────────────── -// (salt pool helper removed — sessions use one whole profile's salt set) - -async function solveCaptcha() { - await primeCookies(); - const profs = loadProfiles(); - // VERBATIM-REPLAY bisection (NATIVE_REPLAY_CAPTURE=1): after a fresh init, - // re-encrypt the captured passing session's W/track/uploadLog/data with the - // NEW tokenKey + mid + cert, shift all timestamps by (newMidTs - oldMidTs), - // and send. If this passes, the crypto + construction are correct and the - // bug is in my synthetic randomization; if it F001s, the diff is in the - // environment (IP/TLS/session correlation). - const replay = process.env.NATIVE_REPLAY_CAPTURE === "1" ? profs[profs.length - 1] : null; - // ONE whole profile's salt set per session — [21]/[71]/[73]/[78]/[85]/[86] - // are correlated in captures (e.g. f78 9d4568c0.. always pairs f85=1); - // mixing fields across profiles produces combinations the SDK never emits. - const prof = profs[crypto.randomInt(profs.length)]; - const salt = { w: prof.w }; - void salt; // salt set is carried via wTimes/prof; kept for clarity - - // 1. InitCaptchaV3 — DeviceData is the bundle's static constant (byte- - // identical across every captured era; verified Aug 22). - const initSentAt = Date.now(); - const init = await post( - INIT_URL, - { - AaduaneId: AADUANE, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Timestamp: tsNow(), - Version: "2023-03-05", - Action: "InitCaptchaV3", - SceneId: SCENE, - Language: "en", - Mode: "popup", - DeviceData: INIT_DEVICE_DATA_CONST, - SignatureNonce: crypto.randomUUID(), - }, - VERIFY_KEY, - ); - const cert = init.CertifyId; - if (!cert) throw new Error("init no cert: " + JSON.stringify(init).slice(0, 300)); - - const cfgFields = decryptConfig(init.DeviceConfig).split("#"); - const tokenKey = Buffer.from(Buffer.from(cfgFields[0], "base64").toString(), "utf8"); - const mid = cfgFields[2]; - const midTs = Number(mid.split("-")[2]) || Date.now(); - const serverIp = cfgFields[8] || ""; - const initDoneAt = Date.now(); - const initDurMs = initDoneAt - initSentAt; - - // 2. Wait into the observed mint band. Captured passing sessions: - // [72] = mid+68..81, [74] = mid+151..160, burst dispatch = mid+212..215 - // (the W is minted ~55-60ms BEFORE the burst departs). - // Mint target scales with the observed init response latency: fast init - // (mid+67..113) -> burst ~mid+212; slow init (mid+471..473) -> burst ~mid+469 - // (the SDK waits for its asset loading; captured bands: mint = mid+151..160 - // fast / mid+412..583 slow). Pick band by init RTT, then dispatch = mint+60. - const initRtt = initDoneAt - initSentAt; - const slowBand = initRtt > 300; - - // Mirror the SDK's per-solve CDN fetch: the happy path ALWAYS re-fetches - // the rotating pe..js from g.alicdn.com between init and the burst - // (bypassPeCache — network GET with UA only). The CDN/WAF correlates this - // fetch with the session; skipping it is a verified F001 trigger. - try { - const sp = String(init.StaticPath || ""); - if (sp) { - // StaticPath = "3.29.0/pe.081.a2ede546e341a1b9" (no .js) - const peUrl = `https://g.alicdn.com/captcha-frontend/dynamicJS/${sp}${sp.endsWith(".js") ? "" : ".js"}`; - const r = await origFetch(peUrl, { headers: { "user-agent": BROWSER_HEADERS["user-agent"] } }); - await r.arrayBuffer().catch(() => {}); - } - } catch (_) {} - - if (replay) { - // ── VERBATIM REPLAY BISECTION ────────────────────────────────────────── - // Re-encrypt the captured passing session's artifacts with the new - // session's key/mid/cert; shift all stamps by delta = newMidTs - oldMidTs; - // dispatch at the captured rhythm (delta-shifted). Nothing synthetic. - const delta = midTs - replay.midTs; - const shift = (s) => String(Number(s) + delta); - const capW = [...replay.w]; - capW[77] = cert; - capW[72] = shift(capW[72]); - capW[74] = shift(capW[74]); - capW[87] = shift(capW[87]); - const capW2 = [...replay.deviceTokenW || replay.w]; - capW2[77] = cert; - capW2[72] = shift(capW2[72]); - capW2[74] = shift(capW2[74]); - capW2[87] = shift(capW2[87]); - const capTrack = { ...replay.track, startTime: replay.track.startTime + delta, timestamp: shift(replay.track.timestamp) }; - const capUl = JSON.parse(JSON.stringify(replay.uploadLog)); - capUl.mInit.t += delta; - capUl.js.t += delta; - capUl.cId = cert; - const capDataPlain = replay.dataPlain - .replace(/("startTime":)(\d+)/g, (m, a, v) => a + (Number(v) + delta)) - .replace(/("TrackStartTime":)(\d+)/g, (m, a, v) => a + (Number(v) + delta)) - .replace(/("VerifyTime":)(\d+)/g, (m, a, v) => a + (Number(v) + delta)) - // The 32-hex prefix and b64 arg are per-session nonces — re-mint them. - .replace(/^[0-9a-f]{32}/, randomHex(32)) - .replace(/"arg":"[^"]*"/, `"arg":"${Buffer.from(crypto.randomBytes(10)).toString("base64")}"`); - const deflated = zlib.deflateSync(Buffer.from(capDataPlain, "utf8")); - const capData = h2dCrypt(Buffer.from(deflated.toString("base64"), "utf8"), ksa("3e627e1b4c63f913"), false).toString("base64"); - - const capDd = aesEnc(tokenKey, pkcs7(Buffer.from(capW.join("#"), "utf8"))).toString("base64"); - const capDdOuter = buildDeviceData(tokenKey, mid, capDd, Number(capW[74]) + 2); - const t = replay.timings; - const dispatchAt = midTs + (t.log2At ?? 213); - const w1 = dispatchAt - Date.now(); - if (w1 > 0) await new Promise((r) => setTimeout(r, w1)); - fire(LOG3_URL, { - AaduaneId: LOG_AADUANE, SignatureMethod: "HMAC-SHA1", SignatureVersion: "1.0", - Format: "JSON", Version: "2020-10-15", Action: "Log3", - Data: buildDeviceData(tokenKey, mid, "", Number(capW[74]) + 12, { log3: true, track: JSON.stringify(capTrack) }), - SignatureNonce: crypto.randomUUID(), - }, LOG_KEY); - fire(UPLOAD_URL, { - AaduaneId: AADUANE, SignatureMethod: "HMAC-SHA1", SignatureVersion: "1.0", - Format: "JSON", Timestamp: tsNow(), Version: "2023-03-05", Action: "UploadLog", - log: JSON.stringify(capUl), SignatureNonce: crypto.randomUUID(), - }, VERIFY_KEY); - fire(LOG2_URL, { - AaduaneId: LOG_AADUANE, SignatureMethod: "HMAC-SHA1", SignatureVersion: "1.0", - Format: "JSON", Version: "2020-10-15", Action: "Log2", - Data: capDdOuter, SignatureNonce: crypto.randomUUID(), - }, LOG_KEY); - await new Promise((r) => setTimeout(r, 1)); - const capToken = buildToken(mid, aesEnc(tokenKey, pkcs7(Buffer.from(capW2.join("#"), "utf8"))).toString("base64"), crypto.randomBytes(16).toString("hex")); - const vr2 = await post(VERIFY_URL, { - AaduaneId: AADUANE, SignatureMethod: "HMAC-SHA1", SignatureVersion: "1.0", - Format: "JSON", Timestamp: tsNow(), Version: "2023-03-05", Action: "VerifyCaptchaV3", - SceneId: SCENE, CertifyId: cert, - CaptchaVerifyParam: JSON.stringify({ sceneId: SCENE, certifyId: cert, deviceToken: capToken, data: capData }), - SignatureNonce: crypto.randomUUID(), - }, VERIFY_KEY); - const r2 = vr2.Result || {}; - if (r2.VerifyResult && r2.securityToken) { - return { certifyId: cert, sceneId: SCENE, isSign: true, securityToken: r2.securityToken }; - } - const e2 = new Error("REPLAY verify failed: " + (r2.VerifyCode || vr2.Code)); - e2.verifyCode = r2.VerifyCode; - throw e2; - } - - const mintTarget = midTs + (slowBand ? rand(410, 585) : rand(150, 160)); - const wait1 = mintTarget - Date.now(); - if (wait1 > 0) await new Promise((r) => setTimeout(r, wait1)); - - // 3. Claim lattice (mid-anchored, from captured bands): - // [72]=mid+68..81, [74]=mintTarget-1, behavior startTime=mid+120..150, - // data TrackStartTime=mid+130..145, VerifyTime=+8..12 after that, - // uploadLog mInit.t=mid+40..60 rt=initDur+40..120, js.t=mid+125..145. - const wTimes = { - initTs: midTs + rand(69, 83), - wTs: mintTarget - 1, - startTime: midTs + rand(80, 133), - serverIp, - w87: Number(cfgFields[7]) || midTs + 1, // W[87] = cfg[7] verbatim - }; - const track = { - mousemove: [], mouseclick: [], keyup: [], - scrollTop: [], scrollLeft: [], pointerEvent: [], - clientType: "desktop", - startTime: wTimes.startTime, - timestamp: String(Number(cfgFields[7]) || midTs + 1), // = W[87] - }; - // ONE W for both the Log2 DD and the verify token (captured: byte-identical - // between them except [43]'s 93/94 tail on the token variant). - const wStr = buildWFromProfile(profs, cert, midTs, salt, false, wTimes); - const ddPayloadB64 = aesEnc(tokenKey, pkcs7(Buffer.from(wStr, "utf8"))).toString("base64"); - const dd = buildDeviceData(tokenKey, mid, ddPayloadB64, mintTarget); - - const uploadParams = () => ({ - AaduaneId: AADUANE, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Timestamp: tsNow(), - Version: "2023-03-05", - Action: "UploadLog", - log: JSON.stringify({ - sId: SCENE, - pfx: "no8xfe", - ip: serverIp, - mInit: { t: midTs + 47, s: true, msg: "INIT_SUCCESS", rt: initDurMs + rand(100, 150) }, - hst: "captcha-open-southeast.aliyuncs.com", - cId: cert, - js: { t: midTs + rand(130, 140), s: true, msg: "DYNAMICJS_LOADED", rt: rand(80, 90) }, - rt: initDurMs + rand(200, 260), - }), - SignatureNonce: crypto.randomUUID(), - }); - - // Build all burst payloads FIRST (the SDK's ~60ms crypto window), then - // dispatch at the observed band mid+212..218 — the W claims [74]=mid+151 - // but the burst ARRIVES at mid+213 in every capture; dispatching at mint - // time creates claim-vs-arrival skew (F001). - const log3Params = { - AaduaneId: LOG_AADUANE, - Version: "2020-10-15", - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Action: "Log3", - Data: buildDeviceData(tokenKey, mid, "", mintTarget + 11, { - log3: true, - track: JSON.stringify(track), - }), - SignatureNonce: crypto.randomUUID(), - }; - const uploadP = uploadParams(); - const log2Params = { - AaduaneId: LOG_AADUANE, - Version: "2020-10-15", - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Action: "Log2", - Data: dd, - SignatureNonce: crypto.randomUUID(), - }; - - const dispatchWait = midTs + (slowBand ? rand(468, 472) : rand(212, 218)) - Date.now(); - if (dispatchWait > 0) await new Promise((r) => setTimeout(r, dispatchWait)); - - // 4. The burst: Log3 -> UploadLog -> Log2 (~1ms apart), then Verify ~1-3ms - // after Log2 (all fired async — arrival order matters, not completion). - fire(LOG3_URL, log3Params, LOG_KEY); - fire(UPLOAD_URL, uploadP, VERIFY_KEY); - const log2P = fire(LOG2_URL, log2Params, LOG_KEY); - // Verify departs ~1-3ms after Log2 (captured: same ms or +1). - await new Promise((r) => setTimeout(r, rand(0, 2))); - - // 5. Verify — token W is the SAME W with the 93/94 NaN tail appended to [43] - // (captured: only [43] differs between the Log2 W and token W). - const dTs = wTimes.startTime + rand(4, 10); - const data = buildData(randomHex(32), dTs, dTs + rand(8, 12), Buffer.from(crypto.randomBytes(10)).toString("base64")); - const tokenW = wStr.replace(/92-NaN$/, "92-NaN|93-NaN|94-NaN"); - const payloadB64 = aesEnc(tokenKey, pkcs7(Buffer.from(tokenW, "utf8"))).toString("base64"); - const token = buildToken(mid, payloadB64, randomHex(32)); - - const vr = await post( - VERIFY_URL, - { - AaduaneId: AADUANE, - SignatureMethod: "HMAC-SHA1", - SignatureVersion: "1.0", - Format: "JSON", - Timestamp: tsNow(), - Version: "2023-03-05", - Action: "VerifyCaptchaV3", - SceneId: SCENE, - CertifyId: cert, - CaptchaVerifyParam: JSON.stringify({ sceneId: SCENE, certifyId: cert, deviceToken: token, data }), - SignatureNonce: crypto.randomUUID(), - }, - VERIFY_KEY, - ); - await log2P; - const res = vr.Result || {}; - if (res.VerifyResult && res.securityToken) { - return { certifyId: cert, sceneId: SCENE, isSign: true, securityToken: res.securityToken }; - } - const err = new Error("verify failed: " + (res.VerifyCode || vr.Code) + " " + JSON.stringify(vr).slice(0, 300)); - err.verifyCode = res.VerifyCode; - err.response = vr; - throw err; -} - -module.exports = { solveCaptcha, loadProfiles }; - -// CLI: solve once, print a proxy-compatible captcha param (base64 JSON with -// certifyId — same shape solve-core's extractVerifyParam produces). -if (require.main === module) { - solveCaptcha() - .then((r) => { - const param = Buffer.from(JSON.stringify(r)).toString("base64"); - console.log(param); - }) - .catch((e) => { - console.error("native2 FAIL:", e.message); - process.exit(1); - }); -} diff --git a/captcha_node/package.json b/captcha_node/package.json deleted file mode 100644 index b891570..0000000 --- a/captcha_node/package.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "name": "zcode-proxy-captcha-solver", - "version": "1.0.0", - "private": true, - "description": "Headless Aliyun traceless captcha solver (Playwright + Chromium)", - "type": "commonjs", - "main": "solver.js", - "dependencies": { - "canvas": "^3.2.3", - "jsdom": "^29.1.1", - "playwright": "^1.61.0", - "happy-dom": "^20.11.2", - "undici": "^8.10.0" - }, - "allowScripts": { - "canvas@3.2.3": true, - "happy-dom": true, - "undici": true - } -} diff --git a/captcha_node/solve-core.js b/captcha_node/solve-core.js deleted file mode 100644 index 990fbb4..0000000 --- a/captcha_node/solve-core.js +++ /dev/null @@ -1,1733 +0,0 @@ -process.env.FONTCONFIG_PATH = "/dev/null"; -const { ProxyAgent, setGlobalDispatcher } = require("undici"); -const proxyUrl = process.env.HTTP_PROXY || process.env.HTTPS_PROXY; -if (proxyUrl) { - try { - setGlobalDispatcher(new ProxyAgent(proxyUrl)); - } catch (_) {} -} -const { JSDOM, VirtualConsole, requestInterceptor } = require("jsdom"); -const crypto = require("node:crypto"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const util = require("node:util"); - -const CDN_CACHE_DIR = path.join(os.homedir(), ".zcode-captcha-cdn-cache"); - -// ── Optimization: In-memory CDN cache ────────────────────────────────────── -// Avoids fs.readFileSync on every solve for the same SDK scripts (~1.2MB total). -const _memCdnCache = new Map(); - -// ── Optimization: Cookie cache ───────────────────────────────────────────── -// Session cookies (acw_tc, cdn_sec_tc) are valid for ~30-60min; re-fetching -// them on every solve wastes ~300ms. Cache with a 5-minute TTL. -let _cookieCache = { cookies: [], ts: 0 }; -const COOKIE_CACHE_TTL_MS = 5 * 60 * 1000; - -// ── Optimization: DEBUG flag ─────────────────────────────────────────────── -// Only emit [loader-*] stderr lines when CAPTCHA_DEBUG is set. -const _DEBUG = /^(1|true|yes)$/i.test(process.env.CAPTCHA_DEBUG || process.env.CAPTCHA_DEBUG_BODIES || ""); - -const originalPrepareStackTrace = Error.prepareStackTrace; -Error.prepareStackTrace = (err, callsites) => { - if (!callsites || !Array.isArray(callsites)) { - return originalPrepareStackTrace - ? originalPrepareStackTrace(err, callsites) - : String(err); - } - const filtered = callsites.filter((callsite) => { - try { - const filename = callsite.getFileName(); - if (!filename) return true; - const lower = filename.toLowerCase(); - return ( - !lower.includes("solve-core.js") && - !lower.includes("jsdom") && - !lower.includes("node_modules") && - !lower.includes("node:") && - !lower.includes("internal/") - ); - } catch (_) { - return true; - } - }); - - return ( - err.toString() + - "\n" + - filtered - .map((callsite) => { - try { - return " at " + callsite.toString(); - } catch (_) { - return " at "; - } - }) - .join("\n") - ); -}; - -function generateFingerprint() { - // Fingerprint mirrors what a real headless Chromium actually reports on this - // host (matched empirically against a passing Playwright solve). FeiLin's risk - // model cross-checks these for internal consistency. - const userAgent = - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36"; - const uaMajor = "127"; - const uaFull = "127.0.0.0"; - const platform = "Linux x86_64"; - const screen = { w: 1280, h: 720, aw: 1280, ah: 720 }; - // SwiftShader is what headless Chromium actually exposes; claiming a real GPU - // while being unable to render GL is a strong bot tell. - const webglUnmaskedVendor = "Google Inc. (Google)"; - const webglUnmaskedRenderer = - "ANGLE (Google, Vulkan 1.3.0 (SwiftShader Device (Subzero) (0x0000C0DE)), SwiftShader driver)"; - const canvasImage = - "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg=="; - - return { - userAgent, - uaMajor, - uaFull, - platform, - screen, - webglUnmaskedVendor, - webglUnmaskedRenderer, - canvasImage, - }; -} - -const HTML = ` -
- -`; - -function diskPathFor(url) { - return path.join( - CDN_CACHE_DIR, - crypto.createHash("sha1").update(String(url)).digest("hex"), - ); -} - -// jsdom 29 replaced ResourceLoader with requestInterceptor. The interceptor -// receives a WHATWG Request and may return a Response to short-circuit the -// fetch, or return undefined to let the request proceed via undici normally. -// We short-circuit cached bodies; for alicdn we fetch, persist, and respond. - -// HARVEST-ONLY: patch the pe.* bytecode VM interpreter so its opcode-55 (call) -// handler dumps the bytecode program + stack + locals to window.__DBT when the -// callee is btoa/atob. Version-agnostic via regex (minifier may name the -// locals var s or u). -const peVmCallRegex = - /55==A\?\(f=r\[n\+\+\],l=e\.pop\(\),h=e\.pop\(\),o=\[\],\w+\(f\)\.forEach\(function\(\)\{o\.unshift\(e\.pop\(\)\)\}\),p=null===h\?l\.apply\((\w+),o\):h\[l\]\.apply\(h,o\),r\[n\+\+\]&&e\.push\(p\)\):/; -function patchPeBundle(buf, url) { - if (process.env.PE_PATCH === "off") return buf; - if (!/dynamicJS\/[^/]*\/pe\.\d+\./.test(url)) return buf; - let src = buf.toString("utf8"); - if (src.includes("__DBT")) return buf; // already patched - const m = src.match(peVmCallRegex); - if (!m) return buf; - const locals = m[1]; - const hook = `55==A?(f=r[n++],l=e.pop(),h=e.pop(),o=[],v(f).forEach(function(){o.unshift(e.pop())}),p=null===h?l.apply(${locals},o):h[l].apply(h,o),r[n++]&&e.push(p),function(){try{if(l===window.btoa||l===window.atob){window.__DBT=window.__DBT||[];var __sav=[];for(var __i=0;__i 0) { - _memCdnCache.set(url, buf); // store in memory - try { - fs.mkdirSync(CDN_CACHE_DIR, { recursive: true }); - fs.writeFileSync(diskPath, buf); - } catch (_) {} - } - return new Response(patchPeBundle(buf, url), { - status: upstream.status, - headers: { - "content-type": upstream.headers.get("content-type") || sniffMime(url), - }, - }); - } catch (err) { - if (_DEBUG) process.stderr.write(`[loader-network-err] ${url}: ${err.message}\n`); - return undefined; - } -} - -function sniffMime(url) { - if (/\.js(\?|$)/i.test(url)) return "application/javascript"; - if (/\.css(\?|$)/i.test(url)) return "text/css"; - if (/\.png(\?|$)/i.test(url)) return "image/png"; - if (/\.(jpg|jpeg)(\?|$)/i.test(url)) return "image/jpeg"; - if (/\.json(\?|$)/i.test(url)) return "application/json"; - return "application/octet-stream"; -} - -// Wrap with a post-fetch cache: requestInterceptor only short-circuits on a -// returned Response, so to *populate* the cache we layer a fetch that writes -// the body to disk for alicdn resources, then returns the original response. -const resourceLoader = { - interceptors: [requestInterceptor(cachedCaptchaInterceptor)], -}; -const virtualConsole = new VirtualConsole(); -const formatConsoleArg = (arg) => { - if (arg && typeof arg === "object") { - const props = {}; - for (const name of Object.getOwnPropertyNames(arg)) { - props[name] = arg[name]; - } - try { - let proto = Object.getPrototypeOf(arg); - while (proto && proto !== Object.prototype) { - for (const name of Object.getOwnPropertyNames(proto)) { - if (!(name in props)) { - try { - props[name] = arg[name]; - } catch {} - } - } - proto = Object.getPrototypeOf(proto); - } - } catch (_) {} - return `[Object ${arg.constructor?.name || "Object"}]: ${util.inspect(props, { depth: 5 })}`; - } - return String(arg); -}; - -virtualConsole.on("error", (...args) => { - if (_DEBUG) { - const formatted = args.map(formatConsoleArg).join(" "); - process.stderr.write(`[jsdom-err] ${formatted}\n`); - } -}); -virtualConsole.on("warn", (...args) => { - if (_DEBUG) { - const formatted = args.map(formatConsoleArg).join(" "); - process.stderr.write(`[jsdom-warn] ${formatted}\n`); - } -}); -virtualConsole.on("log", (...args) => { - if (_DEBUG) { - const formatted = args.map(formatConsoleArg).join(" "); - process.stderr.write(`[jsdom-log] ${formatted}\n`); - } -}); -virtualConsole.on("jsdomError", (err) => { - if (_DEBUG) { - process.stderr.write( - `[jsdom-jsdomError] ${err.stack || err.message || String(err)}\n`, - ); - } -}); - -/** - * Make a polyfilled function masquerade as a native browser API: its toString() - * returns `function name() { [native code] }`. FeiLin (and most bot detectors) - * string-check methods for `[native code]`; polyfills that leak JS source are - * an instant tell. - */ -function nativeize(fn, name = "") { - if (typeof fn !== "function") return fn; - try { - const nativeRe = /\[native code\]/; - if (nativeRe.test(Function.prototype.toString.call(fn))) return fn; - const display = name || fn.name || ""; - const spoofed = function (...args) { - return fn.apply(this, args); - }; - const nativeStr = `function ${display}() { [native code] }`; - Object.defineProperty(spoofed, "toString", { - value: () => nativeStr, - configurable: true, - writable: true, - }); - Object.defineProperty(spoofed, "name", { - value: display || fn.name || "", - configurable: true, - }); - return spoofed; - } catch (_) { - return fn; - } -} - -/** - * Globally mask Function#toString so jsdom's JS-implemented platform APIs read - * as native. jsdom re-implements setTimeout, createElement, addEventListener, - * performance.now, etc. in JavaScript; their toString() leaks source, which - * fingerprinting SDKs (FeiLin) detect. We decide per-function whether to spoof: - * real native code and tiny page helpers are left honest; anything multi-line - * or carrying jsdom-internal markers is reported as `function name() { [native code] }`. - */ -function installNativeToString(window) { - // jsdom implements the platform in JavaScript. Node's vm/contextify bypasses - // a plain Function.prototype.toString override for host-bound functions, but - // it DOES honor an own `toString` property on each function. So we walk the - // prototype chains of window, navigator, document, and common platform - // objects, and attach a native-looking own toString to every method we find. - const realToString = Function.prototype.toString; - const nativeRe = /\[native code\]/; - - const mask = (fn) => { - if (typeof fn !== 'function') return; - try { - const real = realToString.call(fn); - if (nativeRe.test(real)) return; - const name = fn.name || ''; - // Only mask jsdom platform code (multi-line or internal vars); keep - // page-authored single-liners honest so we don't break the page's own code. - if (!real.includes('\n') && !/\besValue\b/.test(real) && !/\blocalName\b/.test(real)) return; - const nativeStr = `function ${name}() { [native code] }`; - Object.defineProperty(fn, 'toString', { - value: () => nativeStr, - configurable: true, - writable: true, - }); - } catch (_) {} - }; - - const seen = new window.Set(); - const maskObj = (obj, depth) => { - if (!obj || (typeof obj !== 'object' && typeof obj !== 'function') || depth > 4) return; - if (seen.has(obj)) return; - try { seen.add(obj); } catch (_) { return; } - let names = []; - try { names = Object.getOwnPropertyNames(obj); } catch (_) { return; } - for (const name of names) { - if (name === 'toString' || name === 'constructor') continue; - let desc; - try { desc = Object.getOwnPropertyDescriptor(obj, name); } catch (_) { continue; } - if (!desc) continue; - if (typeof desc.value === 'function') { - mask(desc.value); - } else if (typeof desc.get === 'function') { - mask(desc.get); - try { const v = desc.get.call(obj); if (typeof v === 'function') mask(v); } catch (_) {} - } - // Recurse into prototype-ish members (constructors) at shallow depth. - if (depth < 2) { - try { - const v = desc.value; - if (v && (typeof v === 'function' || typeof v === 'object')) maskObj(v, depth + 1); - } catch (_) {} - } - } - }; - - const targets = [window, window.navigator, window.document, window.Document.prototype, window.Element.prototype, window.HTMLElement.prototype, window.Node.prototype, window.EventTarget.prototype, window.HTMLCanvasElement.prototype, window.XMLHttpRequest.prototype]; - for (const t of targets) { - try { maskObj(t, 0); } catch (_) {} - } -} - -function rawTransportSend(xhr, url, body, fallback) { - // Isolation experiment: send the POST via raw https.request (the native's - // transport: distinct header spelling, no cookie, no undici) while keeping - // the page/GET/SDK-execution context intact. Feeds the response back into - // the XHR so the SDK flow continues normally. - const https = require("https"); - const zlib = require("zlib"); - process.stderr.write(`[rawtransport] ${Date.now()} POST ${url} $(body.length) chars\n`); - let u; - try { u = new URL(url); } catch (e) { return fallback.call(xhr, body); } - const headerSet = { - "Content-Type": "application/x-www-form-urlencoded; charset=UTF-8", - "Content-Length": Buffer.byteLength(body), - Accept: "*/*", - "Accept-Encoding": "gzip, deflate", - Origin: "https://zcode.z.ai", - Referer: "https://zcode.z.ai/", - "User-Agent": - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - "sec-ch-ua": '"Chromium";v="127", "Not)A;Brand";v="24"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Linux"', - "Accept-Language": "en-US,en;q=0.9", - }; - const proxyUrl = process.env.NATIVE_XHR_PROXY || process.env.HTTPS_PROXY || process.env.HTTP_PROXY || ""; - let req; - if (proxyUrl) { - let pu; - try { - pu = new URL(proxyUrl.startsWith("http") ? proxyUrl : "http://" + proxyUrl); - } catch (e) { return fallback.call(xhr, body); } - req = https.request({ - hostname: pu.hostname, - port: pu.port || 443, - method: "CONNECT", - path: `${u.hostname}:443`, - headers: { Host: `${u.hostname}:443` }, - }); - req.on("connect", (res, socket) => { - if (res.statusCode !== 200) { socket.destroy(); return fallback.call(xhr, body); } - req = https.request({ socket, hostname: u.hostname, path: u.path, method: "POST", headers: headerSet, servername: u.hostname }); - req.on("response", (r) => finish(r)); - req.on("error", () => { try { fallback.call(xhr, body); } catch (_) {} }); - req.end(body); - }); - req.on("error", () => { try { fallback.call(xhr, body); } catch (_) {} }); - req.end(); - } else { - req = https.request({ hostname: u.hostname, path: u.path, method: "POST", headers: headerSet, servername: u.hostname }); - req.on("response", (r) => finish(r)); - req.on("error", () => { try { fallback.call(xhr, body); } catch (_) {} }); - req.end(body); - } - function finish(r) { - const chunks = []; - r.on("data", (c) => chunks.push(c)); - r.on("end", () => { - let raw = Buffer.concat(chunks); - const ce = String(r.headers["content-encoding"] || "").toLowerCase(); - try { - if (ce === "gzip") raw = zlib.gunzipSync(raw); - else if (ce === "deflate") raw = zlib.inflateSync(raw); - } catch (_) {} - const text = raw.toString("utf8"); - try { - Object.defineProperty(xhr, "status", { configurable: true, enumerable: true, writable: true, value: r.statusCode || 200 }); - Object.defineProperty(xhr, "readyState", { configurable: true, enumerable: true, writable: true, value: 4 }); - Object.defineProperty(xhr, "statusText", { configurable: true, enumerable: true, writable: true, value: "OK" }); - Object.defineProperty(xhr, "responseText", { configurable: true, enumerable: true, writable: true, value: text }); - Object.defineProperty(xhr, "response", { configurable: true, enumerable: true, writable: true, value: text }); - } catch (_) {} - try { xhr.dispatchEvent(new window.Event("load")); } catch (_) {} - try { xhr.dispatchEvent(new window.Event("readystatechange")); } catch (_) {} - process.stderr.write(`\n===== XHR ${Date.now()} POST ${url} [rawtransport ${r.statusCode}]\n--- RESP (${text.length}b) ---\n${text.slice(0, 40000)}\n=====\n`); - }); - r.on("error", () => { try { fallback.call(xhr, body); } catch (_) {} }); - } -} - -function installTrafficLogger(window) { - const DEBUG_HOSTS = /(cloudauth-device|captcha-open|verify|upload)/i; - const origXHROpen = window.XMLHttpRequest.prototype.open; - const origXHRSend = window.XMLHttpRequest.prototype.send; - const origSetHdr = window.XMLHttpRequest.prototype.setRequestHeader; - window.XMLHttpRequest.prototype.open = function (method, url, ...rest) { - this.__capMethod = method; - this.__capUrl = url; - return origXHROpen.call(this, method, url, ...rest); - }; - window.XMLHttpRequest.prototype.setRequestHeader = function (name, value) { - const h = this.__capHdrs || (this.__capHdrs = {}); - h[String(name)] = String(value); - return origSetHdr && origSetHdr.call(this, name, value); - }; - window.XMLHttpRequest.prototype.send = function (body) { - const url = String(this.__capUrl || ""); - if (/no8xfe-verify/i.test(url)) { - try { - const st = new Error("verify-trace").stack || ""; - process.stderr.write(`\n===== VERIFY TRACE =====\n${st}\n=====\n`); - } catch (_) {} - } - if (DEBUG_HOSTS.test(url)) { - const reqPreview = - typeof body === "string" ? body.slice(0, 40000) : ""; - this.addEventListener("load", () => { - let respPreview = ""; - try { - respPreview = String(this.responseText || "").slice(0, 40000); - } catch (_) {} - process.stderr.write( - `\n===== XHR ${Date.now()} ${String(this.__capMethod || "?")} ${url}\n--- REQ (${typeof body === "string" ? body.length : "?"} chars) ---\n${reqPreview}\n--- RESP (${respPreview.length}b) ---\n${respPreview}\n=====\n`, - ); - }); - } - if (global.__captchaMutator) { - try { - const mutated = global.__captchaMutator(String(this.__capUrl || ""), body); - if (typeof mutated === "string" && mutated !== body) body = mutated; - } catch (_) {} - } - if (process.env.NATIVE_XHR_TRANSPORT === "1" && DEBUG_HOSTS.test(url) && !/^(GET|HEAD)$/i.test(String(this.__capMethod || "POST")) && typeof body === "string" && body.length > 0) { - rawTransportSend(this, url, body, origXHRSend); - return; - } - return origXHRSend.call(this, body); - }; - if (typeof window.fetch === "function") { - const origFetch = window.fetch; - window.fetch = function (input, init) { - const url = String(typeof input === "string" ? input : input?.url || ""); - const p = origFetch.call(this, input, init); - if (DEBUG_HOSTS.test(url)) { - const reqBody = - init && init.body && typeof init.body === "string" - ? init.body.slice(0, 40000) - : ""; - p.then((resp) => { - resp - .clone() - .text() - .then((t) => { - process.stderr.write( - `\n===== FETCH ${(init && init.method) || "GET"} ${url}\n--- REQ (${typeof reqBody === "string" ? reqBody.length : "?"} chars) ---\n${reqBody}\n--- RESP (${t.length}b) ---\n${t.slice(0, 40000)}\n=====\n`, - ); - }) - .catch(() => {}); - }).catch(() => {}); - } - return p; - }; - } -} - -function applyPolyfills(window, fp) { - if (process.env.CAPTCHA_DEBUG_BODIES === "1") { - installTrafficLogger(window); - } - - // jsdom implements browser APIs in JavaScript, so every method (even - // genuinely-native ones like setTimeout / createElement / addEventListener) - // leaks its JS source via Function#toString. A real browser returns - // `function name() { [native code] }`. Fingerprinting SDKs like FeiLin sweep - // toString across the platform, so we override it globally: any function not - // authored by the page itself is reported as native. - installNativeToString(window); - - // Global Observers and events stubs - window.IntersectionObserver = - window.IntersectionObserver || - class { - constructor(cb) { - this.cb = cb; - } - observe() {} - unobserve() {} - disconnect() {} - takeRecords() { - return []; - } - }; - - window.ResizeObserver = - window.ResizeObserver || - class { - constructor(cb) { - this.cb = cb; - } - observe() {} - unobserve() {} - disconnect() {} - }; - - window.DeviceOrientationEvent = - window.DeviceOrientationEvent || - class extends window.Event { - constructor(type, opts) { - super(type, opts); - } - alpha = null; - beta = null; - gamma = null; - absolute = false; - }; - - window.DeviceMotionEvent = - window.DeviceMotionEvent || - class extends window.Event { - constructor(type, opts) { - super(type, opts); - } - acceleration = null; - accelerationIncludingGravity = null; - rotationRate = null; - interval = 16; - }; - - window.requestIdleCallback = - window.requestIdleCallback || - ((cb) => - setTimeout(() => cb({ didTimeout: false, timeRemaining: () => 10 }), 1)); - window.cancelIdleCallback = - window.cancelIdleCallback || ((id) => clearTimeout(id)); - - window.matchMedia = () => ({ - matches: false, - media: "", - onchange: null, - addListener() {}, - removeListener() {}, - addEventListener() {}, - removeEventListener() {}, - dispatchEvent() { - return false; - }, - }); - - // HTML5 Viewport and storage stubs - if (!window.visualViewport) { - const VisualViewport = () => {}; - VisualViewport.prototype = { - width: fp.screen.w - 16, - height: fp.screen.h - 120, - scale: 1, - offsetLeft: 0, - offsetTop: 0, - pageLeft: 0, - pageTop: 0, - onresize: null, - onscroll: null, - onscrollend: null, - }; - window.VisualViewport = VisualViewport; - window.visualViewport = Object.create(window.VisualViewport.prototype); - } - - if (!window.indexedDB) { - const IDBFactory = () => {}; - IDBFactory.prototype = { - open: () => ({ onupgradeneeded: null, onsuccess: null, onerror: null }), - deleteDatabase: () => ({}), - databases: () => Promise.resolve([]), - }; - window.IDBFactory = IDBFactory; - window.indexedDB = Object.create(window.IDBFactory.prototype); - } - - if (!window.speechSynthesis) { - const SpeechSynthesis = () => {}; - SpeechSynthesis.prototype = { - speak() {}, - cancel() {}, - pause() {}, - resume() {}, - getVoices: () => [], - }; - window.SpeechSynthesis = SpeechSynthesis; - window.speechSynthesis = Object.create(window.SpeechSynthesis.prototype); - window.SpeechSynthesisUtterance = () => {}; - } - - // WebGL and Canvas stubs - const proto = window.HTMLCanvasElement.prototype; - // Preserve the native 2D context (provided by the optional `canvas` npm package) - // so FeiLin's canvas fingerprint is real, not a constant. We only mock WebGL, - // which the `canvas` package does not provide. - const nativeGetContext = proto.getContext; - proto.getContext = function (type, ...rest) { - if (/webgl/i.test(type)) { - return makeWebGLMock(this); - } - // 2D and other contexts: use the native implementation when available. - if (typeof nativeGetContext === "function") { - try { - const ctx = nativeGetContext.call(this, type, ...rest); - if (ctx) return ctx; - } catch (_) {} - } - return make2DStub(this); - }; - - function makeWebGLMock(canvas) { - return { - canvas, - getParameter(p) { - // WebGL constant parameters mock - if (p === 7936) return "WebKit"; // VENDOR - if (p === 7937) return "WebKit WebGL"; // RENDERER - if (p === 7938) return "WebGL 1.0 (OpenGL ES 2.0 Chromium)"; // VERSION - if (p === 35724) - return "WebGL GLSL ES 1.0 (OpenGL ES GLSL ES 1.0 Chromium)"; // SHADING_LANGUAGE_VERSION - if (p === 0x9245) return fp.webglUnmaskedVendor; // UNMASKED_VENDOR_WEBGL - if (p === 0x9246) return fp.webglUnmaskedRenderer; // UNMASKED_RENDERER_WEBGL - return "Intel Inc."; - }, - getExtension(name) { - if (name === "WEBGL_debug_renderer_info") { - return { - UNMASKED_VENDOR_WEBGL: 0x9245, - UNMASKED_RENDERER_WEBGL: 0x9246, - }; - } - return null; - }, - getSupportedExtensions() { - return [ - "ANGLE_instanced_arrays", - "EXT_blend_minmax", - "EXT_color_buffer_half_float", - "EXT_disjoint_timer_query", - "EXT_float_blend", - "EXT_frag_depth", - "EXT_shader_texture_lod", - "EXT_texture_compression_bptc", - "EXT_texture_compression_rgtc", - "EXT_texture_filter_anisotropic", - "EXT_sRGB", - "KHR_parallel_shader_compile", - "OES_element_index_uint", - "OES_fbo_render_mipmap", - "OES_standard_derivatives", - "OES_texture_float", - "OES_texture_float_linear", - "OES_texture_half_float", - "OES_texture_half_float_linear", - "OES_vertex_array_object", - "WEBGL_color_buffer_float", - "WEBGL_compressed_texture_astc", - "WEBGL_compressed_texture_etc", - "WEBGL_compressed_texture_etc1", - "WEBGL_compressed_texture_s3tc", - "WEBGL_compressed_texture_s3tc_srgb", - "WEBGL_debug_renderer_info", - "WEBGL_debug_shaders", - "WEBGL_depth_texture", - "WEBGL_draw_buffers", - "WEBGL_lose_context", - "WEBGL_multi_draw", - ]; - }, - getContextAttributes() { - return { - alpha: true, - antialias: true, - depth: true, - failIfMajorPerformanceCaveat: false, - powerPreference: "default", - premultipliedAlpha: true, - preserveDrawingBuffer: false, - stencil: false, - desynchronized: false, - }; - }, - getShaderPrecisionFormat() { - return { precision: 23, rangeMin: 127, rangeMax: 127 }; - }, - }; - } - - function make2DStub(canvas) { - return { - canvas, - fillRect() {}, - clearRect() {}, - getImageData: (_x, _y, w = 1, h = 1) => new window.ImageData(w, h), - putImageData() {}, - createImageData: (w = 1, h = 1) => new window.ImageData(w, h), - setTransform() {}, - transform() {}, - drawImage() {}, - save() {}, - restore() {}, - beginPath() {}, - moveTo() {}, - lineTo() {}, - bezierCurveTo() {}, - quadraticCurveTo() {}, - closePath() {}, - clip() {}, - stroke() {}, - fill() {}, - arc() {}, - rect() {}, - ellipse() {}, - translate() {}, - scale() {}, - rotate() {}, - fillText() {}, - strokeText() {}, - measureText: (t) => ({ width: String(t).length * 8 }), - createLinearGradient: () => ({ addColorStop() {} }), - createRadialGradient: () => ({ addColorStop() {} }), - createPattern: () => ({}), - isPointInPath: () => false, - font: "10px sans-serif", - textBaseline: "alphabetic", - textAlign: "start", - fillStyle: "#000", - strokeStyle: "#000", - globalAlpha: 1, - lineWidth: 1, - shadowBlur: 0, - shadowColor: "", - }; - } - - // toDataURL/toBlob: prefer the native canvas implementation (real fingerprint) - // and only fall back to a stub if the native impl is absent. - if (typeof proto.toDataURL !== "function" || proto.toDataURL.length === 0) { - const nativeToDataURL = proto.toDataURL; - proto.toDataURL = function (...a) { - try { - if (typeof nativeToDataURL === "function") - return nativeToDataURL.apply(this, a); - } catch (_) {} - return fp.canvasImage; - }; - } - const nativeToBlob = proto.toBlob; - if (!nativeToBlob) { - proto.toBlob = (cb) => cb && cb(new window.Blob()); - } - - window.Worker = class { - postMessage() {} - terminate() {} - addEventListener() {} - removeEventListener() {} - }; - - window.OffscreenCanvas = - window.OffscreenCanvas || - class { - constructor(w, h) { - this.width = w; - this.height = h; - } - getContext() { - return proto.getContext.call(this); - } - }; - - // Audio API Mocks - const audioMock = class { - constructor() { - this.sampleRate = 44100; - this.currentTime = 0; - this.state = "suspended"; - } - createOscillator() { - return { - type: "sine", - frequency: { value: 440, setValueAtTime() {} }, - connect() {}, - start() {}, - stop() {}, - }; - } - createDynamicsCompressor() { - return { - threshold: { value: -24, setValueAtTime() {} }, - knee: { value: 30, setValueAtTime() {} }, - ratio: { value: 12, setValueAtTime() {} }, - attack: { value: 0.003, setValueAtTime() {} }, - release: { value: 0.25, setValueAtTime() {} }, - connect() {}, - }; - } - createAnalyser() { - return { - fftSize: 2048, - frequencyBinCount: 1024, - getByteFrequencyData() {}, - getByteTimeDomainData() {}, - connect() {}, - }; - } - createGain() { - return { - gain: { value: 1 }, - connect() {}, - }; - } - destination = {}; - resume() { - this.state = "running"; - return Promise.resolve(); - } - close() { - this.state = "closed"; - return Promise.resolve(); - } - }; - window.AudioContext = window.AudioContext || audioMock; - // Real Chrome 127+ does not expose webkitAudioContext - window.OfflineAudioContext = - window.OfflineAudioContext || - class extends audioMock { - constructor(_channels, length, sampleRate) { - super(); - this.length = length; - this.sampleRate = sampleRate; - } - startRendering() { - // Real Chromium renders a deterministic, non-zero waveform from the - // oscillator+DynamicsCompressor chain. An all-zero buffer is a classic - // bot tell, so synthesize a plausible decaying signal. - const len = this.length || 44100; - const sr = this.sampleRate || 44100; - const buf = new Float32Array(len); - for (let i = 0; i < len; i += 1) { - const t = i / sr; - // 1kHz tone + harmonics, exponentially decaying (compressor envelope) - buf[i] = - Math.sin(2 * Math.PI * 1000 * t) * Math.exp(-t * 1.2) * 0.6 + - Math.sin(2 * Math.PI * 3000 * t) * Math.exp(-t * 1.5) * 0.25 + - Math.sin(2 * Math.PI * 5000 * t) * Math.exp(-t * 2.0) * 0.12; - } - return Promise.resolve({ - numberOfChannels: 1, - length: len, - sampleRate: sr, - getChannelData: () => buf, - }); - } - }; - - // Animation Mocks - window.requestAnimationFrame = - window.requestAnimationFrame || - ((cb) => setTimeout(() => cb(Date.now()), 16)); - window.cancelAnimationFrame = - window.cancelAnimationFrame || ((id) => clearTimeout(id)); - - // Visibility stubs - try { - Object.defineProperty(window.document, "hidden", { - value: false, - configurable: true, - }); - Object.defineProperty(window.document, "visibilityState", { - value: "visible", - configurable: true, - }); - } catch (_) {} - - // Fonts and permissions - if (!window.document.fonts) { - window.document.fonts = { - ready: Promise.resolve(), - check: () => true, - addEventListener() {}, - removeEventListener() {}, - }; - } - - // Emulate Chrome constructors and prototype chains to pass instanceof checks - const NetworkInformation = () => {}; - NetworkInformation.prototype = { - onchange: null, - effectiveType: "4g", - rtt: 50, - downlink: 10, - saveData: false, - }; - window.NetworkInformation = NetworkInformation; - Object.defineProperty(NetworkInformation.prototype, Symbol.toStringTag, { - value: "NetworkInformation", - configurable: true - }); - - const NavigatorUAData = () => {}; - NavigatorUAData.prototype = { - brands: [ - { brand: "Chromium", version: fp.uaMajor }, - { brand: "Not)A;Brand", version: "24" }, - ], - mobile: false, - platform: "Linux", - getHighEntropyValues: (_hints) => { - const res = { - brands: [ - { brand: "Chromium", version: fp.uaMajor }, - { brand: "Not)A;Brand", version: "24" }, - ], - mobile: false, - platform: "Linux", - platformVersion: "6.5.0", - architecture: "x86", - model: "", - uaFullVersion: fp.uaFull, - fullVersionList: [ - { brand: "Chromium", version: fp.uaFull }, - { brand: "Not)A;Brand", version: "24.0.0.0" }, - ], - }; - return Promise.resolve(res); - }, - }; - window.NavigatorUAData = NavigatorUAData; - Object.defineProperty(NavigatorUAData.prototype, Symbol.toStringTag, { - value: "NavigatorUAData", - configurable: true - }); - - const Permissions = () => {}; - Permissions.prototype = { - query: (param) => { - return Promise.resolve({ - state: param.name === "notifications" ? "prompt" : "granted", - onchange: null, - }); - }, - }; - window.Permissions = Permissions; - Object.defineProperty(Permissions.prototype, Symbol.toStringTag, { - value: "Permissions", - configurable: true - }); - - const Clipboard = () => {}; - Clipboard.prototype = { - readText: () => Promise.resolve(""), - writeText: () => Promise.resolve(), - }; - window.Clipboard = Clipboard; - Object.defineProperty(Clipboard.prototype, Symbol.toStringTag, { - value: "Clipboard", - configurable: true - }); - - const Geolocation = () => {}; - Geolocation.prototype = { - getCurrentPosition: (success) => - success && - success({ coords: { latitude: 0, longitude: 0, accuracy: 1 } }), - watchPosition: () => 1, - clearWatch: () => {}, - }; - window.Geolocation = Geolocation; - Object.defineProperty(Geolocation.prototype, Symbol.toStringTag, { - value: "Geolocation", - configurable: true - }); - - const CredentialsContainer = () => {}; - CredentialsContainer.prototype = { - get: () => Promise.resolve(null), - create: () => Promise.resolve(null), - store: () => Promise.resolve(), - preventSilentAccess: () => Promise.resolve(), - }; - window.CredentialsContainer = CredentialsContainer; - Object.defineProperty(CredentialsContainer.prototype, Symbol.toStringTag, { - value: "CredentialsContainer", - configurable: true - }); - - const StorageManager = () => {}; - StorageManager.prototype = { - estimate: () => Promise.resolve({ quota: 100000000, usage: 0 }), - persisted: () => Promise.resolve(false), - persist: () => Promise.resolve(false), - }; - window.StorageManager = StorageManager; - Object.defineProperty(StorageManager.prototype, Symbol.toStringTag, { - value: "StorageManager", - configurable: true - }); - - const USB = () => {}; - USB.prototype = { - getDevices: () => Promise.resolve([]), - requestDevice: () => Promise.reject(new Error("no devices")), - }; - window.USB = USB; - Object.defineProperty(USB.prototype, Symbol.toStringTag, { - value: "USB", - configurable: true - }); - - const MediaDevices = () => {}; - MediaDevices.prototype = { - enumerateDevices: () => Promise.resolve([]), - getUserMedia: () => Promise.reject(new Error("NotAllowedError")), - }; - window.MediaDevices = MediaDevices; - Object.defineProperty(MediaDevices.prototype, Symbol.toStringTag, { - value: "MediaDevices", - configurable: true - }); - - const nav = window.navigator; - const { plugins, mimeTypes } = createNavigatorPlugins(window); - const navPatch = { - userAgent: fp.userAgent, - platform: fp.platform, - language: "en-US", - languages: ["en-US", "en"], - vendor: "Google Inc.", - webdriver: false, - hardwareConcurrency: 12, - deviceMemory: 8, - maxTouchPoints: 0, - cookieEnabled: true, - plugins, - mimeTypes, - connection: Object.create(window.NetworkInformation.prototype), - userAgentData: Object.create(window.NavigatorUAData.prototype), - permissions: Object.create(window.Permissions.prototype), - clipboard: Object.create(window.Clipboard.prototype), - geolocation: Object.create(window.Geolocation.prototype), - credentials: Object.create(window.CredentialsContainer.prototype), - storage: Object.create(window.StorageManager.prototype), - usb: Object.create(window.USB.prototype), - mediaDevices: Object.create(window.MediaDevices.prototype), - sendBeacon: (url, data) => { - try { - const xhr = new window.XMLHttpRequest(); - xhr.open("POST", url, true); - xhr.send(data); - return true; - } catch (_) { - return false; - } - }, - appVersion: fp.userAgent.replace(/^Mozilla\//, ""), - appName: "Netscape", - appCodeName: "Mozilla", - product: "Gecko", - productSub: "20030107", - vendorSub: "", - oscpu: undefined, - }; - for (const [k, v] of Object.entries(navPatch)) { - try { - Object.defineProperty(nav, k, { value: v, configurable: true }); - } catch (_) {} - } - - // Chrome runtime stubs - window.chrome = { - app: { - isInstalled: false, - InstallState: { DISABLED: "disabled", INSTALLED: "installed", NOT_INSTALLED: "not_installed" }, - RunningState: { CANNOT_RUN: "cannot_run", CAN_RUN: "can_run", RUNNING: "running" }, - getDetails() { return null; }, - getIsInstalled() { return false; }, - installState(cb) { if (cb) cb("not_installed"); }, - runningState(cb) { if (cb) cb("cannot_run"); } - }, - csi() { - const now = Date.now(); - return { startE: now - 100, onloadT: now, pageT: 100, tran: 15 }; - }, - loadTimes() { - const now = Date.now() / 1000; - return { - requestTime: now - 0.1, startLoadTime: now - 0.1, - commitLoadTime: now - 0.05, finishDocumentLoadTime: now, - finishLoadTime: now, firstPaintTime: now - 0.02, - firstPaintAfterLoadTime: 0, navigationType: "Other", - wasFetchedViaSpdy: true, wasNpnNegotiated: true, - npnNegotiatedProtocol: "h2", wasAlternateProtocolAvailable: false, - connectionInfo: "h2" - }; - } - }; - - const screenPatch = { - width: fp.screen.w, - height: fp.screen.h, - availWidth: fp.screen.w, - availHeight: fp.screen.ah, - availLeft: 0, - availTop: 0, - colorDepth: 24, - pixelDepth: 24, - orientation: { angle: 0, type: "landscape-primary", onchange: null } - }; - for (const [k, v] of Object.entries(screenPatch)) { - try { - Object.defineProperty(window.screen, k, { get() { return v; }, configurable: true }); - } catch (_) {} - } - - window.outerWidth = fp.screen.w; - window.outerHeight = fp.screen.h - 40; - window.innerWidth = fp.screen.w - 16; - window.innerHeight = fp.screen.h - 120; - window.devicePixelRatio = 1; - - // Masquerade every polyfilled method as a native browser API so that - // Function#toString checks (a staple of fingerprinting SDKs like FeiLin) - // see `[native code]` instead of leaked JS source. - nativeize(window.navigator.sendBeacon, "sendBeacon"); - if (window.navigator.permissions) - nativeize(window.navigator.permissions.query, "query"); - if (window.navigator.clipboard) { - nativeize(window.navigator.clipboard.readText, "readText"); - nativeize(window.navigator.clipboard.writeText, "writeText"); - } - if (window.navigator.credentials) { - nativeize(window.navigator.credentials.get, "get"); - nativeize(window.navigator.credentials.create, "create"); - nativeize(window.navigator.credentials.store, "store"); - } - if (window.navigator.storage) - nativeize(window.navigator.storage.estimate, "estimate"); - if (window.navigator.mediaDevices) - nativeize( - window.navigator.mediaDevices.enumerateDevices, - "enumerateDevices", - ); - nativeize(proto.toDataURL, "toDataURL"); - if (proto.toBlob) nativeize(proto.toBlob, "toBlob"); - nativeize(proto.getContext, "getContext"); -} - -function createNavigatorPlugins(window) { - // FeiLin 1.4.2 reads navigator.plugins[0].__proto__ to fingerprint the - // plugin prototype chain. A plain object with no indexed entries makes - // plugins[0] undefined and crashes that probe (-> F001 risk rejection). - // Build a real PluginArray on every platform, not just win32. - const indexed = [ - { - name: "PDF Viewer", - filename: "internal-pdf-viewer", - description: "Portable Document Format", - }, - { - name: "Chrome PDF Viewer", - filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", - description: "", - }, - { - name: "Chromium PDF Viewer", - filename: "mhjfbmdgcfjbbpaeojofohoefgiehjai", - description: "", - }, - ]; - const plugins = Object.create(window.PluginArray.prototype); - const mockIndexed = []; - for (let i = 0; i < indexed.length; i++) { - const p = Object.create(window.Plugin.prototype); - Object.defineProperty(p, "name", { - value: indexed[i].name, - configurable: true, - enumerable: true, - }); - Object.defineProperty(p, "filename", { - value: indexed[i].filename, - configurable: true, - enumerable: true, - }); - Object.defineProperty(p, "description", { - value: indexed[i].description, - configurable: true, - enumerable: true, - }); - Object.defineProperty(p, "length", { - value: 1, - configurable: true, - enumerable: true, - }); - Object.defineProperty(p, "0", { - value: p, - configurable: true, - enumerable: true, - }); - p.item = () => p; - p.namedItem = () => p; - plugins[i] = p; - mockIndexed.push(p); - } - Object.defineProperty(plugins, "length", { - value: indexed.length, - configurable: true, - enumerable: true, - }); - plugins.item = (i) => plugins[i] ?? null; - plugins.namedItem = (name) => - mockIndexed.find((p) => p.name === name) ?? null; - plugins.refresh = () => {}; - const mimeTypes = Object.create(window.MimeTypeArray.prototype); - Object.defineProperty(mimeTypes, "length", { - value: 0, - configurable: true, - enumerable: true, - }); - mimeTypes.item = () => null; - mimeTypes.namedItem = () => null; - return { plugins, mimeTypes }; -} - -function waitFor(cond, timeoutMs = 12_000, intervalMs = 50) { - return new Promise((res, rej) => { - const started = Date.now(); - const timer = setInterval(() => { - let ok = false; - try { - ok = cond(); - } catch (_) {} - if (ok) { - clearInterval(timer); - res(); - } else if (Date.now() - started > timeoutMs) { - clearInterval(timer); - rej(new Error("timeout")); - } - }, intervalMs); - }); -} - -function mapHostPrototypes(window) { - const HostFunctionProto = Function.prototype; - const GuestFunctionProto = window.Function.prototype; - - const seen = new Set(); - function walk(obj, depth = 0) { - // Optimization: reduced max depth from 5 to 3 — FeiLin doesn't probe - // deeper than 3 levels of nested constructors/prototypes. - if (!obj || depth > 3 || (typeof obj !== "object" && typeof obj !== "function")) return; - if (seen.has(obj)) return; - seen.add(obj); - - let keys = []; - try { keys = Object.getOwnPropertyNames(obj); } catch(_) { return; } - - for (const k of keys) { - try { - const desc = Object.getOwnPropertyDescriptor(obj, k); - if (!desc) continue; - - if (typeof desc.value === "function") { - if (Object.getPrototypeOf(desc.value) === HostFunctionProto) { - Object.setPrototypeOf(desc.value, GuestFunctionProto); - } - walk(desc.value, depth + 1); - } - - if (typeof desc.get === "function") { - if (Object.getPrototypeOf(desc.get) === HostFunctionProto) { - Object.setPrototypeOf(desc.get, GuestFunctionProto); - } - } - if (typeof desc.set === "function") { - if (Object.getPrototypeOf(desc.set) === HostFunctionProto) { - Object.setPrototypeOf(desc.set, GuestFunctionProto); - } - } - - if (desc.value && (typeof desc.value === "object" || typeof desc.value === "function")) { - walk(desc.value, depth + 1); - } - } catch (_) {} - } - - try { - const proto = Object.getPrototypeOf(obj); - if (proto && proto !== Object.prototype) { - walk(proto, depth + 1); - } - } catch (_) {} - } - - walk(window); -} - -// ── Optimization: Pre-compute guest-context eval string ──────────────────── -// This is a constant; building it once avoids repeated string concatenation. -const GUEST_EVAL_PATCH = ` -(function() { - Object.defineProperty(Event.prototype, "isTrusted", { - get() { return true; }, - configurable: true - }); - const OrigDocument = window.Document; - class HTMLDocument extends OrigDocument {} - Object.defineProperty(HTMLDocument, "name", { value: "HTMLDocument", configurable: true }); - Object.setPrototypeOf(HTMLDocument.prototype, OrigDocument.prototype); - Object.defineProperty(HTMLDocument.prototype, Symbol.toStringTag, { - value: "HTMLDocument", - configurable: true - }); - Object.defineProperty(window, "HTMLDocument", { - value: HTMLDocument, - writable: true, - configurable: true - }); - Object.setPrototypeOf(window.document, HTMLDocument.prototype); - const origGetOwnPropertyNames = Object.getOwnPropertyNames; - const origOwnKeys = Reflect.ownKeys; - const origGetOwnPropertyDescriptors = Object.getOwnPropertyDescriptors; - const origGetOwnPropertyDescriptor = Object.getOwnPropertyDescriptor; - const blacklist = new Set([ - "CSSStyleProperties", "XPathException", "SharedArrayBuffer" - ]); - const filterKeys = (keys) => { - return keys.filter(k => { - if (typeof k === "string") { - return !k.startsWith("_") && !blacklist.has(k); - } - return true; - }); - }; - Object.getOwnPropertyNames = function getOwnPropertyNames(obj) { - const keys = origGetOwnPropertyNames.call(this, obj); - return obj === window ? filterKeys(keys) : keys; - }; - Reflect.ownKeys = function ownKeys(target) { - const keys = origOwnKeys.call(this, target); - return target === window ? filterKeys(keys) : keys; - }; - Object.getOwnPropertyDescriptors = function getOwnPropertyDescriptors(obj) { - const descs = origGetOwnPropertyDescriptors.call(this, obj); - if (obj === window) { - for (const k of Object.keys(descs)) { - if (k.startsWith("_") || blacklist.has(k)) { - delete descs[k]; - } - } - } - return descs; - }; - Object.getOwnPropertyDescriptor = function getOwnPropertyDescriptor(obj, prop) { - if (obj === window && (String(prop).startsWith("_") || blacklist.has(prop))) { - return undefined; - } - return origGetOwnPropertyDescriptor.call(this, obj, prop); - }; - const allKeys = origGetOwnPropertyNames(window); - for (const k of allKeys) { - if (blacklist.has(k)) { - try { delete window[k]; } catch (_) {} - } else if (k.startsWith("_")) { - try { - const desc = origGetOwnPropertyDescriptor(window, k); - if (desc && desc.configurable) { - Object.defineProperty(window, k, { enumerable: false }); - } - } catch (_) {} - } - } -})(); -`; - -async function createDom(region, prefix) { - const fp = generateFingerprint(); - - // Optimization: Cached cookie fetch — reuses cookies for 5 minutes - let initialCookies = []; - const now = Date.now(); - if (_cookieCache.ts > 0 && (now - _cookieCache.ts) < COOKIE_CACHE_TTL_MS) { - initialCookies = _cookieCache.cookies; - } else { - try { - const res = await fetch("https://zcode.z.ai/", { - headers: { - "User-Agent": fp.userAgent, - "sec-ch-ua": '"Chromium";v="' + fp.uaMajor + '", "Not)A;Brand";v="24"', - "sec-ch-ua-mobile": "?0", - "sec-ch-ua-platform": '"Linux"', - "Accept-Language": "en-US,en;q=0.9" - } - }); - initialCookies = res.headers.getSetCookie?.() || []; - _cookieCache = { cookies: initialCookies, ts: Date.now() }; - } catch (_) {} - } - - const dom = new JSDOM(HTML, { - url: "https://zcode.z.ai/", - runScripts: "dangerously", - resources: resourceLoader, - pretendToBeVisual: true, - virtualConsole, - userAgent: fp.userAgent, - beforeParse(window) { - if (window.Error) { - window.Error.prepareStackTrace = Error.prepareStackTrace; - } - applyPolyfills(window, fp); - mapHostPrototypes(window); - - // Hook JSDOM dispatcher to inject Chromium client hints - if (window._dispatcher && typeof window._dispatcher.request === "function") { - const origRequest = window._dispatcher.request; - window._dispatcher.request = function(opts) { - const headers = opts.headers; - if (headers && typeof headers.set === "function") { - headers.set("sec-ch-ua", '"Chromium";v="' + fp.uaMajor + '", "Not)A;Brand";v="24"'); - headers.set("sec-ch-ua-mobile", "?0"); - headers.set("sec-ch-ua-platform", '"Linux"'); - headers.set("user-agent", fp.userAgent); - headers.set("accept-language", "en-US,en;q=0.9"); - } - return origRequest.call(this, opts); - }; - } - - // Guest-context patches (pre-computed string — avoids repeated concat) - window.eval(GUEST_EVAL_PATCH); - - window.AliyunCaptchaConfig = { region, prefix }; - }, - }); - - // Inject pre-fetched cookies - for (const cookieStr of initialCookies) { - try { dom.cookieJar.setCookieSync(cookieStr, "https://zcode.z.ai/"); } catch (_) {} - } - const visitorId = crypto.randomUUID(); - const deviceMid = crypto.randomUUID(); - dom.cookieJar.setCookieSync(`zcode_visitor_id=${visitorId}; path=/; domain=zcode.z.ai; Secure`, "https://zcode.z.ai/"); - dom.cookieJar.setCookieSync(`zcode_device_mid=${deviceMid}; path=/; domain=zcode.z.ai; Secure`, "https://zcode.z.ai/"); - dom.cookieJar.setCookieSync(`visitor_id=${visitorId}; path=/; domain=zcode.z.ai; HttpOnly`, "https://zcode.z.ai/"); - - return dom; -} - -function destroyDom(dom) { - if (!dom) return; - try { - const cap = dom.window.document.getElementById("cap"); - if (cap) cap.replaceChildren(); - dom.window.close(); - } catch (_) {} -} - -function extractVerifyParam(param) { - let verifyParam = param; - if (param && typeof param === "object") { - verifyParam = param.verifyParam || param.data || param.param; - } - if (!verifyParam || String(verifyParam).length < 20) { - throw new Error("solver returned empty param"); - } - // Strict validation (same rule as solve-happy-lib): a REAL Aliyun verify - // param is ~280 chars of base64 JSON carrying certifyId + a long - // securityToken. Degraded SDK success paths emit ~76-char params with no - // securityToken that always 3007 upstream — refuse them here so they can - // be retried instead of poisoning the token pool. - const str = String(verifyParam); - if (str.length < 200) { - throw new Error( - "verify param too short (" + str.length + " chars) — degraded result, refusing", - ); - } - try { - const decoded = JSON.parse(Buffer.from(str, "base64").toString("utf8")); - const secTok = decoded && (decoded.securityToken || decoded.SecurityToken); - if (!secTok || String(secTok).length < 50) { - throw new Error("verify param missing securityToken — degraded result, refusing"); - } - } catch (err) { - if (err instanceof SyntaxError) { - throw new Error("verify param not base64-JSON: " + str.slice(0, 80)); - } - throw err; - } - return str; -} - -function handleCaptchaResult(result) { - if (result && typeof result === "object" && result.verifyResult === false) { - throw new Error( - "verify rejected: " + - JSON.stringify({ - verifyCode: result.verifyCode, - certifyId: result.certifyId, - }), - ); - } - return result; -} - -/** - * Dispatch a realistic interaction trajectory so FeiLin's behavioral collectors - * record human-like input. Traceless verification grades this heavily — without - * it the server returns F001 ("suspected attack"). - * - * FeiLin subscribes (via EventTarget.addEventListener) to: mousemove, mousedown, - * mouseup, click, keyup, touchstart, touchmove, touchend, pointerdown, scroll, - * focusin, focusout. We synthesize a coherent mouse-down→move→up→click arc with - * a keystroke, dispatching on document (where the collectors are bound). - */ -function simulateBehavior(window, durationMs = 600) { - const { document, MouseEvent, KeyboardEvent, UIEvent } = window; - if (!document || !MouseEvent) return; - - const fire = (type, ctor, opts) => { - try { - const Ctor = ctor || UIEvent; - const ev = new Ctor(type, { - bubbles: true, - cancelable: true, - view: window, - ...opts, - }); - document.dispatchEvent(ev); - if (document.body) document.body.dispatchEvent(ev); - } catch (_) {} - }; - - let x = 140 + Math.random() * 30; - let y = 110 + Math.random() * 20; - const targetX = 540 + Math.random() * 40; - const targetY = 380 + Math.random() * 30; - const steps = 22; - let i = 0; - const start = Date.now(); - - const moveStep = () => { - if (i > steps) return; - // ease toward target with jitter - x += (targetX - x) * 0.16 + (Math.random() - 0.5) * 5; - y += (targetY - y) * 0.16 + (Math.random() - 0.5) * 4; - fire("mousemove", MouseEvent, { - screenX: Math.round(x), - screenY: Math.round(y), - clientX: Math.round(x), - clientY: Math.round(y), - button: 0, - buttons: 1, - }); - i += 1; - const done = Date.now() - start >= durationMs; - if (i <= steps && !done) { - setTimeout(moveStep, 26 + Math.floor(Math.random() * 32)); - } else { - // conclude with a click at the landing point - fire("mousedown", MouseEvent, { - clientX: Math.round(x), - clientY: Math.round(y), - button: 0, - buttons: 1, - }); - fire("mouseup", MouseEvent, { - clientX: Math.round(x), - clientY: Math.round(y), - button: 0, - buttons: 0, - }); - fire("click", MouseEvent, { - clientX: Math.round(x), - clientY: Math.round(y), - button: 0, - }); - // a keystroke (human presence signal) - try { - fire("keyup", KeyboardEvent, { - key: "a", - code: "KeyA", - keyCode: 65, - which: 65, - }); - } catch (_) {} - } - }; - moveStep(); -} - -/** - * One-shot solve: create jsdom → verify → destroy. Memory is freed after each token. - * @param {{ scene: string, region: string, prefix: string, timeoutMs?: number }} opts - */ -async function solveTraceless(opts) { - const scene = opts.scene || "11xygtvd"; - const region = opts.region || "sgp"; - const prefix = opts.prefix || "no8xfe"; - const timeoutMs = opts.timeoutMs ?? 25_000; - - const dom = await createDom(region, prefix); - try { - const { window } = dom; - await waitFor( - () => typeof window.initAliyunCaptcha === "function", - timeoutMs, - ); - - // Prime FeiLin's behavioral buffer with a human-like mouse trajectory before - // the traceless verification snapshot is taken. - // Optimization: reduced from 1500ms to 600ms — Playwright only needs - // ~500ms of mouse priming; 600ms gives margin without wasting time. - simulateBehavior(window, 600); - - const param = await new Promise((resolve, reject) => { - const timer = setTimeout( - () => reject(new Error("captcha solve timeout")), - timeoutMs, - ); - const finish = (fn) => (value) => { - clearTimeout(timer); - fn(value); - }; - - try { - window.initAliyunCaptcha({ - SceneId: scene, - mode: "popup", - region, - prefix, - language: "en", - element: "#cap", - button: "#btn", - captchaLogoImg: "", - showErrorTip: false, - getInstance: (inst) => { - try { - (inst.startTracelessVerification || inst.show).call(inst); - } catch (e) { - finish(reject)(new Error(`start: ${e.message}`)); - } - }, - success: (result) => { - try { - finish(resolve)(handleCaptchaResult(result)); - } catch (err) { - finish(reject)(err); - } - }, - fail: (err) => - finish(reject)(new Error(`fail: ${JSON.stringify(err)}`)), - onError: (err) => - finish(reject)(new Error(`onError: ${JSON.stringify(err)}`)), - }); - } catch (err) { - clearTimeout(timer); - reject(err); - } - }); - - return extractVerifyParam(param); - } finally { - destroyDom(dom); - } -} - -module.exports = { solveTraceless, createDom, destroyDom, generateFingerprint }; diff --git a/captcha_node/solve-playwright.js b/captcha_node/solve-playwright.js deleted file mode 100644 index 077ac15..0000000 --- a/captcha_node/solve-playwright.js +++ /dev/null @@ -1,276 +0,0 @@ -/** - * Aliyun traceless captcha via real Chromium on https://zcode.z.ai/ - * (Vanszs/xscope0 pattern — do not intercept or replace the page origin.) - * - * Env toggles for isolated benchmarks: - * ZCODE_CAPTCHA_FAST_WAITS=1 — event-driven waits, shorter mouse priming - * ZCODE_CAPTCHA_BLOCK_RESOURCES=1 — abort images/fonts/css/media - * ZCODE_CAPTCHA_LIGHT_CHROME=1 — extra memory-saving launch flags - * ZCODE_CAPTCHA_LOW_CPU=1 — 4 vCPU profile: smaller viewport, block images, lean Chrome - */ -const fs = require("fs"); -const { chromium } = require("playwright"); - -const ZCODE_ORIGIN = "https://zcode.z.ai/"; -const SDK_URL = - "https://o.alicdn.com/captcha-frontend/aliyunCaptcha/AliyunCaptcha.js"; -const SOLVE_TIMEOUT_MS = Number(process.env.ZCODE_CAPTCHA_TIMEOUT || 40) * 1000; - -const FAST_WAITS = process.env.ZCODE_CAPTCHA_FAST_WAITS === "1"; -const BLOCK_RESOURCES = process.env.ZCODE_CAPTCHA_BLOCK_RESOURCES === "1"; -const LIGHT_CHROME = process.env.ZCODE_CAPTCHA_LIGHT_CHROME === "1"; -const LOW_CPU = process.env.ZCODE_CAPTCHA_LOW_CPU === "1"; - -const VIEWPORT = - LOW_CPU || BLOCK_RESOURCES - ? { width: 640, height: 480 } - : { width: 1280, height: 720 }; - -function resolveChromePath() { - const env = - process.env.ZCODE_CHROME_PATH?.trim() || - process.env.PLAYWRIGHT_CHROMIUM_EXECUTABLE_PATH?.trim(); - if (env && fs.existsSync(env)) return env; - try { - const exe = chromium.executablePath(); - if (exe && fs.existsSync(exe)) return exe; - } catch {} - return undefined; -} - -function launchOptions() { - const args = [ - "--no-sandbox", - "--disable-dev-shm-usage", - "--disable-blink-features=AutomationControlled", - "--disable-features=IsolateOrigins,site-per-process", - `--window-size=${VIEWPORT.width},${VIEWPORT.height}`, - ]; - if (LIGHT_CHROME || LOW_CPU) { - args.push( - "--disable-gpu", - "--disable-extensions", - "--disable-background-networking", - "--disable-default-apps", - "--disable-sync", - "--disable-translate", - "--mute-audio", - "--no-first-run", - "--disable-component-update", - `--renderer-process-limit=${LOW_CPU ? 2 : 4}`, - ); - } - if (LOW_CPU) { - args.push( - "--disable-accelerated-2d-canvas", - "--disable-smooth-scrolling", - "--disable-threaded-animation", - "--disable-threaded-scrolling", - ); - } - const opts = { - headless: !/^(1|true|yes)$/i.test(String(process.env.PW_HEADED || "")), - args, - ignoreDefaultArgs: ["--enable-automation"], - }; - if (process.env.HTTP_PROXY) { - opts.proxy = { - server: process.env.HTTP_PROXY, - }; - } - const exe = resolveChromePath(); - if (exe) opts.executablePath = exe; - return opts; -} - -function contextOptions(region) { - return { - userAgent: - "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36", - viewport: VIEWPORT, - locale: "en-US", - timezoneId: Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC", - extraHTTPHeaders: { "Accept-Language": "en-US,en;q=0.9" }, - serviceWorkers: "block", - }; -} - -function initScriptPayload(region, prefix) { - return { - captchaRegion: region, - captchaPrefix: prefix, - }; -} - -const INIT_SCRIPT = ({ captchaRegion, captchaPrefix }) => { - Object.defineProperty(navigator, "webdriver", { get: () => undefined }); - Object.defineProperty(navigator, "plugins", { - get: () => [1, 2, 3, 4, 5], - }); - Object.defineProperty(navigator, "languages", { - get: () => ["en-US", "en"], - }); - window.AliyunCaptchaConfig = { - region: captchaRegion, - prefix: captchaPrefix, - }; -}; - -async function setupPage(page, region, prefix) { - await page.route("**/*", (route) => { - const req = route.request(); - if (req.resourceType() === "document") { - return route.continue({ - headers: { - ...req.headers(), - "cache-control": "no-cache", - pragma: "no-cache", - }, - }); - } - if (BLOCK_RESOURCES) { - const type = req.resourceType(); - if (["image", "font", "media", "stylesheet"].includes(type)) { - return route.abort(); - } - } else if (LOW_CPU) { - const type = req.resourceType(); - if (["image", "font", "media"].includes(type)) { - return route.abort(); - } - } - return route.continue(); - }); - - await page.goto(ZCODE_ORIGIN, { - waitUntil: "domcontentloaded", - timeout: 20_000, - }); - - if (FAST_WAITS) { - await page - .waitForFunction(() => typeof window.initAliyunCaptcha === "function", { - timeout: 10_000, - }) - .catch(() => {}); - await page.mouse.move(100, 100); - await page.mouse.move(300, 200); - await page.waitForTimeout(150); - } else if (LOW_CPU) { - await page.waitForTimeout(1200); - await page.mouse.move(100, 100); - await page.mouse.move(300, 200); - await page.waitForTimeout(200); - } else { - await page.waitForTimeout(2000); - await page.mouse.move(100, 100); - await page.mouse.move(300, 200); - await page.mouse.move(500, 300); - await page.waitForTimeout(500); - } -} - -async function runCaptchaOnPage(page, scene, region, prefix) { - const param = await page.evaluate( - async ({ sceneId, captchaRegion, captchaPrefix, sdkUrl, timeoutMs }) => { - if (!window.initAliyunCaptcha) { - await new Promise((res, rej) => { - const s = document.createElement("script"); - s.src = sdkUrl; - s.onload = res; - s.onerror = () => rej(new Error("SDK script load error")); - document.head.appendChild(s); - }); - } - return new Promise((resolve, reject) => { - const timeout = setTimeout( - () => reject(new Error(`captcha solve timeout ${timeoutMs}ms`)), - timeoutMs, - ); - window.initAliyunCaptcha({ - SceneId: sceneId, - mode: "popup", - region: captchaRegion, - prefix: captchaPrefix, - element: "#cap", - button: "#btn", - getInstance: (inst) => { - if ( - inst && - typeof inst.startTracelessVerification === "function" - ) { - inst.startTracelessVerification(); - } - }, - success: (p) => { - clearTimeout(timeout); - resolve(p); - }, - fail: (e) => { - clearTimeout(timeout); - reject(new Error(`fail: ${JSON.stringify(e).slice(0, 300)}`)); - }, - onError: (e) => { - clearTimeout(timeout); - reject(new Error(`onError: ${JSON.stringify(e).slice(0, 300)}`)); - }, - }); - }); - }, - { - sceneId: scene, - captchaRegion: region, - captchaPrefix: prefix, - sdkUrl: SDK_URL, - timeoutMs: SOLVE_TIMEOUT_MS, - }, - ); - - const value = - param && typeof param === "object" - ? param.verifyParam || param.param || param.data - : param; - const out = String(value ?? param ?? ""); - if (out.length < 20) { - throw new Error( - `empty verify param: ${JSON.stringify(param).slice(0, 200)}`, - ); - } - return out; -} - -/** - * Solve using an existing browser (worker pool). Creates and closes a context per solve. - * @param {import('playwright').Browser} browser - */ -async function solveWithBrowser(browser, { scene, region, prefix }) { - const ctx = await browser.newContext(contextOptions(region)); - try { - await ctx.addInitScript(INIT_SCRIPT, initScriptPayload(region, prefix)); - const page = await ctx.newPage(); - await setupPage(page, region, prefix); - return await runCaptchaOnPage(page, scene, region, prefix); - } finally { - await ctx.close().catch(() => {}); - } -} - -/** - * One-shot solve: launch browser → verify → close. - * @param {{ scene: string, region: string, prefix: string }} opts - * @returns {Promise} - */ -async function solveTraceless(opts) { - const browser = await chromium.launch(launchOptions()); - try { - return await solveWithBrowser(browser, opts); - } finally { - await browser.close().catch(() => {}); - } -} - -module.exports = { - solveTraceless, - solveWithBrowser, - launchOptions, -}; diff --git a/captcha_node/solver.js b/captcha_node/solver.js deleted file mode 100644 index 4165e21..0000000 --- a/captcha_node/solver.js +++ /dev/null @@ -1,22 +0,0 @@ -process.env.FONTCONFIG_PATH = "/dev/null"; -const backend = process.env.ZCODE_CAPTCHA_BACKEND || "jsdom"; -const solve = - backend === "jsdom" - ? require("./solve-core").solveTraceless - : backend === "happy" - ? require("./solve-happy-lib").solveTraceless - : require("./solve-playwright").solveTraceless; - -const SCENE = process.argv[2] || "11xygtvd"; -const REGION = process.argv[3] || "sgp"; -const PREFIX = process.argv[4] || "no8xfe"; - -solve({ scene: SCENE, region: REGION, prefix: PREFIX }) - .then((param) => { - console.log("VERIFY_PARAM=" + param); - process.exit(0); - }) - .catch((err) => { - process.stderr.write(String(err?.message || err) + "\n"); - process.exit(1); - }); diff --git a/captcha_node/worker.js b/captcha_node/worker.js deleted file mode 100644 index 4c705ef..0000000 --- a/captcha_node/worker.js +++ /dev/null @@ -1,233 +0,0 @@ -/** - * Long-lived browser worker — one Chromium per process, context per solve. - * Reads JSON lines on stdin: { id, scene?, region?, prefix? } - * Writes JSON lines on stdout: { id, ok, param? | error? } - */ -const readline = require("node:readline"); - -process.env.FONTCONFIG_PATH = "/dev/null"; -const backend = process.env.ZCODE_CAPTCHA_BACKEND || "jsdom"; -const isJsdom = backend === "jsdom"; -const isPlaywright = backend === "playwright"; - -let playwright = null; -let solvePlaywright = null; -let solveImpl = null; - -if (isJsdom) { - solveImpl = require("./solve-core").solveTraceless; -} else if (isPlaywright) { - playwright = require("playwright"); - solvePlaywright = require("./solve-playwright"); -} else { - // happy (default alternative): happy-dom solver — no browser needed. - solveImpl = require("./solve-happy-lib").solveTraceless; -} - -const SOLVE_TIMEOUT_MS = Number(process.env.ZCODE_CAPTCHA_TIMEOUT || 40) * 1000; -const MAX_SOLVE_ATTEMPTS = Math.max(1, Number(process.env.ZCODE_CAPTCHA_RETRIES || 4)); - -let browser = null; -let busy = false; -const pending = []; -/** certifyIds produced by this worker — reject duplicates before returning. */ -const recentCertifyIds = new Map(); - -let solvesCount = 0; -// Recycle workers to bound per-process memory growth (DOM/DOM-tree leaks). -// Measured leak ≈12MB/solve on happy-dom — recycle at 25 solves / 320MB. -const MAX_SOLVES_PER_WORKER = Number( - process.env.ZCODE_CAPTCHA_MAX_SOLVES || (isJsdom ? 30 : isPlaywright ? 100 : 25), -); -// Hard RSS ceiling: recycle a worker the moment it crosses this, regardless of -// solve count (default 350MB; 0 disables). -const WORKER_MAX_RSS_MB = Number(process.env.ZCODE_CAPTCHA_WORKER_MAX_RSS_MB ?? 350); -// Playwright mode: close the idle Chromium after this long to free RAM; -// relaunched on demand by ensureBrowser(). -const BROWSER_IDLE_CLOSE_MS = Number(process.env.ZCODE_CAPTCHA_BROWSER_IDLE_MS || 120) * 1000; -let browserIdleTimer = null; - -function workerRssMb() { - try { - const status = require("node:fs").readFileSync("/proc/self/status", "utf8"); - const m = status.match(/VmRSS:\s*(\d+)/); - return m ? Number(m[1]) / 1024 : 0; - } catch (_) { - return process.memoryUsage().rss / 1024 / 1024; - } -} - -function scheduleBrowserIdleClose() { - if (!isPlaywright) return; - if (browserIdleTimer) clearTimeout(browserIdleTimer); - browserIdleTimer = setTimeout(() => { - browserIdleTimer = null; - if (busy) { - scheduleBrowserIdleClose(); - return; - } - void resetBrowser(); - }, BROWSER_IDLE_CLOSE_MS); - browserIdleTimer.unref?.(); -} - -function parseCertifyId(param) { - try { - const json = JSON.parse(Buffer.from(param, "base64").toString("utf8")); - return typeof json.certifyId === "string" && json.certifyId ? json.certifyId : null; - } catch { - return null; - } -} - -function isDuplicateError(msg) { - return /F008|"verifyCode"\s*:\s*"F008"/i.test(msg); -} - -function rememberCertifyId(id) { - recentCertifyIds.set(id, Date.now()); - const cutoff = Date.now() - 120_000; - for (const [k, at] of recentCertifyIds) { - if (at < cutoff) recentCertifyIds.delete(k); - } -} - -async function resetBrowser() { - if (browserIdleTimer) { - clearTimeout(browserIdleTimer); - browserIdleTimer = null; - } - if (!isPlaywright) return; - try { - await browser?.close().catch(() => {}); - } catch (_) {} - browser = null; -} - -async function ensureBrowser() { - if (isJsdom) return null; - if (browser?.isConnected()) return browser; - browser = await playwright.chromium.launch(solvePlaywright.launchOptions()); - return browser; -} - -async function processOne(req) { - const scene = req.scene || "11xygtvd"; - const region = req.region || "sgp"; - const prefix = req.prefix || "no8xfe"; - - let lastErr = "unknown"; - for (let attempt = 1; attempt <= MAX_SOLVE_ATTEMPTS; attempt += 1) { - const timer = setTimeout(() => {}, SOLVE_TIMEOUT_MS); - try { - let param; - if (isPlaywright) { - const b = await ensureBrowser(); - param = await solvePlaywright.solveWithBrowser(b, { scene, region, prefix }); - } else { - param = await solveImpl({ scene, region, prefix }); - } - clearTimeout(timer); - - const certifyId = parseCertifyId(param); - if (certifyId && recentCertifyIds.has(certifyId)) { - lastErr = `duplicate certifyId ${certifyId}`; - await resetBrowser(); - continue; - } - if (certifyId) rememberCertifyId(certifyId); - - process.stdout.write( - JSON.stringify({ id: req.id, ok: true, param }) + "\n", - ); - - // Recycle the worker after max solves OR when RSS crosses the - // ceiling — either bound keeps memory strictly bounded per worker. - solvesCount++; - if (solvesCount >= MAX_SOLVES_PER_WORKER || (WORKER_MAX_RSS_MB > 0 && workerRssMb() > WORKER_MAX_RSS_MB)) { - setTimeout(() => process.exit(0), 10); - } - return; - } catch (err) { - clearTimeout(timer); - lastErr = err?.message || String(err); - if ( - isDuplicateError(lastErr) || - (isPlaywright && /Target closed|Browser closed|crashed/i.test(lastErr)) - ) { - await resetBrowser(); - continue; - } - break; - } - } - - process.stdout.write( - JSON.stringify({ id: req.id, ok: false, error: lastErr.slice(0, 300) }) + "\n", - ); -} - -async function drain() { - if (busy || pending.length === 0) return; - busy = true; - const req = pending.shift(); - try { - await processOne(req); - } finally { - busy = false; - scheduleBrowserIdleClose(); - drain(); - } -} - -function enqueue(req) { - pending.push(req); - drain(); -} - -async function main() { - if (isPlaywright) { - // Warm launch only for playwright mode; jsdom/happy workers are ready - // immediately (no browser). Keep this — the first solve needs the - // browser, and warm floor workers exist for exactly that. - await ensureBrowser(); - } - process.stderr.write("worker ready\n"); - process.stdout.write(JSON.stringify({ workerReady: true }) + "\n"); - - const rl = readline.createInterface({ input: process.stdin, crlfDelay: Infinity }); - rl.on("line", (line) => { - const trimmed = line.trim(); - if (!trimmed) return; - let req; - try { - req = JSON.parse(trimmed); - } catch { - process.stdout.write( - JSON.stringify({ id: null, ok: false, error: "invalid json" }) + "\n", - ); - return; - } - if (typeof req.id !== "number") { - process.stdout.write( - JSON.stringify({ - id: req.id ?? null, - ok: false, - error: "missing id", - }) + "\n", - ); - return; - } - enqueue(req); - }); - - process.on("SIGTERM", async () => { - await resetBrowser(); - process.exit(0); - }); -} - -main().catch((err) => { - process.stderr.write(String(err?.message || err) + "\n"); - process.exit(1); -}); diff --git a/package.json b/package.json index 1d50679..06263b8 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "build:android-bundle": "esbuild src/index.ts --bundle --platform=node --format=cjs --outfile=dist/android/server.cjs --external:jsdom --define:process.env.NODE_ENV=\\\"production\\\"" }, "dependencies": { + "happy-dom": "^20.11.6", "jsdom": "^25.0.0", "undici": "^8.10.0", "yaml": "^2.5.0" diff --git a/captcha_node/solve-happy-lib.js b/src/proxy/captcha-happy.ts similarity index 79% rename from captcha_node/solve-happy-lib.js rename to src/proxy/captcha-happy.ts index 90c0654..5c1034f 100644 --- a/captcha_node/solve-happy-lib.js +++ b/src/proxy/captcha-happy.ts @@ -1,27 +1,124 @@ -#!/usr/bin/env node -// ============================================================================ -// solve-happy.js — happy-dom port of zcode-proxy's AliyunCaptcha solver -// ISOLATED EXPERIMENT: prove happy-dom can execute the real Aliyun captcha SDK -// and produce a REAL ~280-char verify param via startTracelessVerification(). -// -// Mechanics ported from -// /home/wraient/Projects/zcode-api-auto-captcha/captcha_node/solve-core.js -// 1. cookie priming of https://zcode.z.ai/ (5-min cache) -// 2. CDN disk cache at ~/.zcode-captcha-cdn-cache/ + in-mem cache -// 3. installNativeToString (mask JS-implemented platform APIs as native) -// 4. per-request client-hint / UA / origin / referer injection (interceptor) -// 5. guest-side patches (Event.isTrusted, HTMLDocument naming, btoa) -// 6. solve contract: initAliyunCaptcha + getInstance().startTracelessVerification() -// ============================================================================ -process.env.FONTCONFIG_PATH = "/dev/null"; - -const { Window, PropertySymbol } = require("happy-dom"); -const { ProxyAgent, setGlobalDispatcher } = require("undici"); -const crypto = require("node:crypto"); -const fs = require("node:fs"); -const os = require("node:os"); -const path = require("node:path"); -const { spawnSync } = require("node:child_process"); +// @ts-nocheck — ported from the proven Node happy-dom solver (solve-happy-lib.js) +/** + * captcha-happy.ts — in-process happy-dom Aliyun captcha solver. + * + * Ported from the production-proven standalone happy-dom solver to run + * INSIDE the Bun process so the release binary stays self-contained: + * no external Node.js, no canvas/playwright/Chromium. + * + * Mechanics: + * 1. cookie priming of https://zcode.z.ai/ (5-min cache) + * 2. CDN disk cache at ~/.zcode-captcha-cdn-cache/ + in-mem cache + * 3. installNativeToString (mask JS-implemented platform APIs as native) + * 4. per-request client-hint / UA / origin / referer injection (interceptor) + * 5. guest-side patches (Event.isTrusted, HTMLDocument naming, btoa) + * 6. solve contract: initAliyunCaptcha + getInstance().startTracelessVerification() + */ +import { GlobalWindow as Window, PropertySymbol } from "happy-dom"; +import WindowBrowserContext from "happy-dom/lib/window/WindowBrowserContext.js"; +import { ProxyAgent, setGlobalDispatcher } from "undici"; +import crypto from "node:crypto"; +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; +import { Worker } from "node:worker_threads"; + +// ── Blocking fetch for sync XHR (self-contained builds) ──────────────────── +// happy-dom implements sync XHR by spawning `process.argv[0] -e