mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 增加拟人鼠标轨迹选择,Token 允许留空 (closes #12)
This commit is contained in:
@@ -59,7 +59,7 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
await gotoWithCheck(page, TARGET_URL);
|
||||
|
||||
// 1. 等待输入框加载
|
||||
await waitForInput(page, textareaSelector, { click: false });
|
||||
await waitForInput(page, textareaSelector, { click: true });
|
||||
|
||||
// 2. 选择模型
|
||||
if (modelId) {
|
||||
@@ -81,13 +81,22 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
document.execCommand('insertText', false, text);
|
||||
}, searchText);
|
||||
|
||||
// 等待下拉选项出现后再按回车
|
||||
// 等待过滤完成:第一个选项包含目标模型的主 ID
|
||||
// searchText 可能是 codeName(含括号说明),但过滤后的选项应该包含 modelId
|
||||
try {
|
||||
await page.waitForSelector('[role="option"]', { timeout: 5000 });
|
||||
await page.waitForFunction(
|
||||
(targetId) => {
|
||||
const firstOption = document.querySelector('[role="option"]');
|
||||
return firstOption && firstOption.textContent?.includes(targetId);
|
||||
},
|
||||
modelId,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
} catch {
|
||||
// 超时也继续,可能选项已经存在
|
||||
// 超时也继续,可能列表结构不同
|
||||
logger.debug('适配器', `等待模型选项过滤超时,继续执行`, meta);
|
||||
}
|
||||
await sleep(200, 300);
|
||||
await sleep(300, 500);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
@@ -196,7 +205,6 @@ export const manifest = {
|
||||
|
||||
// 模型列表
|
||||
models: [
|
||||
{ id: 'gemini-3-pro-image-preview-2k', imagePolicy: 'optional' },
|
||||
{ id: 'gemini-3-pro-image-preview', codeName: 'gemini-3-pro-image-preview (nano-banana-pro)', imagePolicy: 'optional' },
|
||||
{ id: 'hunyuan-image-3.0', imagePolicy: 'forbidden' },
|
||||
{ id: 'vidu-q2-image', imagePolicy: 'optional' },
|
||||
|
||||
@@ -65,13 +65,22 @@ async function generate(context, prompt, imgPaths, modelId, meta = {}) {
|
||||
document.execCommand('insertText', false, text);
|
||||
}, searchText);
|
||||
|
||||
// 等待下拉选项出现后再按回车
|
||||
// 等待过滤完成:第一个选项包含目标模型的主 ID
|
||||
// searchText 可能是 codeName(含括号说明),但过滤后的选项应该包含 modelId
|
||||
try {
|
||||
await page.waitForSelector('[role="option"]', { timeout: 5000 });
|
||||
await page.waitForFunction(
|
||||
(targetId) => {
|
||||
const firstOption = document.querySelector('[role="option"]');
|
||||
return firstOption && firstOption.textContent?.includes(targetId);
|
||||
},
|
||||
modelId,
|
||||
{ timeout: 5000 }
|
||||
);
|
||||
} catch {
|
||||
// 超时也继续,可能选项已经存在
|
||||
// 超时也继续,可能列表结构不同
|
||||
logger.debug('适配器', `等待模型选项过滤超时,继续执行`, meta);
|
||||
}
|
||||
await sleep(200, 300);
|
||||
await sleep(300, 500);
|
||||
await page.keyboard.press('Enter');
|
||||
}
|
||||
|
||||
|
||||
@@ -295,6 +295,7 @@ export async function initBrowserBase(config, options = {}) {
|
||||
block_webrtc: true,
|
||||
exclude_addons: ['UBO'],
|
||||
geoip: true,
|
||||
humanize: browserConfig.humanizeCursor === 'camou',
|
||||
config: {
|
||||
forceScopeAccess: true,
|
||||
// Canvas 抗指纹:注入固定噪点偏移
|
||||
|
||||
@@ -229,6 +229,9 @@ export async function safeClick(page, target, options = {}) {
|
||||
const timeout = options.timeout || TIMEOUTS.ELEMENT_CLICK;
|
||||
const waitStable = options.waitStable !== false; // 默认 true
|
||||
const selector = typeof target === 'string' ? target : '元素';
|
||||
// humanizeCursorMode: false=禁用, true=ghost-cursor, "camou"=Camoufox内置
|
||||
// 只有 true 时才使用 ghost-cursor,其他情况都使用原生点击
|
||||
const useGhostCursor = page?._humanizeCursorMode === true && page?.cursor;
|
||||
|
||||
const doClick = async () => {
|
||||
let el;
|
||||
@@ -260,9 +263,8 @@ export async function safeClick(page, target, options = {}) {
|
||||
await waitForElementStable(el);
|
||||
logger.debug('浏览器', `[safeClick] 元素已稳定`);
|
||||
}
|
||||
|
||||
// 使用 ghost-cursor 点击
|
||||
if (page.cursor) {
|
||||
// 使用自维护 ghost-cursor 拟人鼠标轨迹 (仅当 humanizeCursor=true)
|
||||
if (useGhostCursor) {
|
||||
const box = await el.boundingBox();
|
||||
logger.debug('浏览器', `[safeClick] boundingBox: ${JSON.stringify(box)}`);
|
||||
if (box) {
|
||||
@@ -279,9 +281,11 @@ export async function safeClick(page, target, options = {}) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 降级逻辑
|
||||
logger.debug('浏览器', `[safeClick] 无 cursor,使用原生 click`);
|
||||
await el.click({ clickCount });
|
||||
// 使用原生点击 (humanizeCursor=false 或 "camou")
|
||||
const mode = page?._humanizeCursorMode;
|
||||
logger.debug('浏览器', `[safeClick] humanizeCursor=${mode} 使用原生点击`);
|
||||
// force: true 跳过可操作性检查(遮挡检测等),避免在复杂页面卡住
|
||||
await el.click({ clickCount, force: true });
|
||||
};
|
||||
|
||||
// 带超时的执行(移除了重试机制)
|
||||
@@ -673,8 +677,18 @@ export async function uploadFilesViaChooser(page, triggerTarget, filePaths, opti
|
||||
const clickCount = clickAction === 'dblclick' ? 2 : 1;
|
||||
await safeClick(page, triggerTarget, { bias: 'button', clickCount });
|
||||
|
||||
// 等待 filechooser 事件并设置文件
|
||||
const fileChooser = await fileChooserPromise;
|
||||
// 等待 filechooser 事件并设置文件(带异常保护)
|
||||
let fileChooser;
|
||||
try {
|
||||
fileChooser = await fileChooserPromise;
|
||||
} catch (e) {
|
||||
// filechooser 超时通常意味着点击没有触发文件选择器
|
||||
// 抛出可识别的错误让上层决定是否重试
|
||||
const error = new Error(`文件选择器等待超时: ${e.message}`);
|
||||
error.code = 'UPLOAD_FILECHOOSER_TIMEOUT';
|
||||
throw error;
|
||||
}
|
||||
|
||||
await fileChooser.setFiles(filePaths);
|
||||
logger.debug('浏览器', '已通过 filechooser 提交文件');
|
||||
|
||||
|
||||
@@ -116,7 +116,12 @@ export class Worker {
|
||||
this.browser = sharedBrowser;
|
||||
this.page = await sharedBrowser.newPage();
|
||||
this.page.authState = { isHandlingAuth: false };
|
||||
this.page.cursor = createCursor(this.page);
|
||||
const humanizeCursorMode = this.globalConfig?.browser?.humanizeCursor;
|
||||
this.page._humanizeCursorMode = humanizeCursorMode;
|
||||
// true 表示使用项目维护的 ghost-cursor
|
||||
if (humanizeCursorMode === true) {
|
||||
this.page.cursor = createCursor(this.page);
|
||||
}
|
||||
|
||||
// 保存参数用于重新初始化
|
||||
this._targetUrl = targetUrl;
|
||||
@@ -165,7 +170,11 @@ export class Worker {
|
||||
async _recreatePage() {
|
||||
this.page = await this.browser.newPage();
|
||||
this.page.authState = { isHandlingAuth: false };
|
||||
this.page.cursor = createCursor(this.page);
|
||||
const humanizeCursorMode = this.globalConfig?.browser?.humanizeCursor;
|
||||
this.page._humanizeCursorMode = humanizeCursorMode;
|
||||
if (humanizeCursorMode === true) {
|
||||
this.page.cursor = createCursor(this.page);
|
||||
}
|
||||
await this._navigateToTarget(this._targetUrl || 'about:blank');
|
||||
|
||||
if (this._navigationHandler) {
|
||||
@@ -195,7 +204,11 @@ export class Worker {
|
||||
this.browser = base.context;
|
||||
this.page = base.page;
|
||||
this.page.authState = { isHandlingAuth: false };
|
||||
this.page.cursor = createCursor(this.page);
|
||||
const humanizeCursorMode = this.globalConfig?.browser?.humanizeCursor;
|
||||
this.page._humanizeCursorMode = humanizeCursorMode;
|
||||
if (humanizeCursorMode === true) {
|
||||
this.page.cursor = createCursor(this.page);
|
||||
}
|
||||
|
||||
if (navigationHandler) {
|
||||
this.page.on('framenavigated', async () => {
|
||||
@@ -244,7 +257,11 @@ export class Worker {
|
||||
sharedWorker.browser = this.browser;
|
||||
sharedWorker.page = await this.browser.newPage();
|
||||
sharedWorker.page.authState = { isHandlingAuth: false };
|
||||
sharedWorker.page.cursor = createCursor(sharedWorker.page);
|
||||
const sharedCursorMode = this.globalConfig?.browser?.humanizeCursor;
|
||||
sharedWorker.page._humanizeCursorMode = sharedCursorMode;
|
||||
if (sharedCursorMode === true) {
|
||||
sharedWorker.page.cursor = createCursor(sharedWorker.page);
|
||||
}
|
||||
await sharedWorker._navigateToTarget(sharedWorker._targetUrl || 'about:blank');
|
||||
sharedWorker._registerPageCloseHandler(); // 重新注册标签页关闭处理器
|
||||
sharedWorker.initialized = true;
|
||||
|
||||
+13
-4
@@ -231,11 +231,14 @@ export function loadConfig() {
|
||||
if (typeof port !== 'number' || !Number.isInteger(port) || port < 1 || port > 65535) {
|
||||
throw new Error(`server.port 必须是 1-65535 范围内的整数,当前值: ${port}`);
|
||||
}
|
||||
// Auth Token 校验:允许留空,但输出安全警告
|
||||
if (!config.server.auth) {
|
||||
throw new Error('配置文件缺少必需字段: server.auth');
|
||||
}
|
||||
if (typeof config.server.auth !== 'string' || config.server.auth.length < 10) {
|
||||
throw new Error('server.auth 必须是至少 10 个字符的字符串 (建议使用 npm run genkey 生成)');
|
||||
logger.warn('配置器', 'server.auth 未配置!API 和 WebUI 将无需认证即可访问!');
|
||||
logger.warn('配置器', '请勿在公网环境中留空 auth,建议使用 npm run genkey 生成密钥');
|
||||
} else if (config.server.auth === 'sk-change-me-to-your-secure-key') {
|
||||
logger.warn('配置器', '检测到默认密钥!请勿在公网环境中使用默认密钥');
|
||||
} else if (typeof config.server.auth !== 'string' || config.server.auth.length < 10) {
|
||||
logger.warn('配置器', 'server.auth 长度少于 10 个字符,安全性较低,建议使用 npm run genkey 生成密钥');
|
||||
}
|
||||
|
||||
// 设置 keepalive 配置默认值
|
||||
@@ -249,6 +252,12 @@ export function loadConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
// 设置 browser 配置默认值
|
||||
if (!config.browser) config.browser = {};
|
||||
if (config.browser.humanizeCursor === undefined) {
|
||||
config.browser.humanizeCursor = true;
|
||||
}
|
||||
|
||||
// 设置 Pool 配置默认值
|
||||
if (!config.backend) config.backend = {};
|
||||
if (!config.backend.pool) config.backend.pool = {};
|
||||
|
||||
@@ -83,6 +83,7 @@ export function getBrowserConfig() {
|
||||
path: browser.path || '',
|
||||
headless: browser.headless || false,
|
||||
fission: browser.fission !== false, // 默认 true
|
||||
humanizeCursor: browser.humanizeCursor ?? true, // false | true | 'camou'
|
||||
cssInject: {
|
||||
animation: cssInject.animation || false,
|
||||
filter: cssInject.filter || false,
|
||||
@@ -112,6 +113,7 @@ export function saveBrowserConfig(data) {
|
||||
if (data.path !== undefined) config.browser.path = data.path;
|
||||
if (data.headless !== undefined) config.browser.headless = data.headless;
|
||||
if (data.fission !== undefined) config.browser.fission = data.fission;
|
||||
if (data.humanizeCursor !== undefined) config.browser.humanizeCursor = data.humanizeCursor;
|
||||
|
||||
// CSS 性能优化配置
|
||||
if (data.cssInject) {
|
||||
|
||||
@@ -22,12 +22,12 @@ export function validateServerConfig(data) {
|
||||
}
|
||||
}
|
||||
|
||||
// Auth Token 校验
|
||||
// Auth Token 校验:允许留空,但非空时必须至少 10 个字符
|
||||
if (data.authToken !== undefined) {
|
||||
if (typeof data.authToken !== 'string') {
|
||||
errors.push('authToken 必须是字符串');
|
||||
} else if (data.authToken.length < 10) {
|
||||
errors.push('authToken 必须至少 10 个字符');
|
||||
} else if (data.authToken.length > 0 && data.authToken.length < 10) {
|
||||
errors.push('authToken 如果设置则必须至少 10 个字符,或留空');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,10 @@ export function createAuthMiddleware(authToken) {
|
||||
* @returns {boolean} 是否通过鉴权
|
||||
*/
|
||||
return function authMiddleware(req, res) {
|
||||
// 如果 authToken 为空,跳过认证(允许所有请求)
|
||||
if (!authToken) {
|
||||
return true;
|
||||
}
|
||||
if (!checkAuth(req, authToken)) {
|
||||
sendApiError(res, { code: ERROR_CODES.UNAUTHORIZED });
|
||||
return false;
|
||||
|
||||
@@ -58,11 +58,6 @@ const PORT = config.server?.port || 3000;
|
||||
/** @type {string} 认证令牌 */
|
||||
const AUTH_TOKEN = config.server?.auth;
|
||||
|
||||
// 检测默认密钥
|
||||
if (AUTH_TOKEN === 'sk-change-me-to-your-secure-key') {
|
||||
logger.warn('服务器', '检测到默认密钥!如果在公网环境下请修改默认密钥');
|
||||
}
|
||||
|
||||
/** @type {string} 心跳模式 */
|
||||
const KEEPALIVE_MODE = config.server?.keepalive?.mode || 'comment';
|
||||
|
||||
|
||||
Reference in New Issue
Block a user