Spaces:
Paused
Add local CAPTCHA solvers + integrate self-hosted turnstile-solver
Browse filesDrop 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 +16 -5
- Dockerfile +28 -9
- docker-compose.yml +13 -3
- helpers/captcha/audio.js +46 -0
- helpers/captcha/cookies.js +66 -0
- helpers/captcha/index.js +80 -0
- helpers/captcha/math.js +26 -0
- helpers/captcha/mouse.js +54 -0
- helpers/captcha/ocr.js +48 -0
- helpers/captcha/package.json +6 -0
- helpers/captcha/recaptcha.js +116 -0
- helpers/captcha/turnstile.js +222 -0
- helpers/stealth/index.js +28 -92
- package.json +3 -1
- server.js +51 -14
|
@@ -6,11 +6,22 @@ HOST_PORT=7860
|
|
| 6 |
# Per-request timeout in milliseconds. Default 30 minutes.
|
| 7 |
TIMEOUT_MS=1800000
|
| 8 |
|
| 9 |
-
# Resource caps
|
| 10 |
CPU_LIMIT=4.0
|
| 11 |
MEM_LIMIT=8g
|
| 12 |
|
| 13 |
-
#
|
| 14 |
-
#
|
| 15 |
-
#
|
| 16 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
@@ -2,14 +2,13 @@ FROM mcr.microsoft.com/playwright:v1.49.0-jammy
|
|
| 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 |
-
# 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
|
| 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 /
|
|
|
|
| 48 |
&& chown -R 1000:1000 /app /home/pwuser
|
| 49 |
|
| 50 |
USER 1000
|
| 51 |
|
| 52 |
-
# Bun install
|
| 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
|
| 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 |
|
|
@@ -12,9 +12,11 @@ services:
|
|
| 12 |
environment:
|
| 13 |
PORT: 7860
|
| 14 |
TIMEOUT_MS: ${TIMEOUT_MS:-1800000} # 30 minutes per request
|
| 15 |
-
#
|
| 16 |
-
|
| 17 |
-
|
|
|
|
|
|
|
| 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:
|
|
@@ -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 };
|
|
@@ -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 };
|
|
@@ -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 |
+
};
|
|
@@ -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 };
|
|
@@ -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 };
|
|
@@ -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 };
|
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"name": "captcha",
|
| 3 |
+
"version": "1.0.0",
|
| 4 |
+
"main": "index.js",
|
| 5 |
+
"type": "commonjs"
|
| 6 |
+
}
|
|
@@ -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 };
|
|
@@ -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 |
+
};
|
|
@@ -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
|
| 261 |
-
// 3.
|
| 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
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
}
|
| 307 |
-
|
| 308 |
-
|
| 309 |
-
|
| 310 |
-
//
|
| 311 |
-
|
| 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
|
| 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 |
};
|
|
@@ -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 |
}
|
|
@@ -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
|
| 451 |
-
"stealth.waitForCloudflare(page
|
| 452 |
-
"stealth.clickTurnstile(page)": "click 'Verify you are human' / Turnstile checkbox
|
| 453 |
-
"stealth.solveTurnstileWithService(page)": "use 2captcha/capsolver if TWOCAPTCHA_KEY/CAPSOLVER_KEY env is set",
|
| 454 |
},
|
| 455 |
-
|
| 456 |
-
|
| 457 |
-
|
| 458 |
-
|
| 459 |
-
"
|
| 460 |
-
"
|
| 461 |
-
"
|
| 462 |
-
"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 463 |
},
|
| 464 |
-
|
| 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 |
}
|