Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
11 changes: 10 additions & 1 deletion packages/tiers/src/index.ts
Original file line number Diff line number Diff line change
@@ -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"
Expand All @@ -19,6 +26,7 @@ export {
hasDdosGuardChallenge,
hasHcaptcha,
hasImpervaChallenge,
hasPowChallenge,
hasRecaptcha,
hasTurnstile,
isBlocked,
Expand All @@ -27,6 +35,7 @@ export {
isCloudflarePage,
needsJs,
} from "./utils/detect"
export { waitForPowResolution } from "./utils/powWait"
export { normalizeProxy, ProxyPool } from "./utils/proxyRotator"
export {
isValidMethod,
Expand Down
104 changes: 104 additions & 0 deletions packages/tiers/src/solvers/altcha.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<boolean> {
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
}
}
158 changes: 158 additions & 0 deletions packages/tiers/src/solvers/friendlyCaptcha.ts
Original file line number Diff line number Diff line change
@@ -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<boolean> {
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<boolean> {
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<boolean> => {
// 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
}
}
42 changes: 37 additions & 5 deletions packages/tiers/src/solvers/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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[]
Expand Down Expand Up @@ -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: [] }
}

Expand All @@ -110,24 +126,30 @@ 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 (${[
mightHaveTurnstile && "turnstile",
mightHaveRecaptcha && "recaptcha",
mightHaveHcaptcha && "hcaptcha",
mightHaveGeetest && "geetest",
mightHaveAltcha && "altcha",
mightHaveFriendlyCaptcha && "friendly-captcha",
]
.filter(Boolean)
.join(",")})`,
Expand Down Expand Up @@ -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(",")}]`)
}
Expand Down
Loading