1007 lines
40 KiB
JavaScript
Executable File
1007 lines
40 KiB
JavaScript
Executable File
require('dotenv').config({ path: require('path').join(__dirname, '.env') });
|
||
|
||
const express = require('express');
|
||
const fs = require('fs');
|
||
const {
|
||
rewriteHttpsToHttpForLiteralIp,
|
||
attachHttpIpRewriteInterceptor,
|
||
} = require('./http-ip-rewrite');
|
||
const {
|
||
resolveHttpRedirects,
|
||
extractShareTokenFromUrl,
|
||
} = require('./url-resolve');
|
||
const {
|
||
DEFAULT_UA,
|
||
RUNTIME_DIR,
|
||
resolveChromeExecutable,
|
||
MAX_CONCURRENT_BROWSERS,
|
||
MAX_CONCURRENT_BROWSERS_REAL,
|
||
API_INTERCEPT_TIMEOUT_MS,
|
||
API_INTERCEPT_TIMEOUT_REAL_MS,
|
||
NAVIGATION_TIMEOUT_MS,
|
||
NAVIGATION_WAIT_UNTIL,
|
||
NAVIGATION_MAX_RETRIES,
|
||
SESSION_TTL_MS,
|
||
PERSIST_BROWSER_PROFILE,
|
||
POOL_IDLE_MS,
|
||
MAX_POOLED_BROWSERS,
|
||
} = require('./lib/constants');
|
||
const {
|
||
normalizeAntiBot,
|
||
createBrowserSession,
|
||
acquireBrowserSession,
|
||
runWithProfileLimit,
|
||
createManagedPage,
|
||
getFactoryStats,
|
||
standardLimiter,
|
||
browserPool,
|
||
} = require('./lib/browser-factory');
|
||
const { launchStandardBrowser } = require('./lib/launch-standard');
|
||
const { isRealBrowserAvailable } = require('./lib/launch-real-browser');
|
||
const { handleCloudflareChallenge, isCaptchaConfigured } = require('./lib/cf-handler');
|
||
const {
|
||
saveSession,
|
||
getSessionDebugInfo,
|
||
getSessionStats,
|
||
} = require('./lib/session-store');
|
||
const { resolveSessionContext, touchSessionIfPresent } = require('./lib/session-context');
|
||
const { getProfileStats, profileExists } = require('./lib/profile-dir');
|
||
const { runUiPagination } = require('./lib/ui-pagination-runner');
|
||
const {
|
||
awaitPostCfBusinessReady,
|
||
executeAuthActionsWithDiagnostics,
|
||
} = require('./lib/post-cf-ready');
|
||
|
||
const app = express();
|
||
app.use(express.json({ limit: '50mb' }));
|
||
|
||
let shuttingDown = false;
|
||
|
||
/** HTTP 层:槽位排队 + 队列超时/关闭中 的统一错误响应 */
|
||
async function runBrowserTask(taskName, handler, profile = 'standard') {
|
||
if (shuttingDown) {
|
||
const err = new Error('SERVICE_SHUTTING_DOWN');
|
||
err.code = 'SERVICE_SHUTTING_DOWN';
|
||
throw err;
|
||
}
|
||
return runWithProfileLimit(taskName, profile, handler);
|
||
}
|
||
|
||
function sendTaskError(res, error, extra = {}) {
|
||
if (error.code === 'QUEUE_TIMEOUT') {
|
||
return res.status(503).json({
|
||
success: false,
|
||
error: '服务繁忙,排队超时,请稍后重试',
|
||
queue: standardLimiter.getStats(),
|
||
...extra,
|
||
});
|
||
}
|
||
if (error.code === 'SERVICE_SHUTTING_DOWN') {
|
||
return res.status(503).json({ success: false, error: '服务正在关闭', ...extra });
|
||
}
|
||
const errType = classifyPuppeteerError(error);
|
||
console.error(`[${errType}]`, error.message);
|
||
const payload = { success: false, error: error.message, ...extra };
|
||
if (error.pageDiagnostics) {
|
||
payload.page = error.pageDiagnostics;
|
||
}
|
||
return res.status(500).json(payload);
|
||
}
|
||
|
||
async function navigateToPage(page, url) {
|
||
await withExponentialBackoff(
|
||
() => page.goto(url, { waitUntil: NAVIGATION_WAIT_UNTIL, timeout: NAVIGATION_TIMEOUT_MS }),
|
||
{ maxRetries: NAVIGATION_MAX_RETRIES, baseDelayMs: 1000 }
|
||
);
|
||
}
|
||
|
||
async function safeCloseBrowser(browser) {
|
||
if (!browser) return;
|
||
try {
|
||
const pages = await browser.pages();
|
||
await Promise.all(pages.map((p) => p.close().catch(() => {})));
|
||
await browser.close();
|
||
} catch (_) {
|
||
try {
|
||
const proc = browser.process();
|
||
if (proc && !proc.killed) proc.kill('SIGKILL');
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
}
|
||
|
||
function classifyPuppeteerError(err) {
|
||
const msg = (err?.message || '').toLowerCase();
|
||
if (err?.name === 'TimeoutError' || msg.includes('timeout')) return 'timeout';
|
||
if (msg.includes('net::') || msg.includes('network') || msg.includes('err_connection')
|
||
|| msg.includes('err_address_unreachable') || msg.includes('err_name_not_resolved')) return 'network';
|
||
if (msg.includes('navigation') || msg.includes('frame was detached')) return 'navigation';
|
||
if (msg.includes('target closed') || msg.includes('session closed')) return 'target_closed';
|
||
return 'unknown';
|
||
}
|
||
|
||
function isRetryableError(err) {
|
||
const type = classifyPuppeteerError(err);
|
||
return type === 'timeout' || type === 'network' || type === 'navigation';
|
||
}
|
||
|
||
async function withExponentialBackoff(fn, {
|
||
maxRetries = 3,
|
||
baseDelayMs = 500,
|
||
maxDelayMs = 8000,
|
||
shouldRetry = isRetryableError,
|
||
} = {}) {
|
||
let lastError;
|
||
for (let attempt = 0; attempt <= maxRetries; attempt++) {
|
||
try {
|
||
return await fn(attempt);
|
||
} catch (err) {
|
||
lastError = err;
|
||
if (attempt >= maxRetries || !shouldRetry(err)) throw err;
|
||
const delay = Math.min(baseDelayMs * Math.pow(2, attempt), maxDelayMs);
|
||
await new Promise((r) => setTimeout(r, delay));
|
||
}
|
||
}
|
||
throw lastError;
|
||
}
|
||
|
||
async function waitForUrlSettled(page, { maxAttempts = 15, stableMs = 500, redirectSettleMs = 1500 } = {}) {
|
||
let currentUrl = page.url();
|
||
let urlSettled = false;
|
||
let settleAttempts = 0;
|
||
|
||
while (!urlSettled && settleAttempts < maxAttempts) {
|
||
const urlBeforeWait = currentUrl;
|
||
await page.waitForFunction(
|
||
(expectedUrl) => window.location.href === expectedUrl,
|
||
{ timeout: stableMs, polling: 100 },
|
||
urlBeforeWait
|
||
).catch(() => {});
|
||
|
||
const newUrl = page.url();
|
||
if (newUrl === currentUrl) {
|
||
urlSettled = true;
|
||
} else {
|
||
console.log(`[路由重定向探测] 页面从 ${currentUrl} 跳转到了 ${newUrl}`);
|
||
currentUrl = newUrl;
|
||
await page.waitForNetworkIdle({ idleTime: 300, timeout: redirectSettleMs }).catch(() => {});
|
||
}
|
||
settleAttempts++;
|
||
}
|
||
return currentUrl;
|
||
}
|
||
|
||
async function executeAuthActions(page, authActions) {
|
||
if (!Array.isArray(authActions)) return;
|
||
|
||
for (const action of authActions) {
|
||
if (action.type === 'type') {
|
||
await page.waitForSelector(action.selector, { timeout: 15000 });
|
||
await page.type(action.selector, action.value, { delay: 100 });
|
||
} else if (action.type === 'click') {
|
||
await page.waitForSelector(action.selector, { timeout: 15000 });
|
||
await page.evaluate((sel) => { const el = document.querySelector(sel); if (el) el.click(); }, action.selector);
|
||
} else if (action.type === 'press') {
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
await page.keyboard.press(action.key);
|
||
} else if (action.type === 'wait') {
|
||
await new Promise((r) => setTimeout(r, action.ms));
|
||
} else if (action.type === 'vue_fill') {
|
||
await page.waitForSelector(action.selector, { timeout: 15000 });
|
||
await page.waitForFunction(
|
||
(sel) => {
|
||
const el = document.querySelector(sel);
|
||
if (!el) return false;
|
||
const rect = el.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
},
|
||
{ timeout: 2000, polling: 100 },
|
||
action.selector
|
||
).catch(() => new Promise((r) => setTimeout(r, 500)));
|
||
|
||
await page.evaluate((sel, val) => {
|
||
const inputs = document.querySelectorAll(sel);
|
||
for (const input of inputs) {
|
||
if (input.getBoundingClientRect().width > 0 && input.getBoundingClientRect().height > 0) {
|
||
input.value = val;
|
||
input.dispatchEvent(new Event('input', { bubbles: true }));
|
||
input.dispatchEvent(new Event('change', { bubbles: true }));
|
||
input.focus();
|
||
return;
|
||
}
|
||
}
|
||
}, action.selector, action.value);
|
||
} else if (action.type === 'vue_click') {
|
||
await page.waitForSelector(action.selector, { timeout: 15000 });
|
||
await page.waitForFunction(
|
||
(sel) => {
|
||
const el = document.querySelector(sel);
|
||
if (!el) return false;
|
||
const rect = el.getBoundingClientRect();
|
||
return rect.width > 0 && rect.height > 0;
|
||
},
|
||
{ timeout: 2000, polling: 100 },
|
||
action.selector
|
||
).catch(() => new Promise((r) => setTimeout(r, 500)));
|
||
|
||
await page.evaluate((sel, matchText) => {
|
||
const btns = document.querySelectorAll(sel);
|
||
for (const btn of btns) {
|
||
if (btn.getBoundingClientRect().width > 0 && btn.getBoundingClientRect().height > 0) {
|
||
let shouldClick = false;
|
||
if (matchText) {
|
||
const text = (btn.textContent || btn.innerText || '').trim();
|
||
if (text.includes(matchText.trim())) { shouldClick = true; }
|
||
} else {
|
||
shouldClick = true;
|
||
}
|
||
if (shouldClick) {
|
||
btn.scrollIntoView({ behavior: 'instant', block: 'center' });
|
||
const eventOpts = { bubbles: true, cancelable: true, view: window };
|
||
btn.dispatchEvent(new MouseEvent('mousedown', eventOpts));
|
||
btn.dispatchEvent(new MouseEvent('mouseup', eventOpts));
|
||
btn.dispatchEvent(new MouseEvent('click', eventOpts));
|
||
btn.click();
|
||
return;
|
||
}
|
||
}
|
||
}
|
||
}, action.selector, action.text);
|
||
}
|
||
}
|
||
}
|
||
|
||
function createApiInterceptor(page, apiUrls, defaultTimeoutMs = API_INTERCEPT_TIMEOUT_MS) {
|
||
const interceptedApis = {};
|
||
let responseHandler = null;
|
||
let timeoutId = null;
|
||
let resolveWait = null;
|
||
let waitPromise = null;
|
||
|
||
const tryResolveWait = () => {
|
||
if (!resolveWait || Object.keys(interceptedApis).length < apiUrls.length) {
|
||
return;
|
||
}
|
||
if (timeoutId) {
|
||
clearTimeout(timeoutId);
|
||
timeoutId = null;
|
||
}
|
||
if (responseHandler) {
|
||
page.off('response', responseHandler);
|
||
responseHandler = null;
|
||
}
|
||
const done = resolveWait;
|
||
resolveWait = null;
|
||
done();
|
||
};
|
||
|
||
const cleanup = () => {
|
||
if (timeoutId) {
|
||
clearTimeout(timeoutId);
|
||
timeoutId = null;
|
||
}
|
||
if (responseHandler) {
|
||
page.off('response', responseHandler);
|
||
responseHandler = null;
|
||
}
|
||
if (resolveWait) {
|
||
const done = resolveWait;
|
||
resolveWait = null;
|
||
done();
|
||
}
|
||
};
|
||
|
||
responseHandler = async (response) => {
|
||
const request = response.request();
|
||
if (request.method() === 'OPTIONS') return;
|
||
const matchedPath = apiUrls.find((api) => request.url().includes(api) && !interceptedApis[api]);
|
||
if (matchedPath && response.status() >= 200 && response.status() < 300) {
|
||
try {
|
||
interceptedApis[matchedPath] = {
|
||
url: request.url(),
|
||
headers: request.headers(),
|
||
data: JSON.parse(await response.text()),
|
||
};
|
||
tryResolveWait();
|
||
} catch (_) { /* JSON 解析失败跳过 */ }
|
||
}
|
||
};
|
||
page.on('response', responseHandler);
|
||
|
||
const beginWaiting = (timeoutMs = defaultTimeoutMs) => {
|
||
if (waitPromise) {
|
||
return waitPromise;
|
||
}
|
||
if (Object.keys(interceptedApis).length >= apiUrls.length) {
|
||
waitPromise = Promise.resolve();
|
||
return waitPromise;
|
||
}
|
||
waitPromise = new Promise((resolve) => {
|
||
resolveWait = resolve;
|
||
timeoutId = setTimeout(() => {
|
||
cleanup();
|
||
resolve();
|
||
}, timeoutMs);
|
||
});
|
||
return waitPromise;
|
||
};
|
||
|
||
const waitForApis = async (timeoutMs = defaultTimeoutMs) => {
|
||
await beginWaiting(timeoutMs);
|
||
return waitPromise;
|
||
};
|
||
|
||
return { interceptedApis, beginWaiting, waitForApis, cleanup };
|
||
}
|
||
|
||
async function seedShareTokenCookie(page, pageUrl) {
|
||
try {
|
||
const parsed = new URL(pageUrl);
|
||
const token = parsed.searchParams.get('token');
|
||
if (!token) return;
|
||
const cookieUrl = `http://${parsed.hostname}/`;
|
||
await page.setCookie({ name: 'share_token', value: token, url: cookieUrl });
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
|
||
function resolveShareToken(...urls) {
|
||
for (const url of urls) {
|
||
const token = extractShareTokenFromUrl(url);
|
||
if (token) return token;
|
||
}
|
||
return '';
|
||
}
|
||
|
||
function resolveInterceptTimeout(antiBot) {
|
||
const cfg = normalizeAntiBot(antiBot);
|
||
return cfg.enabled && cfg.profile === 'real'
|
||
? API_INTERCEPT_TIMEOUT_REAL_MS
|
||
: API_INTERCEPT_TIMEOUT_MS;
|
||
}
|
||
|
||
/** 并发监控:供上游调度器判断负载 */
|
||
app.get('/api/stats', (_req, res) => {
|
||
const executablePath = resolveChromeExecutable();
|
||
const factoryStats = getFactoryStats();
|
||
res.json({
|
||
success: true,
|
||
shuttingDown,
|
||
queue: factoryStats.standard,
|
||
queueReal: factoryStats.real,
|
||
pool: factoryStats.pool,
|
||
sessions: getSessionStats(),
|
||
profiles: getProfileStats(),
|
||
realBrowserReady: factoryStats.realBrowserReady,
|
||
captchaConfigured: isCaptchaConfigured(),
|
||
chrome: {
|
||
executablePath,
|
||
exists: fs.existsSync(executablePath),
|
||
},
|
||
config: {
|
||
maxConcurrentBrowsers: MAX_CONCURRENT_BROWSERS,
|
||
maxConcurrentBrowsersReal: MAX_CONCURRENT_BROWSERS_REAL,
|
||
apiInterceptTimeoutMs: API_INTERCEPT_TIMEOUT_MS,
|
||
apiInterceptTimeoutRealMs: API_INTERCEPT_TIMEOUT_REAL_MS,
|
||
navigationTimeoutMs: NAVIGATION_TIMEOUT_MS,
|
||
navigationWaitUntil: NAVIGATION_WAIT_UNTIL,
|
||
sessionTtlMs: SESSION_TTL_MS,
|
||
persistBrowserProfile: PERSIST_BROWSER_PROFILE,
|
||
poolIdleMs: POOL_IDLE_MS,
|
||
maxPooledBrowsers: MAX_POOLED_BROWSERS,
|
||
},
|
||
});
|
||
});
|
||
|
||
/** 运维调试:查看指定 sessionKey 的磁盘会话与 profile 状态 */
|
||
app.get('/api/session/:sessionKey', (req, res) => {
|
||
const sessionKey = decodeURIComponent(req.params.sessionKey || '');
|
||
if (!sessionKey) {
|
||
return res.status(400).json({ success: false, error: 'sessionKey 不能为空' });
|
||
}
|
||
const info = getSessionDebugInfo(sessionKey);
|
||
const profileStandard = getProfileStats();
|
||
res.json({
|
||
success: true,
|
||
session: info,
|
||
profileExists: {
|
||
standard: profileExists(sessionKey, 'standard'),
|
||
real: profileExists(sessionKey, 'real'),
|
||
},
|
||
profilesDir: profileStandard.profilesDir,
|
||
});
|
||
});
|
||
|
||
/** 运维自检:实际尝试 launch 一次 Browser(profile=standard|real) */
|
||
app.get('/api/browser-launch-test', async (req, res) => {
|
||
const profile = req.query.profile === 'real' ? 'real' : 'standard';
|
||
let browser;
|
||
try {
|
||
await runBrowserTask(`browser-launch-test-${profile}`, async () => {
|
||
if (profile === 'real') {
|
||
const session = await createBrowserSession({ profile: 'real', extraArgs: ['--mute-audio'] });
|
||
browser = session.browser;
|
||
const pages = await browser.pages();
|
||
return { pageCount: pages.length, profile };
|
||
}
|
||
browser = await launchStandardBrowser(['--mute-audio']);
|
||
const pages = await browser.pages();
|
||
return { pageCount: pages.length, profile };
|
||
}, profile);
|
||
res.json({ success: true, message: 'Browser 启动成功', profile });
|
||
} catch (error) {
|
||
res.status(500).json({
|
||
success: false,
|
||
error: error.message,
|
||
profile,
|
||
chrome: {
|
||
executablePath: resolveChromeExecutable(),
|
||
exists: fs.existsSync(resolveChromeExecutable()),
|
||
},
|
||
realBrowserReady: isRealBrowserAvailable(),
|
||
runtimeDir: RUNTIME_DIR,
|
||
});
|
||
} finally {
|
||
if (browser) {
|
||
try {
|
||
await browser.close();
|
||
} catch (_) { /* ignore */ }
|
||
}
|
||
}
|
||
});
|
||
|
||
/** 接口一:初始化授权与首屏拦截 */
|
||
app.post('/api/auth-and-intercept', async (req, res) => {
|
||
const { pageUrl, apiUrls, authActions, antiBot: rawAntiBot } = req.body;
|
||
if (!pageUrl || !Array.isArray(apiUrls)) {
|
||
return res.status(400).json({ success: false, error: '参数错误' });
|
||
}
|
||
|
||
const antiBot = normalizeAntiBot(rawAntiBot);
|
||
const profile = antiBot.profile || 'standard';
|
||
const interceptTimeout = resolveInterceptTimeout(antiBot);
|
||
const cfLog = { profile, cfDetected: false, turnstileStage: null, solverUsed: false, elapsedMs: 0, browserReused: false };
|
||
|
||
try {
|
||
await runBrowserTask('auth-and-intercept', async () => {
|
||
let browserSession = null;
|
||
let interceptorCleanup = null;
|
||
const taskStarted = Date.now();
|
||
let cfDestroyBrowser = false;
|
||
|
||
try {
|
||
browserSession = await acquireBrowserSession({
|
||
profile,
|
||
extraArgs: ['--mute-audio'],
|
||
sessionKey: antiBot.sessionKey || '',
|
||
});
|
||
cfLog.browserReused = !!browserSession.reused;
|
||
|
||
const { storedSession, mergedCookies } = resolveSessionContext(antiBot, pageUrl);
|
||
|
||
const page = await createManagedPage(browserSession.browser, browserSession.page, {
|
||
userAgent: DEFAULT_UA,
|
||
cookies: mergedCookies,
|
||
});
|
||
|
||
const httpResolvedUrl = await resolveHttpRedirects(pageUrl).catch(() => pageUrl);
|
||
if (httpResolvedUrl !== pageUrl) {
|
||
console.log(`[URL解析] HTTP 重定向: ${pageUrl} -> ${httpResolvedUrl}`);
|
||
}
|
||
|
||
const interceptor = createApiInterceptor(page, apiUrls, interceptTimeout);
|
||
interceptorCleanup = interceptor.cleanup;
|
||
|
||
let shareToken = resolveShareToken(pageUrl, httpResolvedUrl);
|
||
await seedShareTokenCookie(page, pageUrl);
|
||
if (httpResolvedUrl !== pageUrl) {
|
||
await seedShareTokenCookie(page, httpResolvedUrl);
|
||
}
|
||
await attachHttpIpRewriteInterceptor(page, 'auth-and-intercept', { shareToken });
|
||
|
||
await navigateToPage(page, pageUrl);
|
||
|
||
let finalPageUrl = await waitForUrlSettled(page);
|
||
if (finalPageUrl !== pageUrl) {
|
||
console.log(`[URL解析] 浏览器落地: ${pageUrl} -> ${finalPageUrl}`);
|
||
}
|
||
|
||
if (antiBot.enabled) {
|
||
const cfResult = await handleCloudflareChallenge(page, antiBot, waitForUrlSettled, storedSession);
|
||
cfLog.cfDetected = cfResult.cfDetected;
|
||
cfLog.turnstileStage = cfResult.stage;
|
||
cfLog.solverUsed = cfResult.solverUsed;
|
||
cfLog.elapsedMs = cfResult.elapsedMs;
|
||
|
||
if (!cfResult.success) {
|
||
cfDestroyBrowser = true;
|
||
res.status(422).json({
|
||
success: false,
|
||
code: cfResult.code || 'CF_TURNSTILE_FAILED',
|
||
challengeType: cfResult.challengeType || 'turnstile',
|
||
stage: cfResult.stage || 'timeout',
|
||
error: 'Cloudflare Turnstile 验证失败',
|
||
cf: cfLog,
|
||
});
|
||
return;
|
||
}
|
||
|
||
finalPageUrl = page.url();
|
||
touchSessionIfPresent(antiBot.sessionKey);
|
||
}
|
||
|
||
const finalShareToken = resolveShareToken(finalPageUrl);
|
||
if (finalShareToken && finalShareToken !== shareToken) {
|
||
shareToken = finalShareToken;
|
||
await seedShareTokenCookie(page, finalPageUrl);
|
||
}
|
||
|
||
if (antiBot.enabled) {
|
||
await awaitPostCfBusinessReady(page, antiBot, waitForUrlSettled);
|
||
}
|
||
|
||
await executeAuthActionsWithDiagnostics(page, authActions, executeAuthActions);
|
||
await interceptor.waitForApis(interceptTimeout);
|
||
|
||
const rawCookies = await page.cookies();
|
||
const cookiePayload = rawCookies.map((c) => ({
|
||
name: c.name,
|
||
value: c.value,
|
||
domain: c.domain,
|
||
path: c.path,
|
||
}));
|
||
|
||
if (antiBot.sessionKey) {
|
||
let lastHost = '';
|
||
try {
|
||
lastHost = new URL(finalPageUrl).hostname;
|
||
} catch (_) { /* ignore */ }
|
||
saveSession(antiBot.sessionKey, cookiePayload, SESSION_TTL_MS, {
|
||
lastHost,
|
||
savedProfile: profile,
|
||
});
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
pageUrl,
|
||
finalPageUrl,
|
||
interceptedApis: interceptor.interceptedApis,
|
||
cookies: cookiePayload,
|
||
cf: cfLog,
|
||
profile,
|
||
browserReused: browserSession.reused,
|
||
elapsedMs: Date.now() - taskStarted,
|
||
});
|
||
} finally {
|
||
if (interceptorCleanup) interceptorCleanup();
|
||
if (browserSession) {
|
||
await browserSession.release(cfDestroyBrowser);
|
||
}
|
||
}
|
||
}, profile);
|
||
} catch (error) {
|
||
if (!res.headersSent) sendTaskError(res, error, { cf: cfLog, profile });
|
||
}
|
||
});
|
||
|
||
/** 接口二:脱离 UI 极速并发拉取 */
|
||
app.post('/api/batch-fetch', async (req, res) => {
|
||
const { tasks, cookies, antiBot: rawAntiBot } = req.body;
|
||
if (!Array.isArray(tasks) || tasks.length === 0) {
|
||
return res.status(400).json({ success: false, error: 'tasks 参数错误' });
|
||
}
|
||
|
||
const antiBot = normalizeAntiBot(rawAntiBot);
|
||
const profile = antiBot.profile || 'standard';
|
||
|
||
try {
|
||
await runBrowserTask('batch-fetch', async () => {
|
||
let browserSession = null;
|
||
try {
|
||
browserSession = await acquireBrowserSession({
|
||
profile,
|
||
extraArgs: ['--disable-web-security'],
|
||
sessionKey: antiBot.sessionKey || '',
|
||
});
|
||
|
||
const firstUrl = tasks[0].fullUrl;
|
||
const { mergedCookies } = resolveSessionContext(antiBot, firstUrl, cookies || []);
|
||
|
||
const page = await createManagedPage(browserSession.browser, browserSession.page, { cookies: mergedCookies });
|
||
const firstOrigin = new URL(firstUrl).origin;
|
||
|
||
await page.setRequestInterception(true);
|
||
page.on('request', (reqObj) => {
|
||
if (reqObj.isInterceptResolutionHandled()) return;
|
||
const url = rewriteHttpsToHttpForLiteralIp(reqObj.url());
|
||
const resourceType = reqObj.resourceType();
|
||
if (['image', 'media', 'font'].includes(resourceType)) {
|
||
reqObj.abort();
|
||
return;
|
||
}
|
||
if (url === firstOrigin || url === firstOrigin + '/') {
|
||
reqObj.respond({ status: 200, contentType: 'text/html', body: '<html><body>Context Ready</body></html>' });
|
||
} else {
|
||
reqObj.continue();
|
||
}
|
||
});
|
||
|
||
await navigateToPage(page, firstOrigin);
|
||
|
||
const FETCH_CONCURRENCY = Math.max(1, parseInt(process.env.FETCH_CONCURRENCY || '5', 10));
|
||
const FETCH_RETRY = 2;
|
||
|
||
const fetchResults = await page.evaluate(async (taskList, concurrency, maxRetry) => {
|
||
const out = {};
|
||
const createLimiter = (limit) => {
|
||
let active = 0;
|
||
const queue = [];
|
||
return (fn) => new Promise((resolve, reject) => {
|
||
const run = async () => {
|
||
active++;
|
||
try { resolve(await fn()); }
|
||
catch (e) { reject(e); }
|
||
finally {
|
||
active--;
|
||
if (queue.length) queue.shift()();
|
||
}
|
||
};
|
||
if (active < limit) run();
|
||
else queue.push(run);
|
||
});
|
||
};
|
||
const limit = createLimiter(concurrency);
|
||
|
||
const fetchWithRetry = async (finalUrl, options, retriesLeft) => {
|
||
try {
|
||
const res = await fetch(finalUrl, options);
|
||
return { success: true, data: await res.json() };
|
||
} catch (err) {
|
||
if (retriesLeft <= 0) return { success: false, error: err.toString() };
|
||
await new Promise((r) => setTimeout(r, 300 * Math.pow(2, maxRetry - retriesLeft)));
|
||
return fetchWithRetry(finalUrl, options, retriesLeft - 1);
|
||
}
|
||
};
|
||
|
||
const jobs = [];
|
||
for (const task of taskList) {
|
||
out[task.apiPath] = [];
|
||
const cleanHeaders = { ...task.headers };
|
||
['content-length', 'host', 'origin', 'referer', 'accept-encoding'].forEach((k) => delete cleanHeaders[k]);
|
||
|
||
for (const params of task.paramList) {
|
||
jobs.push(limit(async () => {
|
||
let finalUrl = task.fullUrl;
|
||
const options = { method: task.method.toUpperCase(), headers: cleanHeaders };
|
||
if (options.method === 'GET') {
|
||
const qs = new URLSearchParams(params).toString();
|
||
finalUrl = finalUrl.includes('?') ? `${finalUrl}&${qs}` : `${finalUrl}?${qs}`;
|
||
} else {
|
||
options.body = JSON.stringify(params);
|
||
}
|
||
const result = await fetchWithRetry(finalUrl, options, maxRetry);
|
||
out[task.apiPath].push({ params, ...result });
|
||
await new Promise((r) => setTimeout(r, 200));
|
||
}));
|
||
}
|
||
}
|
||
await Promise.all(jobs);
|
||
return out;
|
||
}, tasks, FETCH_CONCURRENCY, FETCH_RETRY);
|
||
|
||
res.json({ success: true, results: fetchResults, profile, browserReused: browserSession.reused });
|
||
} finally {
|
||
if (browserSession) {
|
||
await browserSession.release(false);
|
||
}
|
||
}
|
||
}, profile);
|
||
} catch (error) {
|
||
if (!res.headersSent) sendTaskError(res, error, { profile });
|
||
}
|
||
});
|
||
|
||
/** 接口三:强制 UI 模拟点击拉取 */
|
||
app.post('/api/ui-pagination', async (req, res) => {
|
||
const {
|
||
pageUrl,
|
||
finalPageUrl,
|
||
apiUrl,
|
||
cookies,
|
||
nextBtnSelector,
|
||
clicksToPerform,
|
||
waitMs = 2000,
|
||
firstPageData,
|
||
authActions,
|
||
antiBot: rawAntiBot,
|
||
} = req.body;
|
||
const navigationUrl = finalPageUrl || pageUrl;
|
||
const antiBot = normalizeAntiBot(rawAntiBot);
|
||
const profile = antiBot.profile || 'standard';
|
||
|
||
try {
|
||
await runBrowserTask('ui-pagination', async () => {
|
||
let browserSession = null;
|
||
const debugLogs = [];
|
||
let cfDestroyBrowser = false;
|
||
|
||
try {
|
||
browserSession = await acquireBrowserSession({
|
||
profile,
|
||
extraArgs: ['--window-size=1920,1080'],
|
||
sessionKey: antiBot.sessionKey || '',
|
||
});
|
||
|
||
const { storedSession, mergedCookies } = resolveSessionContext(antiBot, navigationUrl, cookies || []);
|
||
|
||
const page = await createManagedPage(browserSession.browser, browserSession.page, {
|
||
viewport: { width: 1920, height: 1080 },
|
||
cookies: mergedCookies,
|
||
});
|
||
|
||
const log = (msg) => { console.log(msg); debugLogs.push(msg); };
|
||
log(`[启动] 准备访问页面: ${navigationUrl}`);
|
||
if (finalPageUrl && finalPageUrl !== pageUrl) {
|
||
log(`[URL解析] 使用 auth 阶段落地地址,跳过短链重定向: ${pageUrl} -> ${finalPageUrl}`);
|
||
}
|
||
|
||
const shareToken = resolveShareToken(finalPageUrl, pageUrl);
|
||
await seedShareTokenCookie(page, navigationUrl);
|
||
await attachHttpIpRewriteInterceptor(page, 'ui-pagination', { shareToken });
|
||
|
||
await navigateToPage(page, navigationUrl);
|
||
|
||
const settledPageUrl = await waitForUrlSettled(page);
|
||
if (settledPageUrl !== navigationUrl) {
|
||
log(`[URL解析] 翻页阶段落地: ${navigationUrl} -> ${settledPageUrl}`);
|
||
}
|
||
|
||
if (antiBot.enabled) {
|
||
const cfResult = await handleCloudflareChallenge(page, antiBot, waitForUrlSettled, storedSession);
|
||
if (!cfResult.success) {
|
||
cfDestroyBrowser = true;
|
||
res.status(422).json({
|
||
success: false,
|
||
code: cfResult.code || 'CF_TURNSTILE_FAILED',
|
||
challengeType: cfResult.challengeType,
|
||
stage: cfResult.stage,
|
||
error: 'Cloudflare Turnstile 验证失败(翻页阶段)',
|
||
debug: debugLogs,
|
||
});
|
||
return;
|
||
}
|
||
touchSessionIfPresent(antiBot.sessionKey);
|
||
}
|
||
|
||
if (antiBot.enabled) {
|
||
await awaitPostCfBusinessReady(page, antiBot, waitForUrlSettled);
|
||
}
|
||
|
||
const paginationResult = await runUiPagination(page, {
|
||
apiUrl,
|
||
nextBtnSelector,
|
||
clicksToPerform,
|
||
waitMs,
|
||
firstPageData,
|
||
authActions,
|
||
executeAuthActions,
|
||
});
|
||
|
||
res.json({
|
||
success: true,
|
||
data: paginationResult.data,
|
||
debug: [...debugLogs, ...paginationResult.debugLogs],
|
||
profile,
|
||
browserReused: browserSession.reused,
|
||
});
|
||
} catch (error) {
|
||
if (!res.headersSent) {
|
||
sendTaskError(res, error, { debug: debugLogs, profile });
|
||
}
|
||
} finally {
|
||
if (browserSession) {
|
||
await browserSession.release(cfDestroyBrowser);
|
||
}
|
||
}
|
||
}, profile);
|
||
} catch (error) {
|
||
if (!res.headersSent) sendTaskError(res, error, { profile });
|
||
}
|
||
});
|
||
|
||
/** 接口四:单 Browser 内 auth + 拦截 + UI 翻页(降低 CF 二次触发) */
|
||
app.post('/api/auth-intercept-and-paginate', async (req, res) => {
|
||
const {
|
||
pageUrl,
|
||
apiUrls,
|
||
authActions,
|
||
antiBot: rawAntiBot,
|
||
pagination,
|
||
} = req.body;
|
||
|
||
if (!pageUrl || !Array.isArray(apiUrls) || !pagination) {
|
||
return res.status(400).json({ success: false, error: '参数错误:需要 pageUrl、apiUrls、pagination' });
|
||
}
|
||
|
||
const {
|
||
apiUrl,
|
||
nextBtnSelector,
|
||
clicksToPerform = 0,
|
||
waitMs = 2000,
|
||
firstPageData,
|
||
} = pagination;
|
||
|
||
const antiBot = normalizeAntiBot(rawAntiBot);
|
||
const profile = antiBot.profile || 'standard';
|
||
const interceptTimeout = resolveInterceptTimeout(antiBot);
|
||
const cfLog = { profile, cfDetected: false, turnstileStage: null, solverUsed: false, elapsedMs: 0, browserReused: false };
|
||
|
||
try {
|
||
await runBrowserTask('auth-intercept-and-paginate', async () => {
|
||
let browserSession = null;
|
||
let interceptorCleanup = null;
|
||
const taskStarted = Date.now();
|
||
let cfDestroyBrowser = false;
|
||
|
||
try {
|
||
browserSession = await acquireBrowserSession({
|
||
profile,
|
||
extraArgs: ['--mute-audio', '--window-size=1920,1080'],
|
||
sessionKey: antiBot.sessionKey || '',
|
||
});
|
||
cfLog.browserReused = !!browserSession.reused;
|
||
|
||
const { storedSession, mergedCookies } = resolveSessionContext(antiBot, pageUrl);
|
||
|
||
const page = await createManagedPage(browserSession.browser, browserSession.page, {
|
||
userAgent: DEFAULT_UA,
|
||
viewport: { width: 1920, height: 1080 },
|
||
cookies: mergedCookies,
|
||
});
|
||
|
||
const httpResolvedUrl = await resolveHttpRedirects(pageUrl).catch(() => pageUrl);
|
||
const interceptor = createApiInterceptor(page, apiUrls, interceptTimeout);
|
||
interceptorCleanup = interceptor.cleanup;
|
||
|
||
let shareToken = resolveShareToken(pageUrl, httpResolvedUrl);
|
||
await seedShareTokenCookie(page, pageUrl);
|
||
await attachHttpIpRewriteInterceptor(page, 'auth-intercept-and-paginate', { shareToken });
|
||
|
||
await navigateToPage(page, pageUrl);
|
||
let finalPageUrl = await waitForUrlSettled(page);
|
||
|
||
if (antiBot.enabled) {
|
||
const cfResult = await handleCloudflareChallenge(page, antiBot, waitForUrlSettled, storedSession);
|
||
cfLog.cfDetected = cfResult.cfDetected;
|
||
cfLog.turnstileStage = cfResult.stage;
|
||
cfLog.solverUsed = cfResult.solverUsed;
|
||
cfLog.elapsedMs = cfResult.elapsedMs;
|
||
|
||
if (!cfResult.success) {
|
||
cfDestroyBrowser = true;
|
||
res.status(422).json({
|
||
success: false,
|
||
code: cfResult.code || 'CF_TURNSTILE_FAILED',
|
||
challengeType: cfResult.challengeType || 'turnstile',
|
||
stage: cfResult.stage || 'timeout',
|
||
error: 'Cloudflare Turnstile 验证失败',
|
||
cf: cfLog,
|
||
});
|
||
return;
|
||
}
|
||
finalPageUrl = page.url();
|
||
touchSessionIfPresent(antiBot.sessionKey);
|
||
}
|
||
|
||
if (antiBot.enabled) {
|
||
await awaitPostCfBusinessReady(page, antiBot, waitForUrlSettled);
|
||
}
|
||
|
||
await executeAuthActionsWithDiagnostics(page, authActions, executeAuthActions);
|
||
await interceptor.waitForApis(interceptTimeout);
|
||
|
||
const rawCookies = await page.cookies();
|
||
const cookiePayload = rawCookies.map((c) => ({
|
||
name: c.name,
|
||
value: c.value,
|
||
domain: c.domain,
|
||
path: c.path,
|
||
}));
|
||
|
||
if (antiBot.sessionKey) {
|
||
let lastHost = '';
|
||
try {
|
||
lastHost = new URL(finalPageUrl).hostname;
|
||
} catch (_) { /* ignore */ }
|
||
saveSession(antiBot.sessionKey, cookiePayload, SESSION_TTL_MS, {
|
||
lastHost,
|
||
savedProfile: profile,
|
||
});
|
||
}
|
||
|
||
let extraPages = [];
|
||
let paginationDebug = [];
|
||
if (clicksToPerform > 0 && apiUrl && nextBtnSelector) {
|
||
let effectiveFirstPageData = firstPageData;
|
||
if (!effectiveFirstPageData) {
|
||
const matchedKey = Object.keys(interceptor.interceptedApis).find((k) => k === apiUrl || k.includes(apiUrl));
|
||
if (matchedKey) {
|
||
effectiveFirstPageData = interceptor.interceptedApis[matchedKey].data;
|
||
}
|
||
}
|
||
const paginationResult = await runUiPagination(page, {
|
||
apiUrl,
|
||
nextBtnSelector,
|
||
clicksToPerform,
|
||
waitMs,
|
||
firstPageData: effectiveFirstPageData,
|
||
authActions,
|
||
executeAuthActions,
|
||
});
|
||
extraPages = paginationResult.data;
|
||
paginationDebug = paginationResult.debugLogs;
|
||
}
|
||
|
||
res.json({
|
||
success: true,
|
||
pageUrl,
|
||
finalPageUrl,
|
||
interceptedApis: interceptor.interceptedApis,
|
||
cookies: cookiePayload,
|
||
extraPages,
|
||
debug: paginationDebug,
|
||
cf: cfLog,
|
||
profile,
|
||
browserReused: browserSession.reused,
|
||
elapsedMs: Date.now() - taskStarted,
|
||
});
|
||
} finally {
|
||
if (interceptorCleanup) interceptorCleanup();
|
||
if (browserSession) {
|
||
await browserSession.release(cfDestroyBrowser);
|
||
}
|
||
}
|
||
}, profile);
|
||
} catch (error) {
|
||
if (!res.headersSent) sendTaskError(res, error, { cf: cfLog, profile });
|
||
}
|
||
});
|
||
|
||
const PORT = parseInt(process.env.PORT || '3001', 10);
|
||
const server = app.listen(PORT, '127.0.0.1', () => {
|
||
const chromePath = resolveChromeExecutable();
|
||
const chromeReady = fs.existsSync(chromePath);
|
||
console.log(`🚀 Node.js 自愈型防脱轨引擎已启动 (standard: ${MAX_CONCURRENT_BROWSERS}, real: ${MAX_CONCURRENT_BROWSERS_REAL})`);
|
||
console.log(`[chrome] path=${chromePath} exists=${chromeReady}`);
|
||
console.log(`[real-browser] ready=${isRealBrowserAvailable()} captcha=${isCaptchaConfigured()}`);
|
||
if (!chromeReady) {
|
||
console.warn('[chrome] 未找到 Chrome,请在 puppeteer-api 目录执行: npx puppeteer browsers install chrome');
|
||
}
|
||
});
|
||
|
||
async function gracefulShutdown(signal) {
|
||
console.log(`[shutdown] 收到 ${signal},停止接收新任务...`);
|
||
shuttingDown = true;
|
||
server.close();
|
||
|
||
const deadline = Date.now() + 60000;
|
||
while (standardLimiter.active > 0 && Date.now() < deadline) {
|
||
const { active, queued } = standardLimiter.getStats();
|
||
console.log(`[shutdown] 等待在途任务完成... active=${active}, queued=${queued}`);
|
||
await new Promise((r) => setTimeout(r, 1000));
|
||
}
|
||
|
||
console.log('[shutdown] 排空 Browser 温池...');
|
||
await browserPool.drain();
|
||
|
||
const remaining = standardLimiter.getStats();
|
||
if (remaining.active > 0) {
|
||
console.warn(`[shutdown] 超时,仍有 ${remaining.active} 个 Browser 在运行,强制退出`);
|
||
process.exit(1);
|
||
}
|
||
console.log('[shutdown] 所有任务已完成,进程退出');
|
||
process.exit(0);
|
||
}
|
||
|
||
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
||
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|