From e0c4c807399f4fa84446b0c44bdaa0e41dd91b11 Mon Sep 17 00:00:00 2001 From: foxhui Date: Sat, 13 Dec 2025 15:57:25 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=80=82=E9=85=8D=E5=9C=B0=E7=90=86?= =?UTF-8?q?=E4=BC=AA=E8=A3=85=EF=BC=8C=E4=BC=98=E5=8C=96=E5=88=9D=E5=A7=8B?= =?UTF-8?q?=E5=8C=96=EF=BC=8C=E5=A2=9E=E5=8A=A0=E8=87=AA=E6=A3=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- CHANGELOG.md | 9 + package.json | 3 +- patches/camoufox-js@0.8.3.locale.patched.js | 289 ++++++++++++++++ patches/camoufox-js@0.8.3.pkgman.patched.js | 351 ++++++++++++++++++++ scripts/init.js | 175 ++++++---- scripts/postinstall.js | 63 ++++ server.js | 14 +- src/browser/launcher.js | 2 +- src/utils/logger.js | 3 +- src/utils/preflight.js | 125 +++++++ 10 files changed, 964 insertions(+), 70 deletions(-) create mode 100644 patches/camoufox-js@0.8.3.locale.patched.js create mode 100644 patches/camoufox-js@0.8.3.pkgman.patched.js create mode 100644 scripts/postinstall.js create mode 100644 src/utils/preflight.js diff --git a/CHANGELOG.md b/CHANGELOG.md index cc92fe4..f020ded 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,15 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [2.4.0] - 2025-12-13 +### Added +- **浏览器伪装** + - 适配了 GEOIP 数据库,支持时区伪装,完善伪装机制 +- **初始化脚本** + - 支持自定义初始化步骤 npm run init -- -custom + - 添加下载 GeoLite2-City.mmdb 数据库的步骤 +- **增加服务器自检** + - 自动排查缺少的依赖和未应用的补丁并提供解决方案,降低使用门槛 + ### Changed - **重构部分** - 重构服务器部分,并整理项目目录 diff --git a/package.json b/package.json index a6f9626..ec506ab 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,8 @@ "start": "node server.js", "test": "node scripts/test.js", "genkey": "node scripts/genkey.js", - "init": "node scripts/init.js" + "init": "node scripts/init.js", + "postinstall": "node scripts/postinstall.js" }, "dependencies": { "@inquirer/prompts": "^8.0.1", diff --git a/patches/camoufox-js@0.8.3.locale.patched.js b/patches/camoufox-js@0.8.3.locale.patched.js new file mode 100644 index 0000000..c716809 --- /dev/null +++ b/patches/camoufox-js@0.8.3.locale.patched.js @@ -0,0 +1,289 @@ +import * as fs from "node:fs"; +import * as path from "node:path"; +import tags from "language-tags"; +import maxmind from "maxmind"; +import xml2js from "xml2js"; +import { InvalidLocale, MissingRelease, NotInstalledGeoIPExtra, UnknownIPLocation, UnknownLanguage, UnknownTerritory, } from "./exceptions.js"; +import { validateIP } from "./ip.js"; +import { GitHubDownloader, INSTALL_DIR, webdl } from "./pkgman.js"; +import { getAsBooleanFromENV } from "./utils.js"; +import { LeakWarning } from "./warnings.js"; +export const ALLOW_GEOIP = true; +class Locale { + language; + region; + script; + constructor(language, region, script) { + this.language = language; + this.region = region; + this.script = script; + } + asString() { + if (this.region) { + return `${this.language}-${this.region}`; + } + return this.language; + } + asConfig() { + if (!this.region) { + throw new Error("Region is required for config"); + } + const data = { + "locale:region": this.region, + "locale:language": this.language, + }; + if (this.script) { + data["locale:script"] = this.script; + } + return data; + } +} +class Geolocation { + locale; + longitude; + latitude; + timezone; + accuracy; + constructor(locale, longitude, latitude, timezone, accuracy) { + this.locale = locale; + this.longitude = longitude; + this.latitude = latitude; + this.timezone = timezone; + this.accuracy = accuracy; + } + asConfig() { + const data = { + "geolocation:longitude": this.longitude, + "geolocation:latitude": this.latitude, + timezone: this.timezone, + ...this.locale.asConfig(), + }; + if (this.accuracy !== undefined) { + data["geolocation:accuracy"] = this.accuracy; + } + return data; + } +} +function verifyLocale(loc) { + if (tags.check(loc)) { + return; + } + throw InvalidLocale.invalidInput(loc); +} +export function normalizeLocale(locale) { + verifyLocale(locale); + const parser = tags(locale); + if (!parser.region) { + throw InvalidLocale.invalidInput(locale); + } + return new Locale(parser.language()?.format() ?? "en", parser.region()?.format(), parser.language()?.script()?.format()); +} +export function handleLocale(locale, ignoreRegion = false) { + if (locale.length > 3) { + return normalizeLocale(locale); + } + try { + return SELECTOR.fromRegion(locale); + } + catch (e) { + if (e instanceof UnknownTerritory) { + } + else { + throw e; + } + } + if (ignoreRegion) { + verifyLocale(locale); + return new Locale(locale); + } + try { + const language = SELECTOR.fromLanguage(locale); + LeakWarning.warn("no_region"); + return language; + } + catch (e) { + if (e instanceof UnknownLanguage) { + } + else { + throw e; + } + } + throw InvalidLocale.invalidInput(locale); +} +export function handleLocales(locales, config) { + if (typeof locales === "string") { + locales = locales.split(",").map((loc) => loc.trim()); + } + const intlLocale = handleLocale(locales[0]).asConfig(); + for (const key in intlLocale) { + config[key] = intlLocale[key]; + } + if (locales.length < 2) { + return; + } + config["locale:all"] = joinUnique(locales.map((locale) => handleLocale(locale, true).asString())); +} +function joinUnique(seq) { + const seen = new Set(); + return seq.filter((x) => !seen.has(x) && seen.add(x)).join(", "); +} +// [PATCH] Portable Mode: 优先检查项目根目录下的 camoufox 文件夹 +const localMMDB = path.join(process.cwd(), "camoufox", "GeoLite2-City.mmdb"); +const MMDB_FILE = fs.existsSync(localMMDB) + ? localMMDB + : path.join(INSTALL_DIR.toString(), "GeoLite2-City.mmdb"); +//const MMDB_FILE = path.join(INSTALL_DIR.toString(), "GeoLite2-City.mmdb"); + +const MMDB_REPO = "P3TERX/GeoLite.mmdb"; +class MaxMindDownloader extends GitHubDownloader { + checkAsset(asset) { + if (asset.name.endsWith("-City.mmdb")) { + return asset.browser_download_url; + } + return null; + } + missingAssetError() { + throw new MissingRelease("Failed to find GeoIP database release asset"); + } +} +export function geoipAllowed() { + if (!ALLOW_GEOIP) { + throw new NotInstalledGeoIPExtra("Please install the geoip extra to use this feature: pip install camoufox[geoip]"); + } +} +export async function downloadMMDB() { + geoipAllowed(); + if (getAsBooleanFromENV("PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD", false)) { + console.log("Skipping GeoIP database download due to PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD set!"); + return; + } + const assetUrl = await new MaxMindDownloader(MMDB_REPO).getAsset(); + const fileStream = fs.createWriteStream(MMDB_FILE); + await webdl(assetUrl, "Downloading GeoIP database", true, fileStream); +} +export function removeMMDB() { + if (!fs.existsSync(MMDB_FILE)) { + console.log("GeoIP database not found."); + return; + } + fs.unlinkSync(MMDB_FILE); + console.log("GeoIP database removed."); +} +export async function getGeolocation(ip) { + if (!fs.existsSync(MMDB_FILE)) { + await downloadMMDB(); + } + validateIP(ip); + const reader = await maxmind.open(MMDB_FILE); + const resp = reader.get(ip); + const isoCode = resp.country?.iso_code.toUpperCase(); + const location = resp.location; + if (!location?.longitude || + !location?.latitude || + !location?.time_zone || + !isoCode) { + throw new UnknownIPLocation(`Unknown IP location: ${ip}`); + } + // [PATCHED] 强制锁定为美式英语,但保留 IP 的经纬度和时区 + const locale = new Locale("en", "US"); + return new Geolocation(locale, location.longitude, location.latitude, location.time_zone); + //const locale = SELECTOR.fromRegion(isoCode); + //return new Geolocation(locale, location.longitude, location.latitude, location.time_zone); +} +async function getUnicodeInfo() { + const data = await fs.promises.readFile(path.join(import.meta.dirname, "data-files", "territoryInfo.xml")); + const parser = new xml2js.Parser(); + return parser.parseStringPromise(data); +} +function asFloat(element, attr) { + return parseFloat(element[attr] || "0"); +} +class StatisticalLocaleSelector { + root; + constructor() { + this.loadUnicodeInfo(); + } + async loadUnicodeInfo() { + this.root = await getUnicodeInfo(); + } + loadTerritoryData(isoCode) { + const territory = this.root.territoryInfo.territory.find((t) => t.$.type === isoCode); + if (!territory) { + throw new UnknownTerritory(`Unknown territory: ${isoCode}`); + } + const langPopulations = territory.languagePopulation; + if (!langPopulations) { + throw new Error(`No language data found for region: ${isoCode}`); + } + const languages = langPopulations.map((lang) => lang.$.type); + const percentages = langPopulations.map((lang) => asFloat(lang.$, "populationPercent")); + return this.normalizeProbabilities(languages, percentages); + } + loadLanguageData(language) { + const territories = this.root.territory.filter((t) => t.languagePopulation.some((lp) => lp.$.type === language)); + if (!territories.length) { + throw new UnknownLanguage(`No region data found for language: ${language}`); + } + const regions = []; + const percentages = []; + for (const terr of territories) { + const region = terr.$.type; + const langPop = terr.languagePopulation.find((lp) => lp.$.type === language); + if (region && langPop) { + regions.push(region); + percentages.push(((asFloat(langPop.$, "populationPercent") * + asFloat(terr.$, "literacyPercent")) / + 10000) * + asFloat(terr.$, "population")); + } + } + if (!regions.length) { + throw new Error(`No valid region data found for language: ${language}`); + } + return this.normalizeProbabilities(regions, percentages); + } + normalizeProbabilities(languages, freq) { + const total = freq.reduce((a, b) => a + b, 0); + return [languages, freq.map((f) => f / total)]; + } + weightedRandomChoice(items, weights) { + if (items.length === 0) { + throw new Error("items must not be empty"); + } + if (items.length !== weights.length) { + throw new Error("items and weights must have the same length"); + } + let total = 0; + for (const w of weights) { + if (w < 0) { + throw new Error("weights must be non-negative"); + } + total += w; + } + // Fallback to uniform choice if all weights are zero + if (total === 0) { + return items[Math.floor(Math.random() * items.length)]; + } + const r = Math.random() * total; + let acc = 0; + for (let i = 0; i < items.length; i++) { + acc += weights[i]; + if (r < acc) { + return items[i]; + } + } + // Numerical edge case + return items[items.length - 1]; + } + fromRegion(region) { + const [languages, probabilities] = this.loadTerritoryData(region); + const language = this.weightedRandomChoice(languages, probabilities).replace("_", "-"); + return normalizeLocale(`${language}-${region}`); + } + fromLanguage(language) { + const [regions, probabilities] = this.loadLanguageData(language); + const region = this.weightedRandomChoice(regions, probabilities); + return normalizeLocale(`${language}-${region}`); + } +} +const SELECTOR = new StatisticalLocaleSelector(); diff --git a/patches/camoufox-js@0.8.3.pkgman.patched.js b/patches/camoufox-js@0.8.3.pkgman.patched.js new file mode 100644 index 0000000..ec8eb88 --- /dev/null +++ b/patches/camoufox-js@0.8.3.pkgman.patched.js @@ -0,0 +1,351 @@ +import { execSync } from "node:child_process"; +import * as fs from "node:fs"; +import * as os from "node:os"; +import * as path from "node:path"; +import { setTimeout } from "node:timers/promises"; +import AdmZip from "adm-zip"; +import ProgressBar from "progress"; +import { CONSTRAINTS } from "./__version__.js"; +import { CamoufoxNotInstalled, FileNotFoundError, MissingRelease, UnsupportedArchitecture, UnsupportedOS, UnsupportedVersion, } from "./exceptions.js"; +const ARCH_MAP = { + x64: "x86_64", + ia32: "i686", + arm64: "arm64", + arm: "arm64", +}; +const OS_MAP = { + darwin: "mac", + linux: "lin", + win32: "win", +}; +if (!(process.platform in OS_MAP)) { + throw new UnsupportedOS(`OS ${process.platform} is not supported`); +} +export const OS_NAME = OS_MAP[process.platform]; +// [PATCH] Portable Mode: 优先使用项目目录下的 camoufox 文件夹 +const localInstallDir = path.join(process.cwd(), "camoufox"); +export const INSTALL_DIR = fs.existsSync(localInstallDir) ? localInstallDir : userCacheDir("camoufox"); +//export const INSTALL_DIR = userCacheDir("camoufox"); + +export const LOCAL_DATA = path.join(import.meta.dirname, "data-files"); +export const OS_ARCH_MATRIX = { + win: ["x86_64", "i686"], + mac: ["x86_64", "arm64"], + lin: ["x86_64", "arm64", "i686"], +}; +const LAUNCH_FILE = { + win: "camoufox.exe", + mac: "../MacOS/camoufox", + lin: "camoufox-bin", +}; +class Version { + release; + version; + sorted_rel; + constructor(release, version) { + this.release = release; + this.version = version; + this.sorted_rel = this.buildSortedRel(); + } + buildSortedRel() { + const parts = this.release + .split(".") + .map((x) => Number.isNaN(Number(x)) ? x.charCodeAt(0) - 1024 : Number(x)); + while (parts.length < 5) { + parts.push(0); + } + return parts; + } + get fullString() { + return `${this.version}-${this.release}`; + } + equals(other) { + return this.sorted_rel.join(".") === other.sorted_rel.join("."); + } + lessThan(other) { + for (let i = 0; i < this.sorted_rel.length; i++) { + if (this.sorted_rel[i] < other.sorted_rel[i]) + return true; + if (this.sorted_rel[i] > other.sorted_rel[i]) + return false; + } + return false; + } + isSupported() { + return VERSION_MIN.lessThan(this) && this.lessThan(VERSION_MAX); + } + static fromPath(filePath = INSTALL_DIR) { + const versionPath = path.join(filePath.toString(), "version.json"); + if (!fs.existsSync(versionPath)) { + throw new FileNotFoundError(`Version information not found at ${versionPath}. Please run \`camoufox fetch\` to install.`); + } + const versionData = JSON.parse(fs.readFileSync(versionPath, "utf-8")); + return new Version(versionData.release, versionData.version); + } + static isSupportedPath(path) { + return Version.fromPath(path).isSupported(); + } + static buildMinMax() { + return [ + new Version(CONSTRAINTS.MIN_VERSION), + new Version(CONSTRAINTS.MAX_VERSION), + ]; + } +} +const [VERSION_MIN, VERSION_MAX] = Version.buildMinMax(); +export class GitHubDownloader { + githubRepo; + apiUrl; + constructor(githubRepo) { + this.githubRepo = githubRepo; + this.apiUrl = `https://api.github.com/repos/${githubRepo}/releases`; + } + checkAsset(asset) { + return asset.browser_download_url; + } + missingAssetError() { + throw new MissingRelease(`Could not find a release asset in ${this.githubRepo}.`); + } + async getAsset({ retries } = { retries: 5 }) { + let attempts = 0; + let response; + while (attempts < retries) { + try { + response = await fetch(this.apiUrl); + if (response.ok) + break; + } + catch (e) { + console.error(e, `retrying (${attempts + 1}/${retries})...`); + await setTimeout(5e3); + } + attempts++; + } + if (!response || !response.ok) { + throw new Error(`Failed to fetch releases from ${this.apiUrl} after ${retries} attempts`); + } + const releases = await response.json(); + for (const release of releases) { + for (const asset of release.assets) { + const data = this.checkAsset(asset); + if (data) { + return data; + } + } + } + this.missingAssetError(); + } +} +export class CamoufoxFetcher extends GitHubDownloader { + arch; + _version_obj; + pattern; + _url; + constructor() { + super("daijro/camoufox"); + this.arch = CamoufoxFetcher.getPlatformArch(); + this.pattern = new RegExp(`camoufox-(.+)-(.+)-${OS_NAME}\\.${this.arch}\\.zip`); + } + async init() { + await this.fetchLatest(); + } + checkAsset(asset) { + const match = asset.name.match(this.pattern); + if (!match) + return null; + const version = new Version(match[2], match[1]); + if (!version.isSupported()) + return null; + return [version, asset.browser_download_url]; + } + missingAssetError() { + throw new MissingRelease(`No matching release found for ${OS_NAME} ${this.arch} in the supported range: (${CONSTRAINTS.asRange()}). Please update the library.`); + } + static getPlatformArch() { + const platArch = os.arch().toLowerCase(); + if (!(platArch in ARCH_MAP)) { + throw new UnsupportedArchitecture(`Architecture ${platArch} is not supported`); + } + const arch = ARCH_MAP[platArch]; + if (!OS_ARCH_MATRIX[OS_NAME].includes(arch)) { + throw new UnsupportedArchitecture(`Architecture ${arch} is not supported for ${OS_NAME}`); + } + return arch; + } + async fetchLatest() { + if (this._version_obj) + return; + const releaseData = await this.getAsset(); + this._version_obj = releaseData[0]; + this._url = releaseData[1]; + } + static async downloadFile(url) { + const response = await fetch(url); + return Buffer.from(await response.arrayBuffer()); + } + async extractZip(zipFile) { + const zip = new AdmZip(zipFile); + zip.extractAllTo(INSTALL_DIR.toString(), true); + } + static cleanup() { + if (fs.existsSync(INSTALL_DIR)) { + fs.rmSync(INSTALL_DIR, { recursive: true }); + return true; + } + return false; + } + setVersion() { + fs.writeFileSync(path.join(INSTALL_DIR.toString(), "version.json"), JSON.stringify({ version: this.version, release: this.release })); + } + async install() { + await this.init(); + await CamoufoxFetcher.cleanup(); + try { + fs.mkdirSync(INSTALL_DIR, { recursive: true }); + const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "camoufox-")); + const tempFilePath = path.join(tempDir, "camoufox.zip"); + const tempFileStream = fs.createWriteStream(tempFilePath); + await webdl(this.url, "Downloading Camoufox...", true, tempFileStream); + await new Promise((r) => tempFileStream.close(r)); + await this.extractZip(tempFilePath); + this.setVersion(); + if (OS_NAME !== "win") { + execSync(`chmod -R 755 ${INSTALL_DIR}`); + } + console.log("Camoufox successfully installed."); + } + catch (e) { + console.error(`Error installing Camoufox: ${e}`); + await CamoufoxFetcher.cleanup(); + throw e; + } + } + get url() { + if (!this._url) { + throw new Error("Url is not available. Make sure to run fetchLatest first."); + } + return this._url; + } + get version() { + if (!this._version_obj || !this._version_obj.version) { + throw new Error("Version is not available. Make sure to run fetchLatest first."); + } + return this._version_obj.version; + } + get release() { + if (!this._version_obj) { + throw new Error("Release information is not available. Make sure to run the installation first."); + } + return this._version_obj.release; + } + get verstr() { + if (!this._version_obj) { + throw new Error("Version is not available. Make sure to run the installation first."); + } + return this._version_obj.fullString; + } +} +function userCacheDir(appName) { + if (OS_NAME === "win") { + return path.join(os.homedir(), "AppData", "Local", appName, appName, "Cache"); + } + else if (OS_NAME === "mac") { + return path.join(os.homedir(), "Library", "Caches", appName); + } + else { + return path.join(os.homedir(), ".cache", appName); + } +} +export function installedVerStr() { + return Version.fromPath().fullString; +} +export function camoufoxPath(downloadIfMissing = true) { + // Ensure the directory exists and is not empty + if (!fs.existsSync(INSTALL_DIR) || fs.readdirSync(INSTALL_DIR).length === 0) { + if (!downloadIfMissing) { + throw new Error(`Camoufox executable not found at ${INSTALL_DIR}`); + } + } + else if (fs.existsSync(INSTALL_DIR) && + Version.isSupportedPath(INSTALL_DIR)) { + return INSTALL_DIR; + } + else { + if (!downloadIfMissing) { + throw new UnsupportedVersion("Camoufox executable is outdated."); + } + } + // Install and recheck + const fetcher = new CamoufoxFetcher(); + fetcher.install().then(() => camoufoxPath()); + return INSTALL_DIR; +} +export function getPath(file) { + if (OS_NAME === "mac") { + return path.resolve(camoufoxPath().toString(), "Camoufox.app", "Contents", "Resources", file); + } + return path.join(camoufoxPath().toString(), file); +} +export function launchPath() { + const launchPath = getPath(LAUNCH_FILE[OS_NAME]); + if (!fs.existsSync(launchPath)) { + throw new CamoufoxNotInstalled(`Camoufox is not installed at ${camoufoxPath()}. Please run \`camoufox fetch\` to install.`); + } + return launchPath; +} +export async function webdl(url, desc = "", bar = true, buffer = null, { retries } = { retries: 5 }) { + let attempts = 0; + let response; + while (attempts < retries) { + try { + response = await fetch(url); + if (response.ok) + break; + } + catch (e) { + console.error(e, `retrying (${attempts + 1}/${retries})...`); + await setTimeout(5e3); + } + attempts++; + } + if (!response || !response.ok) { + throw new Error(`Failed to download from ${url} after ${retries} attempts`); + } + const totalSize = parseInt(response.headers.get("content-length") || "0", 10); + const progressBar = bar + ? new ProgressBar(`${desc} [:bar] :percent :etas`, { + total: totalSize, + width: 40, + }) + : null; + const chunks = []; + for await (const chunk of response.body) { + if (buffer) { + buffer.write(chunk); + } + else { + chunks.push(chunk); + } + if (progressBar) { + progressBar.tick(chunk.length, "X"); + } + } + const fileBuffer = Buffer.concat(chunks); + return fileBuffer; +} +export async function unzip(zipFile, extractPath, desc, bar = true) { + const zip = new AdmZip(zipFile); + const zipEntries = zip.getEntries(); + if (bar) { + console.log(desc || "Extracting files..."); + } + for (const entry of zipEntries) { + if (bar) { + console.log(`Extracting ${entry.entryName}`); + } + zip.extractEntryTo(entry, extractPath, false, true); + } + if (bar) { + console.log("Extraction complete."); + } +} diff --git a/scripts/init.js b/scripts/init.js index 2e4f5ca..f646b0c 100644 --- a/scripts/init.js +++ b/scripts/init.js @@ -6,7 +6,6 @@ */ import fs from 'fs'; -import { execSync } from 'child_process'; import path from 'path'; import os from 'os'; import { fileURLToPath } from 'url'; @@ -15,6 +14,7 @@ import compressing from 'compressing'; import yaml from 'yaml'; import { logger } from '../src/utils/logger.js'; import { getHttpProxy, getProxyConfig } from '../src/utils/proxy.js'; +import { select } from '@inquirer/prompts'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PROJECT_ROOT = path.join(__dirname, '..'); @@ -451,17 +451,52 @@ function updateConfigPath(platform, camoufoxDir) { logger.warn('初始化', '无法读取配置文件或转换代理,不使用代理'); } - // 安装 better-sqlite3 - await installBetterSqlite3(platform, arch, abi, proxyUrl); + // 检查是否为自定义模式 + const isCustomMode = process.argv.includes('-custom'); - // 安装 Camoufox - await installCamoufox(platform, arch, proxyUrl); + if (isCustomMode) { + // 自定义模式:交互式选择步骤 + const action = await select({ + message: '请选择要执行的操作:', + choices: [ + { name: '安装 better-sqlite3 预编译文件', value: 'sqlite' }, + { name: '安装 Camoufox 浏览器', value: 'camoufox' }, + { name: '安装 GeoLite2-City.mmdb 数据库', value: 'geolite' }, + { name: '修复 macOS 环境下的 properties.json', value: 'macos_fix' }, + { name: '修复 version.json 缺失', value: 'version_fix' }, + { name: '退出', value: 'exit' } + ] + }); - // 修复 Camoufox 环境 (Linux) - fixCamoufoxEnv(); + switch (action) { + case 'sqlite': + await installBetterSqlite3(platform, arch, abi, proxyUrl); + break; + case 'camoufox': + await installCamoufox(platform, arch, proxyUrl); + break; + case 'geolite': + await downloadGeoLiteDb(proxyUrl, true); // 强制下载 + break; + case 'macos_fix': + fixMacOSProperties(); + break; + case 'version_fix': + fixVersionJson(); + break; + case 'exit': + logger.info('初始化', '已退出'); + break; + } + } else { + // 正常模式:执行所有步骤 + await installBetterSqlite3(platform, arch, abi, proxyUrl); + await installCamoufox(platform, arch, proxyUrl); + await downloadGeoLiteDb(proxyUrl); + } logger.info('初始化', '========================================'); - logger.info('初始化', '所有依赖安装完成!'); + logger.info('初始化', '操作完成!'); logger.info('初始化', '========================================'); process.exit(0); @@ -472,66 +507,78 @@ function updateConfigPath(platform, camoufoxDir) { })(); /** - * 自动修复 Linux 下 Camoufox 的路径依赖 - * 目的:建立软链接,欺骗 camoufox-js 以为浏览器安装在默认目录,从而防止自动下载 + * 下载 GeoLite2-City.mmdb 到 camoufox 目录 + * @param {string|null} proxyUrl - 代理 URL + * @param {boolean} [force=false] - 是否强制下载(忽略已存在检查) */ -function fixCamoufoxEnv() { - // 1. 仅在 Linux 下执行 - if (os.platform() !== 'linux') return; +async function downloadGeoLiteDb(proxyUrl, force = false) { + const camoufoxDir = path.join(PROJECT_ROOT, 'camoufox'); + const destPath = path.join(camoufoxDir, 'GeoLite2-City.mmdb'); - logger.info('初始化', '正在检查 Camoufox 环境配置...'); + // 确保目录存在 + if (!fs.existsSync(camoufoxDir)) { + fs.mkdirSync(camoufoxDir, { recursive: true }); + } - // --- 路径配置 --- - // 假设浏览器存放在项目根目录下的 camoufox 文件夹中 - // 依赖 init.js 中已定义的 PROJECT_ROOT 变量 - const customBrowserDir = path.join(PROJECT_ROOT, 'camoufox'); - - // 官方默认缓存路径: ~/.cache/camoufox - const defaultCacheDir = path.join(os.homedir(), '.cache'); - const defaultLinkPath = path.join(defaultCacheDir, 'camoufox'); - - // 2. 预检查:确保源文件存在 - if (!fs.existsSync(customBrowserDir)) { - logger.warn('初始化', `未找到自定义浏览器目录: ${customBrowserDir}`); - logger.warn('初始化', `请确保已将浏览器解压至项目根目录的 camoufox 文件夹`); + // 如果已存在且非强制模式,跳过下载 + if (!force && fs.existsSync(destPath)) { + logger.info('初始化', 'GeoLite2-City.mmdb 已存在,跳过下载'); return; } - // 3. 检查并修复软链接 - if (fs.existsSync(defaultLinkPath)) { - const stats = fs.lstatSync(defaultLinkPath); - if (stats.isSymbolicLink()) { - const currentTarget = fs.readlinkSync(defaultLinkPath); - if (currentTarget === customBrowserDir) { - logger.info('初始化', 'Camoufox 路径映射已就绪'); - return; - } - logger.info('初始化', '路径映射不一致,正在更新...'); - fs.unlinkSync(defaultLinkPath); - } else { - // 备份旧的实体文件夹 - logger.warn('初始化', `默认路径被占用,正在备份...`); - fs.renameSync(defaultLinkPath, `${defaultLinkPath}_backup_${Date.now()}`); - } - } else { - if (!fs.existsSync(defaultCacheDir)) { - fs.mkdirSync(defaultCacheDir, { recursive: true }); - } - } - - // 4. 创建软链接 - try { - // 使用 shell 命令创建软链接,比 fs.symlinkSync 在某些 Linux 环境下更可靠 - execSync(`ln -sf "${customBrowserDir}" "${defaultLinkPath}"`); - - // 验证链接是否有效 - if (fs.existsSync(defaultLinkPath)) { - const linkTarget = fs.readlinkSync(defaultLinkPath); - logger.info('初始化', `成功创建路径映射: ${defaultLinkPath} -> ${linkTarget}`); - } else { - logger.warn('初始化', '软链接创建后无法访问,可能存在权限问题'); - } - } catch (e) { - logger.error('初始化', `创建软链接失败: ${e.message}`); - } + logger.info('初始化', '开始下载 GeoLite2-City.mmdb...'); + const url = 'https://github.com/P3TERX/GeoLite.mmdb/releases/latest/download/GeoLite2-City.mmdb'; + await downloadFile(url, destPath, proxyUrl); + logger.info('初始化', `GeoLite2-City.mmdb 下载完成: ${destPath}`); +} + +/** + * 修复 macOS 环境下的 properties.json + */ +function fixMacOSProperties() { + const platform = os.platform(); + if (platform !== 'darwin') { + logger.warn('初始化', '此操作仅适用于 macOS 系统'); + return; + } + + const camoufoxDir = path.join(PROJECT_ROOT, 'camoufox'); + const resourcesPath = path.join(camoufoxDir, 'Camoufox.app', 'Contents', 'Resources', 'properties.json'); + const macOSDir = path.join(camoufoxDir, 'Camoufox.app', 'Contents', 'MacOS'); + const macOSPath = path.join(macOSDir, 'properties.json'); + + if (!fs.existsSync(resourcesPath)) { + logger.error('初始化', `源文件不存在: ${resourcesPath}`); + logger.error('初始化', '请先安装 Camoufox 浏览器'); + return; + } + + if (!fs.existsSync(macOSDir)) { + fs.mkdirSync(macOSDir, { recursive: true }); + } + + fs.copyFileSync(resourcesPath, macOSPath); + logger.info('初始化', `已复制 properties.json 到 MacOS 目录: ${macOSPath}`); +} + +/** + * 修复 version.json 缺失 + */ +function fixVersionJson() { + const camoufoxDir = path.join(PROJECT_ROOT, 'camoufox'); + const versionJsonPath = path.join(camoufoxDir, 'version.json'); + + if (!fs.existsSync(camoufoxDir)) { + logger.error('初始化', `camoufox 目录不存在: ${camoufoxDir}`); + logger.error('初始化', '请先安装 Camoufox 浏览器'); + return; + } + + const versionData = { + version: "135.0", + release: "beta.24" + }; + + fs.writeFileSync(versionJsonPath, JSON.stringify(versionData, null, 2), 'utf8'); + logger.info('初始化', `已生成 version.json: ${versionJsonPath}`); } diff --git a/scripts/postinstall.js b/scripts/postinstall.js new file mode 100644 index 0000000..eca74e2 --- /dev/null +++ b/scripts/postinstall.js @@ -0,0 +1,63 @@ +/** + * @fileoverview npm postinstall 钩子脚本 + * @description 在 `npm install` 后自动应用 camoufox-js 补丁。 + * + * 用法:在 package.json scripts 中配置 "postinstall": "node scripts/postinstall.js" + */ + +import fs from 'fs'; +import path from 'path'; +import { fileURLToPath } from 'url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const PROJECT_ROOT = path.join(__dirname, '..'); + +// 简易日志 +const log = (msg) => console.log(`[postinstall] ${msg}`); +const warn = (msg) => console.warn(`[postinstall] ⚠️ ${msg}`); +const error = (msg) => console.error(`[postinstall] ❌ ${msg}`); + +/** + * 复制 camoufox-js 补丁文件到 node_modules + */ +function patchCamoufoxJs() { + log('正在应用 camoufox-js 补丁...'); + + const patchDir = path.join(PROJECT_ROOT, 'patches'); + const targetDir = path.join(PROJECT_ROOT, 'node_modules', 'camoufox-js', 'dist'); + + // 检查目标目录是否存在 + if (!fs.existsSync(targetDir)) { + warn(`目标目录不存在: ${targetDir}`); + warn('camoufox-js 可能未安装,跳过补丁。'); + 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)) { + const srcPath = path.join(patchDir, srcName); + const destPath = path.join(targetDir, destName); + + if (!fs.existsSync(srcPath)) { + warn(`补丁文件不存在: ${srcPath}`); + continue; + } + + try { + fs.copyFileSync(srcPath, destPath); + log(`已应用补丁: ${srcName} -> ${destName}`); + } catch (e) { + error(`应用补丁失败: ${e.message}`); + } + } + + log('补丁应用完成。'); +} + +// 执行 +patchCamoufoxJs(); diff --git a/server.js b/server.js index a5952e7..52638a8 100644 --- a/server.js +++ b/server.js @@ -14,9 +14,17 @@ */ import http from 'http'; -import { getBackend } from './src/backend/index.js'; -import { logger } from './src/utils/logger.js'; -import { handleDisplayParams, createQueueManager, createRouter } from './src/server/index.js'; + +// ==================== 启动前自检 ==================== +import { runPreflight } from './src/utils/preflight.js'; +// Xvfb 子进程跳过自检(父进程已完成) +if (!process.env.XVFB_RUNNING) { + runPreflight(); +} +// ==================== 加载其他依赖 ==================== +const { getBackend } = await import('./src/backend/index.js'); +const { logger } = await import('./src/utils/logger.js'); +const { handleDisplayParams, createQueueManager, createRouter } = await import('./src/server/index.js'); // ==================== 命令行参数处理 ==================== diff --git a/src/browser/launcher.js b/src/browser/launcher.js index 7676843..913af5d 100644 --- a/src/browser/launcher.js +++ b/src/browser/launcher.js @@ -230,7 +230,7 @@ export async function initBrowserBase(config, options) { i_know_what_im_doing: true, block_webrtc: true, exclude_addons: ['UBO'], - geoip: false + geoip: true }; // Headless 模式配置 diff --git a/src/utils/logger.js b/src/utils/logger.js index a953351..2b09b84 100644 --- a/src/utils/logger.js +++ b/src/utils/logger.js @@ -70,7 +70,8 @@ export function log(level, mod, msg, meta = {}) { if (!shouldLog(level)) return; const ts = formatTime(); - const levelTag = level.toUpperCase(); + const levelMap = { debug: 'DBUG', info: 'INFO', warn: 'WARN', error: 'ERRO' }; + const levelTag = levelMap[level.toLowerCase()] || level.toUpperCase().slice(0, 4); const base = `${ts} [${levelTag}] [${mod}] ${msg}`; const metaStr = Object.keys(meta).length diff --git a/src/utils/preflight.js b/src/utils/preflight.js new file mode 100644 index 0000000..5f86415 --- /dev/null +++ b/src/utils/preflight.js @@ -0,0 +1,125 @@ +/** + * @fileoverview 服务器启动前自检模块 + * @description 检查补丁、Camoufox 可执行文件、version.json、GeoLite2 数据库和 better-sqlite3 是否就绪。 + */ + +import fs from 'fs'; +import path from 'path'; +import os from 'os'; +import crypto from 'crypto'; +import { logger } from './logger.js'; + +const PROJECT_ROOT = process.cwd(); + +/** + * 计算文件的 MD5 哈希值 + * @param {string} filePath - 文件路径 + * @returns {string|null} MD5 哈希值,文件不存在返回 null + */ +function getFileMD5(filePath) { + if (!fs.existsSync(filePath)) return null; + + const content = fs.readFileSync(filePath); + return crypto.createHash('md5').update(content).digest('hex'); +} + +/** + * 获取 Camoufox 可执行文件路径(根据平台) + * @returns {string} + */ +function getCamoufoxExecutablePath() { + const camoufoxDir = path.join(PROJECT_ROOT, 'camoufox'); + const platform = os.platform(); + + if (platform === 'win32') { + return path.join(camoufoxDir, 'camoufox.exe'); + } else if (platform === 'darwin') { + return path.join(camoufoxDir, 'Camoufox.app', 'Contents', 'MacOS', 'camoufox'); + } else { + return path.join(camoufoxDir, 'camoufox'); + } +} + +/** + * 执行服务器启动前自检 + * @returns {{ ok: boolean, errors: string[] }} + */ +export function preflight() { + const errors = []; + + // 1. 检查 better-sqlite3 预编译文件 + const sqlitePath = path.join(PROJECT_ROOT, 'node_modules', 'better-sqlite3', 'build', 'Release', 'better_sqlite3.node'); + if (!fs.existsSync(sqlitePath)) { + errors.push('better-sqlite3 预编译文件缺失,请运行: npm run init'); + } + + // 2. 检查 camoufox-js 补丁(通过 MD5 对比) + 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)) { + const patchPath = path.join(patchDir, patchName); + const targetPath = path.join(targetDir, targetName); + + const patchHash = getFileMD5(patchPath); + const targetHash = getFileMD5(targetPath); + + if (!patchHash) { + // 补丁源文件不存在,跳过检查 + continue; + } + + if (patchHash !== targetHash) { + errors.push('camoufox-js 补丁未应用,请运行: pnpm install'); + break; // 只报告一次 + } + } + + // 3. 检查 Camoufox 可执行文件 + const executablePath = getCamoufoxExecutablePath(); + if (!fs.existsSync(executablePath)) { + errors.push(`Camoufox 可执行文件缺失,请运行: npm run init`); + } + + // 4. 检查 version.json + const versionJsonPath = path.join(PROJECT_ROOT, 'camoufox', 'version.json'); + if (!fs.existsSync(versionJsonPath)) { + errors.push('camoufox/version.json 缺失,请运行: npm run init'); + } + + // 5. 检查 GeoLite2-City.mmdb + const geoDbPath = path.join(PROJECT_ROOT, 'camoufox', 'GeoLite2-City.mmdb'); + if (!fs.existsSync(geoDbPath)) { + errors.push('camoufox/GeoLite2-City.mmdb 缺失,请运行: npm run init'); + } + + return { + ok: errors.length === 0, + errors + }; +} + +/** + * 执行自检并在失败时退出程序 + */ +export function runPreflight() { + logger.info('服务器', '正在执行自检...'); + + const result = preflight(); + + if (!result.ok) { + logger.error('服务器', '自检失败,以下依赖缺失:'); + for (const err of result.errors) { + logger.error('服务器', ` - ${err}`); + } + logger.error('服务器', '提示: 您可以使用 npm run init -- -custom 来自定义初始化步骤'); + process.exit(1); + } + + logger.info('服务器', '自检通过'); +}