File size: 6,410 Bytes
5ef6e9d | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 | /**
* 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<void> {
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<string | null> {
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 <input name="cf-turnstile-response">
const token = await page
.waitForFunction(
() => {
// Standard Turnstile hidden input
const standard = document.querySelector<HTMLInputElement>(
'input[name="cf-turnstile-response"]'
);
if (standard?.value && standard.value.length > 20) return standard.value;
// Some implementations use textarea
const ta = document.querySelector<HTMLTextAreaElement>(
'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<string>)
.catch(() => null);
return token;
} finally {
await page.close().catch(() => {});
}
}
export interface SolveResult {
token: string;
cached: boolean;
solvedInMs?: number;
}
export async function solveTurnstile(): Promise<SolveResult> {
// 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;
}
|