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/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/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-happy.ts b/src/proxy/captcha-happy.ts new file mode 100644 index 0000000..5c1034f --- /dev/null +++ b/src/proxy/captcha-happy.ts @@ -0,0 +1,2083 @@ +// @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 +`; + +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; + } +} + +// ── 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; + } + } + // sync interceptor serves only from cache; the async interceptor + // above warms the cache on first load, so misses fall through to + // the async fetch path handled by happy-dom. + 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; + } + } + // sync interceptor serves only from cache; the async interceptor + // above warms the cache on first load, so misses fall through to + // the async fetch path handled by happy-dom. + } + 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: serve it blocking via a worker thread. Never + // fall through to happy-dom's own sync fetch — it spawns a child + // process with `process.argv[0] -e`, which breaks compiled binaries. + const init = { method: request.method, headers: {} as Record }; + request.headers.forEach((value, key) => { + init.headers[key] = value; + }); + const cookie = cookieHeader(request, w, global.__browserFrame); + if (cookie) init.headers.cookie = cookie; + try { + if (request.body) { + const ab = request.body; + if (ab && (ab as any).byteLength > 0) init.body = ab; + } + } catch (_) {} + const res = syncFetchBlocking(url, init as any) as any; + if (res.error) { + process.stderr.write(`[sync-xhr-err] ${url}: ${res.error}\n`); + return new w.Response("", { status: 503, statusText: "sync fetch failed" }); + } + try { + for (const raw of res.setCookie || []) { + const cookieContainer = global.__cookieContainer; + if (!cookieContainer) break; + const u = new URL(url); + const parts = raw.split(";"); + const pair = parts[0].split("="); + const cookie: any = { + 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 (_) {} + const hdrs: Record = {}; + for (const [k, v] of Object.entries(res.headers || {})) hdrs[k] = String(v); + // Sync interceptor contract: PLAIN OBJECT with Buffer body (happy-dom's + // SyncFetch reads `.body.toString()`); a window.Response here would + // stringify its ReadableStream body and corrupt script loading. + return { + status: res.status, + statusText: res.statusText || "", + ok: res.status >= 200 && res.status < 300, + url, + redirected: false, + headers: new w.Headers(hdrs), + body: Buffer.from(res.body), + [PropertySymbol.virtualServerFile]: null, + }; + }, + }; +} + +// ── 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) { + 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; + // Skip host-realm objects (under Bun, installGlobalWindowAlias exposes + // Bun internals via window getters; sweeping them crashes on native + // internal-field slots). happy-dom objects live in the window realm. + try { + if (obj.constructor && obj.constructor.prototype !== Object.prototype) { + const ctorName = obj.constructor.name; + if (/^(WriteStream|ReadStream|Socket|Process|Timeout|Immediate)$/.test(ctorName)) return; + } + } catch (_) {} + 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); + } + + // Element constructor shortcuts every real browser exposes. The FeiLin + // fingerprint SDK references `Option` as a bare identifier; missing it + // throws inside its probe chain and degrades the fingerprint. + if (typeof w.Option !== "function") { + w.Option = class Option extends w.HTMLOptionElement { + constructor(text, value, defaultSelected, selected) { + super(); + if (text !== undefined) { + const el = w.document.createElement("option"); + el.text = text; + if (value !== undefined) el.value = value; + if (defaultSelected) el.defaultSelected = true; + if (selected) el.selected = true; + return el; + } + } + }; + } + if (typeof w.Video !== "function" && w.HTMLVideoElement) { + w.Video = class Video extends w.HTMLVideoElement { + constructor() { return w.document.createElement("video"); } + }; + } + + // 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 scripts (rotated pe/FeiLin bundles) can throw synchronous errors + // that surface as uncaughtExceptions. Without a handler, happy-dom's + // exception observer (or Bun's default) terminates the whole proxy — + // a single bad pe version must only fail that one solve, not the server. + process.on("uncaughtException", (err) => { + try { + const msg = err && err.message ? err.message : String(err); + process.stderr.write(`[captcha-guest-uncaught] ${msg}\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). + // WindowBrowserContext imported at module scope + 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. + // Bun compatibility: happy-dom's VM realm isolation doesn't apply under + // Bun — script tags execute against the host globalThis, where bare + // `window`/`document`/`location` identifiers don't exist. Node needs none + // of this (its VM context resolves them natively). We alias the current + // solve's window on globalThis and remove the aliases when the window is + // destroyed, so concurrent solves with window reuse stay consistent. + applyPolyfills(w); + installNativeToString(w); + installEvalInstrumentation(w); + // Bun alias pass runs AFTER polyfills so polyfilled props (Option, Video, + // alert, ...) are visible to guest scripts via globalThis too. + const needsGlobalAlias = typeof Bun !== "undefined"; + if (needsGlobalAlias) { + const g = globalThis; + installGlobalWindowAlias(g, w); + } + if (w.Error) { + w.Error.prepareStackTrace = Error.prepareStackTrace; + } + // 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 }; +} + +// Bun-only: alias the active window on globalThis (script tags run in the +// Bun-only: alias the active window on globalThis (script tags run in the +// host realm under Bun). Every own enumerable window property is exposed as a +// getter so guest scripts resolving bare identifiers (window, document, +// XMLHttpRequest, Range, HTMLElement, ...) find them, exactly as Node's VM +// realm would. Removed again in destroyDom. +// Names that must NOT be shadowed on globalThis — Bun/Node host internals the +// window happens to expose but the host runtime depends on. +const HOST_CRITICAL_GLOBALS = new Set([ + "process", "Bun", "console", "performance", "crypto", "fetch", + "queueMicrotask", "structuredClone", "TextEncoder", "TextDecoder", + "requestAnimationFrame", "cancelAnimationFrame", "print", + "URL", "URLSearchParams", "AbortController", "AbortSignal", + "ReadableStream", "WritableStream", "TransformStream", "Blob", "File", + "FormData", "Headers", "Request", "Response", "Event", "EventTarget", + "MessageChannel", "MessagePort", "Buffer", "global", "globalThis", + // JS intrinsics — GlobalWindow re-exposes them as class fields; the host + // versions are fine, so never shadow them. + "Array", "ArrayBuffer", "Boolean", "DataView", "Date", "Error", + "EvalError", "Float32Array", "Float64Array", "Function", "Infinity", + "Int8Array", "Int16Array", "Int32Array", "Intl", "JSON", "Map", "Math", + "NaN", "Number", "Object", "Promise", "RangeError", "ReferenceError", + "RegExp", "Reflect", "Set", "String", "Symbol", "SyntaxError", "TypeError", + "URIError", "Uint8Array", "Uint8ClampedArray", "Uint16Array", "Uint32Array", + "WeakMap", "WeakSet", "decodeURI", "decodeURIComponent", "encodeURI", + "encodeURIComponent", "escape", "isFinite", "isNaN", "parseFloat", + "parseInt", "unescape", "eval", +]); +// Window methods that exist as prototype members, not own props — the alias +// pass must include them so guest bare-name references resolve (moveBy, +// scrollTo, ... are referenced by the FeiLin fingerprint SDK). +const EXTRA_WINDOW_PROPS = [ + "moveBy", "moveTo", "resizeBy", "resizeTo", "scrollTo", "scrollBy", "scroll", + "open", "close", "stop", "focus", "blur", "print", "alert", "confirm", + "prompt", "getSelection", "find", +]; + +// Ref-count: the pool solves in parallel waves; each window must keep the +// aliases alive until the LAST concurrent window is destroyed, otherwise one +// destroyDom() pulls `window` out from under a sibling mid-solve. +let _aliasRefCount = 0; + +function installGlobalWindowAlias(g, w) { + _aliasRefCount += 1; + const props = new Set(Object.getOwnPropertyNames(w)); + for (const name of EXTRA_WINDOW_PROPS) props.add(name); + // also walk the prototype chain one level (BrowserWindow getters like + // navigator/location live there in some versions) + for (const proto = Object.getPrototypeOf(w); proto && proto !== Object.prototype;) { + for (const name of Object.getOwnPropertyNames(proto)) props.add(name); + break; + } + for (const prop of props) { + if (HOST_CRITICAL_GLOBALS.has(prop)) continue; + try { + Object.defineProperty(g, prop, { + get() { + return w[prop]; + }, + set(v) { + try { w[prop] = v; } catch (_) {} + }, + configurable: true, + }); + } catch (_) {} + } + // w.window/self may not exist as own props on this happy-dom build + for (const prop of ["window", "self", "top", "parent"]) { + try { + Object.defineProperty(g, prop, { get() { return w; }, configurable: true }); + } catch (_) {} + } + // Guest timers must live on the window's timer registry (destroyed with + // the window). The host's setTimeout would let pe-VM callbacks fire after + // destroyDom, when the `window` alias is gone ("window is not defined"). + for (const prop of ["setTimeout", "setInterval", "clearTimeout", "clearInterval"]) { + try { + Object.defineProperty(g, prop, { + get() { return w[prop]?.bind(w); }, + configurable: true, + }); + } catch (_) {} + } + // Dynamic catch-all: guest code occasionally references window methods that + // only exist on the prototype (moveBy, scrollTo, ...) or lands mid-solve on + // new props. Proxy fallback for any still-missing global property. + try { + Object.defineProperty(g, "__capWindowFor", { + get() { return w; }, + configurable: true, + }); + } catch (_) {} +} +function removeGlobalWindowAlias(g, w) { + _aliasRefCount -= 1; + if (_aliasRefCount > 0) return; + for (const name of Object.getOwnPropertyNames(w)) { + try { + const d = Object.getOwnPropertyDescriptor(g, name); + if (d?.get) delete g[name]; + } catch (_) {} + } + for (const prop of ["window", "self", "top", "parent"]) { + try { delete g[prop]; } catch (_) {} + } +} + +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 (_) {} + try { + if (typeof Bun !== "undefined") removeGlobalWindowAlias(globalThis, win); + } catch (_) {} + try { shutdownSyncFetchWorker(); } 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 || []; + 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); + } + } +} + +export { solveTraceless, createDom, destroyDom }; diff --git a/src/proxy/captcha-jsdom.ts b/src/proxy/captcha-jsdom.ts new file mode 100644 index 0000000..6f3bf32 --- /dev/null +++ b/src/proxy/captcha-jsdom.ts @@ -0,0 +1,725 @@ +import { + CaptchaCpuGovernor, + resolveCpuGovernorConfig, + type CpuGovernorSnapshot, +} from "./captcha-cpu-governor.js"; +import { + 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; + + 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; + 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(); +} 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-solver.ts b/src/proxy/captcha-solver.ts new file mode 100644 index 0000000..b31cc7f --- /dev/null +++ b/src/proxy/captcha-solver.ts @@ -0,0 +1,34 @@ +/** + * Solver backend dispatch — fully in-process, self-contained. + * + * Backends (ZCODE_CAPTCHA_BACKEND): + * - "happy" (default): happy-dom solver in src/proxy/captcha-happy.ts. + * Runs inside the Bun process; bundled into the single-file release + * binary by `bun build --compile`. No external Node.js, no browser. + * - "jsdom": the original in-process jsdom path in captcha.ts. + * + * The historical Node-daemon path (captcha_node/daemon.js with + * playwright/Chromium workers) required an external Node runtime and is not + * compatible with self-contained release binaries, so it is not shipped. + */ +const BACKEND = process.env.ZCODE_CAPTCHA_BACKEND?.trim().toLowerCase() || "happy"; + +let happyMod: typeof import("./captcha-happy.js") | null = null; + +export async function runCaptchaSolve(scene: string, region: string, prefix: string): Promise { + if (BACKEND !== "happy") { + throw new Error(`captcha backend "${BACKEND}" requires an external runtime and is not available in self-contained builds; use ZCODE_CAPTCHA_BACKEND=happy`); + } + if (!happyMod) happyMod = await import("./captcha-happy.js"); + return happyMod.solveTraceless({ scene, region, prefix }); +} + +/** In-process solving needs no worker pool management — kept for the pool API. */ +export function setCaptchaSolverConcurrency(_n: number): void {} + +/** In-process solving needs no worker pool management — kept for the pool API. */ +export function shutdownCaptchaSolver(): void {} + +export function captchaSolverConcurrency(): number { + return Number(process.env.CAPTCHA_DAEMON_CONCURRENCY || 4); +} 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 beff4ac..6fa3b86 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,100 @@ 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"); + if (USE_POOL) { + // Pre-solved token pool: requests take an already-minted token (sub-ms) + // while background solves 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 }; } +async function solveCaptchaWithRetry(cfg: FetchedCaptchaConfig): Promise { + if (USE_POOL) { + // Retries are handled inside the pool (ZCODE_CAPTCHA_RETRIES attempts + // with fresh InitCaptchaV3 per retry); this outer guard is unreachable + // in practice but kept for the jsdom->happy transition path. + 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 {} + try { stopCaptchaPool(); } catch {} +} + +/** + * Start background pre-solving of the token pool (happy backend). + * 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 (USE_POOL) 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 { let lastErr: Error | null = null; for (let attempt = 1; attempt <= SOLVE_RETRIES; attempt++) {