feat: 将项目迁移到Playwright+Camoufox方案

This commit is contained in:
foxhui
2025-12-06 23:37:36 +08:00
Unverified
parent 5c758a7288
commit e88d4941c9
23 changed files with 2901 additions and 2314 deletions
+79 -128
View File
@@ -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(() => { });
}
}
+9 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+166
View File
@@ -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 };