/** * Playwright-based Cloudflare Turnstile solver. * * Strategy: * 1. Launch headless Chromium with stealth patches * 2. Navigate to geminigen.ai/video (where the Turnstile widget auto-solves) * 3. Extract the cf-turnstile-response hidden input value * 4. Cache it for 4.5 minutes (tokens last ~5 min) * * For Vercel deployment: uses @sparticuz/chromium-min which downloads the * Chromium binary at runtime, keeping the function bundle small. */ import { chromium, type Browser, type BrowserContext } from "playwright-core"; // The video-gen app page — this URL triggers the Turnstile widget we need. // NOTE: non-authed visitors are redirected to /auth/signin which also has // the same Turnstile sitekey (0x4AAAAAACDBydnKT0zYzh2H), so the token // is still valid for the API call regardless of which page resolves. const TARGET_URL = "https://geminigen.ai/app/video-gen"; const TOKEN_TTL_MS = 270_000; // 4.5 minutes interface TokenCache { token: string; expiresAt: number; } let _cache: TokenCache | null = null; /** Inject stealth patches to avoid bot detection */ async function applyStealthPatches(context: BrowserContext): Promise { await context.addInitScript(() => { // Spoof navigator.webdriver Object.defineProperty(navigator, "webdriver", { get: () => undefined, }); // Add chrome object (expected by fingerprint checks) (window as any).chrome = { runtime: { onConnect: null, onMessage: null, }, }; // Spoof permissions API const originalQuery = window.navigator.permissions.query.bind( window.navigator.permissions ); (window.navigator.permissions as any).query = (params: any) => { if (params.name === "notifications") { return Promise.resolve({ state: Notification.permission }); } return originalQuery(params); }; // Spoof plugin array (headless has none) Object.defineProperty(navigator, "plugins", { get: () => [ { name: "Chrome PDF Plugin" }, { name: "Chrome PDF Viewer" }, { name: "Native Client" }, ], }); // Spoof languages Object.defineProperty(navigator, "languages", { get: () => ["zh-TW", "zh", "en-US", "en"], }); // Canvas fingerprint randomization const origToDataURL = HTMLCanvasElement.prototype.toDataURL; HTMLCanvasElement.prototype.toDataURL = function (type?: string) { const dataURL = origToDataURL.call(this, type); return dataURL; }; }); } /** Wait for the Turnstile widget to complete and return the token */ async function waitForTurnstileToken( context: BrowserContext, timeoutMs = 30_000 ): Promise { const page = await context.newPage(); try { // Block heavy resources to speed up load await page.route( "**/*.{png,jpg,jpeg,gif,webp,svg,ico,woff,woff2,ttf,otf,mp4,mp3,wav}", (route) => route.abort() ); await page.goto(TARGET_URL, { waitUntil: "domcontentloaded", timeout: 30_000, }); // Wait for cf-turnstile-response to be populated // Turnstile widget injects a hidden const token = await page .waitForFunction( () => { // Standard Turnstile hidden input const standard = document.querySelector( 'input[name="cf-turnstile-response"]' ); if (standard?.value && standard.value.length > 20) return standard.value; // Some implementations use textarea const ta = document.querySelector( 'textarea[name="cf-turnstile-response"]' ); if (ta?.value && ta.value.length > 20) return ta.value; // Check for window-level token set by custom callbacks const win = window as any; if (win.__cfTurnstileToken && win.__cfTurnstileToken.length > 20) { return win.__cfTurnstileToken; } return null; }, { timeout: timeoutMs, polling: 500 } ) .then((h) => h.jsonValue() as Promise) .catch(() => null); return token; } finally { await page.close().catch(() => {}); } } export interface SolveResult { token: string; cached: boolean; solvedInMs?: number; } export async function solveTurnstile(): Promise { // Return cached token if still valid if (_cache && _cache.expiresAt > Date.now()) { return { token: _cache.token, cached: true }; } const start = Date.now(); let browser: Browser | null = null; try { // Resolve Chromium executable path // In Vercel Lambda: uses @sparticuz/chromium-min (downloads binary at runtime) // In local dev: uses system Playwright Chromium let executablePath: string; if (process.env.AWS_LAMBDA_FUNCTION_NAME || process.env.VERCEL) { // Production (Vercel / Lambda): use sparticuz chromium const chromiumMin = await import("@sparticuz/chromium-min"); const chromiumPack = process.env.CHROMIUM_PACK_URL || "https://github.com/Sparticuz/chromium/releases/download/v133.0.0/chromium-v133.0.0-pack.tar"; executablePath = await chromiumMin.default.executablePath(chromiumPack); browser = await chromium.launch({ args: chromiumMin.default.args, executablePath, headless: true, }); } else { // Local dev: use system Playwright Chromium browser = await chromium.launch({ headless: true }); } const context = await browser.newContext({ userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/133.0.0.0 Safari/537.36", viewport: { width: 1280, height: 800 }, locale: "zh-TW", timezoneId: "Asia/Taipei", }); await applyStealthPatches(context); const token = await waitForTurnstileToken(context, 25_000); await context.close().catch(() => {}); if (!token) { throw new Error("Turnstile token not found in page DOM after 25s"); } _cache = { token, expiresAt: Date.now() + TOKEN_TTL_MS }; return { token, cached: false, solvedInMs: Date.now() - start }; } finally { if (browser) await browser.close().catch(() => {}); } } /** Force-invalidate cached token */ export function invalidateCache(): void { _cache = null; }