fiptcha 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,222 @@
1
+ #!/usr/bin/env node
2
+ const http = require('http');
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const crypto = require('crypto');
7
+
8
+ process.env.CAPTCHA_COMPANION_MODE = 'true';
9
+ process.env.CAPTCHA_DISABLE_COMPANION = 'true';
10
+ process.env.CAPTCHA_MODEL_DEVICE = process.env.CAPTCHA_MODEL_DEVICE || 'auto';
11
+
12
+ const { captchaModelManager, selectModelTier, getMemorySnapshot, parseFlag } = require('../src/captcha-model-manager');
13
+ const { TOKEN_FILE, DEFAULT_PORT } = require('../src/captcha-companion-client');
14
+ const { loadVerifiedMlxRuntime } = require('../src/captcha-mlx-runtime');
15
+
16
+ const dockerMode = process.argv.includes('--docker');
17
+ const host = dockerMode ? '0.0.0.0' : '127.0.0.1';
18
+ const port = Number(process.env.CAPTCHA_COMPANION_PORT || DEFAULT_PORT);
19
+ const MAX_BODY_BYTES = 7 * 1024 * 1024;
20
+ const MAX_IMAGE_BYTES = 5 * 1024 * 1024;
21
+ const INFERENCE_TIMEOUT_MS = Number(process.env.CAPTCHA_COMPANION_INFERENCE_TIMEOUT_MS || 45_000);
22
+ let detectQueue = Promise.resolve();
23
+ const modelRoot = path.resolve(process.env.CAPTCHA_MODEL_CACHE_DIR || path.join(process.cwd(), 'data', 'captcha-model'));
24
+ let activeMlx = null;
25
+ let initializationError = null;
26
+
27
+ function ensureToken() {
28
+ const configured = String(process.env.CAPTCHA_COMPANION_TOKEN || '').trim();
29
+ if (configured) return;
30
+ fs.mkdirSync(path.dirname(TOKEN_FILE), { recursive: true });
31
+ if (!fs.existsSync(TOKEN_FILE)) fs.writeFileSync(TOKEN_FILE, `${crypto.randomBytes(32).toString('hex')}\n`, { mode: 0o600 });
32
+ else fs.chmodSync(TOKEN_FILE, 0o600);
33
+ }
34
+
35
+ function readToken() {
36
+ const token = String(process.env.CAPTCHA_COMPANION_TOKEN || '').trim();
37
+ if (token) return token;
38
+ try { return fs.readFileSync(TOKEN_FILE, 'utf8').trim(); } catch { return ''; }
39
+ }
40
+
41
+ function sendJson(response, status, payload) {
42
+ const body = JSON.stringify(payload);
43
+ response.writeHead(status, { 'Content-Type': 'application/json', 'Content-Length': Buffer.byteLength(body), 'Cache-Control': 'no-store' });
44
+ response.end(body);
45
+ }
46
+
47
+ function authorized(request) {
48
+ const token = readToken();
49
+ const supplied = String(request.headers.authorization || '');
50
+ const expected = `Bearer ${token}`;
51
+ if (!token || supplied.length !== expected.length) return false;
52
+ return crypto.timingSafeEqual(Buffer.from(supplied), Buffer.from(expected));
53
+ }
54
+
55
+ async function readJson(request) {
56
+ let size = 0;
57
+ const chunks = [];
58
+ for await (const chunk of request) {
59
+ size += chunk.length;
60
+ if (size > MAX_BODY_BYTES) throw Object.assign(new Error('Request body too large'), { status: 413 });
61
+ chunks.push(chunk);
62
+ }
63
+ try { return JSON.parse(Buffer.concat(chunks).toString('utf8')); }
64
+ catch { throw Object.assign(new Error('Malformed JSON'), { status: 400 }); }
65
+ }
66
+
67
+ function enqueueDetect(operation) {
68
+ const pending = detectQueue.then(operation, operation);
69
+ detectQueue = pending.catch(() => undefined);
70
+ return pending;
71
+ }
72
+
73
+ async function removeModelDirectories(names) {
74
+ for (const name of names) await fs.promises.rm(path.join(modelRoot, name), { recursive: true, force: true });
75
+ }
76
+
77
+ async function activateMlx() {
78
+ if (activeMlx) return activeMlx;
79
+ let runtime;
80
+ try {
81
+ runtime = await loadVerifiedMlxRuntime(modelRoot);
82
+ } catch (error) {
83
+ const enospc = error?.code === 'ENOSPC' || /ENOSPC|no space left/i.test(error?.message || '');
84
+ if (!enospc || !captchaModelManager.status().healthy) throw error;
85
+ await captchaModelManager.stop();
86
+ await removeModelDirectories(['owlvit', 'florence2']);
87
+ runtime = await loadVerifiedMlxRuntime(modelRoot);
88
+ }
89
+ const previousMlx = activeMlx;
90
+ activeMlx = runtime;
91
+ await captchaModelManager.stop();
92
+ await removeModelDirectories(['owlvit', 'florence2']);
93
+ await previousMlx?.dispose?.().catch?.(() => undefined);
94
+ await fs.promises.mkdir(modelRoot, { recursive: true });
95
+ const temporary = path.join(modelRoot, '.active.json.tmp');
96
+ await fs.promises.writeFile(temporary, JSON.stringify({
97
+ tier: 'florence2', model: 'mlx-community/Florence-2-base-ft-4bit', backend: 'companion', device: 'mlx', activatedAt: new Date().toISOString()
98
+ }, null, 2), { mode: 0o600 });
99
+ await fs.promises.rename(temporary, path.join(modelRoot, 'active.json'));
100
+ return runtime;
101
+ }
102
+
103
+ async function activateOnnx(tier) {
104
+ const previousTier = process.env.CAPTCHA_MODEL_TIER;
105
+ process.env.CAPTCHA_MODEL_TIER = tier;
106
+ try {
107
+ await captchaModelManager.start();
108
+ await captchaModelManager.reconcile();
109
+ if (!captchaModelManager.status().healthy) throw new Error(captchaModelManager.status().error || 'Native ONNX backend is unhealthy');
110
+ } finally {
111
+ if (previousTier === undefined) delete process.env.CAPTCHA_MODEL_TIER;
112
+ else process.env.CAPTCHA_MODEL_TIER = previousTier;
113
+ }
114
+ const previousMlx = activeMlx;
115
+ activeMlx = null;
116
+ await previousMlx?.dispose?.().catch?.(() => undefined);
117
+ await removeModelDirectories(['florence2-mlx', '.staging-florence2-mlx']);
118
+ }
119
+
120
+ async function detectTier(tier, image, label, threshold) {
121
+ if (tier === 'florence2' && process.env.CAPTCHA_MODEL_DEVICE !== 'cpu' && !parseFlag(process.env.CAPTCHA_COMPANION_DISABLE_MLX)) {
122
+ try {
123
+ const runtime = await activateMlx();
124
+ return { detections: await runtime.detect(image, label, threshold), backend: 'companion', device: 'mlx' };
125
+ } catch (error) {
126
+ initializationError = `MLX unavailable; using native ONNX: ${error.message}`;
127
+ console.warn(`[CAPTCHA_COMPANION] ${initializationError}`);
128
+ }
129
+ }
130
+ await activateOnnx(tier);
131
+ const previousTier = process.env.CAPTCHA_MODEL_TIER;
132
+ process.env.CAPTCHA_MODEL_TIER = tier;
133
+ try {
134
+ const detections = await captchaModelManager.detect(image, label, threshold);
135
+ const status = captchaModelManager.status();
136
+ return { detections, backend: status.backend, device: status.device };
137
+ } finally {
138
+ if (previousTier === undefined) delete process.env.CAPTCHA_MODEL_TIER;
139
+ else process.env.CAPTCHA_MODEL_TIER = previousTier;
140
+ }
141
+ }
142
+
143
+ async function initializeCompanion() {
144
+ const tier = selectModelTier(getMemorySnapshot());
145
+ if (!tier) throw new Error('Host has insufficient available unified memory for a companion model');
146
+ await detectTier(tier, Buffer.from('iVBORw0KGgoAAAANSUhEUgAAACAAAAAgCAIAAAD8GO2jAAAAGUlEQVR4nO3BAQ0AAADCoPdPbQ43oAAAAAAAAAB4Gx4gAAE7Bq0AAAAASUVORK5CYII=', 'base64'), 'object', 0.99);
147
+ initializationError = null;
148
+ }
149
+
150
+ const server = http.createServer(async (request, response) => {
151
+ try {
152
+ if (!authorized(request)) return sendJson(response, 401, { error: 'UNAUTHORIZED' });
153
+ if (request.method === 'GET' && request.url === '/v1/health') {
154
+ const status = captchaModelManager.status();
155
+ const mlxHealthy = Boolean(activeMlx);
156
+ return sendJson(response, 200, {
157
+ ...status,
158
+ healthy: mlxHealthy || status.healthy,
159
+ activeTier: mlxHealthy ? 'florence2' : status.activeTier,
160
+ version: 1,
161
+ backend: mlxHealthy ? 'mlx' : status.device === 'coreml' ? 'coreml' : 'onnx-native',
162
+ error: initializationError || status.error,
163
+ memory: { totalMb: os.totalmem() / 1048576, availableMb: os.freemem() / 1048576 }
164
+ });
165
+ }
166
+ if (request.method === 'POST' && request.url === '/v1/detect') {
167
+ return enqueueDetect(async () => {
168
+ const body = await readJson(request);
169
+ if (!['owlvit', 'florence2'].includes(body.tier)) return sendJson(response, 400, { error: 'INVALID_TIER' });
170
+ if (typeof body.label !== 'string' || !body.label.trim() || typeof body.imageBase64 !== 'string') {
171
+ return sendJson(response, 400, { error: 'INVALID_REQUEST' });
172
+ }
173
+ const image = Buffer.from(body.imageBase64, 'base64');
174
+ if (!image.length || image.length > MAX_IMAGE_BYTES) return sendJson(response, 413, { error: 'INVALID_IMAGE_SIZE' });
175
+ const inference = detectTier(body.tier, image, body.label.trim(), Number(body.threshold));
176
+ let timeoutHandle;
177
+ const timeout = new Promise((resolve) => {
178
+ timeoutHandle = setTimeout(() => resolve({ timedOut: true }), INFERENCE_TIMEOUT_MS);
179
+ timeoutHandle.unref?.();
180
+ });
181
+ const outcome = await Promise.race([
182
+ inference.then((result) => ({ result }), (error) => ({ error })),
183
+ timeout
184
+ ]);
185
+ clearTimeout(timeoutHandle);
186
+ if (outcome.timedOut) {
187
+ sendJson(response, 504, { error: 'INFERENCE_TIMEOUT' });
188
+ await inference.catch(() => undefined);
189
+ return undefined;
190
+ }
191
+ if (outcome.error) throw outcome.error;
192
+ return sendJson(response, 200, outcome.result);
193
+ });
194
+ }
195
+ return sendJson(response, 404, { error: 'NOT_FOUND' });
196
+ } catch (error) {
197
+ return sendJson(response, error.status || 500, { error: error.message || 'COMPANION_ERROR' });
198
+ }
199
+ });
200
+
201
+ async function shutdown() {
202
+ server.close();
203
+ await activeMlx?.dispose?.().catch?.(() => undefined);
204
+ await captchaModelManager.stop().catch(() => undefined);
205
+ process.exit(0);
206
+ }
207
+
208
+ server.requestTimeout = Math.max(INFERENCE_TIMEOUT_MS + 5000, 50_000);
209
+ server.headersTimeout = 10_000;
210
+
211
+ if (require.main === module) {
212
+ process.on('SIGINT', shutdown);
213
+ process.on('SIGTERM', shutdown);
214
+ ensureToken();
215
+ initializeCompanion().catch((error) => {
216
+ initializationError = error.message;
217
+ console.warn(`[CAPTCHA_COMPANION] Initial model probe failed: ${error.message}`);
218
+ });
219
+ server.listen(port, host, () => console.log(`[CAPTCHA_COMPANION] Listening on http://${host}:${port}`));
220
+ }
221
+
222
+ module.exports = { authorized, readJson, server };
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const { captchaModelManager, getMemorySnapshot } = require('../src/captcha-model-manager');
4
+
5
+ function currentUse() {
6
+ for (const file of ['/sys/fs/cgroup/memory.current', '/sys/fs/cgroup/memory/memory.usage_in_bytes']) {
7
+ try { return Number(fs.readFileSync(file, 'utf8').trim()); } catch { /* unsupported cgroup layout */ }
8
+ }
9
+ return process.memoryUsage().rss;
10
+ }
11
+
12
+ async function main() {
13
+ let peakBytes = currentUse();
14
+ const sampler = setInterval(() => { peakBytes = Math.max(peakBytes, currentUse()); }, 25);
15
+ try {
16
+ await captchaModelManager.start();
17
+ const status = captchaModelManager.status();
18
+ const expected = process.env.CAPTCHA_ACCEPT_EXPECTED_TIER || '';
19
+ if ((status.activeTier || '') !== expected) throw new Error(`Expected tier ${expected || 'none'}, got ${status.activeTier || 'none'}: ${status.error || ''}`);
20
+ console.log(JSON.stringify({ status, memory: getMemorySnapshot(), peakBytes }));
21
+ } finally {
22
+ clearInterval(sampler);
23
+ await captchaModelManager.stop();
24
+ }
25
+ }
26
+
27
+ main().catch((error) => { console.error(error); process.exit(1); });
@@ -0,0 +1,66 @@
1
+ #!/usr/bin/env python3
2
+ import base64
3
+ import contextlib
4
+ import io
5
+ import json
6
+ import os
7
+ import sys
8
+ import traceback
9
+
10
+ os.environ.setdefault("HF_HUB_OFFLINE", "1")
11
+ os.environ.setdefault("TRANSFORMERS_OFFLINE", "1")
12
+
13
+ from PIL import Image
14
+
15
+
16
+ def emit(payload):
17
+ sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
18
+ sys.stdout.flush()
19
+
20
+
21
+ def extract_detections(parsed, label, threshold):
22
+ if not isinstance(parsed, dict):
23
+ return []
24
+ value = parsed.get("<OPEN_VOCABULARY_DETECTION>", parsed)
25
+ boxes = value.get("bboxes", value.get("boxes", [])) if isinstance(value, dict) else []
26
+ labels = value.get("labels", []) if isinstance(value, dict) else []
27
+ return [
28
+ {"box": box, "label": labels[index] if index < len(labels) else label, "score": threshold}
29
+ for index, box in enumerate(boxes)
30
+ ]
31
+
32
+
33
+ def main():
34
+ if len(sys.argv) != 2:
35
+ raise RuntimeError("Expected a verified local model directory")
36
+ model_path = os.path.realpath(sys.argv[1])
37
+ if not os.path.isdir(model_path):
38
+ raise RuntimeError("Verified local MLX model directory is missing")
39
+ with contextlib.redirect_stdout(sys.stderr):
40
+ from mlx_vlm import generate, load
41
+ model, processor = load(model_path, lazy=False, trust_remote_code=True)
42
+ emit({"ready": True, "backend": "mlx"})
43
+ for line in sys.stdin:
44
+ request = json.loads(line)
45
+ request_id = request.get("id")
46
+ try:
47
+ image = Image.open(io.BytesIO(base64.b64decode(request["imageBase64"], validate=True))).convert("RGB")
48
+ label = str(request["label"]).strip()
49
+ threshold = float(request.get("threshold", 0.18))
50
+ task = f"<OPEN_VOCABULARY_DETECTION>{label}"
51
+ with contextlib.redirect_stdout(sys.stderr):
52
+ result = generate(model, processor, task, image, max_tokens=64, temperature=0.0)
53
+ text = result.text if hasattr(result, "text") else str(result)
54
+ parsed = processor.post_process_generation(text, task=task, image_size=image.size)
55
+ emit({"id": request_id, "detections": extract_detections(parsed, label, threshold)})
56
+ except Exception as error:
57
+ emit({"id": request_id, "error": str(error)[:500]})
58
+
59
+
60
+ if __name__ == "__main__":
61
+ try:
62
+ main()
63
+ except Exception as error:
64
+ sys.stderr.write(traceback.format_exc())
65
+ emit({"ready": False, "error": str(error)[:500]})
66
+ sys.exit(1)
@@ -0,0 +1,47 @@
1
+ #!/usr/bin/env node
2
+ const fs = require('fs');
3
+ const path = require('path');
4
+ const crypto = require('crypto');
5
+ const { execFileSync } = require('child_process');
6
+
7
+ if (process.platform !== 'darwin' || process.arch !== 'arm64') {
8
+ console.error('The CAPTCHA accelerator companion requires Apple Silicon macOS.');
9
+ process.exit(1);
10
+ }
11
+
12
+ async function main() {
13
+ const ort = require('onnxruntime-node');
14
+ const backends = await ort.listSupportedBackends();
15
+ if (!backends.some((backend) => backend.name === 'coreml' && backend.bundled)) {
16
+ throw new Error('The installed ONNX Runtime does not include CoreMLExecutionProvider');
17
+ }
18
+ const dataDir = path.resolve(process.cwd(), 'data');
19
+ const environmentDir = path.join(dataDir, 'captcha-companion', 'venv');
20
+ const tokenFile = path.join(dataDir, 'captcha-companion-token');
21
+ await fs.promises.mkdir(dataDir, { recursive: true });
22
+ const candidates = [process.env.PYTHON, 'python3.13', 'python3.12', 'python3.11', 'python3.10', 'python3'].filter(Boolean);
23
+ const python = candidates.find((candidate) => {
24
+ try {
25
+ const version = execFileSync(candidate, ['-c', 'import sys; print(int(sys.version_info >= (3, 10)))'], { encoding: 'utf8', timeout: 5000 }).trim();
26
+ return version === '1';
27
+ } catch { return false; }
28
+ });
29
+ if (!python) throw new Error('Python 3.10 or newer is required for the pinned MLX companion environment');
30
+ if (!fs.existsSync(path.join(environmentDir, 'bin', 'python3'))) {
31
+ execFileSync(python, ['-m', 'venv', environmentDir], { stdio: 'inherit', timeout: 120_000 });
32
+ }
33
+ execFileSync(path.join(environmentDir, 'bin', 'python3'), [
34
+ '-m', 'pip', 'install', '--disable-pip-version-check', '--requirement', path.join(__dirname, 'captcha-companion-requirements.txt')
35
+ ], { stdio: 'inherit', timeout: 15 * 60_000 });
36
+ const exists = await fs.promises.access(tokenFile).then(() => true).catch(() => false);
37
+ if (!exists) await fs.promises.writeFile(tokenFile, `${crypto.randomBytes(32).toString('hex')}\n`, { mode: 0o600 });
38
+ else await fs.promises.chmod(tokenFile, 0o600).catch(() => undefined);
39
+ console.log(`CAPTCHA companion isolated environment: ${environmentDir}`);
40
+ console.log(`CAPTCHA companion ready. Authentication token: ${tokenFile}`);
41
+ console.log('Model weights will be downloaded and verified only when the companion starts.');
42
+ }
43
+
44
+ main().catch((error) => {
45
+ console.error(`CAPTCHA companion installation failed: ${error.message}`);
46
+ process.exit(1);
47
+ });
@@ -0,0 +1,25 @@
1
+ function metricsAtThreshold(samples, threshold) {
2
+ let truePositive = 0;
3
+ let falsePositive = 0;
4
+ let falseNegative = 0;
5
+ for (const sample of samples) {
6
+ const predicted = Number(sample.score) >= threshold;
7
+ if (predicted && sample.match) truePositive += 1;
8
+ else if (predicted) falsePositive += 1;
9
+ else if (sample.match) falseNegative += 1;
10
+ }
11
+ const recall = truePositive / Math.max(1, truePositive + falseNegative);
12
+ const precision = truePositive / Math.max(1, truePositive + falsePositive);
13
+ const f1 = (2 * precision * recall) / Math.max(Number.EPSILON, precision + recall);
14
+ return { threshold, truePositive, falsePositive, falseNegative, precision, recall, f1 };
15
+ }
16
+
17
+ function selectConfidenceThreshold(samples, { minimumRecall = 0.9, minimumF1 = 0.8 } = {}) {
18
+ const candidates = [...new Set(samples.map((sample) => Number(sample.score)).filter(Number.isFinite))].sort((a, b) => a - b);
19
+ const selected = candidates.map((threshold) => metricsAtThreshold(samples, threshold))
20
+ .find((metrics) => metrics.recall >= minimumRecall && metrics.f1 >= minimumF1);
21
+ if (!selected) throw new Error(`No threshold reaches recall ${minimumRecall} and F1 ${minimumF1}`);
22
+ return selected;
23
+ }
24
+
25
+ module.exports = { metricsAtThreshold, selectConfidenceThreshold };
@@ -0,0 +1,74 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ const DATA_DIR = path.resolve(process.cwd(), 'data');
5
+ const TOKEN_FILE = path.join(DATA_DIR, 'captcha-companion-token');
6
+ const DEFAULT_PORT = 11438;
7
+
8
+ function isDocker() {
9
+ return fs.existsSync('/.dockerenv') || Boolean(process.env.CONTAINER);
10
+ }
11
+
12
+ function resolveCompanionConfig(env = process.env) {
13
+ if (String(env.CAPTCHA_DISABLE_COMPANION || '').toLowerCase() === 'true') return null;
14
+ let baseUrl = String(env.CAPTCHA_COMPANION_URL || '').trim();
15
+ if (!baseUrl && process.platform === 'darwin') baseUrl = `http://127.0.0.1:${DEFAULT_PORT}`;
16
+ if (!baseUrl && isDocker()) baseUrl = `http://host.docker.internal:${DEFAULT_PORT}`;
17
+ if (!baseUrl) return null;
18
+ let parsed;
19
+ try { parsed = new URL(baseUrl); } catch { throw new Error('Invalid CAPTCHA_COMPANION_URL'); }
20
+ if (!['http:', 'https:'].includes(parsed.protocol)) throw new Error('CAPTCHA companion URL must use HTTP or HTTPS');
21
+ let token = String(env.CAPTCHA_COMPANION_TOKEN || '').trim();
22
+ if (!token) {
23
+ try { token = fs.readFileSync(TOKEN_FILE, 'utf8').trim(); } catch { /* optional */ }
24
+ }
25
+ return { baseUrl: baseUrl.replace(/\/+$/, ''), token };
26
+ }
27
+
28
+ async function companionRequest(endpoint, { method = 'GET', body, timeout = 1000 } = {}) {
29
+ const config = resolveCompanionConfig();
30
+ if (!config) return null;
31
+ const headers = { Accept: 'application/json' };
32
+ if (config.token) headers.Authorization = `Bearer ${config.token}`;
33
+ if (body !== undefined) headers['Content-Type'] = 'application/json';
34
+ const response = await fetch(`${config.baseUrl}${endpoint}`, {
35
+ method,
36
+ headers,
37
+ ...(body === undefined ? {} : { body: JSON.stringify(body) }),
38
+ signal: AbortSignal.timeout(timeout)
39
+ });
40
+ let payload;
41
+ try { payload = await response.json(); } catch { throw new Error(`Apple companion returned malformed JSON (HTTP ${response.status})`); }
42
+ if (!response.ok) throw new Error(payload.error || `Apple companion HTTP ${response.status}`);
43
+ return payload;
44
+ }
45
+
46
+ async function probeCompanion(timeout = 750) {
47
+ try {
48
+ const health = await companionRequest('/v1/health', { timeout });
49
+ if (!health?.healthy || !Number.isFinite(health.memory?.totalMb)) return null;
50
+ return health;
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ async function detectWithCompanion(imageBuffer, label, tier, threshold, timeout = 30_000) {
57
+ const payload = await companionRequest('/v1/detect', {
58
+ method: 'POST',
59
+ timeout,
60
+ body: { tier, label, threshold, imageBase64: imageBuffer.toString('base64') }
61
+ });
62
+ if (!Array.isArray(payload?.detections)) throw new Error('Apple companion returned invalid detections');
63
+ return { detections: payload.detections, backend: payload.backend || 'companion', device: payload.device || 'coreml' };
64
+ }
65
+
66
+ module.exports = {
67
+ DEFAULT_PORT,
68
+ TOKEN_FILE,
69
+ isDocker,
70
+ resolveCompanionConfig,
71
+ companionRequest,
72
+ probeCompanion,
73
+ detectWithCompanion
74
+ };
@@ -0,0 +1,158 @@
1
+ const { captchaModelManager, checksumBuffer } = require('./captcha-model-manager');
2
+
3
+ const PROVIDERS = Object.freeze({
4
+ recaptcha_v2: Object.freeze({
5
+ framePatterns: ['/recaptcha/api2/bframe', '/recaptcha/enterprise/bframe'],
6
+ instruction: '.rc-imageselect-desc-wrapper, .rc-imageselect-instructions',
7
+ grid: '.rc-imageselect-table-33, .rc-imageselect-table-44',
8
+ cells: '.rc-imageselect-tile',
9
+ submit: '#recaptcha-verify-button',
10
+ noMatch: '#recaptcha-verify-button',
11
+ error: '.rc-imageselect-error-select-more, .rc-imageselect-error-dynamic-more, .rc-imageselect-incorrect-response',
12
+ cleanPrompt(text) {
13
+ return text.replace(/select all (images|squares) with/ig, '')
14
+ .replace(/click verify once there are none left/ig, '')
15
+ .replace(/if there are none, click skip/ig, '');
16
+ }
17
+ }),
18
+ hcaptcha: Object.freeze({
19
+ framePatterns: ['hcaptcha.com/captcha', 'newassets.hcaptcha.com/captcha'],
20
+ instruction: '.prompt-text, .challenge-header .prompt-text, h2',
21
+ grid: '.task-grid',
22
+ cells: '.task-grid .task-image',
23
+ submit: '.button-submit, button[type="submit"]',
24
+ noMatch: '.button-submit, button[type="submit"]',
25
+ error: '.error-text, .challenge-error',
26
+ cleanPrompt(text) {
27
+ return text.replace(/please (click|select) (on )?(all|each) (the )?(images|squares) (containing|with)/ig, '')
28
+ .replace(/if there are none, (click|select) (skip|next)/ig, '');
29
+ }
30
+ })
31
+ });
32
+
33
+ async function findChallengeFrame(page, adapter) {
34
+ for (const frame of page.frames()) {
35
+ if (!adapter.framePatterns.some((pattern) => frame.url().includes(pattern))) continue;
36
+ if (await frame.locator(adapter.grid).first().isVisible({ timeout: 250 }).catch(() => false)) return frame;
37
+ }
38
+ return null;
39
+ }
40
+
41
+ function normalizePrompt(adapter, text) {
42
+ return adapter.cleanPrompt(String(text || '')).replace(/\s+/g, ' ').replace(/[.!]+$/, '').trim();
43
+ }
44
+
45
+ function normalizeBox(detection) {
46
+ const box = detection?.box || detection?.bbox;
47
+ if (Array.isArray(box) && box.length >= 4) {
48
+ const [xmin, ymin, xmax, ymax] = box.map(Number);
49
+ return { xmin, ymin, xmax, ymax };
50
+ }
51
+ if (box && typeof box === 'object') {
52
+ const xmin = Number(box.xmin ?? box.x ?? box.left);
53
+ const ymin = Number(box.ymin ?? box.y ?? box.top);
54
+ const xmax = Number(box.xmax ?? box.right ?? (Number.isFinite(xmin) ? xmin + Number(box.width) : NaN));
55
+ const ymax = Number(box.ymax ?? box.bottom ?? (Number.isFinite(ymin) ? ymin + Number(box.height) : NaN));
56
+ if ([xmin, ymin, xmax, ymax].every(Number.isFinite)) return { xmin, ymin, xmax, ymax };
57
+ }
58
+ return null;
59
+ }
60
+
61
+ function pngDimensions(buffer) {
62
+ if (!Buffer.isBuffer(buffer) || buffer.length < 24 || buffer.toString('ascii', 1, 4) !== 'PNG') return null;
63
+ return { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) };
64
+ }
65
+
66
+ function mapDetectionsToCells(detections, gridBox, cellBoxes, imageSize = null) {
67
+ const indexes = new Set();
68
+ const scaleX = imageSize?.width ? gridBox.width / imageSize.width : 1;
69
+ const scaleY = imageSize?.height ? gridBox.height / imageSize.height : 1;
70
+ for (const detection of detections) {
71
+ const box = normalizeBox(detection);
72
+ if (!box) continue;
73
+ const centerX = gridBox.x + (((box.xmin + box.xmax) / 2) * scaleX);
74
+ const centerY = gridBox.y + (((box.ymin + box.ymax) / 2) * scaleY);
75
+ const index = cellBoxes.findIndex((cell) => cell
76
+ && centerX >= cell.x && centerX <= cell.x + cell.width
77
+ && centerY >= cell.y && centerY <= cell.y + cell.height);
78
+ if (index >= 0) indexes.add(index);
79
+ }
80
+ return [...indexes];
81
+ }
82
+
83
+ async function classifyGrid(frame, adapter, cells, label, seenTiles) {
84
+ const grid = frame.locator(adapter.grid).first();
85
+ const gridBox = await grid.boundingBox().catch(() => null);
86
+ if (gridBox) {
87
+ const image = await grid.screenshot({ type: 'png' });
88
+ const detections = await captchaModelManager.detect(image, label);
89
+ if (detections.length === 0) return [];
90
+ const cellBoxes = [];
91
+ for (let index = 0; index < await cells.count(); index += 1) cellBoxes.push(await cells.nth(index).boundingBox().catch(() => null));
92
+ const mapped = mapDetectionsToCells(detections, gridBox, cellBoxes, pngDimensions(image));
93
+ if (mapped.length || detections.every((item) => normalizeBox(item))) {
94
+ const changed = [];
95
+ for (const index of mapped) {
96
+ const image = await cells.nth(index).screenshot({ type: 'png' });
97
+ const fingerprint = checksumBuffer(image);
98
+ if (seenTiles.get(index) === fingerprint) continue;
99
+ seenTiles.set(index, fingerprint);
100
+ changed.push(index);
101
+ }
102
+ return changed;
103
+ }
104
+ }
105
+
106
+ const selected = [];
107
+ for (let index = 0; index < await cells.count(); index += 1) {
108
+ const cell = cells.nth(index);
109
+ const image = await cell.screenshot({ type: 'png' });
110
+ const fingerprint = checksumBuffer(image);
111
+ if (seenTiles.get(index) === fingerprint) continue;
112
+ seenTiles.set(index, fingerprint);
113
+ if ((await captchaModelManager.detect(image, label)).length) selected.push(index);
114
+ }
115
+ return selected;
116
+ }
117
+
118
+ async function visibleText(frame, selector) {
119
+ return frame.locator(selector).first().innerText({ timeout: 1500 }).catch(() => '');
120
+ }
121
+
122
+ async function solveImageGrid(page, { captchaType, deadline, waitForToken, logs = [] }) {
123
+ const adapter = PROVIDERS[captchaType];
124
+ if (!adapter) throw new Error(`No image-grid adapter for ${captchaType}`);
125
+ const seenTiles = new Map();
126
+ let previousPrompt = '';
127
+ for (let round = 0; round < 6 && Date.now() < deadline; round += 1) {
128
+ const frame = await findChallengeFrame(page, adapter);
129
+ if (!frame) throw new Error(`${captchaType} image challenge frame disappeared`);
130
+ const rejection = await visibleText(frame, adapter.error);
131
+ if (rejection) throw new Error(`${captchaType} rejected the previous selection: ${rejection.replace(/\s+/g, ' ').trim()}`);
132
+ const prompt = normalizePrompt(adapter, await visibleText(frame, adapter.instruction));
133
+ if (!prompt) throw new Error(`${captchaType} solver could not read the image challenge instruction`);
134
+ if (prompt !== previousPrompt) {
135
+ seenTiles.clear();
136
+ previousPrompt = prompt;
137
+ }
138
+ let totalSelections = 0;
139
+ for (let sweep = 0; sweep < 4 && Date.now() < deadline; sweep += 1) {
140
+ const cells = frame.locator(adapter.cells);
141
+ const count = await cells.count();
142
+ if (![9, 16].includes(count)) throw new Error(`Unsupported ${captchaType} grid size (${count} cells)`);
143
+ const selected = await classifyGrid(frame, adapter, cells, prompt, seenTiles);
144
+ for (const index of selected) await cells.nth(index).click({ timeout: 2000 });
145
+ totalSelections += selected.length;
146
+ if (!selected.length) break;
147
+ await page.waitForTimeout(600);
148
+ }
149
+ logs.push(`Local ${captchaType} grid round ${round + 1}: selected ${totalSelections} cells for "${prompt}"`);
150
+ await frame.locator(totalSelections ? adapter.submit : adapter.noMatch).first().click({ timeout: 2000 });
151
+ const token = await waitForToken(page, captchaType, Math.min(2500, Math.max(0, deadline - Date.now())));
152
+ if (token) return token;
153
+ await page.waitForTimeout(350);
154
+ }
155
+ return null;
156
+ }
157
+
158
+ module.exports = { PROVIDERS, normalizePrompt, normalizeBox, pngDimensions, mapDetectionsToCells, solveImageGrid };
@@ -0,0 +1,39 @@
1
+ function installTurnstileInterceptor(options = {}) {
2
+ const root = globalThis;
3
+ const state = root.__figraniumCaptcha = root.__figraniumCaptcha || {};
4
+ state.blockManaged = Boolean(options.blockManaged);
5
+
6
+ const wrapRender = () => {
7
+ const api = root.turnstile;
8
+ if (!api || typeof api.render !== 'function' || api.render.__figraniumWrapped) return;
9
+ const originalRender = api.render;
10
+ const wrappedRender = function (container, renderOptions = {}) {
11
+ const managed = Boolean(renderOptions.cData && renderOptions.chlPageData);
12
+ state.turnstile = {
13
+ siteKey: renderOptions.sitekey || null,
14
+ action: renderOptions.action || null,
15
+ cData: renderOptions.cData || null,
16
+ chlPageData: renderOptions.chlPageData || null,
17
+ callback: typeof renderOptions.callback === 'function' ? renderOptions.callback : null,
18
+ callbackName: typeof renderOptions.callback === 'string' ? renderOptions.callback : null,
19
+ container: typeof container === 'string' ? root.document?.querySelector(container) : container,
20
+ userAgent: navigator.userAgent,
21
+ managed,
22
+ blocked: managed && state.blockManaged,
23
+ readyAt: Date.now(),
24
+ callbackInvoked: false
25
+ };
26
+ if (managed && state.blockManaged) return `figranium-turnstile-${Date.now()}`;
27
+ return originalRender.apply(this, arguments);
28
+ };
29
+ Object.defineProperty(wrappedRender, '__figraniumWrapped', { value: true });
30
+ Object.defineProperty(wrappedRender, '__figraniumOriginal', { value: originalRender });
31
+ api.render = wrappedRender;
32
+ };
33
+
34
+ wrapRender();
35
+ const interval = setInterval(wrapRender, 10);
36
+ root.addEventListener?.('pagehide', () => clearInterval(interval), { once: true });
37
+ }
38
+
39
+ module.exports = { installTurnstileInterceptor };