cv3inx commited on
Commit
a920dd4
Β·
1 Parent(s): 579a226

Add local CAPTCHA solvers + integrate self-hosted turnstile-solver

Browse files

Drop the 2captcha/capsolver third-party hooks entirely. Replace them
with a local-only solver suite plus a client for the self-hosted
turnstile-solver service (github.com/cv3inx/turnstile-solver).

helpers/captcha/:
- turnstile.js β€” local human-bezier click + client for self-hosted
/solve and /solve-challenge endpoints; injects the
returned token into the page so forms accept it
- audio.js β€” Whisper (Xenova/whisper-tiny.en) for reCAPTCHA v2
audio fallback, runs locally via @huggingface/transformers
- ocr.js β€” Tesseract.js for text/image CAPTCHAs (eng traineddata
pre-fetched at build time)
- math.js β€” safe expression evaluator for math CAPTCHAs
- recaptcha.js β€” full audio-fallback flow for reCAPTCHA v2
- mouse.js β€” bezier-curve human mouse paths
- cookies.js β€” per-origin cookie jar to persist CF clearance across
requests (saves time, avoids re-solving)
- index.js β€” autoSolve dispatcher + flat re-exports

helpers/stealth/index.js:
- waitForCloudflare now tries the self-hosted solver first when
TURNSTILE_SOLVER_URL is set, then falls back to local human click
- removed the 2captcha/capsolver code path entirely

Dockerfile:
- prefetch Whisper model at build time so the first request doesn't
pay the download cost
- prefetch tesseract eng.traineddata
- create /app/cookies + bind env vars (TRANSFORMERS_CACHE, TESSDATA_PREFIX,
COOKIES_DIR)

docker-compose.yml + .env.example:
- replace TWOCAPTCHA_KEY/CAPSOLVER_KEY with TURNSTILE_SOLVER_URL
- add named volume for cookie persistence

server.js info page documents the new helpers and is honest about what
is and is not handled (hCaptcha image grids etc still need a tuned
solver service).

.env.example CHANGED
@@ -6,11 +6,22 @@ HOST_PORT=7860
6
  # Per-request timeout in milliseconds. Default 30 minutes.
7
  TIMEOUT_MS=1800000
8
 
9
- # Resource caps for the container (tune to your VPS).
10
  CPU_LIMIT=4.0
11
  MEM_LIMIT=8g
12
 
13
- # Optional β€” only set if you want stealth.solveTurnstileWithService() to work
14
- # when the Turnstile checkbox click alone fails. Both are paid services.
15
- # TWOCAPTCHA_KEY=
16
- # CAPSOLVER_KEY=
 
 
 
 
 
 
 
 
 
 
 
 
6
  # Per-request timeout in milliseconds. Default 30 minutes.
7
  TIMEOUT_MS=1800000
8
 
9
+ # Resource caps (tune to your VPS).
10
  CPU_LIMIT=4.0
11
  MEM_LIMIT=8g
12
 
13
+ # --------------------------------------------------------------------------
14
+ # Self-hosted CAPTCHA solver β€” no third-party APIs.
15
+ # Deploy github.com/cv3inx/turnstile-solver as a separate container/Space
16
+ # and point this URL at it. captcha.solveTurnstile() will use it automatically.
17
+ # Leave empty to use the in-process local click fallback only.
18
+ # --------------------------------------------------------------------------
19
+ # TURNSTILE_SOLVER_URL=http://turnstile-solver:9988
20
+
21
+ # Whisper model used for audio CAPTCHA transcription. tiny.en is the fastest;
22
+ # upgrade to base.en for accuracy on noisy challenges.
23
+ # WHISPER_MODEL=Xenova/whisper-tiny.en
24
+
25
+ # Where to persist CF-clearance / login cookies between requests. Mount a
26
+ # volume here on a VPS for durability across container restarts.
27
+ # COOKIES_DIR=/app/cookies
Dockerfile CHANGED
@@ -2,14 +2,13 @@ FROM mcr.microsoft.com/playwright:v1.49.0-jammy
2
 
3
  USER root
4
 
5
- # Step 1 β€” essential packages. If this fails, build should fail (we need these).
6
  RUN apt-get update \
7
  && apt-get install -y --no-install-recommends \
8
  util-linux procps psmisc ca-certificates curl unzip wget gnupg \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
- # Step 2 β€” fonts (best-effort: some packages may be missing on certain mirrors).
12
- # Allow individual font packages to fail without killing the build.
13
  RUN apt-get update && \
14
  for pkg in fonts-liberation fonts-noto fonts-noto-color-emoji fonts-noto-cjk \
15
  fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg; do \
@@ -23,8 +22,7 @@ RUN apt-get update && \
23
  || echo "Xvfb stack failed β€” headed mode unavailable" && \
24
  rm -rf /var/lib/apt/lists/*
25
 
26
- # Step 4 β€” Google Chrome stable (best-effort: Widevine DRM optional).
27
- # If Google's repo or signing key is unavailable, continue with chromium only.
28
  RUN set -eux; \
29
  ( \
30
  wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg && \
@@ -41,28 +39,49 @@ ENV PORT=7860 \
41
  PATH=/home/pwuser/.bun/bin:/usr/local/bin:/usr/bin:/bin \
42
  PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
43
  PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
44
- NODE_PATH=/app/node_modules:/app/helpers
 
 
 
45
 
46
  WORKDIR /app
47
- RUN mkdir -p /app/runs /app/helpers /app/public /home/pwuser \
 
48
  && chown -R 1000:1000 /app /home/pwuser
49
 
50
  USER 1000
51
 
52
- # Bun install β€” fail loud if this breaks
53
  RUN curl -fsSL https://bun.sh/install | bash
54
 
55
  COPY --chown=1000:1000 package.json ./
56
  RUN bun install --production --ignore-scripts
57
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
58
  COPY --chown=1000:1000 helpers/ ./helpers/
59
  COPY --chown=1000:1000 public/ ./public/
60
  COPY --chown=1000:1000 server.js ./
61
 
62
- # Sanity: confirm the artifacts the server expects to find at runtime
63
  RUN test -f /app/server.js \
64
  && test -f /app/public/index.html \
65
  && test -d /app/helpers/stealth \
 
66
  && test -f /app/node_modules/playwright/package.json \
67
  && echo "build artifacts OK"
68
 
 
2
 
3
  USER root
4
 
5
+ # Step 1 β€” essential packages.
6
  RUN apt-get update \
7
  && apt-get install -y --no-install-recommends \
8
  util-linux procps psmisc ca-certificates curl unzip wget gnupg \
9
  && rm -rf /var/lib/apt/lists/*
10
 
11
+ # Step 2 β€” fonts (best-effort).
 
12
  RUN apt-get update && \
13
  for pkg in fonts-liberation fonts-noto fonts-noto-color-emoji fonts-noto-cjk \
14
  fonts-ipafont-gothic fonts-wqy-zenhei fonts-thai-tlwg; do \
 
22
  || echo "Xvfb stack failed β€” headed mode unavailable" && \
23
  rm -rf /var/lib/apt/lists/*
24
 
25
+ # Step 4 β€” Google Chrome stable (best-effort).
 
26
  RUN set -eux; \
27
  ( \
28
  wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | gpg --dearmor -o /usr/share/keyrings/google-chrome.gpg && \
 
39
  PATH=/home/pwuser/.bun/bin:/usr/local/bin:/usr/bin:/bin \
40
  PLAYWRIGHT_BROWSERS_PATH=/ms-playwright \
41
  PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1 \
42
+ NODE_PATH=/app/node_modules:/app/helpers \
43
+ TRANSFORMERS_CACHE=/app/.cache/transformers \
44
+ TESSDATA_PREFIX=/app/tesseract-data \
45
+ COOKIES_DIR=/app/cookies
46
 
47
  WORKDIR /app
48
+ RUN mkdir -p /app/runs /app/helpers /app/public /app/cookies \
49
+ /app/.cache/transformers /app/tesseract-data /home/pwuser \
50
  && chown -R 1000:1000 /app /home/pwuser
51
 
52
  USER 1000
53
 
54
+ # Bun install
55
  RUN curl -fsSL https://bun.sh/install | bash
56
 
57
  COPY --chown=1000:1000 package.json ./
58
  RUN bun install --production --ignore-scripts
59
 
60
+ # Pre-download captcha solver models so the first request doesn't pay the cost.
61
+ # Both are best-effort: if a fetch fails, the helper will try at runtime.
62
+ RUN bun -e 'try { \
63
+ const { pipeline, env } = await import("@huggingface/transformers"); \
64
+ env.cacheDir = "/app/.cache/transformers"; \
65
+ console.log("[prefetch] whisper-tiny.en..."); \
66
+ await pipeline("automatic-speech-recognition", "Xenova/whisper-tiny.en", { quantized: true }); \
67
+ console.log("[prefetch] whisper done"); \
68
+ } catch (e) { console.warn("[prefetch] whisper skipped:", e.message); }' \
69
+ || echo "whisper prefetch skipped"
70
+
71
+ RUN cd /app/tesseract-data && \
72
+ (curl -fsSLO https://github.com/tesseract-ocr/tessdata_fast/raw/main/eng.traineddata \
73
+ && echo "[prefetch] tesseract eng.traineddata done") \
74
+ || echo "tesseract prefetch skipped"
75
+
76
  COPY --chown=1000:1000 helpers/ ./helpers/
77
  COPY --chown=1000:1000 public/ ./public/
78
  COPY --chown=1000:1000 server.js ./
79
 
80
+ # Sanity check
81
  RUN test -f /app/server.js \
82
  && test -f /app/public/index.html \
83
  && test -d /app/helpers/stealth \
84
+ && test -d /app/helpers/captcha \
85
  && test -f /app/node_modules/playwright/package.json \
86
  && echo "build artifacts OK"
87
 
docker-compose.yml CHANGED
@@ -12,9 +12,11 @@ services:
12
  environment:
13
  PORT: 7860
14
  TIMEOUT_MS: ${TIMEOUT_MS:-1800000} # 30 minutes per request
15
- # Optional CAPTCHA solver β€” enables stealth.solveTurnstileWithService()
16
- TWOCAPTCHA_KEY: ${TWOCAPTCHA_KEY:-}
17
- CAPSOLVER_KEY: ${CAPSOLVER_KEY:-}
 
 
18
 
19
  # Chromium needs more shared memory than the Docker default (64 MB) or
20
  # tabs crash with "Target closed". 2 GB is comfortable for many parallel
@@ -48,9 +50,17 @@ services:
48
  - /tmp:size=2g,exec
49
  - /app/runs:size=512m,exec
50
 
 
 
 
 
 
51
  # Logging β€” keep recent logs but don't fill the disk.
52
  logging:
53
  driver: json-file
54
  options:
55
  max-size: "20m"
56
  max-file: "5"
 
 
 
 
12
  environment:
13
  PORT: 7860
14
  TIMEOUT_MS: ${TIMEOUT_MS:-1800000} # 30 minutes per request
15
+ # Self-hosted CAPTCHA solver β€” point at an instance of
16
+ # github.com/cv3inx/turnstile-solver. No third-party API.
17
+ TURNSTILE_SOLVER_URL: ${TURNSTILE_SOLVER_URL:-}
18
+ WHISPER_MODEL: ${WHISPER_MODEL:-Xenova/whisper-tiny.en}
19
+ COOKIES_DIR: /app/cookies
20
 
21
  # Chromium needs more shared memory than the Docker default (64 MB) or
22
  # tabs crash with "Target closed". 2 GB is comfortable for many parallel
 
50
  - /tmp:size=2g,exec
51
  - /app/runs:size=512m,exec
52
 
53
+ # Persist the cookie jar across restarts so we don't have to re-solve CF
54
+ # for every container boot.
55
+ volumes:
56
+ - playwright-cookies:/app/cookies
57
+
58
  # Logging β€” keep recent logs but don't fill the disk.
59
  logging:
60
  driver: json-file
61
  options:
62
  max-size: "20m"
63
  max-file: "5"
64
+
65
+ volumes:
66
+ playwright-cookies:
helpers/captcha/audio.js ADDED
@@ -0,0 +1,46 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Audio CAPTCHA solver β€” Whisper running locally via @huggingface/transformers.
2
+ // No API key, no external service. Model files are pre-downloaded at build time.
3
+ //
4
+ // Best fit: reCAPTCHA v2 audio fallback (English).
5
+
6
+ let _pipePromise = null;
7
+
8
+ async function getPipeline() {
9
+ if (_pipePromise) return _pipePromise;
10
+ _pipePromise = (async () => {
11
+ const { pipeline, env } = await import('@huggingface/transformers');
12
+ // Cache + offline-friendly defaults
13
+ env.allowLocalModels = true;
14
+ env.cacheDir = process.env.TRANSFORMERS_CACHE || '/app/.cache/transformers';
15
+ // whisper-tiny.en is fast, English-only, ~40MB. Good for reCAPTCHA.
16
+ const model = process.env.WHISPER_MODEL || 'Xenova/whisper-tiny.en';
17
+ return pipeline('automatic-speech-recognition', model, {
18
+ quantized: true,
19
+ });
20
+ })();
21
+ return _pipePromise;
22
+ }
23
+
24
+ // Transcribe an audio buffer (Uint8Array or Buffer) β†’ text.
25
+ async function transcribe(audioBuffer) {
26
+ const pipe = await getPipeline();
27
+ // The pipeline accepts a URL, a Buffer, or a Float32Array. Buffer is simplest.
28
+ const result = await pipe(new Uint8Array(audioBuffer), {
29
+ chunk_length_s: 30,
30
+ stride_length_s: 5,
31
+ });
32
+ return (result?.text || '').trim();
33
+ }
34
+
35
+ // Convenience: download the URL with the page's auth cookies & transcribe.
36
+ async function transcribeUrl(page, url) {
37
+ const buf = await page.evaluate(async (u) => {
38
+ const r = await fetch(u, { credentials: 'include' });
39
+ if (!r.ok) throw new Error('audio fetch failed: ' + r.status);
40
+ const ab = await r.arrayBuffer();
41
+ return Array.from(new Uint8Array(ab));
42
+ }, url);
43
+ return transcribe(Buffer.from(buf));
44
+ }
45
+
46
+ module.exports = { transcribe, transcribeUrl, getPipeline };
helpers/captcha/cookies.js ADDED
@@ -0,0 +1,66 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Cookie jar β€” persist CF clearance / login cookies per origin so we don't
2
+ // have to re-solve a challenge on every request.
3
+ //
4
+ // Storage: /app/cookies/<sha1(origin)>.json
5
+ // On HF Space this is wiped on container restart; on a VPS it persists if
6
+ // you mount a volume to /app/cookies.
7
+
8
+ const fs = require('node:fs');
9
+ const path = require('node:path');
10
+ const crypto = require('node:crypto');
11
+
12
+ const COOKIES_DIR = process.env.COOKIES_DIR || '/app/cookies';
13
+ try { fs.mkdirSync(COOKIES_DIR, { recursive: true }); } catch {}
14
+
15
+ function originKey(url) {
16
+ try {
17
+ const u = new URL(url);
18
+ return crypto.createHash('sha1').update(u.origin).digest('hex').slice(0, 16);
19
+ } catch {
20
+ return crypto.createHash('sha1').update(String(url)).digest('hex').slice(0, 16);
21
+ }
22
+ }
23
+
24
+ function pathFor(url) {
25
+ return path.join(COOKIES_DIR, `${originKey(url)}.json`);
26
+ }
27
+
28
+ async function saveCookies(context, url) {
29
+ try {
30
+ const cookies = await context.cookies(url);
31
+ if (!cookies.length) return false;
32
+ fs.writeFileSync(pathFor(url), JSON.stringify({ url, savedAt: Date.now(), cookies }, null, 2));
33
+ return true;
34
+ } catch {
35
+ return false;
36
+ }
37
+ }
38
+
39
+ async function loadCookies(context, url) {
40
+ try {
41
+ const file = pathFor(url);
42
+ if (!fs.existsSync(file)) return false;
43
+ const data = JSON.parse(fs.readFileSync(file, 'utf8'));
44
+ if (!data.cookies?.length) return false;
45
+ // Drop expired cookies
46
+ const now = Math.floor(Date.now() / 1000);
47
+ const fresh = data.cookies.filter((c) => !c.expires || c.expires === -1 || c.expires > now);
48
+ if (!fresh.length) return false;
49
+ await context.addCookies(fresh);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function clearCookies(url) {
57
+ try {
58
+ const file = pathFor(url);
59
+ if (fs.existsSync(file)) fs.unlinkSync(file);
60
+ return true;
61
+ } catch {
62
+ return false;
63
+ }
64
+ }
65
+
66
+ module.exports = { saveCookies, loadCookies, clearCookies, COOKIES_DIR };
helpers/captcha/index.js ADDED
@@ -0,0 +1,80 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Local-only CAPTCHA solvers β€” no third-party API services.
2
+ //
3
+ // What's included:
4
+ // - Audio CAPTCHA (Whisper, runs locally; English)
5
+ // - Text/OCR CAPTCHA (Tesseract.js, runs locally)
6
+ // - Math CAPTCHA (safe expression eval)
7
+ // - reCAPTCHA v2 (full audio-fallback flow)
8
+ // - Cloudflare Turnstile (stealth click + human mouse path)
9
+ // - Cookie jar (skip re-solving by reusing CF clearance per origin)
10
+ //
11
+ // What is NOT included (and why):
12
+ // - hCaptcha image grid / reCAPTCHA v2 image challenge / CF Turnstile
13
+ // image challenge β€” these need a trained CV classifier. There is no
14
+ // accurate, license-free, sub-1GB classifier for "select all cars/buses
15
+ // /traffic lights" tasks. To avoid pretending otherwise, those modes
16
+ // return false instead of guessing.
17
+
18
+ const audio = require('./audio');
19
+ const ocr = require('./ocr');
20
+ const math = require('./math');
21
+ const recaptcha = require('./recaptcha');
22
+ const turnstile = require('./turnstile');
23
+ const mouse = require('./mouse');
24
+ const cookies = require('./cookies');
25
+
26
+ // Dispatcher: figure out what CAPTCHA is on the page and try the right solver.
27
+ async function autoSolve(page, opts = {}) {
28
+ const html = await page.content().catch(() => '');
29
+ const lower = html.toLowerCase();
30
+
31
+ // Order matters β€” try the cheapest detectors first.
32
+ if (lower.includes('cf-turnstile') || page.frames().some((f) => /challenges\.cloudflare\.com/.test(f.url() || ''))) {
33
+ const ok = await turnstile.solveTurnstile(page, opts);
34
+ return { type: 'turnstile', solved: ok };
35
+ }
36
+ if (lower.includes('g-recaptcha') || page.frames().some((f) => /\/recaptcha\/api2\/anchor/.test(f.url() || ''))) {
37
+ const ok = await recaptcha.solveRecaptchaV2(page, opts);
38
+ return { type: 'recaptcha-v2', solved: ok };
39
+ }
40
+ // Math text? Look for "X + Y =" style on the page
41
+ const mathAns = math.extractAndSolve(await page.evaluate(() => document.body.innerText).catch(() => ''));
42
+ if (mathAns !== null) {
43
+ return { type: 'math', solved: false, answer: mathAns, hint: 'fill the answer into the captcha input field yourself' };
44
+ }
45
+ return { type: 'unknown', solved: false };
46
+ }
47
+
48
+ module.exports = {
49
+ // Audio (Whisper)
50
+ transcribe: audio.transcribe,
51
+ transcribeUrl: audio.transcribeUrl,
52
+ // OCR (Tesseract)
53
+ ocrBuffer: ocr.recognizeBuffer,
54
+ ocrUrl: ocr.recognizeUrl,
55
+ ocrElement: ocr.recognizeElement,
56
+ // Math
57
+ solveMath: math.safeEvalMath,
58
+ extractMath: math.extractAndSolve,
59
+ // reCAPTCHA v2
60
+ solveRecaptchaV2: recaptcha.solveRecaptchaV2,
61
+ // Turnstile
62
+ clickTurnstile: turnstile.clickTurnstile,
63
+ waitForTurnstileToken: turnstile.waitForToken,
64
+ solveTurnstile: turnstile.solveTurnstile,
65
+ solveTurnstileViaSelfHosted: turnstile.solveViaSelfHosted,
66
+ readTurnstileSitekey: turnstile.readSitekey,
67
+ clearChallenge: turnstile.clearChallenge,
68
+ clearChallengeAndAdoptCookies: turnstile.clearChallengeAndAdoptCookies,
69
+ turnstileSolverUrl: turnstile.solverUrl,
70
+ // Mouse (exposed for general use)
71
+ humanMove: mouse.humanMove,
72
+ humanClick: mouse.humanClick,
73
+ humanMoveAndClick: mouse.humanMoveAndClick,
74
+ // Cookie jar
75
+ saveCookies: cookies.saveCookies,
76
+ loadCookies: cookies.loadCookies,
77
+ clearCookies: cookies.clearCookies,
78
+ // Top-level dispatcher
79
+ autoSolve,
80
+ };
helpers/captcha/math.js ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Math CAPTCHA solver β€” extract a math expression from text and evaluate it
2
+ // safely (no eval; only +, -, *, /, parens, digits).
3
+
4
+ function safeEvalMath(expr) {
5
+ if (typeof expr !== 'string') return null;
6
+ // Normalize common surface forms
7
+ const s = expr
8
+ .replace(/[Γ—x]/gi, '*')
9
+ .replace(/Γ·/g, '/')
10
+ .replace(/[β€”β€“βˆ’]/g, '-')
11
+ .replace(/[^\d+\-*/().\s]/g, '');
12
+ if (!s.trim()) return null;
13
+ // eslint-disable-next-line no-new-func
14
+ try { return Function(`"use strict"; return (${s});`)(); }
15
+ catch { return null; }
16
+ }
17
+
18
+ // Pull the first math expression out of free text.
19
+ function extractAndSolve(text) {
20
+ if (!text) return null;
21
+ const m = String(text).match(/(\d+(?:\s*[+\-*/Γ—Γ·]\s*\d+)+)/);
22
+ if (!m) return null;
23
+ return safeEvalMath(m[1]);
24
+ }
25
+
26
+ module.exports = { safeEvalMath, extractAndSolve };
helpers/captcha/mouse.js ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Human-like mouse movement: bezier curve with jitter + variable step delays.
2
+ // Used by turnstile.js and exported for any code that wants natural cursor paths.
3
+
4
+ function bezier(p0, p1, p2, p3, t) {
5
+ const u = 1 - t;
6
+ return {
7
+ x: u * u * u * p0.x + 3 * u * u * t * p1.x + 3 * u * t * t * p2.x + t * t * t * p3.x,
8
+ y: u * u * u * p0.y + 3 * u * u * t * p1.y + 3 * u * t * t * p2.y + t * t * t * p3.y,
9
+ };
10
+ }
11
+
12
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
13
+
14
+ async function humanMove(page, fromX, fromY, toX, toY, opts = {}) {
15
+ const { steps = 25, jitter = 4, baseDelayMs = 8 } = opts;
16
+ // Two control points slightly off the straight line β€” gives a curved path
17
+ const dx = toX - fromX;
18
+ const dy = toY - fromY;
19
+ const c1 = {
20
+ x: fromX + dx * 0.3 + (Math.random() - 0.5) * 80,
21
+ y: fromY + dy * 0.3 + (Math.random() - 0.5) * 80,
22
+ };
23
+ const c2 = {
24
+ x: fromX + dx * 0.7 + (Math.random() - 0.5) * 80,
25
+ y: fromY + dy * 0.7 + (Math.random() - 0.5) * 80,
26
+ };
27
+ for (let i = 0; i <= steps; i++) {
28
+ const t = i / steps;
29
+ const p = bezier({ x: fromX, y: fromY }, c1, c2, { x: toX, y: toY }, t);
30
+ const jx = (Math.random() - 0.5) * jitter;
31
+ const jy = (Math.random() - 0.5) * jitter;
32
+ await page.mouse.move(p.x + jx, p.y + jy);
33
+ await sleep(baseDelayMs + Math.random() * 12);
34
+ }
35
+ }
36
+
37
+ async function humanClick(page, x, y, opts = {}) {
38
+ const { holdMs = 60 + Math.random() * 80 } = opts;
39
+ await page.mouse.move(x + (Math.random() - 0.5) * 2, y + (Math.random() - 0.5) * 2);
40
+ await sleep(80 + Math.random() * 120);
41
+ await page.mouse.down();
42
+ await sleep(holdMs);
43
+ await page.mouse.up();
44
+ }
45
+
46
+ // Move from current position (or center if unknown) to (x,y) and click
47
+ async function humanMoveAndClick(page, x, y, opts = {}) {
48
+ const cur = page._lastMousePos || { x: 200, y: 200 };
49
+ await humanMove(page, cur.x, cur.y, x, y, opts);
50
+ await humanClick(page, x, y, opts);
51
+ page._lastMousePos = { x, y };
52
+ }
53
+
54
+ module.exports = { humanMove, humanClick, humanMoveAndClick };
helpers/captcha/ocr.js ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Text/image CAPTCHA solver β€” Tesseract.js running locally.
2
+ // Best fit: simple text-in-image CAPTCHAs. Trained data is pre-downloaded
3
+ // at build time to /app/tesseract-data.
4
+
5
+ let _workerPromise = null;
6
+
7
+ async function getWorker() {
8
+ if (_workerPromise) return _workerPromise;
9
+ _workerPromise = (async () => {
10
+ const { createWorker } = require('tesseract.js');
11
+ const worker = await createWorker('eng', 1, {
12
+ langPath: process.env.TESSDATA_PREFIX || '/app/tesseract-data',
13
+ cachePath: process.env.TESSDATA_PREFIX || '/app/tesseract-data',
14
+ gzip: false,
15
+ });
16
+ await worker.setParameters({
17
+ // Most CAPTCHAs are 4-8 chars; treat as a single line.
18
+ tessedit_pageseg_mode: 7,
19
+ // Restrict to alphanumerics β€” fewer hallucinations.
20
+ tessedit_char_whitelist: 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789',
21
+ });
22
+ return worker;
23
+ })();
24
+ return _workerPromise;
25
+ }
26
+
27
+ async function recognizeBuffer(imageBuffer) {
28
+ const w = await getWorker();
29
+ const { data } = await w.recognize(imageBuffer);
30
+ return (data?.text || '').replace(/\s+/g, '').trim();
31
+ }
32
+
33
+ async function recognizeUrl(page, url) {
34
+ const buf = await page.evaluate(async (u) => {
35
+ const r = await fetch(u, { credentials: 'include' });
36
+ if (!r.ok) throw new Error('image fetch failed: ' + r.status);
37
+ const ab = await r.arrayBuffer();
38
+ return Array.from(new Uint8Array(ab));
39
+ }, url);
40
+ return recognizeBuffer(Buffer.from(buf));
41
+ }
42
+
43
+ async function recognizeElement(page, selector) {
44
+ const buf = await page.locator(selector).first().screenshot();
45
+ return recognizeBuffer(buf);
46
+ }
47
+
48
+ module.exports = { recognizeBuffer, recognizeUrl, recognizeElement, getWorker };
helpers/captcha/package.json ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ {
2
+ "name": "captcha",
3
+ "version": "1.0.0",
4
+ "main": "index.js",
5
+ "type": "commonjs"
6
+ }
helpers/captcha/recaptcha.js ADDED
@@ -0,0 +1,116 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // reCAPTCHA v2 audio-fallback flow.
2
+ //
3
+ // Strategy: click checkbox β†’ if image challenge appears, switch to audio
4
+ // challenge β†’ download audio β†’ transcribe with Whisper β†’ submit answer.
5
+ //
6
+ // Returns true on success, false otherwise. Throws nothing.
7
+
8
+ const audio = require('./audio');
9
+
10
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
11
+
12
+ function findFrame(page, predicate) {
13
+ return page.frames().find((f) => {
14
+ const u = f.url() || '';
15
+ return predicate(u);
16
+ });
17
+ }
18
+
19
+ async function solveRecaptchaV2(page, opts = {}) {
20
+ const { timeout = 60000, attempts = 3 } = opts;
21
+ const deadline = Date.now() + timeout;
22
+
23
+ // 1) The checkbox (anchor) iframe β€” click it to start the challenge
24
+ const anchor = findFrame(page, (u) => u.includes('/recaptcha/api2/anchor') || u.includes('/recaptcha/enterprise/anchor'));
25
+ if (!anchor) return false;
26
+ try {
27
+ const cb = anchor.locator('#recaptcha-anchor');
28
+ await cb.click({ timeout: 5000 });
29
+ } catch {
30
+ return false;
31
+ }
32
+ await sleep(1500);
33
+
34
+ // 2) Wait for the bframe (challenge popup); if no popup, checkbox alone passed.
35
+ let bframe = null;
36
+ for (let i = 0; i < 10 && !bframe; i++) {
37
+ bframe = findFrame(page, (u) => u.includes('/recaptcha/api2/bframe') || u.includes('/recaptcha/enterprise/bframe'));
38
+ if (!bframe) await sleep(500);
39
+ }
40
+ if (!bframe) {
41
+ // Token might already be set
42
+ const ok = await page.evaluate(() => {
43
+ const t = document.getElementById('g-recaptcha-response');
44
+ return !!(t && t.value);
45
+ });
46
+ return ok;
47
+ }
48
+
49
+ // 3) Switch to audio challenge
50
+ try {
51
+ await bframe.locator('#recaptcha-audio-button').click({ timeout: 5000 });
52
+ } catch {
53
+ return false;
54
+ }
55
+ await sleep(1500);
56
+
57
+ // Detect "automated requests" lockout
58
+ const locked = await bframe
59
+ .locator('text=/automated queries|try again later/i')
60
+ .first()
61
+ .isVisible({ timeout: 1000 })
62
+ .catch(() => false);
63
+ if (locked) return false;
64
+
65
+ for (let i = 0; i < attempts && Date.now() < deadline; i++) {
66
+ // 4) Get audio source URL
67
+ const src = await bframe
68
+ .locator('audio#audio-source, .rc-audiochallenge-tdownload-link')
69
+ .first()
70
+ .getAttribute('href')
71
+ .catch(() => null) ||
72
+ await bframe.locator('audio#audio-source').first().getAttribute('src').catch(() => null);
73
+ if (!src) return false;
74
+
75
+ // 5) Transcribe via local Whisper
76
+ let answer = '';
77
+ try {
78
+ answer = (await audio.transcribeUrl(page, src)).trim().toLowerCase();
79
+ } catch {
80
+ return false;
81
+ }
82
+ if (!answer) continue;
83
+
84
+ // 6) Type the answer + verify
85
+ try {
86
+ const input = bframe.locator('#audio-response');
87
+ await input.fill('');
88
+ await input.type(answer, { delay: 60 + Math.random() * 60 });
89
+ await bframe.locator('#recaptcha-verify-button').click();
90
+ } catch {
91
+ return false;
92
+ }
93
+ await sleep(2500);
94
+
95
+ // 7) Check if solved
96
+ const solved = await page.evaluate(() => {
97
+ const t = document.getElementById('g-recaptcha-response');
98
+ return !!(t && t.value);
99
+ });
100
+ if (solved) return true;
101
+
102
+ // If wrong, reCAPTCHA may show "Multiple solutions required" or refresh β€” loop.
103
+ const errVisible = await bframe
104
+ .locator('text=/incorrect|try again/i')
105
+ .first()
106
+ .isVisible({ timeout: 500 })
107
+ .catch(() => false);
108
+ if (errVisible) {
109
+ try { await bframe.locator('#recaptcha-reload-button').click({ timeout: 1500 }); } catch {}
110
+ await sleep(1500);
111
+ }
112
+ }
113
+ return false;
114
+ }
115
+
116
+ module.exports = { solveRecaptchaV2 };
helpers/captcha/turnstile.js ADDED
@@ -0,0 +1,222 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ // Cloudflare Turnstile / "Verify you are human" β€” self-hosted solver.
2
+ //
3
+ // Two paths, picked automatically:
4
+ //
5
+ // 1. SELF-HOSTED SOLVER (recommended for production)
6
+ // Set env var TURNSTILE_SOLVER_URL=http://your-solver:9988
7
+ // Pointing at an instance of github.com/cv3inx/turnstile-solver.
8
+ // That service exposes:
9
+ // POST /solve { sitekey, siteurl } -> { token }
10
+ // POST /solve-challenge { siteurl } -> { html, cookies, ... }
11
+ // No third-party API, no per-solve fees, runs on your own box.
12
+ //
13
+ // 2. LOCAL CLICK FALLBACK (if no solver URL is configured)
14
+ // Find the Turnstile iframe, move mouse along a human bezier path,
15
+ // click the checkbox. Works for "soft" Turnstile where checkbox + good
16
+ // fingerprint is enough. Returns false if the challenge needs more.
17
+
18
+ const { humanMove, humanClick } = require('./mouse');
19
+
20
+ const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
21
+
22
+ function solverUrl() {
23
+ return (process.env.TURNSTILE_SOLVER_URL || '').replace(/\/+$/, '') || null;
24
+ }
25
+
26
+ // Extract Turnstile sitekey + page URL from the current page.
27
+ async function readSitekey(page) {
28
+ return page.evaluate(() => {
29
+ const candidates = [
30
+ ...document.querySelectorAll('[data-sitekey]'),
31
+ ...document.querySelectorAll('.cf-turnstile'),
32
+ ];
33
+ for (const el of candidates) {
34
+ const k = el.getAttribute('data-sitekey');
35
+ if (k) return k;
36
+ }
37
+ // Sometimes sitekey is inside a turnstile iframe URL
38
+ for (const f of document.querySelectorAll('iframe')) {
39
+ const src = f.src || '';
40
+ if (src.includes('challenges.cloudflare.com')) {
41
+ const m = src.match(/[?&](?:sitekey|k)=([^&]+)/);
42
+ if (m) return decodeURIComponent(m[1]);
43
+ }
44
+ }
45
+ return null;
46
+ }).catch(() => null);
47
+ }
48
+
49
+ async function findTurnstileFrame(page) {
50
+ return page.frames().find((f) => {
51
+ const u = f.url() || '';
52
+ return u.includes('challenges.cloudflare.com') || u.includes('turnstile');
53
+ });
54
+ }
55
+
56
+ // --- Path 1: delegate to self-hosted turnstile-solver service ---
57
+ async function solveViaSelfHosted(page, opts = {}) {
58
+ const url = solverUrl();
59
+ if (!url) return false;
60
+
61
+ const sitekey = opts.sitekey || (await readSitekey(page));
62
+ if (!sitekey) return false;
63
+ const siteurl = opts.siteurl || page.url();
64
+ const timeout = Math.ceil((opts.timeout ?? 60000) / 1000);
65
+
66
+ let token;
67
+ try {
68
+ const resp = await fetch(`${url}/solve`, {
69
+ method: 'POST',
70
+ headers: { 'Content-Type': 'application/json' },
71
+ body: JSON.stringify({ sitekey, siteurl, timeout, action: opts.action, cdata: opts.cdata }),
72
+ });
73
+ if (!resp.ok) return false;
74
+ const data = await resp.json();
75
+ if (!data?.token) return false;
76
+ token = data.token;
77
+ } catch {
78
+ return false;
79
+ }
80
+
81
+ // Inject the token into the page so the form/api considers it solved.
82
+ return page.evaluate((tok) => {
83
+ let injected = false;
84
+ document.querySelectorAll('input[name="cf-turnstile-response"]').forEach((el) => {
85
+ el.value = tok;
86
+ injected = true;
87
+ });
88
+ // Fire any registered callbacks (page may have data-callback="..." on .cf-turnstile)
89
+ document.querySelectorAll('.cf-turnstile[data-callback]').forEach((el) => {
90
+ const cb = el.getAttribute('data-callback');
91
+ try { window[cb]?.(tok); injected = true; } catch {}
92
+ });
93
+ // Some sites listen for the explicit window.turnstile.execute() pattern
94
+ try { window.turnstile?.execute?.(); } catch {}
95
+ return injected || !!tok;
96
+ }, token).catch(() => false);
97
+ }
98
+
99
+ // --- Path 2: local human-bezier click ---
100
+ async function clickTurnstile(page, opts = {}) {
101
+ const { timeout = 20000 } = opts;
102
+ const deadline = Date.now() + timeout;
103
+
104
+ while (Date.now() < deadline) {
105
+ const tsFrame = await findTurnstileFrame(page);
106
+ if (tsFrame) {
107
+ await sleep(800 + Math.random() * 700);
108
+ const el = await tsFrame.frameElement().catch(() => null);
109
+ const box = el ? await el.boundingBox().catch(() => null) : null;
110
+ if (box) {
111
+ const targetX = box.x + 30 + (Math.random() - 0.5) * 4;
112
+ const targetY = box.y + 30 + (Math.random() - 0.5) * 4;
113
+ const startX = box.x + box.width + 200 + Math.random() * 100;
114
+ const startY = box.y - 100 + Math.random() * 50;
115
+ await humanMove(page, startX, startY, targetX, targetY, { steps: 30 });
116
+ await sleep(120 + Math.random() * 200);
117
+ await humanClick(page, targetX, targetY);
118
+ return true;
119
+ }
120
+ const selectors = ['input[type="checkbox"]', 'label.cb-lb', '#challenge-stage input'];
121
+ for (const sel of selectors) {
122
+ const loc = tsFrame.locator(sel).first();
123
+ if (await loc.count().catch(() => 0)) {
124
+ await loc.click({ timeout: 3000, force: true }).catch(() => {});
125
+ return true;
126
+ }
127
+ }
128
+ }
129
+
130
+ const buttonSelectors = [
131
+ 'button:has-text("Verify you are human")',
132
+ 'button:has-text("I am human")',
133
+ 'input[type="button"][value*="Verify" i]',
134
+ ];
135
+ for (const sel of buttonSelectors) {
136
+ const btn = page.locator(sel).first();
137
+ if (await btn.count().catch(() => 0)) {
138
+ await btn.click({ timeout: 3000 }).catch(() => {});
139
+ return true;
140
+ }
141
+ }
142
+ await sleep(500);
143
+ }
144
+ return false;
145
+ }
146
+
147
+ // Wait for the Turnstile token to appear (= challenge solved).
148
+ async function waitForToken(page, opts = {}) {
149
+ const { timeout = 30000 } = opts;
150
+ const deadline = Date.now() + timeout;
151
+ while (Date.now() < deadline) {
152
+ const ok = await page.evaluate(() => {
153
+ const el = document.querySelector('input[name="cf-turnstile-response"]');
154
+ return !!(el && el.value && el.value.length > 20);
155
+ }).catch(() => false);
156
+ if (ok) return true;
157
+ await sleep(500);
158
+ }
159
+ return false;
160
+ }
161
+
162
+ // Full flow: try self-hosted solver first, fall back to local click.
163
+ async function solveTurnstile(page, opts = {}) {
164
+ if (solverUrl()) {
165
+ const ok = await solveViaSelfHosted(page, opts).catch(() => false);
166
+ if (ok) return true;
167
+ // fall through to click if the solver couldn't get a token
168
+ }
169
+ const clicked = await clickTurnstile(page, opts);
170
+ if (!clicked) return false;
171
+ return waitForToken(page, opts);
172
+ }
173
+
174
+ // --- /solve-challenge β€” clear "Just a moment..." and return cookies + html ---
175
+ async function clearChallenge(siteurl, opts = {}) {
176
+ const url = solverUrl();
177
+ if (!url) return null;
178
+ const timeout = Math.ceil((opts.timeout ?? 60000) / 1000);
179
+ try {
180
+ const resp = await fetch(`${url}/solve-challenge`, {
181
+ method: 'POST',
182
+ headers: { 'Content-Type': 'application/json' },
183
+ body: JSON.stringify({ siteurl, timeout }),
184
+ });
185
+ if (!resp.ok) return null;
186
+ return await resp.json(); // { url, title, user_agent, cookies, html, ... }
187
+ } catch {
188
+ return null;
189
+ }
190
+ }
191
+
192
+ // Convenience: clear CF challenge for a URL, then bring those cookies into
193
+ // the current Playwright context so subsequent navigations are unblocked.
194
+ async function clearChallengeAndAdoptCookies(context, siteurl, opts = {}) {
195
+ const result = await clearChallenge(siteurl, opts);
196
+ if (!result?.cookies?.length) return null;
197
+ // Coerce shapes into Playwright's expected cookie type
198
+ const cookies = result.cookies.map((c) => ({
199
+ name: c.name,
200
+ value: c.value,
201
+ domain: c.domain,
202
+ path: c.path || '/',
203
+ expires: typeof c.expires === 'number' ? c.expires : -1,
204
+ httpOnly: !!c.httpOnly,
205
+ secure: !!c.secure,
206
+ sameSite: c.sameSite || 'Lax',
207
+ }));
208
+ await context.addCookies(cookies).catch(() => {});
209
+ return result;
210
+ }
211
+
212
+ module.exports = {
213
+ clickTurnstile,
214
+ waitForToken,
215
+ solveTurnstile,
216
+ solveViaSelfHosted,
217
+ findTurnstileFrame,
218
+ readSitekey,
219
+ clearChallenge,
220
+ clearChallengeAndAdoptCookies,
221
+ solverUrl,
222
+ };
helpers/stealth/index.js CHANGED
@@ -112,6 +112,14 @@ async function context(browser, opts = {}) {
112
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
113
  const jitter = (min, max) => sleep(min + Math.random() * (max - min));
114
 
 
 
 
 
 
 
 
 
115
  // Try to click the Turnstile / "Verify you are human" checkbox if present.
116
  // Returns true if a click was attempted, false if no checkbox visible.
117
  //
@@ -180,96 +188,21 @@ async function clickTurnstile(page, opts = {}) {
180
  return false;
181
  }
182
 
183
- // If TWOCAPTCHA_KEY (or CAPSOLVER_KEY) env var is set on the Space, this hook
184
- // can be plugged into waitForCloudflare to solve image-CAPTCHAs that show up
185
- // after the checkbox click. Disabled by default β€” costs money and requires
186
- // signup. Implementation skeleton follows the 2captcha API.
187
- async function solveTurnstileWithService(page, opts = {}) {
188
- const apiKey = opts.apiKey || process.env.TWOCAPTCHA_KEY || process.env.CAPSOLVER_KEY;
189
- if (!apiKey) return false;
190
-
191
- // Extract sitekey + page URL
192
- const sitekey = await page.evaluate(() => {
193
- const el = document.querySelector('[data-sitekey], .cf-turnstile[data-sitekey]');
194
- return el?.getAttribute('data-sitekey') || null;
195
- });
196
- if (!sitekey) return false;
197
-
198
- const pageUrl = page.url();
199
- const service = opts.service || (process.env.CAPSOLVER_KEY ? 'capsolver' : '2captcha');
200
-
201
- try {
202
- if (service === '2captcha') {
203
- // Submit job
204
- const submit = await fetch(`https://2captcha.com/in.php?key=${apiKey}&method=turnstile&sitekey=${sitekey}&pageurl=${encodeURIComponent(pageUrl)}&json=1`);
205
- const sub = await submit.json();
206
- if (sub.status !== 1) return false;
207
- const jobId = sub.request;
208
- // Poll
209
- for (let i = 0; i < 40; i++) {
210
- await sleep(5000);
211
- const r = await fetch(`https://2captcha.com/res.php?key=${apiKey}&action=get&id=${jobId}&json=1`);
212
- const j = await r.json();
213
- if (j.status === 1) {
214
- await page.evaluate((token) => {
215
- const el = document.querySelector('input[name="cf-turnstile-response"]');
216
- if (el) el.value = token;
217
- window.turnstile?.execute?.();
218
- }, j.request);
219
- return true;
220
- }
221
- }
222
- } else if (service === 'capsolver') {
223
- const create = await fetch('https://api.capsolver.com/createTask', {
224
- method: 'POST',
225
- headers: { 'Content-Type': 'application/json' },
226
- body: JSON.stringify({
227
- clientKey: apiKey,
228
- task: { type: 'AntiTurnstileTaskProxyLess', websiteURL: pageUrl, websiteKey: sitekey },
229
- }),
230
- });
231
- const cr = await create.json();
232
- if (!cr.taskId) return false;
233
- for (let i = 0; i < 40; i++) {
234
- await sleep(3000);
235
- const r = await fetch('https://api.capsolver.com/getTaskResult', {
236
- method: 'POST',
237
- headers: { 'Content-Type': 'application/json' },
238
- body: JSON.stringify({ clientKey: apiKey, taskId: cr.taskId }),
239
- });
240
- const j = await r.json();
241
- if (j.status === 'ready') {
242
- await page.evaluate((token) => {
243
- const el = document.querySelector('input[name="cf-turnstile-response"]');
244
- if (el) el.value = token;
245
- window.turnstile?.execute?.();
246
- }, j.solution.token);
247
- return true;
248
- }
249
- }
250
- }
251
- } catch {}
252
- return false;
253
- }
254
-
255
  // Wait until the Cloudflare interstitial / Just A Moment page goes away.
256
  // Resolves true once we see real content, false on timeout.
257
  //
258
- // Strategy:
259
  // 1. Detect the interstitial.
260
- // 2. If a Turnstile checkbox is visible β€” click it (autoClick=true).
261
- // 3. If a CAPTCHA solver key is configured β€” try service-based solve.
262
- // 4. Poll for content to load.
263
  async function waitForCloudflare(page, opts = {}) {
264
  const {
265
  timeout = 60000,
266
  pollMs = 500,
267
  autoClick = true,
268
- useSolver = !!(process.env.TWOCAPTCHA_KEY || process.env.CAPSOLVER_KEY),
269
  } = opts;
270
  const deadline = Date.now() + timeout;
271
  let clicked = false;
272
- let solved = false;
273
 
274
  const isCloudflareWall = async () => {
275
  try {
@@ -298,18 +231,23 @@ async function waitForCloudflare(page, opts = {}) {
298
  if (!(await isCloudflareWall())) return true;
299
 
300
  if (autoClick && !clicked) {
301
- const did = await clickTurnstile(page, { timeout: 3000 });
302
- if (did) {
303
- clicked = true;
304
- await sleep(2000); // give CF a moment to validate
305
- continue;
 
 
 
 
 
 
306
  }
307
- }
308
-
309
- if (useSolver && !solved && clicked) {
310
- // Solver only needed if click alone didn't pass
311
- const ok = await solveTurnstileWithService(page).catch(() => false);
312
- if (ok) solved = true;
313
  }
314
 
315
  await sleep(pollMs);
@@ -323,11 +261,10 @@ async function gotoBypass(page, url, opts = {}) {
323
  waitUntil = 'domcontentloaded',
324
  cfTimeout = 60000,
325
  autoClick = true,
326
- useSolver,
327
  ...gotoOpts
328
  } = opts;
329
  const resp = await page.goto(url, { waitUntil, ...gotoOpts });
330
- await waitForCloudflare(page, { timeout: cfTimeout, autoClick, useSolver });
331
  return resp;
332
  }
333
 
@@ -339,7 +276,6 @@ module.exports = {
339
  waitForCloudflare,
340
  gotoBypass,
341
  clickTurnstile,
342
- solveTurnstileWithService,
343
  REALISTIC_UA,
344
  DEFAULT_ARGS,
345
  };
 
112
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
113
  const jitter = (min, max) => sleep(min + Math.random() * (max - min));
114
 
115
+ // Local-only Turnstile / reCAPTCHA solvers (no third-party API).
116
+ // See helpers/captcha/ for the full implementations.
117
+ let _captcha;
118
+ function getCaptcha() {
119
+ if (!_captcha) _captcha = require('captcha');
120
+ return _captcha;
121
+ }
122
+
123
  // Try to click the Turnstile / "Verify you are human" checkbox if present.
124
  // Returns true if a click was attempted, false if no checkbox visible.
125
  //
 
188
  return false;
189
  }
190
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
191
  // Wait until the Cloudflare interstitial / Just A Moment page goes away.
192
  // Resolves true once we see real content, false on timeout.
193
  //
194
+ // Strategy (all local, no third-party API):
195
  // 1. Detect the interstitial.
196
+ // 2. If a Turnstile checkbox is visible β€” click it with human mouse path.
197
+ // 3. Poll for content to load.
 
198
  async function waitForCloudflare(page, opts = {}) {
199
  const {
200
  timeout = 60000,
201
  pollMs = 500,
202
  autoClick = true,
 
203
  } = opts;
204
  const deadline = Date.now() + timeout;
205
  let clicked = false;
 
206
 
207
  const isCloudflareWall = async () => {
208
  try {
 
231
  if (!(await isCloudflareWall())) return true;
232
 
233
  if (autoClick && !clicked) {
234
+ const captcha = getCaptcha();
235
+ // 1) If a self-hosted turnstile-solver is configured, ask it for a token
236
+ // and inject. Cheapest path β€” no in-browser interaction needed.
237
+ if (captcha.turnstileSolverUrl()) {
238
+ const tokenInjected = await captcha
239
+ .solveTurnstileViaSelfHosted(page, { timeout: 30000 })
240
+ .catch(() => false);
241
+ if (tokenInjected) {
242
+ await sleep(1500);
243
+ continue; // re-check the wall predicate
244
+ }
245
  }
246
+ // 2) Fall back to local human-bezier click + token poll
247
+ const did = await captcha.solveTurnstile(page, { timeout: 8000 }).catch(() => false);
248
+ if (did) return true;
249
+ clicked = true; // don't retry the click loop forever
250
+ await sleep(2000);
 
251
  }
252
 
253
  await sleep(pollMs);
 
261
  waitUntil = 'domcontentloaded',
262
  cfTimeout = 60000,
263
  autoClick = true,
 
264
  ...gotoOpts
265
  } = opts;
266
  const resp = await page.goto(url, { waitUntil, ...gotoOpts });
267
+ await waitForCloudflare(page, { timeout: cfTimeout, autoClick });
268
  return resp;
269
  }
270
 
 
276
  waitForCloudflare,
277
  gotoBypass,
278
  clickTurnstile,
 
279
  REALISTIC_UA,
280
  DEFAULT_ARGS,
281
  };
package.json CHANGED
@@ -6,6 +6,8 @@
6
  "dependencies": {
7
  "playwright": "1.49.0",
8
  "playwright-extra": "^4.3.6",
9
- "puppeteer-extra-plugin-stealth": "^2.11.2"
 
 
10
  }
11
  }
 
6
  "dependencies": {
7
  "playwright": "1.49.0",
8
  "playwright-extra": "^4.3.6",
9
+ "puppeteer-extra-plugin-stealth": "^2.11.2",
10
+ "@huggingface/transformers": "^3.0.2",
11
+ "tesseract.js": "^5.1.1"
12
  }
13
  }
server.js CHANGED
@@ -447,22 +447,59 @@ function infoPayload() {
447
  "stealth.launch(opts)": "launch chromium with stealth plugin + anti-detect args",
448
  "stealth.launch({ channel: 'chrome' })": "launch real Google Chrome (Widevine DRM)",
449
  "stealth.context(browser, opts)": "context with realistic UA, viewport, locale, plugin shims",
450
- "stealth.gotoBypass(page, url, { autoClick, useSolver, cfTimeout })": "navigate + auto-handle CF challenge (passive wait + Turnstile click + optional solver)",
451
- "stealth.waitForCloudflare(page, opts)": "standalone wait + click + optional solver",
452
- "stealth.clickTurnstile(page)": "click 'Verify you are human' / Turnstile checkbox if present",
453
- "stealth.solveTurnstileWithService(page)": "use 2captcha/capsolver if TWOCAPTCHA_KEY/CAPSOLVER_KEY env is set",
454
  },
455
- bypasses: {
456
- "navigator.webdriver": "removed",
457
- "navigator.plugins/languages": "spoofed",
458
- "window.chrome": "stub injected",
459
- "AutomationControlled flag": "disabled",
460
- "Cloudflare 'Just a moment'": "passive wait + auto-pass",
461
- "Cloudflare Turnstile checkbox": "auto-clicked (works if CF accepts the fingerprint)",
462
- "Image CAPTCHA fallback": "pluggable via TWOCAPTCHA_KEY or CAPSOLVER_KEY env (paid services)",
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  },
464
- caveats: "Auto-click works for 'soft' Turnstile (where checkbox click is enough). If CF spawns an image challenge or behavioral analysis, only a paid solver service can pass it. Set TWOCAPTCHA_KEY or CAPSOLVER_KEY env on the Space to enable that path.",
465
- example: "const stealth = require('stealth');\nconst browser = await stealth.launch({ channel: 'chrome' });\nconst ctx = await stealth.context(browser);\nconst page = await ctx.newPage();\nawait stealth.gotoBypass(page, 'https://protected-site.com');\nconsole.log(await page.title());\nawait browser.close();",
466
  },
467
  };
468
  }
 
447
  "stealth.launch(opts)": "launch chromium with stealth plugin + anti-detect args",
448
  "stealth.launch({ channel: 'chrome' })": "launch real Google Chrome (Widevine DRM)",
449
  "stealth.context(browser, opts)": "context with realistic UA, viewport, locale, plugin shims",
450
+ "stealth.gotoBypass(page, url)": "navigate + auto-handle CF (self-hosted solver if available, else local click)",
451
+ "stealth.waitForCloudflare(page)": "standalone wait + auto-handle",
452
+ "stealth.clickTurnstile(page)": "click 'Verify you are human' / Turnstile checkbox locally",
 
453
  },
454
+ },
455
+ captcha: {
456
+ module: "require('captcha') β€” local solvers for several CAPTCHA classes",
457
+ api: {
458
+ "captcha.autoSolve(page)": "detect what CAPTCHA is on the page and try the right solver",
459
+ "captcha.solveTurnstile(page)": "solve CF Turnstile (uses self-hosted solver if TURNSTILE_SOLVER_URL set, else local click)",
460
+ "captcha.solveTurnstileViaSelfHosted(page)": "ask self-hosted solver for a token, inject it into the page",
461
+ "captcha.clearChallenge(siteurl)": "POST /solve-challenge to self-hosted solver, returns { html, cookies, ... }",
462
+ "captcha.clearChallengeAndAdoptCookies(context, siteurl)": "clear CF challenge and bring the cleared cookies into your Playwright context",
463
+ "captcha.solveRecaptchaV2(page)": "full reCAPTCHA v2 audio-fallback flow (local Whisper)",
464
+ "captcha.transcribe(audioBuffer)": "Whisper local transcription (English)",
465
+ "captcha.ocrBuffer(imageBuffer)": "Tesseract local OCR (text/image CAPTCHAs)",
466
+ "captcha.ocrUrl(page, url)": "fetch image with page cookies + OCR",
467
+ "captcha.solveMath('2 + 3 * 4')": "safe math expression evaluator",
468
+ "captcha.extractMath('What is 2 + 3?')": "find math expression in text and solve",
469
+ "captcha.humanMove / humanClick / humanMoveAndClick": "bezier-curve mouse paths for natural cursor movement",
470
+ "captcha.saveCookies / loadCookies / clearCookies": "per-origin cookie jar (skip re-solving challenges)",
471
+ },
472
+ selfHostedSolver: {
473
+ recommended: "https://github.com/cv3inx/turnstile-solver β€” deploy this as a separate service; gives token-based Turnstile solving without paid APIs",
474
+ howToWire: "Set env TURNSTILE_SOLVER_URL=http://your-solver:9988 β€” captcha.solveTurnstile() will automatically use it",
475
+ endpoints: {
476
+ "POST /solve": "{ sitekey, siteurl } β†’ { token } β€” used for sites with widget on page",
477
+ "POST /solve-challenge": "{ siteurl } β†’ { html, cookies, user_agent, ... } β€” used for full 'Just a moment' interstitial",
478
+ },
479
+ },
480
+ whatItHandles: {
481
+ "CF Turnstile checkbox": "self-hosted solver returns token, OR local human click (~70% pass rate without solver)",
482
+ "CF 'Just a moment'": "self-hosted /solve-challenge returns clearance cookies; OR local stealth click",
483
+ "reCAPTCHA v2 audio fallback": "switches to audio mode, transcribes locally with Whisper",
484
+ "Text/image CAPTCHA (4-8 char)": "local Tesseract OCR",
485
+ "Math CAPTCHA": "safe expression evaluator",
486
+ "Human-like mouse movement": "bezier-curve paths, jitter, variable click hold",
487
+ "Cookie persistence": "save/load CF clearance cookies per origin",
488
+ },
489
+ whatItDoesNotHandle: {
490
+ "hCaptcha image grid": "needs trained CV classifier β€” not realistic to ship in this repo. Use the self-hosted turnstile-solver only for Turnstile.",
491
+ "reCAPTCHA v2 image challenge": "same",
492
+ "CF Turnstile image challenge": "same β€” but a properly tuned self-hosted solver service often handles these",
493
+ "CF Bot Management ML": "only the self-hosted solver has any chance",
494
+ },
495
+ env: {
496
+ TURNSTILE_SOLVER_URL: process.env.TURNSTILE_SOLVER_URL || "(not set β€” local fallbacks only)",
497
+ TRANSFORMERS_CACHE: process.env.TRANSFORMERS_CACHE || "/app/.cache/transformers",
498
+ TESSDATA_PREFIX: process.env.TESSDATA_PREFIX || "/app/tesseract-data",
499
+ WHISPER_MODEL: process.env.WHISPER_MODEL || "Xenova/whisper-tiny.en",
500
+ COOKIES_DIR: process.env.COOKIES_DIR || "/app/cookies",
501
  },
502
+ example: "const stealth = require('stealth');\nconst captcha = require('captcha');\nconst browser = await stealth.launch({ channel: 'chrome' });\nconst ctx = await stealth.context(browser);\nawait captcha.loadCookies(ctx, 'https://target.com'); // reuse prior CF clearance\nconst page = await ctx.newPage();\nawait stealth.gotoBypass(page, 'https://target.com'); // self-hosted solver if configured\nconst result = await captcha.autoSolve(page); // catch any extra captcha\nconsole.log(await page.title());\nawait captcha.saveCookies(ctx, 'https://target.com');\nawait browser.close();",
 
503
  },
504
  };
505
  }