const express = require('express'); const fs = require('fs'); const crypto = require('crypto'); 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, } = require('./lib/constants'); const { normalizeAntiBot, createBrowserSession, runWithProfileLimit, createManagedPage, getFactoryStats, standardLimiter, } = 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 { loadSession, saveSession, sessionCookiesForPage } = require('./lib/session-store'); 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); return res.status(500).json({ success: false, error: error.message, ...extra }); } function generateSafeHash(obj) { if (!obj) return ''; const clone = JSON.parse(JSON.stringify(obj)); const removeDynamicKeys = (target) => { if (typeof target !== 'object' || target === null) return; ['timestamp', 'time', 'serverTime', 'reqId', 'requestId', 'traceId'].forEach((k) => delete target[k]); Object.values(target).forEach((val) => removeDynamicKeys(val)); }; removeDynamicKeys(clone); return crypto.createHash('md5').update(JSON.stringify(clone)).digest('hex'); } 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, 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, }, }); }); /** 运维自检:实际尝试 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 }; try { await runBrowserTask('auth-and-intercept', async () => { let browser; let sessionCleanup = null; let interceptorCleanup = null; const taskStarted = Date.now(); try { const session = await createBrowserSession({ profile, extraArgs: ['--mute-audio'], }); browser = session.browser; sessionCleanup = session.cleanup; const sessionData = antiBot.sessionKey ? loadSession(antiBot.sessionKey) : null; const sessionCookies = sessionData ? sessionCookiesForPage(sessionData, pageUrl) : []; const page = await createManagedPage(browser, session.page, { userAgent: DEFAULT_UA, cookies: sessionCookies, }); 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 withExponentialBackoff( () => page.goto(pageUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }), { maxRetries: 2, baseDelayMs: 1000 } ); let finalPageUrl = await waitForUrlSettled(page); if (finalPageUrl !== pageUrl) { console.log(`[URL解析] 浏览器落地: ${pageUrl} -> ${finalPageUrl}`); } if (antiBot.enabled) { const cfResult = await handleCloudflareChallenge(page, antiBot, waitForUrlSettled); cfLog.cfDetected = cfResult.cfDetected; cfLog.turnstileStage = cfResult.stage; cfLog.solverUsed = cfResult.solverUsed; cfLog.elapsedMs = cfResult.elapsedMs; if (!cfResult.success) { 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(); } const finalShareToken = resolveShareToken(finalPageUrl); if (finalShareToken && finalShareToken !== shareToken) { shareToken = finalShareToken; await seedShareTokenCookie(page, finalPageUrl); } await executeAuthActions(page, authActions); 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) { saveSession(antiBot.sessionKey, cookiePayload); } res.json({ success: true, pageUrl, finalPageUrl, interceptedApis: interceptor.interceptedApis, cookies: cookiePayload, cf: cfLog, profile, elapsedMs: Date.now() - taskStarted, }); } finally { if (interceptorCleanup) interceptorCleanup(); if (sessionCleanup) { await sessionCleanup(); } else { await safeCloseBrowser(browser); } } }, 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 browser; let sessionCleanup = null; try { const session = await createBrowserSession({ profile, extraArgs: ['--disable-web-security'], }); browser = session.browser; sessionCleanup = session.cleanup; const page = await createManagedPage(browser, session.page, { cookies }); const firstOrigin = new URL(tasks[0].fullUrl).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: 'Context Ready' }); } else { reqObj.continue(); } }); await withExponentialBackoff( () => page.goto(firstOrigin, { waitUntil: 'domcontentloaded', timeout: 30000 }), { maxRetries: 2, baseDelayMs: 800 } ); 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 }); } finally { if (sessionCleanup) { await sessionCleanup(); } else { await safeCloseBrowser(browser); } } }, 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 browser; let sessionCleanup = null; const capturedData = []; const debugLogs = []; const log = (msg) => { console.log(msg); debugLogs.push(msg); }; const seenHashes = new Set(); if (firstPageData) { seenHashes.add(generateSafeHash(firstPageData)); } try { const session = await createBrowserSession({ profile, extraArgs: ['--window-size=1920,1080'], }); browser = session.browser; sessionCleanup = session.cleanup; const page = await createManagedPage(browser, session.page, { viewport: { width: 1920, height: 1080 }, cookies, }); 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 withExponentialBackoff( () => page.goto(navigationUrl, { waitUntil: 'domcontentloaded', timeout: 30000 }), { maxRetries: 2, baseDelayMs: 1000 } ); const settledPageUrl = await waitForUrlSettled(page); if (settledPageUrl !== navigationUrl) { log(`[URL解析] 翻页阶段落地: ${navigationUrl} -> ${settledPageUrl}`); } if (antiBot.enabled) { const cfResult = await handleCloudflareChallenge(page, antiBot, waitForUrlSettled); if (!cfResult.success) { res.status(422).json({ success: false, code: cfResult.code || 'CF_TURNSTILE_FAILED', challengeType: cfResult.challengeType, stage: cfResult.stage, error: 'Cloudflare Turnstile 验证失败(翻页阶段)', debug: debugLogs, }); return; } } if (Array.isArray(authActions) && authActions.length > 0) { log('[状态同步] 正在为翻页引擎注入授权状态...'); await executeAuthActions(page, authActions); log('[状态同步] 授权执行完毕,等待目标列表加载...'); const authSettled = await page.waitForResponse( (resp) => resp.url().includes(apiUrl) && resp.request().method() !== 'OPTIONS' && resp.status() >= 200 && resp.status() < 400, { timeout: 5000 } ).catch(() => null); if (!authSettled) { log('[状态同步] 未捕获到目标 API 响应,降级为固定等待 3 秒...'); await new Promise((r) => setTimeout(r, 3000)); } } for (let i = 0; i < clicksToPerform; i++) { const targetPageNum = i + 2; let pageData = null; let attempts = 0; const maxAttempts = 3; await page.evaluate(() => { document.querySelectorAll('.el-loading-mask, .ant-spin-nested-loading, .el-loading-spinner').forEach((el) => el.remove()); }); await page.waitForFunction( () => !document.querySelector('.el-loading-mask, .ant-spin-nested-loading, .el-loading-spinner'), { timeout: 1000, polling: 100 } ).catch(() => new Promise((r) => setTimeout(r, 500))); while (attempts < maxAttempts && !pageData) { attempts++; log(`[抓取] 准备抓取第 ${targetPageNum} 页 (尝试 ${attempts}/${maxAttempts})...`); let responseHandler = null; let apiTimeoutId = null; const waitForNewPageApi = new Promise((resolve) => { const cleanupListener = () => { if (apiTimeoutId) { clearTimeout(apiTimeoutId); apiTimeoutId = null; } if (responseHandler) { page.off('response', responseHandler); responseHandler = null; } }; responseHandler = async (response) => { const status = response.status(); if (response.url().includes(apiUrl) && response.request().method() !== 'OPTIONS' && status >= 200 && status < 400) { try { const responseData = await response.json(); const currentHash = generateSafeHash(responseData); if (!seenHashes.has(currentHash)) { seenHashes.add(currentHash); cleanupListener(); resolve(responseData); } else { log('[防轮询] 抛弃重复数据,继续监听...'); } } catch (_) { /* 非 JSON 响应跳过 */ } } }; page.on('response', responseHandler); apiTimeoutId = setTimeout(() => { cleanupListener(); resolve(null); }, 15000); }); try { await page.waitForSelector(nextBtnSelector, { timeout: 15000 }); const clickStatus = await page.evaluate((sel) => { const btn = document.querySelector(sel); if (!btn) return 'not_found'; if (btn.disabled || btn.classList.contains('is-disabled') || btn.classList.contains('disabled') || btn.getAttribute('aria-disabled') === 'true') { return 'disabled'; } const className = btn.className || ''; if (btn.disabled === true || className.includes('disabled') || btn.getAttribute('aria-disabled') === 'true') { return 'disabled'; } btn.scrollIntoView({ behavior: 'instant', block: 'center' }); btn.click(); btn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true, view: window })); return 'success'; }, nextBtnSelector); if (clickStatus === 'disabled') { log(`[提示] 第 ${targetPageNum} 页按钮已被禁用,说明到底了,抓取提前结束。`); break; } log('[动作] 双引擎原生点击已执行!'); } catch (clickErr) { log(`[错误] 寻找按钮异常 (${classifyPuppeteerError(clickErr)}): ${clickErr.message}`); } pageData = await waitForNewPageApi; if (!pageData && attempts < maxAttempts) { log('[重试警报] 雷达未扫描到新数据,准备重试...'); await new Promise((r) => setTimeout(r, 500 * Math.pow(2, attempts - 1))); } } if (pageData) { capturedData.push(pageData); log(`[成功] 斩获第 ${targetPageNum} 页数据!`); await new Promise((r) => setTimeout(r, waitMs)); } else { log(`[致命异常] 连续 ${maxAttempts} 次尝试均失败,放弃后续翻页。`); break; } } res.json({ success: true, data: capturedData, debug: debugLogs, profile }); } catch (error) { if (!res.headersSent) { sendTaskError(res, error, { debug: debugLogs, profile }); } } finally { if (sessionCleanup) { await sessionCleanup(); } else { await safeCloseBrowser(browser); } } }, profile); } catch (error) { if (!res.headersSent) sendTaskError(res, error, { 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)); } 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'));