From 02847223dc07a46c1414427b909b7a151e88b715 Mon Sep 17 00:00:00 2001 From: foxhui Date: Sun, 25 Jan 2026 03:54:29 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E4=BF=AE=E5=A4=8D=E6=97=A0=E9=9C=80?= =?UTF-8?q?=E9=89=B4=E6=9D=83=E7=9A=84=20Socks5=20=E4=BB=A3=E7=90=86?= =?UTF-8?q?=E6=97=A0=E6=B3=95=E4=BD=BF=E7=94=A8=E7=9A=84=E9=97=AE=E9=A2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 6 + package.json | 2 + patches/camoufox-js@0.8.3.utils.patched.js | 518 +++++++++++++++++++++ pnpm-lock.yaml | 17 + scripts/init.js | 182 +++----- scripts/postinstall.js | 25 +- src/backend/adapter/gemini_biz_text.js | 4 +- src/backend/engine/utils.js | 11 +- src/backend/pool/Worker.js | 4 +- src/server/preflight.js | 10 +- src/utils/proxy.js | 51 +- 11 files changed, 650 insertions(+), 180 deletions(-) create mode 100644 patches/camoufox-js@0.8.3.utils.patched.js diff --git a/CHANGELOG.md b/CHANGELOG.md index 630bb9e..2edab1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.5.1] - 2026-01-24 + +### 🐛 Fixed +- **代理** + - 修复无需鉴权的 Socks5 代理无法使用的问题 + ## [3.5.0] - 2026-01-23 ### ✨ Added diff --git a/package.json b/package.json index fb8e5c7..de4c549 100644 --- a/package.json +++ b/package.json @@ -25,9 +25,11 @@ "fingerprint-generator": "^2.1.78", "ghost-cursor-playwright-port": "^1.4.3", "got-scraping": "^4.1.2", + "https-proxy-agent": "^7.0.6", "playwright-core": "^1.57.0", "proxy-chain": "^2.6.0", "sharp": "^0.34.5", + "socks-proxy-agent": "^8.0.5", "yaml": "^2.8.2" } } \ No newline at end of file diff --git a/patches/camoufox-js@0.8.3.utils.patched.js b/patches/camoufox-js@0.8.3.utils.patched.js new file mode 100644 index 0000000..c23f274 --- /dev/null +++ b/patches/camoufox-js@0.8.3.utils.patched.js @@ -0,0 +1,518 @@ +/** + * ======================================== + * PATCHED VERSION - WebAI-2API + * ======================================== + * + * 修改内容: + * - 第 490 行: 修复 SOCKS5 代理 URL.origin 返回 "null" 的问题 + * 原代码: server: proxyUrl.origin, + * 修改为: server: proxyUrl.protocol + '//' + proxyUrl.host, + * + * 问题原因: + * JavaScript 的 new URL('socks5://...').origin 对非标准协议返回 "null" 字符串, + * 导致浏览器报错 NS_ERROR_UNKNOWN_PROXY_HOST + * + * 搜索 "PATCHED" 可定位所有修改位置 + * ======================================== + */ + +// from browserforge.fingerprints import Fingerprint, Screen +// from screeninfo import get_monitors +// from ua_parser import user_agent_parser +import { readFileSync } from "node:fs"; +import path from "node:path"; +import { UAParser } from "ua-parser-js"; +import { addDefaultAddons, confirmPaths, } from "./addons.js"; +import { InvalidOS, InvalidPropertyType, NonFirefoxFingerprint, UnknownProperty, } from "./exceptions.js"; +import { fromBrowserforge, generateFingerprint, SUPPORTED_OS, } from "./fingerprints.js"; +import { publicIP, validIPv4, validIPv6 } from "./ip.js"; +import { geoipAllowed, getGeolocation, handleLocales } from "./locale.js"; +import FONTS from "./mappings/fonts.config.js"; +import { getPath, installedVerStr, launchPath, OS_NAME } from "./pkgman.js"; +import { LeakWarning } from "./warnings.js"; +import { sampleWebGL } from "./webgl/sample.js"; +// Camoufox preferences to cache previous pages and requests +const CACHE_PREFS = { + "browser.sessionhistory.max_entries": 10, + "browser.sessionhistory.max_total_viewers": -1, + "browser.cache.memory.enable": true, + "browser.cache.disk_cache_ssl": true, + "browser.cache.disk.smart_size.enabled": true, +}; +function getEnvVars(configMap, userAgentOS) { + const envVars = {}; + let updatedConfigData; + try { + updatedConfigData = new TextEncoder().encode(JSON.stringify(configMap)); + } + catch (e) { + console.error(`Error updating config: ${e}`); + process.exit(1); + } + const chunkSize = OS_NAME === "win" ? 2047 : 32767; + const configStr = new TextDecoder().decode(updatedConfigData); + for (let i = 0; i < configStr.length; i += chunkSize) { + const chunk = configStr.slice(i, i + chunkSize); + const envName = `CAMOU_CONFIG_${Math.floor(i / chunkSize) + 1}`; + try { + envVars[envName] = chunk; + } + catch (e) { + console.error(`Error setting ${envName}: ${e}`); + process.exit(1); + } + } + if (OS_NAME === "lin") { + const fontconfigPath = getPath(path.join("fontconfig", userAgentOS)); + envVars.FONTCONFIG_PATH = fontconfigPath; + } + return envVars; +} +export function getAsBooleanFromENV(name, defaultValue) { + const value = process.env[name]; + if (value === "false" || value === "0") + return false; + if (value) + return true; + return !!defaultValue; +} +function loadProperties(filePath) { + let propFile; + filePath = filePath?.toString(); + if (filePath) { + propFile = path.join(path.dirname(filePath), "properties.json"); + } + else { + propFile = getPath("properties.json"); + } + const propData = readFileSync(propFile).toString(); + const propDict = JSON.parse(propData); + return propDict.reduce((acc, prop) => { + acc[prop.property] = prop.type; + return acc; + }, {}); +} +function validateConfig(configMap, path) { + const propertyTypes = loadProperties(path); + for (const [key, value] of Object.entries(configMap)) { + const expectedType = propertyTypes[key]; + if (!expectedType) { + throw new UnknownProperty(`Unknown property ${key} in config`); + } + if (!validateType(value, expectedType)) { + throw new InvalidPropertyType(`Invalid type for property ${key}. Expected ${expectedType}, got ${typeof value}`); + } + } +} +function validateType(value, expectedType) { + switch (expectedType) { + case "str": + return typeof value === "string"; + case "int": + return Number.isInteger(value); + case "uint": + return Number.isInteger(value) && value >= 0; + case "double": + return typeof value === "number"; + case "bool": + return typeof value === "boolean"; + case "array": + return Array.isArray(value); + case "dict": + return (typeof value === "object" && value !== null && !Array.isArray(value)); + default: + return false; + } +} +function getTargetOS(config) { + if (config["navigator.userAgent"]) { + return determineUAOS(config["navigator.userAgent"]); + } + return OS_NAME; +} +function determineUAOS(userAgent) { + const parser = new UAParser(userAgent); + const parsedUA = parser.getOS().name; + if (!parsedUA) { + throw new Error("Could not determine OS from user agent"); + } + if (parsedUA.startsWith("macOS")) { + return "mac"; + } + if (parsedUA.startsWith("Windows")) { + return "win"; + } + return "lin"; +} +function getScreenCons(headless) { + if (headless === false) { + return undefined; + } + // TODO - Implement getMonitors + // try { + // const monitors = getMonitors(); + // if (!monitors.length) { + // return undefined; + // } + // const monitor = monitors.reduce((prev, curr) => (prev.width * prev.height > curr.width * curr.height ? prev : curr)); + // return { maxWidth: monitor.width, maxHeight: monitor.height }; + // } catch { + // return undefined; + // } + return undefined; +} +function updateFonts(config, targetOS) { + const fonts = FONTS[targetOS]; + if (config.fonts) { + config.fonts = Array.from(new Set([...fonts, ...config.fonts])); + } + else { + config.fonts = fonts; + } +} +function checkCustomFingerprint(fingerprint) { + const parser = new UAParser(fingerprint.navigator.userAgent); + const browserName = parser.getBrowser().name || "Non-Firefox"; + if (browserName !== "Firefox") { + throw new NonFirefoxFingerprint(`"${browserName}" fingerprints are not supported in Camoufox. Using fingerprints from a browser other than Firefox WILL lead to detection. If this is intentional, pass i_know_what_im_doing=True.`); + } + LeakWarning.warn("custom_fingerprint", false); +} +function validateOS(os) { + if (!os) + return undefined; + if (Array.isArray(os)) { + os.every(validateOS); + return [...os]; + } + if (!SUPPORTED_OS.includes(os)) { + throw new InvalidOS(`Camoufox does not support the OS: '${os}'`); + } + return [os]; +} +function _cleanLocals(data) { + delete data.playwright; + delete data.persistentContext; + return data; +} +function mergeInto(target, source) { + Object.entries(source).forEach(([key, value]) => { + if (!(key in target)) { + target[key] = value; + } + }); +} +function setInto(target, key, value) { + if (!(key in target)) { + target[key] = value; + } +} +function isDomainSet(config, ...properties) { + return properties.some((prop) => { + if (prop.endsWith(".") || prop.endsWith(":")) { + return Object.keys(config).some((key) => key.startsWith(prop)); + } + return prop in config; + }); +} +function warnManualConfig(config) { + if (isDomainSet(config, "navigator.language", "navigator.languages", "headers.Accept-Language", "locale:")) { + LeakWarning.warn("locale", false); + } + if (isDomainSet(config, "geolocation:", "timezone")) { + LeakWarning.warn("geolocation", false); + } + if (isDomainSet(config, "headers.User-Agent")) { + LeakWarning.warn("header-ua", false); + } + if (isDomainSet(config, "navigator.")) { + LeakWarning.warn("navigator", false); + } + if (isDomainSet(config, "screen.", "window.", "document.body.")) { + LeakWarning.warn("viewport", false); + } +} +async function _asyncAttachVD(browser, virtualDisplay) { + if (!virtualDisplay) { + return browser; + } + const originalClose = browser.close; + browser.close = async (...args) => { + await originalClose.apply(browser, ...args); + if (virtualDisplay) { + virtualDisplay.kill(); + } + }; + browser._virtualDisplay = virtualDisplay; + return browser; +} +export function syncAttachVD(browser, virtualDisplay) { + /** + * Attaches the virtual display to the sync browser cleanup + */ + if (!virtualDisplay) { + // Skip if no virtual display is provided + return browser; + } + const originalClose = browser.close; + browser.close = (...args) => { + originalClose.apply(browser, ...args); + if (virtualDisplay) { + virtualDisplay.kill(); + } + }; + browser._virtualDisplay = virtualDisplay; + return browser; +} +/** + * Convert a Playwright proxy string to a URL object. + * + * Implementation from https://github.com/microsoft/playwright/blob/3873b72ac1441ca691f7594f0ed705bd84518f93/packages/playwright-core/src/server/browserContext.ts#L737-L747 + */ +function getProxyUrl(proxy) { + if (!proxy) + return null; + if (typeof proxy === "string") { + return new URL(proxy); + } + const { server, username, password } = proxy; + let url; + try { + // new URL('127.0.0.1:8080') throws + // new URL('localhost:8080') fails to parse host or protocol + // In both of these cases, we need to try re-parse URL with `http://` prefix. + url = new URL(server); + if (!url.host || !url.protocol) + url = new URL(`http://${server}`); + } + catch (_e) { + url = new URL(`http://${server}`); + } + if (username) + url.username = username; + if (password) + url.password = password; + return url; +} +export async function launchOptions({ config, os, block_images, block_webrtc, block_webgl, disable_coop, webgl_config, geoip, humanize, locale, addons, fonts, custom_fonts_only, exclude_addons, screen, window, fingerprint, ff_version, headless, main_world_eval, executable_path, firefox_user_prefs, proxy, enable_cache, args, env, i_know_what_im_doing, debug, virtual_display, ...launch_options }) { + // Build the config + if (!config) { + config = {}; + } + // Set default values for optional arguments + if (headless === undefined) { + headless = false; + } + if (!addons) { + addons = []; + } + if (!args) { + args = []; + } + if (!firefox_user_prefs) { + firefox_user_prefs = {}; + } + if (custom_fonts_only === undefined) { + custom_fonts_only = false; + } + if (i_know_what_im_doing === undefined) { + i_know_what_im_doing = false; + } + if (!env) { + env = process.env; + } + if (typeof executable_path === "string") { + // Convert executable path to a Path object + executable_path = path.resolve(executable_path); + } + // Handle virtual display + if (virtual_display) { + env.DISPLAY = virtual_display; + } + // Warn the user for manual config settings + if (!i_know_what_im_doing) { + warnManualConfig(config); + } + const operatingSystems = validateOS(os); + // webgl_config requires OS to be set + if (!operatingSystems && webgl_config) { + throw new Error("OS must be set when using webgl_config"); + } + // Add the default addons + addDefaultAddons(addons, exclude_addons); + // Confirm all addon paths are valid + if (addons.length > 0) { + confirmPaths(addons); + config.addons = addons; + } + // Get the Firefox version + let ff_version_str; + if (ff_version) { + ff_version_str = ff_version.toString(); + LeakWarning.warn("ff_version", i_know_what_im_doing); + } + else { + ff_version_str = installedVerStr().split(".", 1)[0]; + } + // Generate a fingerprint + if (!fingerprint) { + fingerprint = generateFingerprint(window, { + screen: screen || getScreenCons(headless || "DISPLAY" in env), + operatingSystems, + }); + } + else { + // Or use the one passed by the user + if (!i_know_what_im_doing) { + checkCustomFingerprint(fingerprint); + } + } + // Inject the fingerprint into the config + mergeInto(config, fromBrowserforge(fingerprint, ff_version_str)); + const targetOS = getTargetOS(config); + // Set a random window.history.length + setInto(config, "window.history.length", Math.floor(Math.random() * 5) + 1); + // Update fonts list + if (fonts) { + config.fonts = fonts; + } + if (custom_fonts_only) { + firefox_user_prefs["gfx.bundled-fonts.activate"] = 0; + if (fonts) { + // The user has passed their own fonts, and OS fonts are disabled. + LeakWarning.warn("custom_fonts_only"); + } + else { + // OS fonts are disabled, and the user has not passed their own fonts either. + throw new Error("No custom fonts were passed, but `custom_fonts_only` is enabled."); + } + } + else { + updateFonts(config, targetOS); + } + // Set a fixed font spacing seed + setInto(config, "fonts:spacing_seed", Math.floor(Math.random() * 1_073_741_824)); + // Handle proxy + const proxyUrl = getProxyUrl(proxy); + // Set geolocation + if (geoip) { + geoipAllowed(); + // Find the user's IP address + geoip = await publicIP(proxyUrl?.href); + // Spoof WebRTC if not blocked + if (!block_webrtc) { + if (validIPv4(geoip)) { + setInto(config, "webrtc:ipv4", geoip); + firefox_user_prefs["network.dns.disableIPv6"] = true; + } + else if (validIPv6(geoip)) { + setInto(config, "webrtc:ipv6", geoip); + } + } + const geolocation = await getGeolocation(geoip); + config = { ...config, ...geolocation.asConfig() }; + } + // Raise a warning when a proxy is being used without spoofing geolocation. + // This is a very bad idea; the warning cannot be ignored with i_know_what_im_doing. + if (proxyUrl && + !proxyUrl.hostname.includes("localhost") && + !isDomainSet(config, "geolocation:")) { + LeakWarning.warn("proxy_without_geoip"); + } + // Set locale + if (locale) { + handleLocales(locale, config); + } + // Pass the humanize option + if (humanize) { + setInto(config, "humanize", true); + if (typeof humanize === "number") { + setInto(config, "humanize:maxTime", humanize); + } + } + // Enable the main world context creation + if (main_world_eval) { + setInto(config, "allowMainWorld", true); + } + // Set Firefox user preferences + if (block_images) { + LeakWarning.warn("block_images", i_know_what_im_doing); + firefox_user_prefs["permissions.default.image"] = 2; + } + if (block_webrtc) { + firefox_user_prefs["media.peerconnection.enabled"] = false; + } + if (disable_coop) { + LeakWarning.warn("disable_coop", i_know_what_im_doing); + firefox_user_prefs["browser.tabs.remote.useCrossOriginOpenerPolicy"] = + false; + } + // Allow allow_webgl parameter for backwards compatibility + if (block_webgl || launch_options.allow_webgl === false) { + firefox_user_prefs["webgl.disabled"] = true; + LeakWarning.warn("block_webgl", i_know_what_im_doing); + } + else { + // If the user has provided a specific WebGL vendor/renderer pair, use it + let webgl_fp; + if (webgl_config) { + webgl_fp = await sampleWebGL(targetOS, ...webgl_config); + } + else { + webgl_fp = await sampleWebGL(targetOS); + } + const { webGl2Enabled, ...webGlConfig } = webgl_fp; + // Merge the WebGL fingerprint into the config + mergeInto(config, webGlConfig); + // Set the WebGL preferences + mergeInto(firefox_user_prefs, { + "webgl.enable-webgl2": webGl2Enabled, + "webgl.force-enabled": true, + }); + } + // Canvas anti-fingerprinting + mergeInto(config, { + "canvas:aaOffset": Math.floor(Math.random() * 101) - 50, // nosec + "canvas:aaCapOffset": true, + }); + // Cache previous pages, requests, etc (uses more memory) + if (enable_cache) { + mergeInto(firefox_user_prefs, CACHE_PREFS); + } + // Print the config if debug is enabled + if (debug) { + console.debug("[DEBUG] Config:"); + console.debug(config); + } + // Validate the config + validateConfig(config, executable_path); + //Prepare environment variables to pass to Camoufox + const env_vars = { + ...getEnvVars(config, targetOS), + ...process.env, + }; + // Prepare the executable path + if (executable_path) { + executable_path = executable_path.toString(); + } + else { + executable_path = launchPath(); + } + const out = { + executablePath: executable_path, + args: args, + env: env_vars, + firefoxUserPrefs: firefox_user_prefs, + proxy: proxyUrl + ? { + server: proxyUrl.protocol + '//' + proxyUrl.host, // PATCHED: fix socks5 origin=null + username: proxyUrl.username, + password: proxyUrl.password, + bypass: typeof proxy === "string" ? undefined : proxy?.bypass, + } + : undefined, + headless: headless, + ...launch_options, + }; + return out; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index bc20c48..a24d290 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -29,6 +29,9 @@ importers: got-scraping: specifier: ^4.1.2 version: 4.1.2 + https-proxy-agent: + specifier: ^7.0.6 + version: 7.0.6 playwright-core: specifier: ^1.57.0 version: 1.57.0 @@ -38,6 +41,9 @@ importers: sharp: specifier: ^0.34.5 version: 0.34.5 + socks-proxy-agent: + specifier: ^8.0.5 + version: 8.0.5 yaml: specifier: ^2.8.2 version: 2.8.2 @@ -689,6 +695,10 @@ packages: resolution: {integrity: sha512-V5nVw1PAOgfI3Lmeaj2Exmeg7fenjhRUgz1lPSezy1CuhPYbgQtbQj4jZfEAEMlaL+vupsvhjqCyjzob0yxsmQ==} engines: {node: '>=10.19.0'} + https-proxy-agent@7.0.6: + resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==} + engines: {node: '>= 14'} + iconv-lite@0.5.2: resolution: {integrity: sha512-kERHXvpSaB4aU3eANwidg79K8FlrN77m8G9V+0vOR3HYaRifrlwMEpT7ZBJqLSEIHnEgJTHcWK82wwLwwKwtag==} engines: {node: '>=0.10.0'} @@ -1705,6 +1715,13 @@ snapshots: quick-lru: 5.1.1 resolve-alpn: 1.2.1 + https-proxy-agent@7.0.6: + dependencies: + agent-base: 7.1.4 + debug: 4.4.3 + transitivePeerDependencies: + - supports-color + iconv-lite@0.5.2: dependencies: safer-buffer: 2.1.2 diff --git a/scripts/init.js b/scripts/init.js index 20ec123..b1f4b9e 100644 --- a/scripts/init.js +++ b/scripts/init.js @@ -19,7 +19,8 @@ import { fileURLToPath } from 'url'; import compressing from 'compressing'; import { logger } from '../src/utils/logger.js'; import { select, input } from '@inquirer/prompts'; -import { anonymizeProxy, closeAnonymizedProxy } from 'proxy-chain'; +import { SocksProxyAgent } from 'socks-proxy-agent'; +import { HttpsProxyAgent } from 'https-proxy-agent'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.join(__dirname, '..'); @@ -150,67 +151,45 @@ function validateABI(abi) { * @param {number} maxRetries - 最大重试次数 */ async function downloadFile(url, destPath, proxyUrl = null, maxRetries = 3) { - // 如果是 SOCKS5 代理,先转换为本地 HTTP 代理 - let effectiveProxyUrl = proxyUrl; - let anonymizedProxy = null; - - if (proxyUrl && proxyUrl.startsWith('socks5://')) { - try { - logger.info('初始化', `检测到 SOCKS5 代理,正在转换为 HTTP 代理...`); - anonymizedProxy = await anonymizeProxy(proxyUrl); - effectiveProxyUrl = anonymizedProxy; - logger.info('初始化', `SOCKS5 代理已转换: ${anonymizedProxy}`); - } catch (error) { - logger.error('初始化', `SOCKS5 代理转换失败: ${error.message}`); - throw error; - } + if (proxyUrl) { + const proxyType = proxyUrl.startsWith('socks') ? 'SOCKS5' : 'HTTP'; + logger.info('初始化', `使用 ${proxyType} 代理: ${proxyUrl}`); } - try { - for (let attempt = 1; attempt <= maxRetries; attempt++) { - try { - if (attempt > 1) { - logger.info('初始化', `第 ${attempt}/${maxRetries} 次尝试下载...`); - // 删除之前失败的文件 - try { - if (fs.existsSync(destPath)) { - fs.unlinkSync(destPath); - } - } catch (e) { } - } else { - logger.info('初始化', `开始下载: ${url}`); - } - - await downloadFileOnce(url, destPath, effectiveProxyUrl); - return destPath; - } catch (error) { - logger.error('初始化', `下载失败 (尝试 ${attempt}/${maxRetries}): ${error.message}`); - - if (attempt === maxRetries) { - throw error; - } - - // 等待后重试(递增延迟) - const delay = attempt * 2000; - logger.info('初始化', `${delay / 1000} 秒后重试...`); - await new Promise(resolve => setTimeout(resolve, delay)); + for (let attempt = 1; attempt <= maxRetries; attempt++) { + try { + if (attempt > 1) { + logger.info('初始化', `第 ${attempt}/${maxRetries} 次尝试下载...`); + // 删除之前失败的文件 + try { + if (fs.existsSync(destPath)) { + fs.unlinkSync(destPath); + } + } catch (e) { } + } else { + logger.info('初始化', `开始下载: ${url}`); } - } - } finally { - // 清理 SOCKS5 代理资源 - if (anonymizedProxy) { - try { - await closeAnonymizedProxy(anonymizedProxy, true); - logger.debug('初始化', '已关闭临时代理桥接'); - } catch (e) { } + + await downloadFileOnce(url, destPath, proxyUrl); + return destPath; + } catch (error) { + logger.error('初始化', `下载失败 (尝试 ${attempt}/${maxRetries}): ${error.message}`); + + if (attempt === maxRetries) { + throw error; + } + + // 等待后重试(递增延迟) + const delay = attempt * 2000; + logger.info('初始化', `${delay / 1000} 秒后重试...`); + await new Promise(resolve => setTimeout(resolve, delay)); } } } /** * 单次下载尝试(内部函数) - * 使用 Node.js 原生 http/https 模块,手动管理超时 - * 只有在指定时间内没有任何数据传输才会触发超时 + * 使用 Node.js 原生 http/https 模块,支持 SOCKS5 和 HTTP 代理 */ async function downloadFileOnce(url, destPath, proxyUrl = null) { const IDLE_TIMEOUT = 180000; // 3 分钟无数据传输才超时 @@ -232,25 +211,25 @@ async function downloadFileOnce(url, destPath, proxyUrl = null) { } }; - // 如果有 HTTP 代理(注意:这里只支持 HTTP 代理,SOCKS 需要额外处理) + // 创建代理 agent let httpModule = isHttps ? https : http; - if (proxyUrl && proxyUrl.startsWith('http')) { - const proxyUrlObj = new URL(proxyUrl); - // 使用 CONNECT 隧道代理 - requestOptions = { - hostname: proxyUrlObj.hostname, - port: proxyUrlObj.port || 80, - method: 'CONNECT', - path: `${urlObj.hostname}:${urlObj.port || (isHttps ? 443 : 80)}`, - headers: { - 'Host': `${urlObj.hostname}:${urlObj.port || (isHttps ? 443 : 80)}` - } - }; - if (proxyUrlObj.username) { - const auth = Buffer.from(`${proxyUrlObj.username}:${proxyUrlObj.password || ''}`).toString('base64'); - requestOptions.headers['Proxy-Authorization'] = `Basic ${auth}`; + let agent = null; + + if (proxyUrl) { + if (proxyUrl.startsWith('socks')) { + // SOCKS5 代理,使用 socks-proxy-agent + logger.debug('初始化', `使用 SOCKS5 代理: ${proxyUrl}`); + agent = new SocksProxyAgent(proxyUrl); + } else if (proxyUrl.startsWith('http')) { + // HTTP 代理,使用 https-proxy-agent + logger.debug('初始化', `使用 HTTP 代理: ${proxyUrl}`); + agent = new HttpsProxyAgent(proxyUrl); } - httpModule = http; // 代理连接始终是 HTTP + } + + // 添加 agent 到请求选项 + if (agent) { + requestOptions.agent = agent; } const fileStream = fs.createWriteStream(destPath); @@ -362,60 +341,15 @@ async function downloadFileOnce(url, destPath, proxyUrl = null) { resetIdleTimer(); - // 如果使用代理,先建立隧道 - if (proxyUrl && proxyUrl.startsWith('http')) { - req = http.request(requestOptions); - req.on('connect', (res, socket) => { - if (res.statusCode !== 200) { - cleanup(); - reject(new Error(`代理连接失败: ${res.statusCode}`)); - return; - } - - // 通过隧道发起真正的 HTTPS 请求 - const tunnelOptions = { - hostname: urlObj.hostname, - port: urlObj.port || (isHttps ? 443 : 80), - path: urlObj.pathname + urlObj.search, - method: 'GET', - headers: { - 'User-Agent': 'Wget/1.21.4 (linux-gnu)', - 'Accept': '*/*', - 'Accept-Encoding': 'identity', - 'Connection': 'keep-alive', - 'Host': urlObj.host - }, - socket: socket, - agent: false - }; - - const tunnelReq = (isHttps ? https : http).request(tunnelOptions, handleResponse); - tunnelReq.on('error', (error) => { - if (finished) return; - cleanup(); - try { fs.unlinkSync(destPath); } catch (e) { } - reject(error); - }); - tunnelReq.end(); - }); - req.on('error', (error) => { - if (finished) return; - cleanup(); - try { fs.unlinkSync(destPath); } catch (e) { } - reject(error); - }); - req.end(); - } else { - // 直连模式 - req = httpModule.request(requestOptions, handleResponse); - req.on('error', (error) => { - if (finished) return; - cleanup(); - try { fs.unlinkSync(destPath); } catch (e) { } - reject(error); - }); - req.end(); - } + // 统一使用 httpModule.request 发起请求(agent 会自动处理代理) + req = httpModule.request(requestOptions, handleResponse); + req.on('error', (error) => { + if (finished) return; + cleanup(); + try { fs.unlinkSync(destPath); } catch (e) { } + reject(error); + }); + req.end(); }); } diff --git a/scripts/postinstall.js b/scripts/postinstall.js index eca74e2..a2d79cb 100644 --- a/scripts/postinstall.js +++ b/scripts/postinstall.js @@ -17,6 +17,16 @@ const log = (msg) => console.log(`[postinstall] ${msg}`); const warn = (msg) => console.warn(`[postinstall] ⚠️ ${msg}`); const error = (msg) => console.error(`[postinstall] ❌ ${msg}`); +/** + * 补丁文件映射: 源文件名 -> 目标文件名 + * 供 preflight.js 自检系统复用 + */ +export const CAMOUFOX_PATCHES = { + 'camoufox-js@0.8.3.locale.patched.js': 'locale.js', + 'camoufox-js@0.8.3.pkgman.patched.js': 'pkgman.js', + 'camoufox-js@0.8.3.utils.patched.js': 'utils.js' // SOCKS5 代理修复 +}; + /** * 复制 camoufox-js 补丁文件到 node_modules */ @@ -33,13 +43,7 @@ function patchCamoufoxJs() { return; } - // 补丁文件映射: 源文件名 -> 目标文件名 - const patches = { - 'camoufox-js@0.8.3.locale.patched.js': 'locale.js', - 'camoufox-js@0.8.3.pkgman.patched.js': 'pkgman.js' - }; - - for (const [srcName, destName] of Object.entries(patches)) { + for (const [srcName, destName] of Object.entries(CAMOUFOX_PATCHES)) { const srcPath = path.join(patchDir, srcName); const destPath = path.join(targetDir, destName); @@ -59,5 +63,8 @@ function patchCamoufoxJs() { log('补丁应用完成。'); } -// 执行 -patchCamoufoxJs(); +import { fileURLToPath as _fileURLToPath } from 'url'; +const isMainModule = process.argv[1] === _fileURLToPath(import.meta.url); +if (isMainModule) { + patchCamoufoxJs(); +} diff --git a/src/backend/adapter/gemini_biz_text.js b/src/backend/adapter/gemini_biz_text.js index 082e947..bf987b4 100644 --- a/src/backend/adapter/gemini_biz_text.js +++ b/src/backend/adapter/gemini_biz_text.js @@ -317,12 +317,12 @@ export const manifest = { models: [ { id: 'gemini-3-pro', codeName: 'gemini-3-pro-preview', imagePolicy: 'optional', type: 'text' }, { id: 'gemini-2.5-pro', codeName: 'gemini-2.5pro', imagePolicy: 'optional', type: 'text' }, - { id: 'gemini-3-flash-preview', codeName: 'gemini-3-pro-preview', imagePolicy: 'optional', type: 'text' }, + { id: 'gemini-3-flash', codeName: 'gemini-3-pro-preview', imagePolicy: 'optional', type: 'text' }, { id: 'gemini-2.5-flash', codeName: 'gemini-2.5-flash', imagePolicy: 'optional', type: 'text' }, { id: 'gemini-3-pro-grounding', codeName: 'gemini-3-pro-preview', imagePolicy: 'optional', type: 'text' }, { id: 'gemini-2.5-pro-grounding', codeName: 'gemini-2.5-pro', imagePolicy: 'optional', type: 'text' }, { id: 'gemini-2.5-flash-grounding', codeName: 'gemini-2.5-flash', imagePolicy: 'optional', type: 'text' }, - { id: 'gemini-3-flash-preview-grounding', codeName: 'gemini-3-flash-preview', imagePolicy: 'optional', type: 'text' }, + { id: 'gemini-3-flash-grounding', codeName: 'gemini-3-flash-preview', imagePolicy: 'optional', type: 'text' }, ], // 导航处理器 diff --git a/src/backend/engine/utils.js b/src/backend/engine/utils.js index d1c7842..9fd92a3 100644 --- a/src/backend/engine/utils.js +++ b/src/backend/engine/utils.js @@ -226,12 +226,20 @@ async function waitForElementStable(element, stableFrames = 10, timeout = 2000) */ export async function safeClick(page, target, options = {}) { const clickCount = options.clickCount || 1; - 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 cursorSpeed = options.cursorSpeed ?? 40; + + // 动态计算超时时间:使用 ghost-cursor 时,速度越慢超时越长 + // 公式:基础超时 + 额外时间(50000ms / 速度) + // 速度40时额外1.25s,速度10时额外5s,速度5时额外10s + const baseTimeout = options.timeout || TIMEOUTS.ELEMENT_CLICK; + const timeout = useGhostCursor + ? baseTimeout + Math.ceil(50000 / cursorSpeed) + : baseTimeout; const doClick = async () => { let el; @@ -270,7 +278,6 @@ export async function safeClick(page, target, options = {}) { if (box) { const { x, y } = getHumanClickPoint(box, options.bias || 'random'); logger.debug('浏览器', `[safeClick] 移动鼠标到 (${x.toFixed(0)}, ${y.toFixed(0)})...`); - const cursorSpeed = options.cursorSpeed ?? 40; await page.cursor.moveTo({ x, y }, { moveSpeed: cursorSpeed }); logger.debug('浏览器', `[safeClick] 执行点击...`); await page.mouse.click(x, y, { clickCount }); diff --git a/src/backend/pool/Worker.js b/src/backend/pool/Worker.js index b7dfe69..88cd714 100644 --- a/src/backend/pool/Worker.js +++ b/src/backend/pool/Worker.js @@ -91,9 +91,9 @@ export class Worker { logger.info('工作池', `[${this.name}] 正在初始化浏览器...`); if (this.proxyConfig) { - logger.debug('工作池', `[${this.name}] 使用代理: ${this.proxyConfig.type}://${this.proxyConfig.host}:${this.proxyConfig.port}`); + logger.info('工作池', `[${this.name}] 使用代理: ${this.proxyConfig.type}://${this.proxyConfig.host}:${this.proxyConfig.port}`); } else { - logger.debug('工作池', `[${this.name}] 直连模式(无代理)`); + logger.info('工作池', `[${this.name}] 直连模式(无代理)`); } if (sharedBrowser) { diff --git a/src/server/preflight.js b/src/server/preflight.js index 0988abe..402a026 100644 --- a/src/server/preflight.js +++ b/src/server/preflight.js @@ -8,6 +8,7 @@ import path from 'path'; import os from 'os'; import crypto from 'crypto'; import { logger } from '../utils/logger.js'; +import { CAMOUFOX_PATCHES } from '../../scripts/postinstall.js'; const PROJECT_ROOT = process.cwd(); @@ -53,16 +54,11 @@ export function preflight() { errors.push('better-sqlite3 预编译文件缺失,请运行: npm run init'); } - // 2. 检查 camoufox-js 补丁(通过 MD5 对比) + // 2. 检查 camoufox-js 补丁(通过 MD5 对比,使用 postinstall.js 导出的补丁列表) const patchDir = path.join(PROJECT_ROOT, 'patches'); const targetDir = path.join(PROJECT_ROOT, 'node_modules', 'camoufox-js', 'dist'); - const patchFiles = { - 'camoufox-js@0.8.3.locale.patched.js': 'locale.js', - 'camoufox-js@0.8.3.pkgman.patched.js': 'pkgman.js' - }; - - for (const [patchName, targetName] of Object.entries(patchFiles)) { + for (const [patchName, targetName] of Object.entries(CAMOUFOX_PATCHES)) { const patchPath = path.join(patchDir, patchName); const targetPath = path.join(targetDir, targetName); diff --git a/src/utils/proxy.js b/src/utils/proxy.js index e97b769..4e0bdeb 100644 --- a/src/utils/proxy.js +++ b/src/utils/proxy.js @@ -94,44 +94,27 @@ export async function getBrowserProxy(proxyConfig) { const { type, host, port, user, passwd } = proxyConfig; - // 对于 SOCKS5 + 认证,需要转换为 HTTP 代理 - if (type === 'socks5' && user && passwd) { - try { - const originalUrl = buildProxyUrl(proxyConfig); - logger.info('代理器', `检测到需鉴权的 SOCKS5 代理,正在创建本地代理桥接: ${host}:${port}`); - - const httpProxyUrl = await anonymizeProxy(originalUrl); - - // 保存状态用于后续清理 - proxyState.anonymizedProxyUrl = httpProxyUrl; - proxyState.originalProxyUrl = originalUrl; - - logger.info('代理器', `本地代理桥接已建立: ${httpProxyUrl} -> ${host}:${port}`); - - return { - server: httpProxyUrl - }; - } catch (error) { - logger.error('代理器', `本地代理桥接创建失败: ${error.message}`); - throw error; + // 构建代理 URL 字符串 + // 注意:Camoufox 对 socks5:// 协议使用 new URL().origin 会返回 null + // 因此我们直接返回完整的 URL 字符串,让 Camoufox 使用 new URL().href + let proxyUrl; + if (user && passwd) { + // 带认证的代理格式: protocol://user:passwd@host:port + const protocol = type === 'socks5' ? 'socks5' : 'http'; + proxyUrl = `${protocol}://${encodeURIComponent(user)}:${encodeURIComponent(passwd)}@${host}:${port}`; + } else { + // 不带认证的代理格式: protocol://host:port + if (type === 'socks5') { + proxyUrl = `socks5://${host}:${port}`; + } else { + proxyUrl = `http://${host}:${port}`; } } - // 对于其他情况(HTTP 代理、不带认证的 SOCKS5) - const proxyUrl = type === 'socks5' ? `socks5://${host}:${port}` : `${host}:${port}`; + logger.info('代理器', `代理配置: ${type}://${host}:${port}${user ? ' (带认证)' : ''}`); - const proxyObject = { - server: proxyUrl - }; - - // 如果有认证信息,添加到代理对象 - if (user && passwd) { - proxyObject.username = user; - proxyObject.password = passwd; - } - - logger.info('代理器', `代理配置: ${type}://${host}:${port}`); - return proxyObject; + // 直接返回字符串格式,Camoufox 会正确解析 + return proxyUrl; } /**