mirror of
https://github.com/germondai/trawl.git
synced 2026-08-17 12:11:23 +02:00
feat: add captcha solver plugin registry
This commit is contained in:
@@ -0,0 +1,165 @@
|
||||
// In-page captcha solver orchestrator.
|
||||
// All solving is done locally — no external APIs, no billing.
|
||||
//
|
||||
// Handles:
|
||||
// Cloudflare Turnstile — iframe checkbox click (embedded widget mode)
|
||||
// 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
|
||||
//
|
||||
// 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 { hasGeetestSlide, solveGeetestSlide } from "./geetest"
|
||||
import { hasHcaptchaWidget, solveHcaptcha } from "./hcaptcha"
|
||||
import { hasRecaptchaV2, solveRecaptchaV2 } from "./recaptcha"
|
||||
import { solveTurnstile } from "./turnstile"
|
||||
|
||||
export interface SolveResult {
|
||||
attempted: string[]
|
||||
solved: string[]
|
||||
}
|
||||
|
||||
// Check for an in-page Turnstile widget via frame URLs and DOM polling.
|
||||
// Avoids page.waitForSelector whose timeout option is silently ignored by camoufox-js —
|
||||
// it always uses the 30s Playwright default regardless of what we pass. We poll instead.
|
||||
//
|
||||
// IMPORTANT: we distinguish in-page widgets from the CF interstitial (just-solved)
|
||||
// by requiring the page's own URL to NOT be a CF challenge/platform URL.
|
||||
async function detectTurnstile(page: Page, timeoutMs: number): Promise<boolean> {
|
||||
const POLL_INTERVAL = 300
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
// If the current page is itself a CF challenge, skip — we're still in the interstitial,
|
||||
// not on the target page with an embedded in-page widget.
|
||||
const pageUrl = page.url()
|
||||
const pageIsCfChallenge =
|
||||
pageUrl.includes("cdn-cgi/challenge-platform") || pageUrl.includes("challenges.cloudflare.com")
|
||||
if (!pageIsCfChallenge) {
|
||||
// Frame URL scan — only count sub-frames in the current (non-challenge) page
|
||||
const viaFrame = page.frames().some((f) => {
|
||||
// Skip main frame (already checked above), skip same-origin CF challenge frames
|
||||
if (f === page.mainFrame()) return false
|
||||
const u = f.url()
|
||||
return u.includes("challenges.cloudflare.com") || u.includes("cdn-cgi/challenge-platform")
|
||||
})
|
||||
if (viaFrame) {
|
||||
console.log("[solvers] turnstile detected via frame scan")
|
||||
return true
|
||||
}
|
||||
|
||||
// DOM check — immediate evaluate on the real page
|
||||
const viaDOM = await page
|
||||
.evaluate(() => {
|
||||
if (
|
||||
document.querySelector(
|
||||
'iframe[src*="challenges.cloudflare.com"], iframe[src*="cdn-cgi/challenge-platform"], .cf-turnstile, #cf-turnstile',
|
||||
)
|
||||
)
|
||||
return "iframe"
|
||||
const inp = document.querySelector('input[name="cf-turnstile-response"]') as HTMLInputElement | null
|
||||
if (inp && inp.value.length > 10) return "token"
|
||||
return null
|
||||
})
|
||||
.catch(() => null)
|
||||
|
||||
if (viaDOM === "iframe") return true
|
||||
if (viaDOM === "token") {
|
||||
console.log("[solvers] turnstile already auto-solved (token present)")
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, POLL_INTERVAL))
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
export async function solvePageCaptchas(page: Page, timeoutMs = 30_000): Promise<SolveResult> {
|
||||
const attempted: string[] = []
|
||||
const solved: string[] = []
|
||||
|
||||
// Quick HTML scan — skip detection entirely for pages with no widget markers
|
||||
const html = await page.content().catch(() => "")
|
||||
const mightHaveTurnstile = /cf-turnstile|cloudflare\.com\/turnstile/i.test(html)
|
||||
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) {
|
||||
return { attempted: [], solved: [] }
|
||||
}
|
||||
|
||||
// Trigger IntersectionObserver-based lazy loading by scrolling, then wait briefly
|
||||
// for JS-rendered widget iframes to appear (CF Turnstile api.js: 2-5s in Firefox).
|
||||
await page
|
||||
.evaluate(() => {
|
||||
const h = document.body.scrollHeight
|
||||
window.scrollTo(0, Math.min(h, 600))
|
||||
})
|
||||
.catch(() => {})
|
||||
|
||||
const frameUrls = page
|
||||
.frames()
|
||||
.map((f) => f.url())
|
||||
.filter((u) => u && u !== "about:blank")
|
||||
if (frameUrls.length > 0) console.log("[solvers] frames:", frameUrls.map((u) => u.slice(0, 80)).join(" | "))
|
||||
|
||||
// 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.
|
||||
const DETECT_MS = 3_000
|
||||
|
||||
const [hasTurnstile, hasHcaptcha, hasRecaptcha, hasGeetest] = 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),
|
||||
])
|
||||
|
||||
const count = [hasTurnstile, hasHcaptcha, hasRecaptcha, hasGeetest].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",
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(",")})`,
|
||||
)
|
||||
return { attempted: [], solved: [] }
|
||||
}
|
||||
|
||||
const perMs = Math.floor(timeoutMs / count)
|
||||
|
||||
if (hasTurnstile) {
|
||||
attempted.push("turnstile")
|
||||
if (await solveTurnstile(page, perMs).catch(() => false)) solved.push("turnstile")
|
||||
}
|
||||
|
||||
if (hasRecaptcha) {
|
||||
attempted.push("recaptcha-v2")
|
||||
if (await solveRecaptchaV2(page, perMs).catch(() => false)) solved.push("recaptcha-v2")
|
||||
}
|
||||
|
||||
if (hasHcaptcha) {
|
||||
attempted.push("hcaptcha")
|
||||
if (await solveHcaptcha(page, perMs).catch(() => false)) solved.push("hcaptcha")
|
||||
}
|
||||
|
||||
if (hasGeetest) {
|
||||
attempted.push("geetest-slide")
|
||||
if (await solveGeetestSlide(page, perMs).catch(() => false)) solved.push("geetest-slide")
|
||||
}
|
||||
|
||||
if (attempted.length > 0) {
|
||||
console.log(`[solvers] attempted=[${attempted.join(",")}] solved=[${solved.join(",")}]`)
|
||||
}
|
||||
|
||||
return { attempted, solved }
|
||||
}
|
||||
Reference in New Issue
Block a user