Files
2026-07-13 13:39:12 +08:00

207 lines
5.1 KiB
TypeScript

/**
* Cloudflare Turnstile Solver for Claude Web
*
* When cf_clearance expires, this service:
* 1. Launches a headless Playwright browser
* 2. Navigates to claude.ai
* 3. Waits for Turnstile challenge to appear
* 4. Waits for challenge to be solved (with retry)
* 5. Extracts cf_clearance cookie
* 6. Returns fresh cookie for tls-client-node
*/
import { chromium, type Browser, type Page } from "playwright";
const CLAUDE_WEB_URL = "https://claude.ai";
const CHALLENGE_TIMEOUT = 60000; // 60s to solve challenge
const CHALLENGE_CHECK_INTERVAL = 500; // Check every 500ms
const MAX_RETRIES = 3;
interface TurnstileSolveResult {
cfClearance: string;
timestamp: number;
}
/**
* Check if Turnstile challenge is solved
*/
async function isTurnstileSolved(page: Page): Promise<boolean> {
try {
// Check if cf_clearance cookie exists
const cookies = await page.context().cookies();
const cfClearance = cookies.find((c) => c.name === "cf_clearance");
return !!cfClearance?.value;
} catch {
return false;
}
}
/**
* Wait for Turnstile challenge to be solved
*/
async function waitForChallengeSolved(page: Page): Promise<void> {
const startTime = Date.now();
while (Date.now() - startTime < CHALLENGE_TIMEOUT) {
if (await isTurnstileSolved(page)) {
return;
}
await page.waitForTimeout(CHALLENGE_CHECK_INTERVAL);
}
throw new Error(`Turnstile challenge not solved within ${CHALLENGE_TIMEOUT}ms`);
}
/**
* Extract cf_clearance cookie from browser
*/
async function extractCfClearance(page: Page): Promise<string> {
const cookies = await page.context().cookies();
const cfClearance = cookies.find((c) => c.name === "cf_clearance");
if (!cfClearance?.value) {
throw new Error("cf_clearance cookie not found after challenge solve");
}
return cfClearance.value;
}
/**
* Solve Turnstile challenge and return cf_clearance
*/
export async function solveTurnstile(options?: {
headless?: boolean;
timeout?: number;
}): Promise<TurnstileSolveResult> {
const headless = options?.headless !== false;
const timeout = options?.timeout ?? CHALLENGE_TIMEOUT;
let browser: Browser | null = null;
let page: Page | null = null;
try {
// Launch headless browser
browser = await chromium.launch({ headless });
const context = await browser.newContext({
userAgent:
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/149.0.0.0 Safari/537.36",
viewport: { width: 1280, height: 720 },
ignoreHTTPSErrors: process.env.OMNIROUTE_TURNSTILE_IGNORE_TLS_ERRORS === "true",
});
page = await context.newPage();
// Navigate to claude.ai
await page.goto(CLAUDE_WEB_URL, { waitUntil: "domcontentloaded" });
// Wait for Turnstile challenge to appear and be solved
// Sometimes it's instant, sometimes it takes a few seconds
await waitForChallengeSolved(page);
// Extract cf_clearance
const cfClearance = await extractCfClearance(page);
return {
cfClearance,
timestamp: Date.now(),
};
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Failed to solve Turnstile: ${message}`);
} finally {
if (page) {
await page.close().catch(() => {
/* ignore */
});
}
if (browser) {
await browser.close().catch(() => {
/* ignore */
});
}
}
}
/**
* Cache for recently solved cf_clearance tokens
* Reduces unnecessary challenge solving for rapid requests
*/
const tokenCache = new Map<
string,
{
token: string;
expiresAt: number;
}
>();
let cfClearanceTokenOverride: string | null = null;
export function setCfClearanceTokenForTesting(token: string | null): void {
cfClearanceTokenOverride = token;
}
/**
* Get or solve cf_clearance (with caching)
*/
export async function getCfClearanceToken(options?: {
force?: boolean;
headless?: boolean;
}): Promise<string> {
const cacheKey = "claude-cf-clearance";
const cached = tokenCache.get(cacheKey);
if (cfClearanceTokenOverride) {
tokenCache.set(cacheKey, {
token: cfClearanceTokenOverride,
expiresAt: Date.now() + 55 * 60 * 1000,
});
return cfClearanceTokenOverride;
}
// Return cached token if still valid (5 min buffer)
if (cached && !options?.force && cached.expiresAt > Date.now() + 5 * 60 * 1000) {
return cached.token;
}
// Solve new challenge
const result = await solveTurnstile({
headless: options?.headless !== false,
});
// Cache for 55 minutes (assuming 1 hour expiry)
tokenCache.set(cacheKey, {
token: result.cfClearance,
expiresAt: Date.now() + 55 * 60 * 1000,
});
return result.cfClearance;
}
/**
* Clear cache (useful for testing)
*/
export function clearCfClearanceCache(): void {
tokenCache.clear();
}
/**
* Get cache status (for diagnostics)
*/
export function getCacheStatus(): {
hasCached: boolean;
expiresIn?: number;
} {
const cacheKey = "claude-cf-clearance";
const cached = tokenCache.get(cacheKey);
if (!cached) {
return { hasCached: false };
}
const expiresIn = Math.max(0, cached.expiresAt - Date.now());
return {
hasCached: true,
expiresIn,
};
}