/** * 适配器公共流程函数 * * - fillPrompt: 填写提示词 * - submit: 提交表单(带回退逻辑) * - waitApiResponse: 等待 API 响应(含页面监听) * - normalizePageError: 页面错误处理 * - normalizeHttpError: HTTP 错误处理 */ import { sleep, humanType, safeClick, isPageValid, createPageCloseWatcher, getRealViewport, clamp, random } from '../browser/utils.js'; import { logger } from '../utils/logger.js'; /** * 任务完成后移开鼠标(拟人化行为) * * @param {import('playwright-core').Page} page - Playwright 页面对象 */ export async function moveMouseAway(page) { if (!page.cursor) return; try { const vp = await getRealViewport(page); await page.cursor.moveTo({ x: clamp(vp.safeWidth * random(0.85, 0.95), 0, vp.safeWidth), y: clamp(vp.height * random(0.3, 0.7), 0, vp.safeHeight) }); } catch (e) { // 忽略鼠标移动失败 } } /** * 填写提示词 (通用) * @param {import('playwright-core').Page} page - Playwright 页面对象 * @param {string|import('playwright-core').ElementHandle} target - 输入目标(选择器或元素句柄) * @param {string} prompt - 提示词内容 * @param {object} [meta={}] - 日志元数据 */ export async function fillPrompt(page, target, prompt, meta = {}) { logger.info('适配器', '正在输入提示词...', meta); await humanType(page, target, prompt); await sleep(800, 1500); } /** * 提交表单 (带回退逻辑) * * 尝试点击指定按钮,失败时回退到按回车提交 * * @param {import('playwright-core').Page} page - Playwright 页面对象 * @param {object} options - 提交选项 * @param {string} options.btnSelector - 按钮选择器 * @param {string|import('playwright-core').ElementHandle} [options.inputTarget] - 输入框(回退时使用) * @param {object} [options.meta={}] - 日志元数据 * @returns {Promise} 是否成功点击按钮(false 表示使用了回退) */ export async function submit(page, options = {}) { const { btnSelector, inputTarget, meta = {} } = options; try { const btnHandle = await page.$(btnSelector); if (btnHandle) { // 确保按钮在可视区域 await btnHandle.scrollIntoViewIfNeeded().catch(() => { }); await sleep(200, 400); await safeClick(page, btnHandle, { bias: 'button' }); return true; } } catch (e) { // 选择器无效或其他错误,继续回退逻辑 } // 回退:按回车提交 logger.warn('适配器', '未找到发送按钮,尝试回车提交', meta); if (inputTarget) { if (typeof inputTarget === 'string') { await page.focus(inputTarget).catch(() => { }); } else { await inputTarget.focus().catch(() => { }); } } await page.keyboard.press('Enter'); return false; } /** * 等待 API 响应 (带页面关闭监听) * * 使用 Promise.race 同时监听: * - API 响应 * - 页面关闭/崩溃事件 * * @param {import('playwright-core').Page} page - Playwright 页面对象 * @param {object} options - 等待选项 * @param {string} options.urlMatch - URL 匹配字符串(包含关系) * @param {string} [options.method='POST'] - HTTP 方法 * @param {number} [options.timeout=120000] - 超时时间(毫秒) * @param {object} [options.meta={}] - 日志元数据 * @returns {Promise} 响应对象 * @throws {Error} 页面关闭/崩溃/超时时抛出错误 */ export async function waitApiResponse(page, options = {}) { const { urlMatch, method = 'POST', timeout = 120000, meta = {} } = options; // 先检查页面状态 if (!isPageValid(page)) { throw new Error('PAGE_INVALID'); } const pageWatcher = createPageCloseWatcher(page); try { const responsePromise = page.waitForResponse( response => response.url().includes(urlMatch) && response.request().method() === method && (response.status() === 200 || response.status() >= 400), { timeout } ); return await Promise.race([responsePromise, pageWatcher.promise]); } finally { pageWatcher.cleanup(); } } /** * 统一处理页面级错误 * * 处理以下错误类型: * - PAGE_CLOSED: 页面被关闭 * - PAGE_CRASHED: 页面崩溃 * - PAGE_INVALID: 页面状态无效 * - TimeoutError: 请求超时 * * @param {Error} err - 原始错误 * @param {object} [meta={}] - 日志元数据 * @returns {{ error: string } | null} 标准化错误对象,未匹配返回 null */ export function normalizePageError(err, meta = {}) { if (err.message === 'PAGE_CLOSED') { logger.error('适配器', '页面已关闭', meta); return { error: '页面已关闭,请勿在生图过程中刷新页面' }; } if (err.message === 'PAGE_CRASHED') { logger.error('适配器', '页面崩溃', meta); return { error: '页面崩溃,请重试' }; } if (err.message === 'PAGE_INVALID') { logger.error('适配器', '页面状态无效', meta); return { error: '页面状态无效,请重新初始化' }; } if (err.name === 'TimeoutError') { logger.error('适配器', '请求超时', meta); return { error: '请求超时 (120秒), 请检查网络或稍后重试' }; } return null; // 未匹配到已知错误类型 } /** * 统一处理 HTTP 响应错误 * * 处理以下错误类型: * - 429: 限流 / CAPTCHA * - recaptcha validation failed: 人机验证失败 * - 4xx/5xx: 服务端错误 * * @param {import('playwright-core').Response} response - HTTP 响应对象 * @param {string} [content=null] - 响应体内容(可选) * @returns {{ error: string, code?: string } | null} 标准化错误对象,无错误返回 null */ export function normalizeHttpError(response, content = null) { const status = response.status(); // 429 限流检查 if (status === 429 || content?.includes('Too Many Requests')) { return { error: '触发限流/上游繁忙', code: '429' }; } // reCAPTCHA 验证失败 if (content?.includes('recaptcha validation failed')) { return { error: '触发人机验证', code: 'RECAPTCHA' }; } // 其他客户端/服务端错误 if (status >= 400) { return { error: `上游服务器错误,HTTP错误码: ${status}`, code: String(status) }; } return null; } /** * 下载图片并转换为 Base64 * * 根据 camoufoxFingerprints.json 动态生成请求头,保持与浏览器指纹一致 * * @param {string} url - 图片 URL * @param {object} config - 配置对象(需包含 proxy 配置) * @returns {Promise<{ image?: string, error?: string }>} 下载结果 */ export async function downloadImage(url, config) { // 动态导入依赖 const { gotScraping } = await import('got-scraping'); const fs = await import('fs'); const path = await import('path'); const { getProxyConfig, getHttpProxy } = await import('../utils/proxy.js'); try { // 读取指纹文件获取浏览器信息 const fingerprintPath = path.join(process.cwd(), 'data', 'camoufoxFingerprints.json'); let browserName = 'firefox'; let browserMinVersion = 100; let os = 'windows'; let locale = 'en-US'; if (fs.existsSync(fingerprintPath)) { try { const fingerprint = JSON.parse(fs.readFileSync(fingerprintPath, 'utf8')); // 从指纹中提取信息 if (fingerprint.navigator?.userAgent) { // 解析 User-Agent 获取浏览器版本 const versionMatch = fingerprint.navigator.userAgent.match(/Firefox\/(\d+)/i); if (versionMatch) { browserMinVersion = parseInt(versionMatch[1], 10); } } if (fingerprint.navigator?.platform) { const platform = fingerprint.navigator.platform.toLowerCase(); if (platform.includes('win')) os = 'windows'; else if (platform.includes('mac')) os = 'macos'; else if (platform.includes('linux')) os = 'linux'; } if (fingerprint.navigator?.language) { locale = fingerprint.navigator.language; } } catch (e) { // 解析失败使用默认值 } } // 获取代理配置 const proxyConfig = getProxyConfig(config); const proxyUrl = await getHttpProxy(proxyConfig); const options = { url, responseType: 'buffer', http2: true, headerGeneratorOptions: { browsers: [{ name: browserName, minVersion: browserMinVersion }], devices: ['desktop'], locales: [locale], operatingSystems: [os], } }; if (proxyUrl) { options.proxyUrl = proxyUrl; } const response = await gotScraping(options); const base64 = response.body.toString('base64'); return { image: `data:image/png;base64,${base64}` }; } catch (e) { return { error: `已获取结果,但图片下载时遇到错误: ${e.message}` }; } }