Files
links/patches/puppeteer-api/lib/turnstile-handler.js
T
2026-07-01 01:26:27 +08:00

188 lines
6.0 KiB
JavaScript
Executable File

/**
* Turnstile 内置求解:等待 widget 自动完成 + token 轮询 + 多 frame sitekey 提取
*/
const { detectCloudflare, isTurnstileSolved, urlIndicatesCfChallenge } = require('./cf-detector');
/**
* @typedef {Object} TurnstileResult
* @property {boolean} success
* @property {string} stage built_in|timeout|already_clear
* @property {number} elapsedMs
*/
/**
* 在单个 frame 内提取 sitekey
* @param {import('puppeteer').Frame} frame
* @returns {Promise<string|null>}
*/
async function extractSitekeyFromFrame(frame) {
return frame.evaluate(() => {
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"]');
for (const iframe of iframes) {
const src = iframe.getAttribute('src') || '';
const match = src.match(/[?&]sitekey=([^&]+)/i);
if (match) {
return decodeURIComponent(match[1]);
}
}
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);
if (match) {
return match[1];
}
}
return null;
}).catch(() => null);
}
/**
* 从主文档及所有子 frame 提取 Turnstile sitekey
* @param {import('puppeteer').Page} page
* @returns {Promise<string|null>}
*/
async function extractTurnstileSitekey(page) {
const mainKey = await extractSitekeyFromFrame(page.mainFrame());
if (mainKey) {
return mainKey;
}
for (const frame of page.frames()) {
if (frame === page.mainFrame()) {
continue;
}
const frameKey = await extractSitekeyFromFrame(frame);
if (frameKey) {
return frameKey;
}
}
return null;
}
/**
* Captcha API 调用前:等待 Turnstile 挑战面出现或 URL 进入 CF 挑战态
*
* @param {import('puppeteer').Page} page
* @param {number} [timeoutMs]
* @returns {Promise<string|null>} 若已提取到 sitekey 则返回,否则 null
*/
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, 8)}...)`);
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"]'
);
}).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, useBuiltInClick?: boolean }} [options]
* @returns {Promise<TurnstileResult>}
*/
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()) && state.hasCfClearance) {
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 };
}
/**
* 将 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);
}
module.exports = {
waitForTurnstile,
waitForTurnstileSurface,
extractTurnstileSitekey,
extractSitekeyFromFrame,
injectTurnstileToken,
};