mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 将项目迁移到Playwright+Camoufox方案
This commit is contained in:
+79
-128
@@ -9,12 +9,13 @@ import {
|
||||
queryDeep,
|
||||
safeClick,
|
||||
humanType,
|
||||
pasteImages
|
||||
pasteImages,
|
||||
getHumanClickPoint
|
||||
} from '../browser/utils.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'chromeUserDataGeminiBiz');
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData');
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
|
||||
// 确保临时目录存在
|
||||
@@ -51,14 +52,14 @@ async function findInput(page) {
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @param {object} config - 配置对象
|
||||
* @param {object} [config.chrome] - Chrome 配置
|
||||
* @param {boolean} [config.chrome.headless] - 是否开启 Headless 模式
|
||||
* @param {string} [config.chrome.path] - Chrome 可执行文件路径
|
||||
* @param {object} [config.chrome.proxy] - 代理配置
|
||||
* @param {object} [config.browser] - Browser 配置
|
||||
* @param {boolean} [config.browser.headless] - 是否开启 Headless 模式
|
||||
* @param {string} [config.browser.path] - Browser 可执行文件路径
|
||||
* @param {object} [config.browser.proxy] - 代理配置
|
||||
* @param {object} [config.backend] - 后端配置
|
||||
* @param {object} [config.backend.geminiBiz] - Gemini Biz 配置
|
||||
* @param {string} config.backend.geminiBiz.entryUrl - Gemini entry URL (必需)
|
||||
* @returns {Promise<{browser: import('puppeteer').Browser, page: import('puppeteer').Page, client: import('puppeteer').CDPSession}>}
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>}
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
// 从配置读取 Gemini Biz entry URL
|
||||
@@ -103,7 +104,8 @@ async function initBrowser(config) {
|
||||
const box = await inputHandle.boundingBox();
|
||||
if (box) {
|
||||
if (page.cursor) {
|
||||
await page.cursor.moveTo({ x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
||||
const { x, y } = getHumanClickPoint(box, 'input');
|
||||
await page.cursor.moveTo({ x, y });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
@@ -114,7 +116,6 @@ async function initBrowser(config) {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
targetUrl,
|
||||
productName: 'Gemini Enterprise Business',
|
||||
reuseExistingTab: false,
|
||||
waitInputValidator
|
||||
});
|
||||
}
|
||||
@@ -128,13 +129,11 @@ async function initBrowser(config) {
|
||||
* @returns {Promise<{image?: string, error?: string}>} 生成结果
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page, client } = context;
|
||||
let fetchPausedHandler = null;
|
||||
const { page } = context;
|
||||
|
||||
try {
|
||||
// 获取配置 (通过闭包或全局)
|
||||
// 这里需要从 context 或其他方式获取 config
|
||||
const { loadConfig } = await import('../config.js');
|
||||
const { loadConfig } = await import('../utils/config.js');
|
||||
const config = loadConfig();
|
||||
const targetUrl = config.backend?.geminiBiz?.entryUrl;
|
||||
|
||||
@@ -143,7 +142,7 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
}
|
||||
|
||||
// 开启新对话
|
||||
await page.goto(targetUrl, { waitUntil: 'networkidle2' });
|
||||
await page.goto(targetUrl, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 1. 查找输入框
|
||||
logger.debug('适配器', '正在寻找输入框...', meta);
|
||||
@@ -195,55 +194,33 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
await humanType(page, inputHandle, prompt);
|
||||
await sleep(1000, 2000);
|
||||
|
||||
// 4. 设置拦截器
|
||||
// 4. 设置拦截器 (使用 Playwright Route)
|
||||
logger.debug('适配器', '已启用请求拦截', meta);
|
||||
await client.send('Fetch.enable', {
|
||||
patterns: [{
|
||||
urlPattern: '*global/widgetStreamAssist*',
|
||||
requestStage: 'Request'
|
||||
}]
|
||||
});
|
||||
|
||||
fetchPausedHandler = async (event) => {
|
||||
const { requestId, request } = event;
|
||||
if (request.method === 'POST' && request.postData) {
|
||||
try {
|
||||
let rawBody = request.postData;
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(rawBody);
|
||||
} catch (e) {
|
||||
try {
|
||||
rawBody = Buffer.from(rawBody, 'base64').toString('utf8');
|
||||
data = JSON.parse(rawBody);
|
||||
} catch (e2) { }
|
||||
}
|
||||
// 清理旧的 route
|
||||
await page.unroute('**/*').catch(() => { });
|
||||
|
||||
if (data) {
|
||||
logger.debug('适配器', '已拦截请求,正在修改...', meta);
|
||||
if (!data.streamAssistRequest) data.streamAssistRequest = {};
|
||||
if (!data.streamAssistRequest.assistGenerationConfig) data.streamAssistRequest.assistGenerationConfig = {};
|
||||
//data.streamAssistRequest.assistGenerationConfig.modelId = "gemini-3-pro-preview";
|
||||
data.streamAssistRequest.toolsSpec = { imageGenerationSpec: {} };
|
||||
await page.route(url => url.href.includes('global/widgetStreamAssist'), async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== 'POST') return route.continue();
|
||||
|
||||
const newBody = JSON.stringify(data);
|
||||
const newBodyBase64 = Buffer.from(newBody).toString('base64');
|
||||
logger.info('适配器', '已拦截请求,强制使用 Nano Banana Pro', meta);
|
||||
await client.send('Fetch.continueRequest', {
|
||||
requestId,
|
||||
postData: newBodyBase64
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message });
|
||||
}
|
||||
}
|
||||
try {
|
||||
await client.send('Fetch.continueRequest', { requestId });
|
||||
} catch (e) { }
|
||||
};
|
||||
client.on('Fetch.requestPaused', fetchPausedHandler);
|
||||
const postData = request.postDataJSON();
|
||||
if (postData) {
|
||||
logger.debug('适配器', '已拦截请求,正在修改...', meta);
|
||||
if (!postData.streamAssistRequest) postData.streamAssistRequest = {};
|
||||
if (!postData.streamAssistRequest.assistGenerationConfig) postData.streamAssistRequest.assistGenerationConfig = {};
|
||||
postData.streamAssistRequest.toolsSpec = { imageGenerationSpec: {} };
|
||||
|
||||
logger.info('适配器', '已拦截请求,强制使用 Nano Banana Pro', meta);
|
||||
await route.continue({ postData: JSON.stringify(postData) });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('适配器', '请求拦截处理失败', { ...meta, error: e.message });
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
|
||||
// 5. 点击发送
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
@@ -266,7 +243,10 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
});
|
||||
|
||||
if (sendBtnHandle && sendBtnHandle.asElement()) {
|
||||
await safeClick(page, sendBtnHandle);
|
||||
// 确保按钮在可视区域
|
||||
await sendBtnHandle.asElement().scrollIntoViewIfNeeded();
|
||||
await sleep(300, 500);
|
||||
await safeClick(page, sendBtnHandle, { bias: 'button' });
|
||||
} else {
|
||||
logger.warn('适配器', '未找到发送按钮,尝试回车提交', meta);
|
||||
await inputHandle.focus();
|
||||
@@ -275,76 +255,51 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
|
||||
logger.info('适配器', '等待生成结果中...', meta);
|
||||
|
||||
// 6. 等待结果
|
||||
const result = await new Promise((resolve, reject) => {
|
||||
const requestMethods = new Map(); // Store request methods by requestId
|
||||
// 6. 等待结果 (使用 Playwright waitForResponse)
|
||||
// 我们需要等待两个响应:
|
||||
// 1. widgetStreamAssist (API 响应,检查是否成功)
|
||||
// 2. download/v1alpha/projects (图片下载请求)
|
||||
|
||||
const cleanup = () => {
|
||||
client.off('Network.requestWillBeSent', onRequest);
|
||||
client.off('Network.responseReceived', onRes);
|
||||
client.off('Network.loadingFinished', onLoad);
|
||||
if (fetchPausedHandler) {
|
||||
client.off('Fetch.requestPaused', fetchPausedHandler);
|
||||
client.send('Fetch.disable').catch(() => { });
|
||||
}
|
||||
};
|
||||
const apiResponsePromise = page.waitForResponse(response =>
|
||||
response.url().includes('global/widgetStreamAssist') &&
|
||||
response.request().method() === 'POST' &&
|
||||
(response.status() === 200 || response.status() >= 400),
|
||||
{ timeout: 120000 }
|
||||
).catch(e => e);
|
||||
|
||||
let targetRequestId = null;
|
||||
const imageDownloadPromise = page.waitForResponse(response =>
|
||||
response.url().includes('download/v1alpha/projects') &&
|
||||
response.request().method() === 'GET' &&
|
||||
response.status() === 200,
|
||||
{ timeout: 120000 }
|
||||
).catch(e => e);
|
||||
|
||||
const onRequest = (e) => {
|
||||
requestMethods.set(e.requestId, e.request.method);
|
||||
};
|
||||
// 等待 API 响应
|
||||
const apiResponse = await apiResponsePromise;
|
||||
|
||||
const onRes = (e) => {
|
||||
// 1. 监听生图接口错误 (如 429 Too Many Requests)
|
||||
if (e.response.url.includes('global/widgetStreamAssist')) {
|
||||
if (e.response.status !== 200) {
|
||||
logger.error('适配器', `请求返回错误状态码: ${e.response.status}`, meta);
|
||||
cleanup();
|
||||
resolve({ error: `API Error: ${e.response.status}` });
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (apiResponse instanceof Error) {
|
||||
throw apiResponse;
|
||||
}
|
||||
|
||||
if (e.response.url.includes('download/v1alpha/projects')) {
|
||||
const method = requestMethods.get(e.requestId);
|
||||
if (method === 'GET') {
|
||||
logger.info('适配器', '捕获到图片下载亲求', meta);
|
||||
targetRequestId = e.requestId;
|
||||
} else {
|
||||
logger.debug('适配器', `忽略非 GET 请求: ${method} - ${e.response.url}`, meta);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (apiResponse.status() !== 200) {
|
||||
logger.error('适配器', `请求返回错误状态码: ${apiResponse.status()}`, meta);
|
||||
return { error: `API Error: ${apiResponse.status()}` };
|
||||
}
|
||||
|
||||
const onLoad = async (e) => {
|
||||
if (e.requestId === targetRequestId) {
|
||||
try {
|
||||
const { body } = await client.send('Network.getResponseBody', { requestId: targetRequestId });
|
||||
// GeminiBiz 返回的 body 已经是不带前缀的 base64 字符串,直接使用
|
||||
const dataUri = `data:image/png;base64,${body}`;
|
||||
// 等待图片下载响应
|
||||
logger.info('适配器', 'API 请求成功,等待图片下载...', meta);
|
||||
const imageResponse = await imageDownloadPromise;
|
||||
|
||||
logger.info('适配器', '生图成功', meta);
|
||||
cleanup();
|
||||
resolve({ image: dataUri });
|
||||
} catch (err) {
|
||||
logger.error('适配器', '生图失败 (提取图片失败)', { ...meta, error: err.message });
|
||||
cleanup();
|
||||
resolve({ error: err.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
if (imageResponse instanceof Error) {
|
||||
throw imageResponse;
|
||||
}
|
||||
|
||||
client.on('Network.requestWillBeSent', onRequest);
|
||||
client.on('Network.responseReceived', onRes);
|
||||
client.on('Network.loadingFinished', onLoad);
|
||||
logger.info('适配器', '捕获到图片下载请求', meta);
|
||||
// 响应体本身就是 base64 字符串,直接获取文本即可,不需要再次 base64 编码
|
||||
const base64 = await imageResponse.text();
|
||||
const dataUri = `data:image/png;base64,${base64}`;
|
||||
|
||||
// 超时保护 (180秒)
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
resolve({ error: 'Timeout' });
|
||||
}, 180000);
|
||||
});
|
||||
logger.info('适配器', '生图成功', meta);
|
||||
|
||||
// 任务结束,移开鼠标
|
||||
if (page.cursor) {
|
||||
@@ -356,18 +311,14 @@ async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
await page.cursor.moveTo({ x: finalX, y: finalY });
|
||||
}
|
||||
|
||||
return result;
|
||||
return { image: dataUri };
|
||||
|
||||
} catch (err) {
|
||||
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
if (fetchPausedHandler) {
|
||||
client.off('Fetch.requestPaused', fetchPausedHandler);
|
||||
try {
|
||||
await client.send('Fetch.disable');
|
||||
} catch (e) { }
|
||||
}
|
||||
// 清理拦截器
|
||||
await page.unroute('**/*').catch(() => { });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { loadConfig } from '../config.js';
|
||||
import { loadConfig } from '../utils/config.js';
|
||||
import * as lmarenaBackend from './lmarena.js';
|
||||
import * as geminiBackend from './gemini_biz.js';
|
||||
import * as nanobananafreeBackend from './nanobananafree_ai.js';
|
||||
|
||||
const config = loadConfig();
|
||||
|
||||
@@ -13,6 +14,13 @@ if (config.backend?.type === 'gemini_biz') {
|
||||
generateImage: (ctx, prompt, paths, model, meta) => geminiBackend.generateImage(ctx, prompt, paths, model, meta),
|
||||
TEMP_DIR: geminiBackend.TEMP_DIR
|
||||
};
|
||||
} else if (config.backend?.type === 'nanobananafree_ai') {
|
||||
activeBackend = {
|
||||
name: 'nanobananafree_ai',
|
||||
initBrowser: (cfg) => nanobananafreeBackend.initBrowser(cfg),
|
||||
generateImage: (ctx, prompt, paths, model, meta) => nanobananafreeBackend.generateImage(ctx, prompt, paths, model, meta),
|
||||
TEMP_DIR: nanobananafreeBackend.TEMP_DIR
|
||||
};
|
||||
} else {
|
||||
activeBackend = {
|
||||
name: 'lmarena',
|
||||
|
||||
+127
-181
@@ -9,12 +9,15 @@ import {
|
||||
clamp,
|
||||
safeClick,
|
||||
humanType,
|
||||
pasteImages
|
||||
pasteImages,
|
||||
getHumanClickPoint
|
||||
} from '../browser/utils.js';
|
||||
import { logger } from '../logger.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
import { loadConfig } from '../utils/config.js';
|
||||
import { getProxyConfig, getHttpProxy } from '../utils/proxy.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'chromeUserData');
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData');
|
||||
const TARGET_URL = 'https://lmarena.ai/c/new?mode=direct&chat-modality=image';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
|
||||
@@ -25,8 +28,8 @@ if (!fs.existsSync(TEMP_DIR)) {
|
||||
|
||||
/**
|
||||
* 从响应文本中提取图片 URL
|
||||
* @param {string} text 响应文本
|
||||
* @returns {string|null} 图片 URL 或 null
|
||||
* @param {string} text - 响应文本内容
|
||||
* @returns {string|null} 提取到的图片 URL,如果未找到则返回 null
|
||||
*/
|
||||
function extractImage(text) {
|
||||
if (!text) return null;
|
||||
@@ -43,21 +46,20 @@ function extractImage(text) {
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @param {object} config 配置对象 (包含 chrome 配置)
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>}
|
||||
* 初始化浏览器会话
|
||||
* @param {object} config - 全局配置对象
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>} 初始化后的浏览器上下文
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
// LMArena 特定的输入框验证
|
||||
// LMArena 特定的输入框验证逻辑
|
||||
const waitInputValidator = async (page) => {
|
||||
const textareaSelector = 'textarea';
|
||||
await page.waitForSelector(textareaSelector, { timeout: 60000 });
|
||||
|
||||
// 移动鼠标到输入框
|
||||
const box = await (await page.$(textareaSelector)).boundingBox();
|
||||
if (box) {
|
||||
if (page.cursor) {
|
||||
await page.cursor.moveTo({ x: box.x + box.width / 2, y: box.y + box.height / 2 });
|
||||
const { x, y } = getHumanClickPoint(box, 'input');
|
||||
await page.cursor.moveTo({ x, y });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
@@ -67,219 +69,163 @@ async function initBrowser(config) {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
targetUrl: TARGET_URL,
|
||||
productName: 'LMArena',
|
||||
reuseExistingTab: true,
|
||||
waitInputValidator
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
* @param {object} context 浏览器上下文 {page, client}
|
||||
* @param {string} prompt 提示词
|
||||
* @param {string[]} imgPaths 图片路径数组
|
||||
* @param {string|null} modelId 模型 UUID (可选)
|
||||
* @returns {Promise<{image?: string, text?: string, error?: string}>}
|
||||
* @param {object} context - 浏览器上下文 { page, client }
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {string[]} imgPaths - 图片路径数组
|
||||
* @param {string} [modelId] - 指定的模型 ID (可选)
|
||||
* @param {object} [meta={}] - 日志元数据
|
||||
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page, client } = context;
|
||||
const { page } = context;
|
||||
const textareaSelector = 'textarea';
|
||||
let fetchPausedHandler = null;
|
||||
|
||||
try {
|
||||
// 1. 强制开启新会话 (通过URL跳转)
|
||||
logger.info('适配器', '开启新会话', meta);
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 等待输入框出现
|
||||
// 等待输入框加载
|
||||
await page.waitForSelector(textareaSelector, { timeout: 30000 });
|
||||
await sleep(1500, 2500); // 等页面稳一点
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 2. 粘贴图片
|
||||
// 1. 上传图片
|
||||
if (imgPaths && imgPaths.length > 0) {
|
||||
await pasteImages(page, textareaSelector, imgPaths);
|
||||
// 如果没有图片,也点击一下输入框获取焦点
|
||||
await safeClick(page, textareaSelector);
|
||||
// 确保焦点在输入框
|
||||
await safeClick(page, textareaSelector, { bias: 'input' });
|
||||
}
|
||||
|
||||
// 3. 输入 Prompt
|
||||
// 2. 输入提示词
|
||||
logger.info('适配器', '正在输入提示词...', meta);
|
||||
await humanType(page, textareaSelector, prompt);
|
||||
await sleep(800, 1500);
|
||||
|
||||
// 注入 CDP 拦截器
|
||||
// 3. 配置请求拦截 (用于修改模型 ID)
|
||||
await page.unroute('**/*').catch(() => { });
|
||||
|
||||
if (modelId) {
|
||||
// 1. 启用 Fetch 域拦截,仅拦截特定 URL
|
||||
await client.send('Fetch.enable', {
|
||||
patterns: [{
|
||||
urlPattern: '*nextjs-api/stream*',
|
||||
requestStage: 'Request'
|
||||
}]
|
||||
});
|
||||
logger.debug('适配器', `准备拦截请求`, meta);
|
||||
await page.route(url => url.href.includes('/nextjs-api/stream'), async (route) => {
|
||||
const request = route.request();
|
||||
if (request.method() !== 'POST') return route.continue();
|
||||
|
||||
// 2. 定义拦截处理函数
|
||||
fetchPausedHandler = async (event) => {
|
||||
const { requestId, request } = event;
|
||||
|
||||
if (request.method === 'POST' && request.postData) {
|
||||
try {
|
||||
// 尝试解码可能是 Base64 编码的postData
|
||||
let rawBody = request.postData;
|
||||
// 尝试解析 JSON
|
||||
let data;
|
||||
try {
|
||||
data = JSON.parse(rawBody);
|
||||
} catch (e) {
|
||||
// 尝试 Base64 解码
|
||||
try {
|
||||
rawBody = Buffer.from(rawBody, 'base64').toString('utf8');
|
||||
data = JSON.parse(rawBody);
|
||||
} catch (e2) {
|
||||
// 无法解析,跳过
|
||||
}
|
||||
}
|
||||
|
||||
if (data && data.modelAId) {
|
||||
logger.debug('适配器', `已拦截请求,原始模型UUID: ${data.modelAId}`, meta);
|
||||
|
||||
// 修改 modelAId
|
||||
data.modelAId = modelId;
|
||||
|
||||
// 重新序列化并转为 Base64 (Fetch.continueRequest 需要 base64)
|
||||
const newBody = JSON.stringify(data);
|
||||
const newBodyBase64 = Buffer.from(newBody).toString('base64');
|
||||
logger.debug('适配器', `已拦截请求,修改模型UUID为: ${data.modelAId}`, meta);
|
||||
logger.info('适配器', '已拦截请求,修改为指定模型', meta);
|
||||
|
||||
await client.send('Fetch.continueRequest', {
|
||||
requestId,
|
||||
postData: newBodyBase64
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('适配器', '请求拦截处理出错', { ...meta, error: e.message });
|
||||
}
|
||||
}
|
||||
|
||||
// 如果不匹配或出错,直接放行
|
||||
try {
|
||||
await client.send('Fetch.continueRequest', { requestId });
|
||||
} catch (e) { }
|
||||
};
|
||||
|
||||
// 3. 监听拦截事件
|
||||
client.on('Fetch.requestPaused', fetchPausedHandler);
|
||||
logger.debug('适配器', `已启用请求拦截`, meta);
|
||||
const postData = request.postDataJSON();
|
||||
if (postData && postData.modelAId) {
|
||||
logger.info('适配器', `已拦截请求并修改模型: ${postData.modelAId} -> ${modelId}`, meta);
|
||||
postData.modelAId = modelId;
|
||||
await route.continue({ postData: JSON.stringify(postData) });
|
||||
return;
|
||||
}
|
||||
} catch (e) {
|
||||
logger.error('适配器', '拦截处理异常', { ...meta, error: e.message });
|
||||
}
|
||||
await route.continue();
|
||||
});
|
||||
}
|
||||
|
||||
// 4. 发送
|
||||
// 4. 建立响应监听器
|
||||
// 只要 URL 匹配且是 POST,无论状态码是 200 还是 429,都立即返回,防止超时死等
|
||||
const responsePromise = page.waitForResponse(response =>
|
||||
response.url().includes('/nextjs-api/stream') &&
|
||||
response.request().method() === 'POST' &&
|
||||
(response.status() === 200 || response.status() >= 400),
|
||||
{ timeout: 120000 }
|
||||
).catch(e => e);
|
||||
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
const btnSelector = 'button[type="submit"]';
|
||||
await safeClick(page, btnSelector);
|
||||
await safeClick(page, btnSelector, { bias: 'button' });
|
||||
|
||||
logger.info('适配器', '等待生成结果中...', meta);
|
||||
logger.info('适配器', '等待生成结果...', meta);
|
||||
|
||||
// 5. 监听网络响应
|
||||
let targetRequestId = null;
|
||||
const result = await new Promise((resolve) => {
|
||||
const cleanup = () => {
|
||||
client.off('Network.responseReceived', onRes);
|
||||
client.off('Network.loadingFinished', onLoad);
|
||||
};
|
||||
const onRes = (e) => {
|
||||
// 监听流式响应接口
|
||||
if (e.response.url.includes('/nextjs-api/stream/')) targetRequestId = e.requestId;
|
||||
};
|
||||
const onLoad = async (e) => {
|
||||
if (e.requestId === targetRequestId) {
|
||||
try {
|
||||
const { body, base64Encoded } = await client.send('Network.getResponseBody', { requestId: targetRequestId });
|
||||
const content = base64Encoded ? Buffer.from(body, 'base64').toString('utf8') : body;
|
||||
// 5. 等待并处理响应
|
||||
const response = await responsePromise;
|
||||
|
||||
// 检查是否包含 reCAPTCHA 错误
|
||||
if (content.includes('recaptcha validation failed')) {
|
||||
cleanup();
|
||||
resolve({ error: 'recaptcha validation failed' });
|
||||
return;
|
||||
}
|
||||
|
||||
const img = extractImage(content);
|
||||
if (img) {
|
||||
logger.info('适配器', '已获取生图结果,正在下载图片...', meta);
|
||||
|
||||
// 下载图片并转换为 Base64
|
||||
try {
|
||||
const response = await gotScraping({
|
||||
url: img,
|
||||
responseType: 'buffer',
|
||||
http2: true,
|
||||
headerGeneratorOptions: {
|
||||
browsers: [{ name: 'chrome', minVersion: 110 }],
|
||||
devices: ['desktop'],
|
||||
locales: ['en-US'],
|
||||
operatingSystems: ['windows'],
|
||||
}
|
||||
});
|
||||
const base64 = response.body.toString('base64');
|
||||
const dataUri = `data:image/png;base64,${base64}`;
|
||||
logger.info('适配器', '生图成功', meta);
|
||||
|
||||
cleanup();
|
||||
resolve({ image: dataUri });
|
||||
} catch (e) {
|
||||
logger.error('适配器', '图片下载失败', { ...meta, error: e.message });
|
||||
cleanup();
|
||||
resolve({ error: `Image download failed: ${e.message}` });
|
||||
}
|
||||
} else {
|
||||
logger.info('适配器', 'AI 返回文本回复', { ...meta, preview: content.substring(0, 150) });
|
||||
cleanup();
|
||||
resolve({ text: content });
|
||||
}
|
||||
} catch (err) {
|
||||
cleanup();
|
||||
resolve({ error: err.message });
|
||||
}
|
||||
}
|
||||
};
|
||||
client.on('Network.responseReceived', onRes);
|
||||
client.on('Network.loadingFinished', onLoad);
|
||||
|
||||
// 超时保护 (120秒)
|
||||
setTimeout(() => {
|
||||
cleanup();
|
||||
resolve({ error: 'Timeout' });
|
||||
}, 120000);
|
||||
});
|
||||
|
||||
// 任务结束,基于当前窗口比例智能移开鼠标
|
||||
if (page.cursor) {
|
||||
// 1. 再次获取最新窗口大小 (用户可能在生成过程中改变了窗口大小)
|
||||
const currentVp = await getRealViewport(page);
|
||||
|
||||
// 2. 计算相对坐标:停靠在屏幕右侧 85% ~ 95% 的位置
|
||||
const relativeX = currentVp.safeWidth * random(0.85, 0.95);
|
||||
const relativeY = currentVp.height * random(0.3, 0.7); // 高度居中随机
|
||||
|
||||
// 3. 再次检查
|
||||
const finalX = clamp(relativeX, 0, currentVp.safeWidth);
|
||||
const finalY = clamp(relativeY, 0, currentVp.safeHeight);
|
||||
await page.cursor.moveTo({ x: finalX, y: finalY });
|
||||
if (response instanceof Error) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
return result;
|
||||
// 检查状态码
|
||||
if (response.status() === 429) {
|
||||
logger.warn('适配器', '触发限流/人机验证', meta);
|
||||
return { error: 'Rate limit exceeded or CAPTCHA triggered (HTTP 429)' };
|
||||
}
|
||||
|
||||
if (response.status() !== 200) {
|
||||
logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta);
|
||||
return { error: `Server error: HTTP ${response.status()}` };
|
||||
}
|
||||
|
||||
// 解析成功响应
|
||||
const content = await response.text();
|
||||
|
||||
// 检查业务错误
|
||||
if (content.includes('recaptcha validation failed')) {
|
||||
return { error: 'recaptcha validation failed' };
|
||||
}
|
||||
|
||||
const img = extractImage(content);
|
||||
if (img) {
|
||||
logger.info('适配器', '已获取生图结果,正在下载图片...', meta);
|
||||
try {
|
||||
// 获取代理配置
|
||||
const config = loadConfig();
|
||||
const proxyConfig = getProxyConfig(config);
|
||||
const proxyUrl = await getHttpProxy(proxyConfig);
|
||||
|
||||
const options = {
|
||||
url: img,
|
||||
responseType: 'buffer',
|
||||
http2: true,
|
||||
headerGeneratorOptions: {
|
||||
browsers: [{ name: 'firefox', minVersion: 100 }],
|
||||
devices: ['desktop'],
|
||||
locales: ['en-US'],
|
||||
operatingSystems: ['windows'],
|
||||
}
|
||||
};
|
||||
|
||||
if (proxyUrl) {
|
||||
options.proxyUrl = proxyUrl;
|
||||
}
|
||||
|
||||
const imgRes = await gotScraping(options);
|
||||
const base64 = imgRes.body.toString('base64');
|
||||
return { image: `data:image/png;base64,${base64}` };
|
||||
} catch (e) {
|
||||
return { error: `Image download failed: ${e.message}` };
|
||||
}
|
||||
} else {
|
||||
logger.info('适配器', 'AI 返回文本回复', { ...meta, preview: content.substring(0, 150) });
|
||||
return { text: content };
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') return { error: 'Timeout: 生成响应超时' };
|
||||
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
if (fetchPausedHandler) {
|
||||
client.off('Fetch.requestPaused', fetchPausedHandler);
|
||||
// 清理拦截器
|
||||
if (modelId) await page.unroute('**/*').catch(() => { });
|
||||
|
||||
// 任务结束,将鼠标移至安全区域
|
||||
if (page.cursor) {
|
||||
try {
|
||||
await client.send('Fetch.disable');
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
+14
-1
@@ -7,6 +7,10 @@ export const IMAGE_POLICY = {
|
||||
|
||||
// LMArena 后端模型配置
|
||||
export const LMARENA_MODELS = {
|
||||
"gemini-3-pro-image-preview-2k": {
|
||||
codeName: "019abc10-e78d-7932-b725-7f1563ed8a12",
|
||||
imagePolicy: IMAGE_POLICY.OPTIONAL
|
||||
},
|
||||
"gemini-3-pro-image-preview": {
|
||||
codeName: "019aa208-5c19-7162-ae3b-0a9ddbb1e16a",
|
||||
imagePolicy: IMAGE_POLICY.OPTIONAL
|
||||
@@ -128,9 +132,16 @@ export const GEMINI_BIZ_MODELS = {
|
||||
}
|
||||
};
|
||||
|
||||
// NanoBananaFree AI 后端模型配置
|
||||
export const NANOBANANAFREE_AI_MODELS = {
|
||||
"gemini-2.5-flash-image": {
|
||||
imagePolicy: IMAGE_POLICY.OPTIONAL
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* 获取后端对应的模型配置表
|
||||
* @param {string} backendName - 后端名称 ('lmarena' 或 'gemini_biz')
|
||||
* @param {string} backendName - 后端名称 ('lmarena' 或 'gemini_biz' 或 'nanobananafree_ai')
|
||||
* @returns {Object} 模型配置对象
|
||||
* @private
|
||||
*/
|
||||
@@ -140,6 +151,8 @@ function getModelsConfigForBackend(backendName) {
|
||||
return LMARENA_MODELS;
|
||||
case 'gemini_biz':
|
||||
return GEMINI_BIZ_MODELS;
|
||||
case 'nanobananafree_ai':
|
||||
return NANOBANANAFREE_AI_MODELS;
|
||||
// 将来新增其它后端:
|
||||
// case 'foo_site':
|
||||
// return FOO_SITE_MODELS;
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { initBrowserBase } from '../browser/launcher.js';
|
||||
import {
|
||||
random,
|
||||
sleep,
|
||||
getRealViewport,
|
||||
clamp,
|
||||
safeClick,
|
||||
humanType,
|
||||
pasteImages,
|
||||
getHumanClickPoint
|
||||
} from '../browser/utils.js';
|
||||
import { logger } from '../utils/logger.js';
|
||||
|
||||
// --- 配置常量 ---
|
||||
const USER_DATA_DIR = path.join(process.cwd(), 'data', 'camoufoxUserData');
|
||||
const TARGET_URL = 'https://nanobananafree.ai/';
|
||||
const TEMP_DIR = path.join(process.cwd(), 'data', 'temp');
|
||||
|
||||
// 确保临时目录存在
|
||||
if (!fs.existsSync(TEMP_DIR)) {
|
||||
fs.mkdirSync(TEMP_DIR, { recursive: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* 初始化浏览器
|
||||
* @param {object} config - 配置对象
|
||||
* @returns {Promise<{browser: object, page: object, client: object}>}
|
||||
*/
|
||||
async function initBrowser(config) {
|
||||
// NanoBananaFree AI 特定的输入框验证逻辑
|
||||
const waitInputValidator = async (page) => {
|
||||
const textareaSelector = 'textarea';
|
||||
await page.waitForSelector(textareaSelector, { timeout: 60000 });
|
||||
const box = await (await page.$(textareaSelector)).boundingBox();
|
||||
if (box) {
|
||||
if (page.cursor) {
|
||||
const { x, y } = getHumanClickPoint(box, 'input');
|
||||
await page.cursor.moveTo({ x, y });
|
||||
}
|
||||
await sleep(500, 1000);
|
||||
}
|
||||
};
|
||||
|
||||
return await initBrowserBase(config, {
|
||||
userDataDir: USER_DATA_DIR,
|
||||
targetUrl: TARGET_URL,
|
||||
productName: 'NanoBananaFree AI',
|
||||
waitInputValidator
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 执行生图任务
|
||||
* @param {object} context - 浏览器上下文 { page, client }
|
||||
* @param {string} prompt - 提示词
|
||||
* @param {string[]} imgPaths - 图片路径数组 (仅取第一张)
|
||||
* @param {string} [modelId] - 指定的模型 ID (可选,目前未使用)
|
||||
* @param {object} [meta={}] - 日志元数据
|
||||
* @returns {Promise<{image?: string, text?: string, error?: string}>} 生成结果
|
||||
*/
|
||||
async function generateImage(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
const { page } = context;
|
||||
const textareaSelector = 'textarea';
|
||||
|
||||
try {
|
||||
logger.info('适配器', '开启新会话', meta);
|
||||
await page.goto(TARGET_URL, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 等待输入框加载
|
||||
await page.waitForSelector(textareaSelector, { timeout: 30000 });
|
||||
await sleep(1500, 2500);
|
||||
|
||||
// 1. 上传图片 (仅取第一张,多余的丢弃)
|
||||
if (imgPaths && imgPaths.length > 0) {
|
||||
const singleImage = [imgPaths[0]]; // 只取第一张
|
||||
if (imgPaths.length > 1) {
|
||||
logger.warn('适配器', `此后端仅支持1张图片,已丢弃 ${imgPaths.length - 1} 张`, meta);
|
||||
}
|
||||
await pasteImages(page, textareaSelector, singleImage);
|
||||
// 确保焦点在输入框
|
||||
await safeClick(page, textareaSelector, { bias: 'input' });
|
||||
}
|
||||
|
||||
// 2. 输入提示词
|
||||
logger.info('适配器', '正在输入提示词...', meta);
|
||||
await humanType(page, textareaSelector, prompt);
|
||||
await sleep(800, 1500);
|
||||
|
||||
// 3. 建立响应监听器
|
||||
// 监听包含 v1/generateContent 路径的 POST 请求
|
||||
const responsePromise = page.waitForResponse(response =>
|
||||
response.url().includes('v1/generateContent') &&
|
||||
response.request().method() === 'POST' &&
|
||||
(response.status() === 200 || response.status() >= 400),
|
||||
{ timeout: 120000 }
|
||||
).catch(e => e);
|
||||
|
||||
// 4. 点击发送按钮 (匹配 class 包含 _sendButton_ 的 div)
|
||||
logger.debug('适配器', '点击发送...', meta);
|
||||
|
||||
// 使用更通用的选择器匹配发送按钮
|
||||
const sendBtnSelector = 'div[class*="_sendButton_"]';
|
||||
await page.waitForSelector(sendBtnSelector, { timeout: 10000 });
|
||||
await safeClick(page, sendBtnSelector, { bias: 'button' });
|
||||
|
||||
logger.info('适配器', '等待生成结果...', meta);
|
||||
|
||||
// 5. 等待并处理响应
|
||||
const response = await responsePromise;
|
||||
|
||||
if (response instanceof Error) {
|
||||
throw response;
|
||||
}
|
||||
|
||||
// 检查状态码
|
||||
if (response.status() !== 200) {
|
||||
// 非200状态,尝试读取错误信息
|
||||
try {
|
||||
const body = await response.json();
|
||||
const errMessage = body?.errMessage || body?.error?.message || `HTTP ${response.status()}`;
|
||||
logger.warn('适配器', `请求返回错误: ${errMessage}`, meta);
|
||||
return { error: errMessage };
|
||||
} catch (e) {
|
||||
logger.warn('适配器', `返回异常状态码: ${response.status()}`, meta);
|
||||
return { error: `Server error: HTTP ${response.status()}` };
|
||||
}
|
||||
}
|
||||
|
||||
// 解析成功响应
|
||||
const body = await response.json();
|
||||
|
||||
// 尝试从响应中提取 base64 图片
|
||||
// 路径: data.candidates[0].content.parts[0].inlineData.data
|
||||
const inlineData = body?.data?.candidates?.[0]?.content?.parts?.[0]?.inlineData?.data;
|
||||
|
||||
if (inlineData) {
|
||||
logger.info('适配器', '已获取生图结果', meta);
|
||||
// 返回带有 data URI 前缀的 base64 图片
|
||||
return { image: `data:image/png;base64,${inlineData}` };
|
||||
} else {
|
||||
// 没有找到图片数据,可能是文本回复或其他格式
|
||||
logger.info('适配器', 'AI 返回非图片响应', { ...meta, preview: JSON.stringify(body).substring(0, 150) });
|
||||
return { text: JSON.stringify(body) };
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
if (err.name === 'TimeoutError') return { error: 'Timeout: 生成响应超时' };
|
||||
logger.error('适配器', '生成任务失败', { ...meta, error: err.message });
|
||||
return { error: err.message };
|
||||
} finally {
|
||||
// 任务结束,将鼠标移至安全区域
|
||||
if (page.cursor) {
|
||||
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) { }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export { initBrowser, generateImage, TEMP_DIR };
|
||||
Reference in New Issue
Block a user