diff --git a/CHANGELOG.md b/CHANGELOG.md index 64456af..5275881 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Added +- **Proof-of-Work (PoW) and Wasm challenge support.** In-page solvers for Altcha (SHA-256 PoW) and Friendly Captcha (v1 and v2 client puzzle) widgets, plus detection and dedicated waiting for PoW gates and WebAssembly interstitials (`hasPowChallenge`, `waitForPowResolution`) (#121). + ## [1.5.0] - 2026-09-04 ### Changed diff --git a/packages/tiers/src/index.ts b/packages/tiers/src/index.ts index b85646e..d7ead4a 100644 --- a/packages/tiers/src/index.ts +++ b/packages/tiers/src/index.ts @@ -1,6 +1,13 @@ export type { AcquireOptions, OrchestratorDeps } from "./orchestrator" export { ScrapeError, scrape } from "./orchestrator" -export { type SolveResult, solvePageCaptchas } from "./solvers" +export { + hasAltchaWidget, + hasFriendlyCaptchaWidget, + type SolveResult, + solveAltcha, + solveFriendlyCaptcha, + solvePageCaptchas, +} from "./solvers" export { runTier1, type Tier1Result } from "./tiers/1" export { runTier2, type Tier2Result } from "./tiers/2" export { runTier3, type Tier3Result } from "./tiers/3" @@ -19,6 +26,7 @@ export { hasDdosGuardChallenge, hasHcaptcha, hasImpervaChallenge, + hasPowChallenge, hasRecaptcha, hasTurnstile, isBlocked, @@ -27,6 +35,7 @@ export { isCloudflarePage, needsJs, } from "./utils/detect" +export { waitForPowResolution } from "./utils/powWait" export { normalizeProxy, ProxyPool } from "./utils/proxyRotator" export { isValidMethod, diff --git a/packages/tiers/src/solvers/altcha.ts b/packages/tiers/src/solvers/altcha.ts new file mode 100644 index 0000000..e882a7c --- /dev/null +++ b/packages/tiers/src/solvers/altcha.ts @@ -0,0 +1,104 @@ +// Altcha Proof-of-Work (PoW) CAPTCHA solver. +// Altcha is an open-source, privacy-first CAPTCHA alternative based on SHA-256 PoW. +// +// Flow: +// 1. Check if the widget is already verified (input[name="altcha"] populated or state="verified"). +// 2. If unverified, click the checkbox/button inside the widget or shadow DOM to initiate PoW hashing. +// 3. Wait for the client-side Web Worker / Wasm solver to complete and populate the verification payload. + +import type { Page } from "patchright" + +export async function hasAltchaWidget(page: Page, timeoutMs = 3000): Promise { + const POLL_INTERVAL = 300 + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + const detected = await page + .evaluate(() => { + if (document.querySelector("altcha-widget, .altcha, [data-altcha]")) return true + const input = document.querySelector('input[name="altcha"]') + if (input) return true + return false + }) + .catch(() => false) + + if (detected) return true + await new Promise((r) => setTimeout(r, POLL_INTERVAL)) + } + return false +} + +export async function solveAltcha(page: Page, timeoutMs = 30_000): Promise { + try { + const hasWidget = await hasAltchaWidget(page, 3000) + if (!hasWidget) return false + + // Check if already auto-verified + const isAlreadyVerified = await page + .evaluate(() => { + const input = document.querySelector('input[name="altcha"]') + if (input instanceof HTMLInputElement && input.value.length > 20) return true + const widget = document.querySelector("altcha-widget") + if (widget?.getAttribute("state") === "verified") return true + return false + }) + .catch(() => false) + + if (isAlreadyVerified) { + console.log("[altcha] already verified ✓") + return true + } + + // Trigger verification: + // 1. Playwright locator click (pierces shadow DOM automatically) + const widgetLocator = page.locator( + 'altcha-widget input[type="checkbox"], altcha-widget, .altcha input[type="checkbox"], .altcha', + ) + await widgetLocator + .first() + .click({ timeout: 2000, force: true }) + .catch(() => {}) + + // 2. DOM evaluate fallback + await page + .evaluate(() => { + const widget = document.querySelector("altcha-widget") + if (widget) { + const root = widget.shadowRoot ?? widget + const btn = root.querySelector('input[type="checkbox"], button, .altcha-checkbox') as HTMLElement | null + if (btn) btn.click() + } else { + const btn = document.querySelector('.altcha input[type="checkbox"], .altcha-checkbox') as HTMLElement | null + if (btn) btn.click() + } + }) + .catch(() => {}) + + console.log("[altcha] triggered PoW challenge computation") + + // Poll until the state reaches 'verified' or the payload input is set + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + const verified = await page + .evaluate(() => { + const input = document.querySelector('input[name="altcha"]') + if (input instanceof HTMLInputElement && input.value.length > 20) return true + const widget = document.querySelector("altcha-widget") + if (widget?.getAttribute("state") === "verified") return true + return false + }) + .catch(() => false) + + if (verified) { + console.log("[altcha] verified successfully ✓") + return true + } + await new Promise((r) => setTimeout(r, 400)) + } + + return false + } catch (err) { + console.log("[altcha] error:", err instanceof Error ? err.message : err) + return false + } +} diff --git a/packages/tiers/src/solvers/friendlyCaptcha.ts b/packages/tiers/src/solvers/friendlyCaptcha.ts new file mode 100644 index 0000000..f678604 --- /dev/null +++ b/packages/tiers/src/solvers/friendlyCaptcha.ts @@ -0,0 +1,158 @@ +// Friendly Captcha Proof-of-Work (PoW) CAPTCHA solver. +// Friendly Captcha is an open-source, privacy-friendly CAPTCHA alternative based on client-side SHA-256 PoW puzzles. +// +// Flow: +// 1. Check if the widget is already verified (input[name="frc-captcha-solution"] populated or state="success"). +// 2. If unverified, click the start button / checkbox inside the widget or shadow DOM to initiate PoW hashing. +// 3. Wait for the client-side Web Worker / Wasm solver to complete and populate the solution token. + +import type { Page } from "patchright" + +const WIDGET_SELECTORS = ".frc-captcha, friendly-captcha, frc-captcha, [data-friendly-captcha]" +const SOLUTION_SELECTORS = 'input[name="frc-captcha-solution"], input[name="frc-captcha-response"]' + +export async function hasFriendlyCaptchaWidget(page: Page, timeoutMs = 3000): Promise { + const POLL_INTERVAL = 300 + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + const detected = await page + .evaluate( + ({ widgetSel, solutionSel }) => { + if (document.querySelector(widgetSel)) return true + if (document.querySelector(solutionSel)) return true + if (document.querySelector('iframe[src*="frcapi.com"], iframe[src*="friendlycaptcha"]')) return true + return false + }, + { widgetSel: WIDGET_SELECTORS, solutionSel: SOLUTION_SELECTORS }, + ) + .catch(() => false) + + if (detected) return true + await new Promise((r) => setTimeout(r, POLL_INTERVAL)) + } + return false +} + +export async function solveFriendlyCaptcha(page: Page, timeoutMs = 30_000): Promise { + try { + const hasWidget = await hasFriendlyCaptchaWidget(page, 3000) + if (!hasWidget) return false + + // Check if already verified (tokens must not be empty or status placeholders like .UNACTIVATED) + const isAlreadyVerified = await page + .evaluate((sel) => { + const input = document.querySelector(sel) + if (input instanceof HTMLInputElement && input.value.length > 20 && !input.value.startsWith(".")) return true + const widget = document.querySelector(".frc-captcha, friendly-captcha, frc-captcha") + if (widget?.classList.contains("frc-success")) return true + if (widget?.getAttribute("data-state") === "success") return true + return false + }, SOLUTION_SELECTORS) + .catch(() => false) + + if (isAlreadyVerified) { + console.log("[friendly-captcha] already verified ✓") + return true + } + + const widgetFrameLocator = page.frameLocator( + 'iframe.frc-i-widget, iframe[src*="captcha/widget"], iframe[src*="frcapi.com"], .frc-captcha iframe, friendly-captcha iframe', + ) + + let clicked = false + const tryClick = async (): Promise => { + // 1. Frame locator (v2 iframe) + const frameBtn = widgetFrameLocator.locator('button[role="checkbox"], button.button, button').first() + const clickedFrame = await frameBtn + .click({ timeout: 1500, force: true }) + .then(() => true) + .catch(() => false) + if (clickedFrame) return true + + // 2. page.frames() lookup + const frcFrame = page.frames().find((f) => { + if (f === page.mainFrame()) return false + const u = f.url() + return u.includes("captcha/widget") || (u.includes("friendlycaptcha") && u.includes("widget")) + }) + if (frcFrame) { + const btn = frcFrame.locator('button[role="checkbox"], button.button, button').first() + const clickedDirect = await btn + .click({ timeout: 1500, force: true }) + .then(() => true) + .catch(() => false) + if (clickedDirect) return true + } + + // 3. In-page element (v1 or custom v2 element) + const clickedInPage = await page + .evaluate((widgetSel) => { + const widget = document.querySelector(widgetSel) + if (widget) { + const root = widget.shadowRoot ?? widget + const btn = root.querySelector( + ".frc-button, button, input[type='button'], input[type='checkbox']", + ) as HTMLElement | null + if (btn) { + btn.click() + return true + } + } + const btn = document.querySelector(".frc-captcha .frc-button, .frc-button") as HTMLElement | null + if (btn) { + btn.click() + return true + } + return false + }, WIDGET_SELECTORS) + .catch(() => false) + + return Boolean(clickedInPage) + } + + // Poll until the solution input is populated or widget reaches success state + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (!clicked) { + clicked = await tryClick() + if (clicked) { + console.log("[friendly-captcha] triggered PoW challenge computation") + } + } + + const verified = await page + .evaluate((sel) => { + const input = document.querySelector(sel) + if (input instanceof HTMLInputElement && input.value.length > 20 && !input.value.startsWith(".")) return true + const widget = document.querySelector(".frc-captcha, friendly-captcha, frc-captcha") + if (widget?.classList.contains("frc-success")) return true + if (widget?.getAttribute("data-state") === "success") return true + return false + }, SOLUTION_SELECTORS) + .catch(() => false) + + if (verified) { + console.log("[friendly-captcha] verified successfully ✓") + return true + } + + const frameChecked = await widgetFrameLocator + .locator('button[role="checkbox"][aria-checked="true"]') + .first() + .isVisible() + .catch(() => false) + if (frameChecked) { + console.log("[friendly-captcha] frame locator marked verified ✓") + return true + } + + await new Promise((r) => setTimeout(r, 400)) + } + + return false + } catch (err) { + console.log("[friendly-captcha] error:", err instanceof Error ? err.message : err) + return false + } +} diff --git a/packages/tiers/src/solvers/index.ts b/packages/tiers/src/solvers/index.ts index 21d6f3e..6acec6b 100644 --- a/packages/tiers/src/solvers/index.ts +++ b/packages/tiers/src/solvers/index.ts @@ -6,16 +6,23 @@ // reCAPTCHA v2 — checkbox auto-pass + audio challenge via Google's free STT // hCaptcha — checkbox click (auto-pass path only; image grids need AI) // GeeTest slide — human-like mouse drag with canvas gap detection +// Altcha PoW — client-side SHA-256 Proof-of-Work computation +// Friendly Captcha PoW — client-side Proof-of-Work puzzle solving // // Called after the page is loaded (post-CF-interstitial). // Interstitial-level CF challenges are handled separately in challengeWait.ts. import type { Page } from "patchright" +import { hasAltchaWidget, solveAltcha } from "./altcha" +import { hasFriendlyCaptchaWidget, solveFriendlyCaptcha } from "./friendlyCaptcha" import { hasGeetestSlide, solveGeetestSlide } from "./geetest" import { hasHcaptchaWidget, solveHcaptcha } from "./hcaptcha" import { hasRecaptchaV2, solveRecaptchaV2 } from "./recaptcha" import { solveTurnstile } from "./turnstile" +export { hasAltchaWidget, solveAltcha } from "./altcha" +export { hasFriendlyCaptchaWidget, solveFriendlyCaptcha } from "./friendlyCaptcha" + export interface SolveResult { attempted: string[] solved: string[] @@ -88,8 +95,17 @@ export async function solvePageCaptchas(page: Page, timeoutMs = 30_000): Promise const mightHaveRecaptcha = /g-recaptcha|google\.com\/recaptcha|recaptcha\.net|grecaptcha/i.test(html) const mightHaveHcaptcha = /h-captcha|hcaptcha\.com/i.test(html) const mightHaveGeetest = /geetest|gt_container|initGeetest/i.test(html) - - if (!mightHaveTurnstile && !mightHaveRecaptcha && !mightHaveHcaptcha && !mightHaveGeetest) { + const mightHaveAltcha = /altcha|data-altcha/i.test(html) + const mightHaveFriendlyCaptcha = /frc-captcha|friendly-captcha|friendlychallenge/i.test(html) + + if ( + !mightHaveTurnstile && + !mightHaveRecaptcha && + !mightHaveHcaptcha && + !mightHaveGeetest && + !mightHaveAltcha && + !mightHaveFriendlyCaptcha + ) { return { attempted: [], solved: [] } } @@ -110,17 +126,21 @@ export async function solvePageCaptchas(page: Page, timeoutMs = 30_000): Promise // waitForSelector already handles waiting for widgets — no blind sleep needed. // 3s: Turnstile/reCAPTCHA iframes typically appear within 2s of page load; - // GeeTest/hCaptcha detect via HTML markers (instant). If nothing in 3s, skip. + // GeeTest/hCaptcha/Altcha/FriendlyCaptcha detect via HTML markers (instant). If nothing in 3s, skip. const DETECT_MS = 3_000 - const [hasTurnstile, hasHcaptcha, hasRecaptcha, hasGeetest] = await Promise.all([ + const [hasTurnstile, hasHcaptcha, hasRecaptcha, hasGeetest, hasAltcha, hasFriendlyCaptcha] = await Promise.all([ mightHaveTurnstile ? detectTurnstile(page, DETECT_MS) : Promise.resolve(false), mightHaveHcaptcha ? hasHcaptchaWidget(page, DETECT_MS) : Promise.resolve(false), mightHaveRecaptcha ? hasRecaptchaV2(page, DETECT_MS) : Promise.resolve(false), mightHaveGeetest ? hasGeetestSlide(page, DETECT_MS) : Promise.resolve(false), + mightHaveAltcha ? hasAltchaWidget(page, DETECT_MS) : Promise.resolve(false), + mightHaveFriendlyCaptcha ? hasFriendlyCaptchaWidget(page, DETECT_MS) : Promise.resolve(false), ]) - const count = [hasTurnstile, hasHcaptcha, hasRecaptcha, hasGeetest].filter(Boolean).length + const count = [hasTurnstile, hasHcaptcha, hasRecaptcha, hasGeetest, hasAltcha, hasFriendlyCaptcha].filter( + Boolean, + ).length if (count === 0) { console.log( `[solvers] markers found in HTML but no interactive widgets detected (${[ @@ -128,6 +148,8 @@ export async function solvePageCaptchas(page: Page, timeoutMs = 30_000): Promise mightHaveRecaptcha && "recaptcha", mightHaveHcaptcha && "hcaptcha", mightHaveGeetest && "geetest", + mightHaveAltcha && "altcha", + mightHaveFriendlyCaptcha && "friendly-captcha", ] .filter(Boolean) .join(",")})`, @@ -157,6 +179,16 @@ export async function solvePageCaptchas(page: Page, timeoutMs = 30_000): Promise if (await solveGeetestSlide(page, perMs).catch(() => false)) solved.push("geetest-slide") } + if (hasAltcha) { + attempted.push("altcha") + if (await solveAltcha(page, perMs).catch(() => false)) solved.push("altcha") + } + + if (hasFriendlyCaptcha) { + attempted.push("friendly-captcha") + if (await solveFriendlyCaptcha(page, perMs).catch(() => false)) solved.push("friendly-captcha") + } + if (attempted.length > 0) { console.log(`[solvers] attempted=[${attempted.join(",")}] solved=[${solved.join(",")}]`) } diff --git a/packages/tiers/src/tiers/1.ts b/packages/tiers/src/tiers/1.ts index 15aa76a..0954a38 100644 --- a/packages/tiers/src/tiers/1.ts +++ b/packages/tiers/src/tiers/1.ts @@ -8,6 +8,7 @@ import { hasAwsWafCaptcha, hasAwsWafChallenge, hasHcaptcha, + hasPowChallenge, hasRecaptcha, hasTurnstile, isBlocked, @@ -186,6 +187,19 @@ export async function runTier1( statusCode: res.status, } } + if (hasPowChallenge(previewText, responseHeaders)) { + return { + tier: 1, + status: "needs-js", + durationMs: Date.now() - start, + reason: "pow-challenge", + challenge: "pow", + responseHeaders, + contentType, + body: rawBytes, + statusCode: res.status, + } + } if (hasAkamaiChallenge(previewText, responseHeaders)) { return { tier: 1, diff --git a/packages/tiers/src/utils/challengeRouter.ts b/packages/tiers/src/utils/challengeRouter.ts index e662fa0..b36945d 100644 --- a/packages/tiers/src/utils/challengeRouter.ts +++ b/packages/tiers/src/utils/challengeRouter.ts @@ -7,6 +7,7 @@ import { type DataDomeResolution, waitForDataDomeResolution } from "./datadomeWa import { waitForDdosGuardResolution } from "./ddosGuardWait" import { type ChallengeType, detectChallengeType, getAwsWafAction, getDataDomeAction, hasAwsWafCaptcha } from "./detect" import { waitForImpervaResolution } from "./impervaWait" +import { waitForPowResolution } from "./powWait" type Resolution = AwsWafResolution | DataDomeResolution type Waiter = (page: Page, timeoutMs: number, originalUrl?: string) => Promise @@ -21,6 +22,7 @@ interface ChallengeWaiters { imperva: Waiter akamai: Waiter ddosGuard: Waiter + pow: Waiter awsWaf: ( page: Page, timeoutMs: number, @@ -40,6 +42,7 @@ const defaultWaiters: ChallengeWaiters = { imperva: waitForImpervaResolution, akamai: waitForAkamaiResolution, ddosGuard: waitForDdosGuardResolution, + pow: waitForPowResolution, awsWaf: (page, timeoutMs, originalUrl, initialTokens) => waitForAwsWafResolution(page, timeoutMs, originalUrl, { initialTokens }), dataDome: (page, timeoutMs, originalUrl, initialCookies) => @@ -74,10 +77,12 @@ export async function routeChallengeWait( ? await waiters.akamai(page, timeoutMs, originalUrl) : challengeType === "ddos-guard" ? await waiters.ddosGuard(page, timeoutMs, originalUrl) - : challengeType === "aws-waf" - ? await waiters.awsWaf(page, timeoutMs, originalUrl, initialCookies?.awsWaf) - : challengeType === "datadome" - ? await waiters.dataDome(page, timeoutMs, originalUrl, initialCookies?.dataDome) - : await waiters.cloudflare(page, timeoutMs, originalUrl, () => headers) + : challengeType === "pow" + ? await waiters.pow(page, timeoutMs, originalUrl) + : challengeType === "aws-waf" + ? await waiters.awsWaf(page, timeoutMs, originalUrl, initialCookies?.awsWaf) + : challengeType === "datadome" + ? await waiters.dataDome(page, timeoutMs, originalUrl, initialCookies?.dataDome) + : await waiters.cloudflare(page, timeoutMs, originalUrl, () => headers) return { challengeType, resolution } } diff --git a/packages/tiers/src/utils/detect.ts b/packages/tiers/src/utils/detect.ts index a626716..6b1fa55 100644 --- a/packages/tiers/src/utils/detect.ts +++ b/packages/tiers/src/utils/detect.ts @@ -9,6 +9,7 @@ export type ChallengeType = | "ddos-guard" | "aws-waf" | "datadome" + | "pow" | "none" export function hasCloudflareChallengeHeader(headers: Record = {}): boolean { @@ -35,6 +36,7 @@ export function getAwsWafAction( export function isCloudflarePage(html: string, headers: Record): boolean { if (hasCloudflareChallengeHeader(headers)) return true if (hasDdosGuardChallenge(html)) return false + if (hasPowChallenge(html)) return false if (/[^<]*(just a moment|please wait|checking|attention required)[^<]*<\/title>/i.test(html)) return true if (/checking your browser/i.test(html)) return true if (/enable javascript and cookies to continue/i.test(html)) return true @@ -210,6 +212,48 @@ export function hasDataDomeCaptcha(html: string, headers: Record<string, string> return getDataDomeAction(html, headers, status) === "captcha" } +// Proof-of-Work (PoW) and WebAssembly challenge detection. +// Covers Altcha gate/interstitials, Friendly Captcha standalone gates, mCaptcha, +// PoW Shield / Anomic PoW, and Wasm-based proof-of-work interstitial challenges. +export function hasPowChallenge(html: string, headers: Record<string, string> = {}): boolean { + const lowerHeaders: Record<string, string> = {} + for (const [k, v] of Object.entries(headers)) lowerHeaders[k.toLowerCase()] = v + if (lowerHeaders["x-pow-challenge"] || lowerHeaders["x-altcha-challenge"]) return true + + if ( + /altcha-widget|altcha\.org\/|data-altcha/i.test(html) && + /challenge|verification|security check|verifying|protected by/i.test(html) + ) { + return true + } + + if ( + /frc-captcha|friendly-captcha|friendlychallenge/i.test(html) && + /verification|security check|verifying|robot|human/i.test(html) + ) { + return true + } + + if (/m-captcha|mcaptcha/i.test(html)) return true + if (/powshield|pow-shield/i.test(html)) return true + + if ( + /(computing challenge|solving challenge|proof of work|proof-of-work|pow challenge|calculating proof)/i.test(html) + ) { + return true + } + + if ( + html.length < 5000 && + /(checking your browser|verifying your request|completing security check)/i.test(html) && + /(worker\.js|\.wasm|webassembly|challenge\.js|pow)/i.test(html) + ) { + return true + } + + return false +} + export function detectChallengeType( html: string, headers: Record<string, string> = {}, @@ -220,6 +264,7 @@ export function detectChallengeType( if (hasDataDomeChallenge(html, headers, status)) return "datadome" if (hasTurnstile(html)) return "cloudflare-turnstile" if (hasDdosGuardChallenge(html, headers)) return "ddos-guard" + if (hasPowChallenge(html, headers)) return "pow" if (isCloudflarePage(html, headers)) return "cloudflare-interstitial" if (hasImpervaChallenge(html, headers)) return "imperva" if (hasAkamaiChallenge(html, headers)) return "akamai" @@ -236,6 +281,7 @@ export function isBlocked(status: number, html: string): boolean { if (hasAkamaiChallenge(html)) return true if (hasDdosGuardChallenge(html)) return true if (hasDataDomeChallenge(html)) return true + if (hasPowChallenge(html)) return true return false } @@ -245,7 +291,8 @@ export function needsJs(html: string, headers: Record<string, string>): boolean hasImpervaChallenge(html, headers) || hasAkamaiChallenge(html, headers) || hasDdosGuardChallenge(html, headers) || - hasDataDomeChallenge(html, headers) + hasDataDomeChallenge(html, headers) || + hasPowChallenge(html, headers) ) } @@ -257,6 +304,7 @@ const LEAN_BODY_THRESHOLDS: Partial<Record<ChallengeType, number>> = { "cloudflare-interstitial": 3000, imperva: 5000, "ddos-guard": 3000, + pow: 5000, } // True if the response is a challenge wall (page access blocked) rather than a page @@ -268,7 +316,13 @@ export function isChallengeWall(status: number, bodyLength: number, challengeTyp if (status === 403 || status === 503) return true // These three never serve real content alongside their wall, so the type alone settles // it. For datadome that leans on the header invariant documented in getDataDomeAction(). - if (challengeType === "akamai" || challengeType === "aws-waf" || challengeType === "datadome") return true + if ( + challengeType === "akamai" || + challengeType === "aws-waf" || + challengeType === "datadome" || + challengeType === "pow" + ) + return true const threshold = LEAN_BODY_THRESHOLDS[challengeType] if (threshold !== undefined && bodyLength < threshold) return true return false diff --git a/packages/tiers/src/utils/powWait.ts b/packages/tiers/src/utils/powWait.ts new file mode 100644 index 0000000..1b78e60 --- /dev/null +++ b/packages/tiers/src/utils/powWait.ts @@ -0,0 +1,103 @@ +// Proof-of-Work (PoW) and Wasm interstitial challenge waiter. +// +// In Tier 3 / Tier 4 (Patchright / Camoufox), JavaScript and WebAssembly run natively. +// When an interstitial challenge appears: +// 1. Check if an interactive start/verify button or checkbox is present and trigger it. +// 2. Wait for background Web Worker / WebAssembly PoW computation to finish. +// 3. Wait for the page to navigate away, auto-submit, or clear the challenge markers. +// 4. If clearance was achieved but page hasn't navigated, re-navigate to the original URL. + +import type { Page } from "patchright" +import { hasPowChallenge } from "./detect" + +type Resolution = "ok" | "ip-blocked" | "timeout" + +const sleep = (ms: number) => new Promise<void>((resolve) => setTimeout(resolve, ms)) + +export async function waitForPowResolution(page: Page, timeoutMs: number, originalUrl?: string): Promise<Resolution> { + if (timeoutMs <= 0) return "timeout" + + const deadline = Date.now() + Math.max(timeoutMs, 0) + + // Early check: if already cleared + const earlyHtml = await page.content().catch(() => "") + if (earlyHtml && !hasPowChallenge(earlyHtml)) { + await page.waitForLoadState("load", { timeout: Math.min(5000, timeoutMs) }).catch(() => {}) + return "ok" + } + + if (Date.now() >= deadline) return "timeout" + + // Attempt to trigger interactive elements if the PoW challenge requires user activation + await page + .evaluate(() => { + // 1. Altcha widget inside shadow DOM or custom element + const altcha = document.querySelector("altcha-widget, .altcha") + if (altcha) { + const root = (altcha as HTMLElement).shadowRoot ?? altcha + const btn = root.querySelector('input[type="checkbox"], button, .altcha-checkbox') as HTMLElement | null + if (btn) btn.click() + } + + // 2. Friendly Captcha button + const frcBtn = document.querySelector(".frc-button, friendly-captcha button") as HTMLElement | null + if (frcBtn) frcBtn.click() + + // 3. Generic PoW start button + const genericBtn = document.querySelector( + 'button[type="submit"], input[type="submit"], #start-challenge, .pow-button, button.verify-btn', + ) as HTMLElement | null + if (genericBtn && !genericBtn.hasAttribute("disabled")) { + genericBtn.click() + } + }) + .catch(() => {}) + + let clearedAt: number | undefined + let navigatedOnce = false + + while (Date.now() < deadline) { + const html = await page.content().catch(() => "") + + // If challenge markers are gone, wait for page to settle and return ok + if (html && !hasPowChallenge(html)) { + await page + .waitForLoadState("load", { timeout: Math.min(5000, Math.max(deadline - Date.now(), 1000)) }) + .catch(() => {}) + return "ok" + } + + // Check if form or token was submitted/verified but page is lingering + const isVerifiedInDom = await page + .evaluate(() => { + const altchaInput = document.querySelector('input[name="altcha"]') + if (altchaInput instanceof HTMLInputElement && altchaInput.value.length > 20) return true + const frcInput = document.querySelector('input[name="frc-captcha-solution"]') + if (frcInput instanceof HTMLInputElement && frcInput.value.length > 10) return true + return false + }) + .catch(() => false) + + if (isVerifiedInDom) { + clearedAt ??= Date.now() + // If token is present and verified for > 2s but page didn't auto-redirect: + if (!navigatedOnce && originalUrl && Date.now() - clearedAt > 2000) { + navigatedOnce = true + console.log("[pow] challenge verified in DOM, navigating to target URL") + await page + .goto(originalUrl, { + waitUntil: "domcontentloaded", + timeout: Math.min(10000, Math.max(deadline - Date.now(), 1000)), + }) + .catch(() => {}) + await page.waitForLoadState("networkidle", { timeout: 5000 }).catch(() => {}) + const resolvedHtml = await page.content().catch(() => "") + return resolvedHtml && !hasPowChallenge(resolvedHtml) ? "ok" : "ip-blocked" + } + } + + await sleep(Math.min(400, Math.max(deadline - Date.now(), 0))) + } + + return "timeout" +} diff --git a/packages/tiers/tests/challengeRouter.test.ts b/packages/tiers/tests/challengeRouter.test.ts index 5e33c53..ea44b1f 100644 --- a/packages/tiers/tests/challengeRouter.test.ts +++ b/packages/tiers/tests/challengeRouter.test.ts @@ -3,6 +3,7 @@ import type { Page } from "patchright" import { routeChallengeWait } from "../src/utils/challengeRouter" import { DATADOME_CAPTCHA, DATADOME_INTERSTITIAL, DATADOME_JSON_HARD_BLOCK } from "./fixtures/datadome" import { DDOS_GUARD_INTERSTITIAL } from "./fixtures/ddosGuard" +import { POW_INTERSTITIAL_HTML } from "./fixtures/pow" describe("browser challenge routing", () => { test("passes response headers into detection and routes an authoritative CF challenge to its waiter", async () => { @@ -22,6 +23,7 @@ describe("browser challenge routing", () => { ddosGuard: waiter("ddos-guard"), imperva: waiter("imperva"), akamai: waiter("akamai"), + pow: waiter("pow"), awsWaf: waiter("aws-waf"), dataDome: waiter("datadome"), }, @@ -42,6 +44,7 @@ describe("browser challenge routing", () => { ddosGuard: waiter("ddos-guard"), imperva: waiter("imperva"), akamai: waiter("akamai"), + pow: waiter("pow"), awsWaf: waiter("aws-waf"), dataDome: waiter("datadome"), }) @@ -67,6 +70,7 @@ describe("browser challenge routing", () => { ddosGuard: waiter("ddos-guard"), imperva: waiter("imperva"), akamai: waiter("akamai"), + pow: waiter("pow"), awsWaf: waiter("aws-waf"), dataDome: waiter("datadome"), }, @@ -87,7 +91,7 @@ describe("browser challenge routing", () => { { "x-amzn-waf-action": "captcha" }, 100, undefined, - { cloudflare: fail, ddosGuard: fail, imperva: fail, akamai: fail, awsWaf: fail, dataDome: fail }, + { cloudflare: fail, ddosGuard: fail, imperva: fail, akamai: fail, pow: fail, awsWaf: fail, dataDome: fail }, 405, ) expect(result).toEqual({ challengeType: "aws-waf", resolution: "captcha-required" }) @@ -110,6 +114,7 @@ describe("browser challenge routing", () => { ddosGuard: waiter("ddos-guard"), imperva: waiter("imperva"), akamai: waiter("akamai"), + pow: waiter("pow"), awsWaf: waiter("aws-waf"), dataDome: waiter("datadome"), }, @@ -124,7 +129,15 @@ describe("browser challenge routing", () => { const fail = async () => { throw new Error("waiter must not run") } - const waiters = { cloudflare: fail, ddosGuard: fail, imperva: fail, akamai: fail, awsWaf: fail, dataDome: fail } + const waiters = { + cloudflare: fail, + ddosGuard: fail, + imperva: fail, + akamai: fail, + pow: fail, + awsWaf: fail, + dataDome: fail, + } expect(await routeChallengeWait({} as Page, DATADOME_CAPTCHA, {}, 100, undefined, waiters, 403)).toEqual({ challengeType: "datadome", @@ -135,4 +148,24 @@ describe("browser challenge routing", () => { resolution: "ip-blocked", }) }) + + test("routes Proof-of-Work (PoW) challenge to its dedicated waiter", async () => { + const calls: string[] = [] + const waiter = (name: string) => async () => { + calls.push(name) + return "ok" as const + } + const result = await routeChallengeWait({} as Page, POW_INTERSTITIAL_HTML, {}, 100, "https://example.test/", { + cloudflare: waiter("cloudflare"), + ddosGuard: waiter("ddos-guard"), + imperva: waiter("imperva"), + akamai: waiter("akamai"), + pow: waiter("pow"), + awsWaf: waiter("aws-waf"), + dataDome: waiter("datadome"), + }) + + expect(result.challengeType).toBe("pow") + expect(calls).toEqual(["pow"]) + }) }) diff --git a/packages/tiers/tests/fixtures/pow.ts b/packages/tiers/tests/fixtures/pow.ts new file mode 100644 index 0000000..ced75ef --- /dev/null +++ b/packages/tiers/tests/fixtures/pow.ts @@ -0,0 +1,58 @@ +export const ALTCHA_WIDGET_HTML = `<!DOCTYPE html> +<html> +<head><title>Form with Altcha + +
+ + + + + + +
+ +` + +export const FRIENDLY_CAPTCHA_WIDGET_HTML = ` + +Form with Friendly Captcha + +
+ +
+
+ + +
+
+ +
+ +` + +export const POW_INTERSTITIAL_HTML = ` + + + Security Check + + + +
+

Checking your browser before accessing the website

+

Please wait while your device completes the proof of work challenge...

+
Computing challenge...
+
+ +` + +export const ALTCHA_INTERSTITIAL_HTML = ` + +Verification Required + +
+

Security Check

+

Protected by Altcha Proof-of-Work verification.

+ +
+ +` diff --git a/packages/tiers/tests/powSolvers.test.ts b/packages/tiers/tests/powSolvers.test.ts new file mode 100644 index 0000000..96fbfec --- /dev/null +++ b/packages/tiers/tests/powSolvers.test.ts @@ -0,0 +1,181 @@ +import { describe, expect, test } from "bun:test" +import type { Page } from "patchright" +import { hasAltchaWidget, hasFriendlyCaptchaWidget, solveAltcha, solveFriendlyCaptcha } from "../src/solvers" +import { runTier1 } from "../src/tiers/1" +import { + detectChallengeType, + hasPowChallenge, + isBlocked, + isChallengeWall, + isCloudflarePage, + needsJs, +} from "../src/utils/detect" +import { waitForPowResolution } from "../src/utils/powWait" +import { + ALTCHA_INTERSTITIAL_HTML, + ALTCHA_WIDGET_HTML, + FRIENDLY_CAPTCHA_WIDGET_HTML, + POW_INTERSTITIAL_HTML, +} from "./fixtures/pow" + +describe("Proof-of-Work (PoW) detection", () => { + test("detects PoW interstitial markers in HTML", () => { + expect(hasPowChallenge(POW_INTERSTITIAL_HTML)).toBe(true) + expect(detectChallengeType(POW_INTERSTITIAL_HTML)).toBe("pow") + expect(isCloudflarePage(POW_INTERSTITIAL_HTML, {})).toBe(false) + expect(needsJs(POW_INTERSTITIAL_HTML, {})).toBe(true) + expect(isBlocked(200, POW_INTERSTITIAL_HTML)).toBe(true) + expect(isChallengeWall(200, POW_INTERSTITIAL_HTML.length, "pow")).toBe(true) + }) + + test("detects Altcha gate interstitial in HTML", () => { + expect(hasPowChallenge(ALTCHA_INTERSTITIAL_HTML)).toBe(true) + expect(detectChallengeType(ALTCHA_INTERSTITIAL_HTML)).toBe("pow") + expect(needsJs(ALTCHA_INTERSTITIAL_HTML, {})).toBe(true) + expect(isBlocked(403, ALTCHA_INTERSTITIAL_HTML)).toBe(true) + }) + + test("detects PoW from response headers", () => { + expect(hasPowChallenge("", { "X-PoW-Challenge": "required" })).toBe(true) + expect(hasPowChallenge("", { "X-Altcha-Challenge": "pending" })).toBe(true) + expect(detectChallengeType("", { "x-pow-challenge": "true" })).toBe("pow") + }) + + test("lets authoritative Cloudflare challenge header win", () => { + const headers = { "CF-Mitigated": "Challenge" } + expect(detectChallengeType(POW_INTERSTITIAL_HTML, headers)).toBe("cloudflare-interstitial") + expect(isCloudflarePage(POW_INTERSTITIAL_HTML, headers)).toBe(true) + }) + + test("does not false-positive on ordinary pages", () => { + const normalHtml = "

Welcome to our site

" + expect(hasPowChallenge(normalHtml)).toBe(false) + expect(detectChallengeType(normalHtml)).toBe("none") + expect(needsJs(normalHtml, {})).toBe(false) + expect(isBlocked(200, normalHtml)).toBe(false) + }) +}) + +describe("In-page PoW widget solvers", () => { + test("fixtures define valid widget structures", () => { + expect(/altcha-widget|\.altcha/.test(ALTCHA_WIDGET_HTML)).toBe(true) + expect(/frc-captcha|friendly-captcha/.test(FRIENDLY_CAPTCHA_WIDGET_HTML)).toBe(true) + }) + + test("hasAltchaWidget detects widget presence via evaluate", async () => { + const mockPage = { + evaluate: async () => true, + } as unknown as Page + + expect(await hasAltchaWidget(mockPage, 100)).toBe(true) + }) + + test("hasFriendlyCaptchaWidget detects widget presence via evaluate", async () => { + const mockPage = { + evaluate: async () => true, + } as unknown as Page + + expect(await hasFriendlyCaptchaWidget(mockPage, 100)).toBe(true) + }) + + test("solveAltcha completes when widget is already verified", async () => { + const mockPage = { + evaluate: async () => true, + } as unknown as Page + + expect(await solveAltcha(mockPage, 1000)).toBe(true) + }) + + test("solveFriendlyCaptcha completes when widget is already verified", async () => { + const mockPage = { + evaluate: async () => true, + } as unknown as Page + + expect(await solveFriendlyCaptcha(mockPage, 1000)).toBe(true) + }) + + test("solveAltcha returns false if no widget found", async () => { + const mockPage = { + evaluate: async () => false, + } as unknown as Page + + expect(await solveAltcha(mockPage, 100)).toBe(false) + }) + + test("solveFriendlyCaptcha returns false if no widget found", async () => { + const mockPage = { + evaluate: async () => false, + } as unknown as Page + + expect(await solveFriendlyCaptcha(mockPage, 100)).toBe(false) + }) +}) + +describe("waitForPowResolution", () => { + test("returns ok when page content clears challenge markers", async () => { + let reads = 0 + const mockPage = { + content: async () => { + reads++ + return reads > 1 ? "

Welcome

" : POW_INTERSTITIAL_HTML + }, + evaluate: async () => {}, + waitForLoadState: async () => {}, + } as unknown as Page + + const res = await waitForPowResolution(mockPage, 2000) + expect(res).toBe("ok") + }) + + test("returns timeout when deadline exceeded and challenge persists", async () => { + const mockPage = { + content: async () => POW_INTERSTITIAL_HTML, + evaluate: async () => false, + } as unknown as Page + + const res = await waitForPowResolution(mockPage, 50) + expect(res).toBe("timeout") + }) +}) + +describe("Tier 1 PoW challenge escalation", () => { + async function withFetch(response: Response, run: () => Promise) { + const original = globalThis.fetch + ;(globalThis as { fetch: typeof fetch }).fetch = (async () => response) as typeof fetch + try { + await run() + } finally { + ;(globalThis as { fetch: typeof fetch }).fetch = original + } + } + + test("escalates PoW interstitial to needs-js with pow challenge", async () => { + await withFetch( + new Response(POW_INTERSTITIAL_HTML, { + status: 200, + headers: { "content-type": "text/html" }, + }), + async () => { + const result = await runTier1("https://example.test/") + expect(result.status).toBe("needs-js") + expect(result.challenge).toBe("pow") + expect(result.reason).toBe("pow-challenge") + }, + ) + }) + + test("escalates PoW response based on X-PoW-Challenge header", async () => { + await withFetch( + new Response("Loading", { + status: 200, + headers: { "content-type": "text/html", "x-pow-challenge": "required" }, + }), + async () => { + const result = await runTier1("https://example.test/") + expect(result.status).toBe("needs-js") + expect(result.challenge).toBe("pow") + expect(result.reason).toBe("pow-challenge") + }, + ) + }) +})