2026-06-20 04:47:34 +08:00
|
|
|
|
/**
|
2026-07-01 01:26:27 +08:00
|
|
|
|
* Turnstile 内置求解:等待 widget 自动完成 + token 轮询 + 多 frame sitekey 提取
|
2026-07-03 20:12:20 +08:00
|
|
|
|
*
|
|
|
|
|
|
* 支持新版 challenge-platform iframe:sitekey 在 URL 路径 /rch/{id}/{sitekey}/ 中
|
2026-06-20 04:47:34 +08:00
|
|
|
|
*/
|
2026-06-29 04:54:41 +08:00
|
|
|
|
const { detectCloudflare, isTurnstileSolved, urlIndicatesCfChallenge } = require('./cf-detector');
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-03 20:12:20 +08:00
|
|
|
|
* 从 challenge-platform / Turnstile URL 解析 sitekey(Node 侧,可读 frame.url())
|
|
|
|
|
|
* @param {string} url
|
|
|
|
|
|
* @returns {string|null}
|
2026-06-20 04:47:34 +08:00
|
|
|
|
*/
|
2026-07-03 20:12:20 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
2026-06-20 04:47:34 +08:00
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
/**
|
2026-07-03 20:12:20 +08:00
|
|
|
|
* 在单个 frame 内提取 sitekey(主文档 DOM + iframe src 属性)
|
2026-07-01 01:26:27 +08:00
|
|
|
|
* @param {import('puppeteer').Frame} frame
|
|
|
|
|
|
* @returns {Promise<string|null>}
|
|
|
|
|
|
*/
|
|
|
|
|
|
async function extractSitekeyFromFrame(frame) {
|
|
|
|
|
|
return frame.evaluate(() => {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
const widget = document.querySelector('.cf-turnstile[data-sitekey], [data-sitekey]');
|
|
|
|
|
|
if (widget) {
|
|
|
|
|
|
const key = widget.getAttribute('data-sitekey');
|
|
|
|
|
|
if (key) {
|
|
|
|
|
|
return key;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const iframes = document.querySelectorAll(
|
|
|
|
|
|
'iframe[src*="challenges.cloudflare.com"], iframe[src*="challenge-platform"], iframe#cf-chl-widget'
|
|
|
|
|
|
);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
for (const iframe of iframes) {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const fromSrc = extractFromSrc(iframe.getAttribute('src') || '');
|
|
|
|
|
|
if (fromSrc) {
|
|
|
|
|
|
return fromSrc;
|
2026-07-01 01:26:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
2026-07-03 20:12:20 +08:00
|
|
|
|
|| text.match(/data-sitekey=['"]([0-9x_a-zA-Z-]{10,})['"]/i)
|
|
|
|
|
|
|| text.match(/\/(0x4[A-Za-z0-9_-]{10,})\//i);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
if (match) {
|
|
|
|
|
|
return match[1];
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}).catch(() => null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从整页 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;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 从主文档及所有子 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()) {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
const fromFrameUrl = extractSitekeyFromChallengeUrl(frame.url());
|
|
|
|
|
|
if (fromFrameUrl) {
|
|
|
|
|
|
return fromFrameUrl;
|
|
|
|
|
|
}
|
2026-07-01 01:26:27 +08:00
|
|
|
|
if (frame === page.mainFrame()) {
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
const frameKey = await extractSitekeyFromFrame(frame);
|
|
|
|
|
|
if (frameKey) {
|
|
|
|
|
|
return frameKey;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
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;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-01 01:26:27 +08:00
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
|
* Captcha API 调用前:等待 Turnstile 挑战面出现或 URL 进入 CF 挑战态
|
|
|
|
|
|
* @param {import('puppeteer').Page} page
|
|
|
|
|
|
* @param {number} [timeoutMs]
|
2026-07-03 20:12:20 +08:00
|
|
|
|
* @returns {Promise<string|null>}
|
2026-07-01 01:26:27 +08:00
|
|
|
|
*/
|
|
|
|
|
|
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) {
|
2026-07-03 20:12:20 +08:00
|
|
|
|
console.log(`[CF] 已检测到 Turnstile sitekey (${sitekey.slice(0, 12)}...)`);
|
2026-07-01 01:26:27 +08:00
|
|
|
|
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"], '
|
2026-07-03 20:12:20 +08:00
|
|
|
|
+ 'iframe[src*="challenges.cloudflare.com"], iframe[src*="challenge-platform"], '
|
|
|
|
|
|
+ 'iframe[id^="cf-chl-widget"]'
|
2026-07-01 01:26:27 +08:00
|
|
|
|
);
|
|
|
|
|
|
}).catch(() => false);
|
|
|
|
|
|
|
|
|
|
|
|
if (hasSurface) {
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
|
|
|
|
continue;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
await new Promise((r) => setTimeout(r, pollMs));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* 等待 Turnstile 通过(内置点击 / 自动跳转)
|
|
|
|
|
|
* @param {import('puppeteer').Page} page
|
2026-07-03 20:12:20 +08:00
|
|
|
|
* @param {{ timeoutMs?: number, pollMs?: number }} [options]
|
2026-06-20 04:47:34 +08:00
|
|
|
|
*/
|
|
|
|
|
|
async function waitForTurnstile(page, options = {}) {
|
|
|
|
|
|
const timeoutMs = options.timeoutMs ?? 60000;
|
|
|
|
|
|
const pollMs = options.pollMs ?? 500;
|
|
|
|
|
|
const started = Date.now();
|
|
|
|
|
|
|
|
|
|
|
|
const initial = await detectCloudflare(page);
|
2026-06-29 04:54:41 +08:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
2026-06-20 04:47:34 +08:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
while (Date.now() - started < timeoutMs) {
|
2026-06-29 04:54:41 +08:00
|
|
|
|
if (await isTurnstileSolved(page) && !urlIndicatesCfChallenge(page.url())) {
|
2026-06-20 04:47:34 +08:00
|
|
|
|
return { success: true, stage: 'built_in', elapsedMs: Date.now() - started };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const state = await detectCloudflare(page);
|
2026-07-03 20:12:20 +08:00
|
|
|
|
if (!state.blocked && !urlIndicatesCfChallenge(page.url())) {
|
2026-06-20 04:47:34 +08:00
|
|
|
|
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 };
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
/**
|
2026-07-03 20:12:20 +08:00
|
|
|
|
* 等待页面离开 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 注入页面并尝试触发提交
|
2026-06-20 04:47:34 +08:00
|
|
|
|
* @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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-07-03 20:12:20 +08:00
|
|
|
|
/**
|
|
|
|
|
|
* token 注入后等待过盾;若仍停留在挑战页则 reload 干净业务 URL
|
|
|
|
|
|
* @param {import('puppeteer').Page} page
|
|
|
|
|
|
* @param {string} cleanUrl
|
|
|
|
|
|
* @param {(page: import('puppeteer').Page) => Promise<string>} [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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-06-20 04:47:34 +08:00
|
|
|
|
module.exports = {
|
|
|
|
|
|
waitForTurnstile,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
waitForTurnstileSurface,
|
2026-06-20 04:47:34 +08:00
|
|
|
|
extractTurnstileSitekey,
|
2026-07-01 01:26:27 +08:00
|
|
|
|
extractSitekeyFromFrame,
|
2026-07-03 20:12:20 +08:00
|
|
|
|
extractSitekeyFromChallengeUrl,
|
|
|
|
|
|
extractSitekeyFromHtml,
|
2026-06-20 04:47:34 +08:00
|
|
|
|
injectTurnstileToken,
|
2026-07-03 20:12:20 +08:00
|
|
|
|
waitForChallengeUrlClear,
|
|
|
|
|
|
recoverAfterTokenInject,
|
2026-06-20 04:47:34 +08:00
|
|
|
|
};
|