From 6c8385694b3686e2e2771f52950f1a25c3603261 Mon Sep 17 00:00:00 2001 From: germondai Date: Wed, 3 Jun 2026 16:00:00 +0200 Subject: [PATCH] feat: implement tier 2 basic headless browser execution --- packages/tiers/src/tier2.ts | 108 ++++++++++++++++++++++++++++++++++++ 1 file changed, 108 insertions(+) create mode 100644 packages/tiers/src/tier2.ts diff --git a/packages/tiers/src/tier2.ts b/packages/tiers/src/tier2.ts new file mode 100644 index 0000000..93b6d9a --- /dev/null +++ b/packages/tiers/src/tier2.ts @@ -0,0 +1,108 @@ +import type { BrowserHandle } from "@trawl/browser" +import type { Cookie, SessionData, TierResult } from "@trawl/types" +import { isCloudflarePage } from "./detect" +import { normalizeHtml } from "./html" +import { solvePageCaptchas } from "./solvers" + +export interface Tier2Result extends TierResult { + tier: 2 + html?: string + cookies?: Cookie[] + statusCode?: number + captchasSolved?: string[] +} + +export async function runTier2( + url: string, + handle: BrowserHandle, + session: SessionData, + maxTimeout: number, +): Promise { + const start = Date.now() + const page = await handle.context.newPage() + + try { + // addCookies replaces cookies by name+domain+path, so no need to clearCookies first. + // Keeping the context's CF cookies (cf_clearance, __cf_bm) intact means CF sees a + // browser with history, which speeds up challenge evaluation on the next Tier 3 run. + await handle.context.addCookies( + session.cookies.map((c) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: c.expires, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: (c.sameSite as "Strict" | "Lax" | "None") ?? "Lax", + })), + ) + + await page.setExtraHTTPHeaders({ "User-Agent": session.userAgent }) + + let statusCode = 200 + page.on("response", (res: { url(): string; status(): number }) => { + if (res.url() === url) statusCode = res.status() + }) + + await page.goto(url, { waitUntil: "domcontentloaded", timeout: maxTimeout }) + await page.waitForLoadState("networkidle", { timeout: 8_000 }).catch(() => {}) + + const html = await page.content() + + if (isCloudflarePage(html, {})) { + return { tier: 2, status: "blocked", durationMs: Date.now() - start, reason: "session-expired" } + } + + // Attempt to solve any embedded captcha widgets (Turnstile, reCAPTCHA, hCaptcha). + // Pages that load cleanly via session cache may still have in-page challenge widgets. + const solveRemaining = maxTimeout - (Date.now() - start) + let captchasSolved: string[] = [] + if (solveRemaining > 5000) { + const result = await solvePageCaptchas(page, solveRemaining).catch(() => ({ attempted: [], solved: [] })) + captchasSolved = result.solved + } + + const rawCookies = await handle.context.cookies() + const cookies: Cookie[] = rawCookies.map( + (c: { + name: string + value: string + domain: string + path: string + expires: number + httpOnly: boolean + secure: boolean + sameSite?: string + }) => ({ + name: c.name, + value: c.value, + domain: c.domain, + path: c.path, + expires: c.expires ?? -1, + httpOnly: c.httpOnly, + secure: c.secure, + sameSite: c.sameSite, + }), + ) + + return { + tier: 2, + status: "success", + durationMs: Date.now() - start, + html: normalizeHtml(html), + cookies, + statusCode, + captchasSolved: captchasSolved.length > 0 ? captchasSolved : undefined, + } + } catch (err) { + return { + tier: 2, + status: "error", + durationMs: Date.now() - start, + reason: err instanceof Error ? err.message : String(err), + } + } finally { + await page.close().catch(() => {}) + } +}