添加whatshub工单

This commit is contained in:
root
2026-06-20 04:47:34 +08:00
parent a6c87e8e25
commit d5a0ffa6db
271 changed files with 9377 additions and 303 deletions
+160
View File
@@ -0,0 +1,160 @@
/**
* BrowserFactorystandard / real 双轨启动 + 独立并发限流
*/
const {
DEFAULT_UA,
MAX_CONCURRENT_BROWSERS,
MAX_CONCURRENT_BROWSERS_REAL,
QUEUE_TIMEOUT_MS,
} = require('./constants');
const { BrowserConcurrencyLimiter } = require('./concurrency');
const { launchStandardBrowser } = require('./launch-standard');
const { launchRealBrowser, isRealBrowserAvailable } = require('./launch-real-browser');
const standardLimiter = new BrowserConcurrencyLimiter(MAX_CONCURRENT_BROWSERS);
const realLimiter = new BrowserConcurrencyLimiter(MAX_CONCURRENT_BROWSERS_REAL);
/**
* @typedef {Object} AntiBotConfig
* @property {boolean} [enabled]
* @property {'standard'|'real'} [profile]
* @property {boolean} [turnstile]
* @property {boolean} [solverFallback]
* @property {string} [sessionKey]
* @property {number} [challengeTimeoutMs]
*/
/**
* 规范化 antiBot 配置(未传则走 standard
* @param {AntiBotConfig|null|undefined} antiBot
* @returns {AntiBotConfig}
*/
function normalizeAntiBot(antiBot) {
if (!antiBot || !antiBot.enabled) {
return { enabled: false, profile: 'standard' };
}
return {
enabled: true,
profile: antiBot.profile === 'real' ? 'real' : 'standard',
turnstile: antiBot.turnstile !== false,
solverFallback: antiBot.solverFallback !== false,
sessionKey: antiBot.sessionKey || '',
challengeTimeoutMs: antiBot.challengeTimeoutMs || 60000,
};
}
/**
* 按 profile 获取限流器
* @param {'standard'|'real'} profile
*/
function getLimiter(profile) {
return profile === 'real' ? realLimiter : standardLimiter;
}
/**
* 创建 Browser 会话
* @param {{ profile?: 'standard'|'real', extraArgs?: string[] }} options
* @returns {Promise<{ browser: import('puppeteer').Browser, page: import('puppeteer').Page|null, profile: string, cleanup: () => Promise<void> }>}
*/
async function createBrowserSession(options = {}) {
const profile = options.profile === 'real' ? 'real' : 'standard';
const extraArgs = options.extraArgs || [];
if (profile === 'real') {
const { browser, page } = await launchRealBrowser(extraArgs);
return {
browser,
page,
profile: 'real',
cleanup: async () => {
try {
await browser.close();
} catch (_) {
/* ignore */
}
},
};
}
const browser = await launchStandardBrowser(extraArgs);
return {
browser,
page: null,
profile: 'standard',
cleanup: async () => {
try {
await browser.close();
} catch (_) {
/* ignore */
}
},
};
}
/**
* 在并发槽位内执行 Browser 任务
* @template T
* @param {string} taskName
* @param {'standard'|'real'} profile
* @param {() => Promise<T>} fn
* @returns {Promise<T>}
*/
async function runWithProfileLimit(taskName, profile, fn) {
const limiter = getLimiter(profile);
try {
return await limiter.run(taskName, fn);
} catch (err) {
if (err.message === 'QUEUE_TIMEOUT') err.code = 'QUEUE_TIMEOUT';
throw err;
}
}
/**
* 创建 Page 并设置 UA / viewport / cookies
* @param {import('puppeteer').Browser} browser
* @param {import('puppeteer').Page|null} existingPage
* @param {{ userAgent?: string, viewport?: object, cookies?: Array<object> }} [options]
*/
async function createManagedPage(browser, existingPage, options = {}) {
const page = existingPage || await browser.newPage();
page.setDefaultNavigationTimeout(30000);
page.setDefaultTimeout(15000);
const userAgent = options.userAgent || DEFAULT_UA;
await page.setUserAgent(userAgent);
if (options.viewport) {
await page.setViewport(options.viewport);
}
if (options.cookies && options.cookies.length > 0) {
await page.setCookie(...options.cookies);
}
page.on('error', (err) => console.error('[Page Error]', err.message));
page.on('pageerror', (err) => console.error('[Page JS Error]', err.message));
return page;
}
/**
* @returns {{ standard: object, real: object, realBrowserReady: boolean }}
*/
function getFactoryStats() {
return {
standard: standardLimiter.getStats(),
real: realLimiter.getStats(),
realBrowserReady: isRealBrowserAvailable(),
};
}
module.exports = {
normalizeAntiBot,
getLimiter,
createBrowserSession,
runWithProfileLimit,
createManagedPage,
getFactoryStats,
standardLimiter,
realLimiter,
QUEUE_TIMEOUT_MS,
};
+153
View File
@@ -0,0 +1,153 @@
/**
* 付费 Captcha API 兜底:CapSolver / 2Captcha 统一 Turnstile 求解接口
*/
const {
CAPTCHA_PROVIDER,
CAPTCHA_API_KEY,
CAPTCHA_MAX_WAIT_MS,
} = require('./constants');
/**
* @param {string} url
* @param {Record<string, string>} [headers]
* @returns {Promise<any>}
*/
async function httpJsonPost(url, body, headers = {}) {
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json', ...headers },
body: JSON.stringify(body),
});
const text = await res.text();
try {
return JSON.parse(text);
} catch (_) {
throw new Error(`Captcha API 返回非 JSON: ${text.slice(0, 200)}`);
}
}
/**
* @param {string} url
* @returns {Promise<any>}
*/
async function httpJsonGet(url) {
const res = await fetch(url);
const text = await res.text();
try {
return JSON.parse(text);
} catch (_) {
throw new Error(`Captcha API 返回非 JSON: ${text.slice(0, 200)}`);
}
}
/**
* CapSolver Turnstile 求解
* @param {{ pageUrl: string, sitekey: string, apiKey: string }} params
* @returns {Promise<string>}
*/
async function solveViaCapSolver({ pageUrl, sitekey, apiKey }) {
const create = await httpJsonPost('https://api.capsolver.com/createTask', {
clientKey: apiKey,
task: {
type: 'AntiTurnstileTaskProxyLess',
websiteURL: pageUrl,
websiteKey: sitekey,
},
});
if (create.errorId !== 0 || !create.taskId) {
throw new Error(`CapSolver createTask 失败: ${create.errorDescription || create.errorCode || 'unknown'}`);
}
const deadline = Date.now() + CAPTCHA_MAX_WAIT_MS;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 3000));
const result = await httpJsonPost('https://api.capsolver.com/getTaskResult', {
clientKey: apiKey,
taskId: create.taskId,
});
if (result.status === 'ready' && result.solution && result.solution.token) {
return result.solution.token;
}
if (result.errorId !== 0) {
throw new Error(`CapSolver getTaskResult 失败: ${result.errorDescription || result.errorCode}`);
}
}
throw new Error('CapSolver 求解超时');
}
/**
* 2Captcha Turnstile 求解
* @param {{ pageUrl: string, sitekey: string, apiKey: string }} params
* @returns {Promise<string>}
*/
async function solveVia2Captcha({ pageUrl, sitekey, apiKey }) {
const params = new URLSearchParams({
key: apiKey,
method: 'turnstile',
sitekey,
pageurl: pageUrl,
json: '1',
});
const create = await httpJsonGet(`https://2captcha.com/in.php?${params.toString()}`);
if (create.status !== 1 || !create.request) {
throw new Error(`2Captcha in.php 失败: ${create.request || create.error_text || 'unknown'}`);
}
const taskId = create.request;
const deadline = Date.now() + CAPTCHA_MAX_WAIT_MS;
while (Date.now() < deadline) {
await new Promise((r) => setTimeout(r, 5000));
const result = await httpJsonGet(
`https://2captcha.com/res.php?key=${encodeURIComponent(apiKey)}&action=get&id=${encodeURIComponent(taskId)}&json=1`
);
if (result.status === 1 && result.request) {
return result.request;
}
if (result.request && result.request !== 'CAPCHA_NOT_READY') {
throw new Error(`2Captcha res.php 失败: ${result.request}`);
}
}
throw new Error('2Captcha 求解超时');
}
/**
* 统一 Turnstile 求解入口
* @param {{ pageUrl: string, sitekey: string, provider?: string, apiKey?: string }} params
* @returns {Promise<{ token: string, provider: string }>}
*/
async function solveTurnstile({ pageUrl, sitekey, provider, apiKey }) {
const resolvedProvider = (provider || CAPTCHA_PROVIDER).toLowerCase();
const resolvedKey = apiKey || CAPTCHA_API_KEY;
if (!sitekey) {
throw new Error('无法提取 Turnstile sitekey');
}
if (!resolvedKey) {
throw new Error('未配置 CAPTCHA_API_KEY,无法使用付费 Captcha 兜底');
}
let token;
if (resolvedProvider === '2captcha') {
token = await solveVia2Captcha({ pageUrl, sitekey, apiKey: resolvedKey });
} else {
token = await solveViaCapSolver({ pageUrl, sitekey, apiKey: resolvedKey });
}
return { token, provider: resolvedProvider };
}
/**
* Captcha API 是否已配置
* @returns {boolean}
*/
function isCaptchaConfigured() {
return CAPTCHA_API_KEY.length > 0;
}
module.exports = {
solveTurnstile,
isCaptchaConfigured,
};
+97
View File
@@ -0,0 +1,97 @@
/**
* Cloudflare / Turnstile 挑战页检测
*/
/**
* @typedef {Object} CloudflareState
* @property {boolean} blocked 是否处于 CF 挑战中
* @property {string|null} challengeType turnstile|js_challenge|unknown|null
* @property {boolean} hasCfClearance 是否已有 cf_clearance cookie
* @property {string} url 当前页面 URL
* @property {string} title 页面 title
*/
/**
* 检测页面是否被 Cloudflare 拦截
* @param {import('puppeteer').Page} page
* @returns {Promise<CloudflareState>}
*/
async function detectCloudflare(page) {
const url = page.url();
const title = await page.title().catch(() => '');
const cookies = await page.cookies().catch(() => []);
const hasCfClearance = cookies.some((c) => c.name === 'cf_clearance');
const domSignals = await page.evaluate(() => {
const hasTurnstileInput = !!document.querySelector('[name="cf-turnstile-response"]');
const hasTurnstileWidget = !!document.querySelector('.cf-turnstile, [data-sitekey]');
const hasChallengeRunning = !!document.querySelector('#challenge-running, #cf-challenge-running');
const titleText = document.title || '';
const bodyText = (document.body && document.body.innerText) ? document.body.innerText.slice(0, 500) : '';
return {
hasTurnstileInput,
hasTurnstileWidget,
hasChallengeRunning,
titleHasJustAMoment: titleText.includes('Just a moment'),
bodyHasJustAMoment: bodyText.includes('Just a moment') || bodyText.includes('Checking your browser'),
};
}).catch(() => ({
hasTurnstileInput: false,
hasTurnstileWidget: false,
hasChallengeRunning: false,
titleHasJustAMoment: false,
bodyHasJustAMoment: false,
}));
const urlChallenge = url.includes('/cdn-cgi/challenge-platform') || url.includes('/cdn-cgi/challenge');
let challengeType = null;
if (domSignals.hasTurnstileInput || domSignals.hasTurnstileWidget) {
challengeType = 'turnstile';
} else if (urlChallenge || domSignals.titleHasJustAMoment || domSignals.bodyHasJustAMoment) {
challengeType = 'js_challenge';
}
const blocked = !hasCfClearance && (
urlChallenge
|| domSignals.titleHasJustAMoment
|| domSignals.bodyHasJustAMoment
|| domSignals.hasTurnstileInput
|| domSignals.hasTurnstileWidget
|| domSignals.hasChallengeRunning
|| title.includes('Just a moment')
);
return {
blocked,
challengeType,
hasCfClearance,
url,
title,
};
}
/**
* 检测 Turnstile 是否已通过
* @param {import('puppeteer').Page} page
* @returns {Promise<boolean>}
*/
async function isTurnstileSolved(page) {
const cookies = await page.cookies().catch(() => []);
if (cookies.some((c) => c.name === 'cf_clearance')) {
return true;
}
const tokenLen = await page.evaluate(() => {
const input = document.querySelector('[name="cf-turnstile-response"]');
return input && input.value ? input.value.length : 0;
}).catch(() => 0);
return tokenLen > 20;
}
module.exports = {
detectCloudflare,
isTurnstileSolved,
};
+121
View File
@@ -0,0 +1,121 @@
/**
* Cloudflare 挑战统一处理:检测 → 内置等待 → Captcha API 兜底
*/
const { detectCloudflare } = require('./cf-detector');
const { waitForTurnstile, extractTurnstileSitekey, injectTurnstileToken } = require('./turnstile-handler');
const { solveTurnstile, isCaptchaConfigured } = require('./captcha-solver');
/**
* @typedef {Object} CfHandleResult
* @property {boolean} success
* @property {string|null} code
* @property {string|null} challengeType
* @property {string|null} stage
* @property {boolean} solverUsed
* @property {number} elapsedMs
* @property {boolean} cfDetected
*/
/**
* 处理 Cloudflare / Turnstile 挑战(在 authActions 之前调用)
* @param {import('puppeteer').Page} page
* @param {{ turnstile?: boolean, solverFallback?: boolean, challengeTimeoutMs?: number }} antiBot
* @param {(page: import('puppeteer').Page) => Promise<string>} [waitForUrlSettled]
* @returns {Promise<CfHandleResult>}
*/
async function handleCloudflareChallenge(page, antiBot, waitForUrlSettled) {
const started = Date.now();
const timeoutMs = antiBot.challengeTimeoutMs || 60000;
let cfState = await detectCloudflare(page);
if (!cfState.blocked) {
return {
success: true,
code: null,
challengeType: null,
stage: null,
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: false,
};
}
console.log(`[CF] 检测到挑战 type=${cfState.challengeType} url=${cfState.url}`);
if (antiBot.turnstile !== false) {
const builtIn = await waitForTurnstile(page, { timeoutMs, useBuiltInClick: true });
if (builtIn.success) {
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
cfState = await detectCloudflare(page);
if (!cfState.blocked) {
return {
success: true,
code: null,
challengeType: cfState.challengeType,
stage: 'built_in',
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
}
}
if (antiBot.solverFallback !== false && isCaptchaConfigured()) {
try {
const sitekey = await extractTurnstileSitekey(page);
const pageUrl = page.url();
const { token, provider } = await solveTurnstile({ pageUrl, sitekey });
console.log(`[CF] Captcha API (${provider}) 返回 token,注入页面`);
await injectTurnstileToken(page, token);
const apiWaitMs = Math.min(timeoutMs, 30000);
await waitForTurnstile(page, { timeoutMs: apiWaitMs });
if (waitForUrlSettled) {
await waitForUrlSettled(page).catch(() => {});
}
cfState = await detectCloudflare(page);
if (!cfState.blocked) {
return {
success: true,
code: null,
challengeType: 'turnstile',
stage: 'api',
solverUsed: true,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
} catch (apiErr) {
console.error('[CF] Captcha API 兜底失败:', apiErr.message);
return {
success: false,
code: 'CF_TURNSTILE_FAILED',
challengeType: cfState.challengeType || 'turnstile',
stage: 'api',
solverUsed: true,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
}
return {
success: false,
code: 'CF_TURNSTILE_FAILED',
challengeType: cfState.challengeType || 'unknown',
stage: antiBot.solverFallback !== false && !isCaptchaConfigured() ? 'built_in' : 'timeout',
solverUsed: false,
elapsedMs: Date.now() - started,
cfDetected: true,
};
}
module.exports = {
handleCloudflareChallenge,
isCaptchaConfigured,
};
+82
View File
@@ -0,0 +1,82 @@
/**
* Browser 并发槽位限流器:standard / real 各自独立队列
*/
const { QUEUE_TIMEOUT_MS } = require('./constants');
class BrowserConcurrencyLimiter {
/**
* @param {number} max 最大并发 Browser 数
*/
constructor(max) {
this.max = max;
this.active = 0;
this.waiters = [];
}
/** @returns {{ max: number, active: number, queued: number }} */
getStats() {
return { max: this.max, active: this.active, queued: this.waiters.length };
}
/**
* @param {string} taskName
* @param {number} [timeoutMs]
*/
async acquire(taskName, timeoutMs = QUEUE_TIMEOUT_MS) {
if (this.active < this.max) {
this.active++;
console.log(`[并发槽位] ${taskName} 立即获取 (${this.active}/${this.max})`);
return;
}
console.log(`[并发槽位] ${taskName} 排队等待 (队列 ${this.waiters.length + 1})`);
await new Promise((resolve, reject) => {
const entry = {
resolve: () => {
this.active++;
console.log(`[并发槽位] ${taskName} 出队执行 (${this.active}/${this.max})`);
resolve();
},
reject,
};
if (timeoutMs > 0) {
entry.timer = setTimeout(() => {
const idx = this.waiters.indexOf(entry);
if (idx >= 0) {
this.waiters.splice(idx, 1);
reject(new Error('QUEUE_TIMEOUT'));
}
}, timeoutMs);
}
this.waiters.push(entry);
});
}
/** @param {string} taskName */
release(taskName) {
this.active = Math.max(0, this.active - 1);
const next = this.waiters.shift();
if (next) {
if (next.timer) clearTimeout(next.timer);
next.resolve();
} else {
console.log(`[并发槽位] ${taskName} 释放 (${this.active}/${this.max})`);
}
}
/**
* @template T
* @param {string} taskName
* @param {() => Promise<T>} fn
* @returns {Promise<T>}
*/
async run(taskName, fn) {
await this.acquire(taskName);
try {
return await fn();
} finally {
this.release(taskName);
}
}
}
module.exports = { BrowserConcurrencyLimiter };
+140
View File
@@ -0,0 +1,140 @@
/**
* puppeteer-api 共享常量与 Chrome 启动环境工具
*/
const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer-extra');
/** 默认 Chrome 可执行文件路径(生产环境可通过环境变量覆盖) */
const DEFAULT_CHROME_EXECUTABLE = '/www/wwwroot/puppeteer-api/.cache/puppeteer/chrome/linux-149.0.7827.22/chrome-linux64/chrome';
/** 默认 User-Agent,与 standard / real 双轨保持一致 */
const DEFAULT_UA = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
/** Chrome 启动基础参数:多实例 headless 场景下复用 */
const BASE_LAUNCH_ARGS = [
'--no-sandbox',
'--disable-setuid-sandbox',
'--disable-dev-shm-usage',
'--disable-gpu',
'--disable-extensions',
'--disable-background-networking',
'--disable-software-rasterizer',
'--no-first-run',
'--no-default-browser-check',
'--disable-breakpad',
'--disable-crash-reporter',
];
/** puppeteer-api 专用运行时目录(与 server.js 同级 .runtime */
const RUNTIME_DIR = path.join(__dirname, '..', '.runtime');
/**
* 确保 .runtime 子目录存在且 www 用户可写
* @param {string} name
* @returns {string}
*/
function ensureRuntimeSubdir(name) {
const dir = path.join(RUNTIME_DIR, name);
fs.mkdirSync(dir, { recursive: true, mode: 0o777 });
return dir;
}
/**
* 解析 Chrome 可执行文件路径
* @returns {string}
*/
function resolveChromeExecutable() {
const candidates = [
process.env.PUPPETEER_EXECUTABLE_PATH,
process.env.CHROME_EXECUTABLE,
DEFAULT_CHROME_EXECUTABLE,
].filter(Boolean);
for (const candidate of candidates) {
if (fs.existsSync(candidate)) {
return candidate;
}
}
try {
const bundled = puppeteer.executablePath();
if (bundled && fs.existsSync(bundled)) {
return bundled;
}
} catch (_) {
/* puppeteer 未内置浏览器时忽略 */
}
return candidates[0] || DEFAULT_CHROME_EXECUTABLE;
}
/**
* 生产 Linux 常注入无效 DBUS 地址;删除而非设为 /dev/null
* @returns {NodeJS.ProcessEnv}
*/
function buildBrowserLaunchEnv() {
const env = { ...process.env };
delete env.DBUS_SESSION_BUS_ADDRESS;
delete env.DBUS_SYSTEM_BUS_ADDRESS;
const xdgRuntime = ensureRuntimeSubdir('xdg-runtime');
env.XDG_RUNTIME_DIR = xdgRuntime;
env.XDG_CONFIG_HOME = ensureRuntimeSubdir('xdg-config');
env.XDG_CACHE_HOME = ensureRuntimeSubdir('xdg-cache');
env.TMPDIR = ensureRuntimeSubdir('tmp');
return env;
}
/**
* 校验 Chrome 可执行文件存在
* @returns {string}
*/
function assertChromeExecutable() {
const executablePath = resolveChromeExecutable();
if (!fs.existsSync(executablePath)) {
throw new Error(
`Chrome 可执行文件不存在: ${executablePath}。请在 puppeteer-api 目录执行: npx puppeteer browsers install chrome`
);
}
return executablePath;
}
/** 并发与超时配置(可通过环境变量覆盖) */
const MAX_CONCURRENT_BROWSERS = Math.max(1, parseInt(process.env.MAX_CONCURRENT_BROWSERS || '4', 10));
const MAX_CONCURRENT_BROWSERS_REAL = Math.max(1, parseInt(process.env.MAX_CONCURRENT_BROWSERS_REAL || '2', 10));
const QUEUE_TIMEOUT_MS = Math.max(5000, parseInt(process.env.QUEUE_TIMEOUT_MS || '120000', 10));
const API_INTERCEPT_TIMEOUT_MS = Math.max(10000, parseInt(process.env.API_INTERCEPT_TIMEOUT_MS || '30000', 10));
const API_INTERCEPT_TIMEOUT_REAL_MS = Math.max(
API_INTERCEPT_TIMEOUT_MS,
parseInt(process.env.API_INTERCEPT_TIMEOUT_REAL_MS || '90000', 10)
);
/** Captcha API 配置 */
const CAPTCHA_PROVIDER = (process.env.CAPTCHA_PROVIDER || 'capsolver').toLowerCase();
const CAPTCHA_API_KEY = process.env.CAPTCHA_API_KEY || '';
const CAPTCHA_MAX_WAIT_MS = Math.max(30000, parseInt(process.env.CAPTCHA_MAX_WAIT_MS || '120000', 10));
/** 会话 Cookie 持久化 TTL(毫秒) */
const SESSION_TTL_MS = Math.max(60000, parseInt(process.env.SESSION_TTL_MS || '1800000', 10));
module.exports = {
DEFAULT_CHROME_EXECUTABLE,
DEFAULT_UA,
BASE_LAUNCH_ARGS,
RUNTIME_DIR,
ensureRuntimeSubdir,
resolveChromeExecutable,
buildBrowserLaunchEnv,
assertChromeExecutable,
MAX_CONCURRENT_BROWSERS,
MAX_CONCURRENT_BROWSERS_REAL,
QUEUE_TIMEOUT_MS,
API_INTERCEPT_TIMEOUT_MS,
API_INTERCEPT_TIMEOUT_REAL_MS,
CAPTCHA_PROVIDER,
CAPTCHA_API_KEY,
CAPTCHA_MAX_WAIT_MS,
SESSION_TTL_MS,
};
+70
View File
@@ -0,0 +1,70 @@
/**
* Real Browser 启动:puppeteer-real-browser(抗 Cloudflare Turnstile
* 注意:不与 puppeteer-extra 混用在同一 Browser 实例
*/
const {
BASE_LAUNCH_ARGS,
resolveChromeExecutable,
} = require('./constants');
/** @type {boolean|null} */
let realBrowserModuleChecked = null;
/** @type {boolean} */
let realBrowserAvailable = false;
/**
* 检测 puppeteer-real-browser 是否已安装
* @returns {boolean}
*/
function isRealBrowserAvailable() {
if (realBrowserModuleChecked !== null) {
return realBrowserAvailable;
}
try {
require.resolve('puppeteer-real-browser');
realBrowserAvailable = true;
} catch (_) {
realBrowserAvailable = false;
}
realBrowserModuleChecked = true;
return realBrowserAvailable;
}
/**
* 启动 real profile Browserheadless:false + Xvfb + turnstile 内置点击)
* @param {string[]} [extraArgs]
* @returns {Promise<{ browser: import('puppeteer').Browser, page: import('puppeteer').Page }>}
*/
async function launchRealBrowser(extraArgs = []) {
if (!isRealBrowserAvailable()) {
throw new Error(
'puppeteer-real-browser 未安装。请在 puppeteer-api 目录执行: npm install puppeteer-real-browser@1.4.4'
);
}
const { connect } = require('puppeteer-real-browser');
const chromePath = resolveChromeExecutable();
const result = await connect({
headless: false,
turnstile: true,
disableXvfb: false,
customConfig: { chromePath },
args: [...BASE_LAUNCH_ARGS, '--mute-audio', ...extraArgs],
});
const browser = result.browser;
const page = result.page;
if (!browser || !page) {
throw new Error('puppeteer-real-browser connect 未返回 browser/page');
}
return { browser, page };
}
module.exports = {
isRealBrowserAvailable,
launchRealBrowser,
};
+58
View File
@@ -0,0 +1,58 @@
/**
* Standard Browser 启动:puppeteer-extra + StealthPlugin
*/
const fs = require('fs');
const path = require('path');
const puppeteer = require('puppeteer-extra');
const StealthPlugin = require('puppeteer-extra-plugin-stealth');
const {
BASE_LAUNCH_ARGS,
assertChromeExecutable,
buildBrowserLaunchEnv,
ensureRuntimeSubdir,
} = require('./constants');
// Stealth 必须在 launch 之前注册
puppeteer.use(StealthPlugin());
/**
* 启动 standard profile Browser
* @param {string[]} [extraArgs]
* @returns {Promise<import('puppeteer').Browser>}
*/
async function launchStandardBrowser(extraArgs = []) {
const executablePath = assertChromeExecutable();
const userDataDir = fs.mkdtempSync(path.join(ensureRuntimeSubdir('chrome-profiles'), 'run-'));
try {
const browser = await puppeteer.launch({
executablePath,
headless: 'new',
args: [...BASE_LAUNCH_ARGS, ...extraArgs],
env: buildBrowserLaunchEnv(),
userDataDir,
});
const originalClose = browser.close.bind(browser);
browser.close = async () => {
try {
await originalClose();
} finally {
try {
fs.rmSync(userDataDir, { recursive: true, force: true });
} catch (_) {
/* 清理失败不影响主流程 */
}
}
};
return browser;
} catch (err) {
try {
fs.rmSync(userDataDir, { recursive: true, force: true });
} catch (_) {
/* ignore */
}
throw err;
}
}
module.exports = { launchStandardBrowser };
+121
View File
@@ -0,0 +1,121 @@
/**
* 会话 Cookie 持久化:按 sessionKey 保存 cf_clearance 等,降低重复过盾概率
*/
const fs = require('fs');
const path = require('path');
const crypto = require('crypto');
const { RUNTIME_DIR, SESSION_TTL_MS } = require('./constants');
const SESSIONS_DIR = path.join(RUNTIME_DIR, 'sessions');
/**
* 确保 sessions 目录存在
*/
function ensureSessionsDir() {
fs.mkdirSync(SESSIONS_DIR, { recursive: true, mode: 0o777 });
}
/**
* sessionKey 转安全文件名
* @param {string} sessionKey
* @returns {string}
*/
function keyToFilename(sessionKey) {
const hash = crypto.createHash('md5').update(sessionKey).digest('hex');
return `${hash}.json`;
}
/**
* @typedef {Object} StoredSession
* @property {string} sessionKey
* @property {number} savedAt
* @property {number} expiresAt
* @property {Array<{name:string,value:string,domain?:string,path?:string}>} cookies
*/
/**
* 加载已保存的会话 cookies
* @param {string} sessionKey
* @returns {StoredSession|null}
*/
function loadSession(sessionKey) {
if (!sessionKey) return null;
ensureSessionsDir();
const filePath = path.join(SESSIONS_DIR, keyToFilename(sessionKey));
if (!fs.existsSync(filePath)) return null;
try {
const raw = JSON.parse(fs.readFileSync(filePath, 'utf8'));
if (!raw || !Array.isArray(raw.cookies)) return null;
if (raw.expiresAt && Date.now() > raw.expiresAt) {
fs.unlinkSync(filePath);
return null;
}
return raw;
} catch (_) {
return null;
}
}
/**
* 保存会话 cookies(优先保留 CF 相关 cookie
* @param {string} sessionKey
* @param {Array<{name:string,value:string,domain?:string,path?:string}>} cookies
* @param {number} [ttlMs]
*/
function saveSession(sessionKey, cookies, ttlMs = SESSION_TTL_MS) {
if (!sessionKey || !Array.isArray(cookies) || cookies.length === 0) return;
ensureSessionsDir();
const cfRelated = cookies.filter((c) =>
['cf_clearance', '__cf_bm', 'cf_chl_2'].includes(c.name)
|| c.name.startsWith('__cf')
);
const toSave = cfRelated.length > 0 ? cfRelated : cookies;
const now = Date.now();
const payload = {
sessionKey,
savedAt: now,
expiresAt: now + ttlMs,
cookies: toSave.map((c) => ({
name: c.name,
value: c.value,
domain: c.domain,
path: c.path || '/',
})),
};
const filePath = path.join(SESSIONS_DIR, keyToFilename(sessionKey));
fs.writeFileSync(filePath, JSON.stringify(payload, null, 0), { mode: 0o666 });
}
/**
* 将会话 cookies 转为 puppeteer setCookie 格式
* @param {StoredSession|null} session
* @param {string} pageUrl
* @returns {Array<{name:string,value:string,domain?:string,path?:string,url?:string}>}
*/
function sessionCookiesForPage(session, pageUrl) {
if (!session || !Array.isArray(session.cookies)) return [];
let hostname = '';
try {
hostname = new URL(pageUrl).hostname;
} catch (_) {
return session.cookies;
}
return session.cookies.map((c) => {
const cookie = { ...c };
if (!cookie.domain && hostname) {
cookie.url = `https://${hostname}/`;
}
return cookie;
});
}
module.exports = {
loadSession,
saveSession,
sessionCookiesForPage,
};
+90
View File
@@ -0,0 +1,90 @@
/**
* Turnstile 内置求解:等待 widget 自动完成 + token 轮询
*/
const { detectCloudflare, isTurnstileSolved } = require('./cf-detector');
/**
* @typedef {Object} TurnstileResult
* @property {boolean} success
* @property {string} stage built_in|timeout|already_clear
* @property {number} elapsedMs
*/
/**
* 等待 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);
if (!initial.blocked || initial.hasCfClearance) {
return { success: true, stage: 'already_clear', elapsedMs: Date.now() - started };
}
// real browser 已开启 turnstile:true 内置点击;此处轮询等待结果
while (Date.now() - started < timeoutMs) {
if (await isTurnstileSolved(page)) {
return { success: true, stage: 'built_in', elapsedMs: Date.now() - started };
}
const state = await detectCloudflare(page);
if (!state.blocked) {
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 };
}
/**
* 从页面提取 Turnstile sitekey
* @param {import('puppeteer').Page} page
* @returns {Promise<string|null>}
*/
async function extractTurnstileSitekey(page) {
return page.evaluate(() => {
const widget = document.querySelector('.cf-turnstile[data-sitekey], [data-sitekey]');
if (widget) {
return widget.getAttribute('data-sitekey');
}
const iframe = document.querySelector('iframe[src*="challenges.cloudflare.com"]');
if (iframe && iframe.src) {
const match = iframe.src.match(/[?&]sitekey=([^&]+)/);
if (match) return decodeURIComponent(match[1]);
}
return null;
}).catch(() => null);
}
/**
* 将 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,
extractTurnstileSitekey,
injectTurnstileToken,
};