/** * Turnstile 内置求解:等待 widget 自动完成 + token 轮询 + 多 frame sitekey 提取 * * 支持新版 challenge-platform iframe:sitekey 在 URL 路径 /rch/{id}/{sitekey}/ 中 */ const { detectCloudflare, isTurnstileSolved, urlIndicatesCfChallenge } = require('./cf-detector'); /** * 从 challenge-platform / Turnstile URL 解析 sitekey(Node 侧,可读 frame.url()) * @param {string} url * @returns {string|null} */ function extractSitekeyFromChallengeUrl(url) { if (!url || typeof url !== 'string') { return null; } const pathMatch = url.match(/\/rch\/[^/]+\/(0x4[A-Za-z0-9_-]+)\//i); if (pathMatch) { return pathMatch[1]; } const queryMatch = url.match(/[?&]sitekey=([^&]+)/i); if (queryMatch) { return decodeURIComponent(queryMatch[1]); } const looseMatch = url.match(/\/(0x4[A-Za-z0-9_-]{10,})\//i); if (looseMatch) { return looseMatch[1]; } return null; } /** * 在单个 frame 内提取 sitekey(主文档 DOM + iframe src 属性) * @param {import('puppeteer').Frame} frame * @returns {Promise} */ async function extractSitekeyFromFrame(frame) { return frame.evaluate(() => { const extractFromSrc = (src) => { if (!src) return null; const pathMatch = src.match(/\/rch\/[^/]+\/(0x4[A-Za-z0-9_-]+)\//i); if (pathMatch) return pathMatch[1]; const queryMatch = src.match(/[?&]sitekey=([^&]+)/i); if (queryMatch) return decodeURIComponent(queryMatch[1]); const looseMatch = src.match(/\/(0x4[A-Za-z0-9_-]{10,})\//i); if (looseMatch) return looseMatch[1]; return null; }; const widget = document.querySelector('.cf-turnstile[data-sitekey], [data-sitekey]'); if (widget) { const key = widget.getAttribute('data-sitekey'); if (key) { return key; } } const iframes = document.querySelectorAll( 'iframe[src*="challenges.cloudflare.com"], iframe[src*="challenge-platform"], iframe#cf-chl-widget' ); for (const iframe of iframes) { const fromSrc = extractFromSrc(iframe.getAttribute('src') || ''); if (fromSrc) { return fromSrc; } } const scripts = document.querySelectorAll('script'); for (const script of scripts) { const text = script.textContent || ''; const match = text.match(/sitekey['"\s:=]+['"]([0-9x_a-zA-Z-]{10,})['"]/i) || text.match(/data-sitekey=['"]([0-9x_a-zA-Z-]{10,})['"]/i) || text.match(/\/(0x4[A-Za-z0-9_-]{10,})\//i); if (match) { return match[1]; } } return null; }).catch(() => null); } /** * 从整页 HTML 文本提取 sitekey(应对 OOPIF 无法 enumerate 的场景) * @param {string} html * @returns {string|null} */ function extractSitekeyFromHtml(html) { if (!html || typeof html !== 'string') { return null; } const pathMatch = html.match(/\/rch\/[^/]+\/(0x4[A-Za-z0-9_-]+)\//i); if (pathMatch) { return pathMatch[1]; } const dataMatch = html.match(/data-sitekey=["']([^"']+)["']/i); if (dataMatch) { return dataMatch[1]; } const queryMatch = html.match(/[?&]sitekey=([^&"']+)/i); if (queryMatch) { return decodeURIComponent(queryMatch[1]); } const looseMatch = html.match(/\/(0x4[A-Za-z0-9_-]{10,})\//i); if (looseMatch) { return looseMatch[1]; } return null; } /** * 从主文档及所有子 frame 提取 Turnstile sitekey * @param {import('puppeteer').Page} page * @returns {Promise} */ async function extractTurnstileSitekey(page) { const mainKey = await extractSitekeyFromFrame(page.mainFrame()); if (mainKey) { return mainKey; } for (const frame of page.frames()) { const fromFrameUrl = extractSitekeyFromChallengeUrl(frame.url()); if (fromFrameUrl) { return fromFrameUrl; } if (frame === page.mainFrame()) { continue; } const frameKey = await extractSitekeyFromFrame(frame); if (frameKey) { return frameKey; } } const html = await page.content().catch(() => ''); const fromHtml = extractSitekeyFromHtml(html); if (fromHtml) { return fromHtml; } const browser = page.browser(); if (browser) { for (const target of browser.targets()) { const fromTarget = extractSitekeyFromChallengeUrl(target.url()); if (fromTarget) { return fromTarget; } } } return null; } /** * Captcha API 调用前:等待 Turnstile 挑战面出现或 URL 进入 CF 挑战态 * @param {import('puppeteer').Page} page * @param {number} [timeoutMs] * @returns {Promise} */ async function waitForTurnstileSurface(page, timeoutMs = 20000) { const started = Date.now(); const pollMs = 400; while (Date.now() - started < timeoutMs) { const sitekey = await extractTurnstileSitekey(page); if (sitekey) { console.log(`[CF] 已检测到 Turnstile sitekey (${sitekey.slice(0, 12)}...)`); return sitekey; } const url = page.url(); if (urlIndicatesCfChallenge(url)) { await new Promise((r) => setTimeout(r, pollMs)); continue; } const state = await detectCloudflare(page); if (!state.blocked && state.hasCfClearance) { return null; } const hasSurface = await page.evaluate(() => { return !!document.querySelector( '.cf-turnstile, [data-sitekey], [name="cf-turnstile-response"], ' + 'iframe[src*="challenges.cloudflare.com"], iframe[src*="challenge-platform"], ' + 'iframe[id^="cf-chl-widget"]' ); }).catch(() => false); if (hasSurface) { await new Promise((r) => setTimeout(r, pollMs)); continue; } await new Promise((r) => setTimeout(r, pollMs)); } return null; } /** * 等待 Turnstile 通过(内置点击 / 自动跳转) * @param {import('puppeteer').Page} page * @param {{ timeoutMs?: number, pollMs?: number }} [options] */ async function waitForTurnstile(page, options = {}) { const timeoutMs = options.timeoutMs ?? 60000; const pollMs = options.pollMs ?? 500; const started = Date.now(); const initial = await detectCloudflare(page); const initialUrlChallenge = urlIndicatesCfChallenge(page.url()); if ((!initial.blocked && !initialUrlChallenge) || initial.hasCfClearance) { if (initial.hasCfClearance && !initialUrlChallenge) { return { success: true, stage: 'already_clear', elapsedMs: Date.now() - started }; } if (!initial.blocked && !initialUrlChallenge) { return { success: true, stage: 'already_clear', elapsedMs: Date.now() - started }; } } while (Date.now() - started < timeoutMs) { if (await isTurnstileSolved(page) && !urlIndicatesCfChallenge(page.url())) { return { success: true, stage: 'built_in', elapsedMs: Date.now() - started }; } const state = await detectCloudflare(page); if (!state.blocked && !urlIndicatesCfChallenge(page.url())) { return { success: true, stage: 'built_in', elapsedMs: Date.now() - started }; } await new Promise((r) => setTimeout(r, pollMs)); } return { success: false, stage: 'timeout', elapsedMs: Date.now() - started }; } /** * 等待页面离开 CF 挑战 URL / 挑战 DOM * @param {import('puppeteer').Page} page * @param {number} timeoutMs * @param {number} pollMs */ async function waitForChallengeUrlClear(page, timeoutMs = 45000, pollMs = 500) { const started = Date.now(); while (Date.now() - started < timeoutMs) { if (!urlIndicatesCfChallenge(page.url())) { const state = await detectCloudflare(page); if (!state.blocked) { return true; } } await new Promise((r) => setTimeout(r, pollMs)); } return false; } /** * 将 Captcha API 返回的 token 注入页面并尝试触发提交 * @param {import('puppeteer').Page} page * @param {string} token */ async function injectTurnstileToken(page, token) { await page.evaluate((turnstileToken) => { const input = document.querySelector('[name="cf-turnstile-response"]'); if (input) { input.value = turnstileToken; input.dispatchEvent(new Event('input', { bubbles: true })); input.dispatchEvent(new Event('change', { bubbles: true })); } const form = document.querySelector('form'); if (form) { form.submit(); } }, token); } /** * token 注入后等待过盾;若仍停留在挑战页则 reload 干净业务 URL * @param {import('puppeteer').Page} page * @param {string} cleanUrl * @param {(page: import('puppeteer').Page) => Promise} [waitForUrlSettled] * @param {number} [timeoutMs] */ async function recoverAfterTokenInject(page, cleanUrl, waitForUrlSettled, timeoutMs = 45000) { if (await waitForChallengeUrlClear(page, Math.min(timeoutMs, 12000))) { return true; } if (!cleanUrl) { return false; } console.log(`[CF] token 注入后仍处挑战页,reload 干净 URL: ${cleanUrl}`); await page.goto(cleanUrl, { waitUntil: 'domcontentloaded', timeout: Math.min(timeoutMs, 60000) }).catch(() => {}); if (typeof waitForUrlSettled === 'function') { await waitForUrlSettled(page).catch(() => {}); } return waitForChallengeUrlClear(page, timeoutMs); } module.exports = { waitForTurnstile, waitForTurnstileSurface, extractTurnstileSitekey, extractSitekeyFromFrame, extractSitekeyFromChallengeUrl, extractSitekeyFromHtml, injectTurnstileToken, waitForChallengeUrlClear, recoverAfterTokenInject, };