feat: 增加拟人鼠标轨迹选择,Token 允许留空 (closes #12)

This commit is contained in:
foxhui
2026-01-24 03:24:49 +08:00
Unverified
parent f7bcddc91b
commit a85b731ce1
43 changed files with 210 additions and 63 deletions
+14 -6
View File
@@ -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' },
+13 -4
View File
@@ -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');
}
+1
View File
@@ -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 抗指纹:注入固定噪点偏移
+22 -8
View File
@@ -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 提交文件');
+21 -4
View File
@@ -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
View File
@@ -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 = {};
+2
View File
@@ -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) {
+3 -3
View File
@@ -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 个字符,或留空');
}
}
+4
View File
@@ -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;
-5
View File
@@ -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';