feat: 适配地理伪装,优化初始化,增加自检

This commit is contained in:
foxhui
2025-12-13 15:57:25 +08:00
Unverified
parent 5d8dc7c0a5
commit e0c4c80739
10 changed files with 964 additions and 70 deletions
+9
View File
@@ -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
- **重构部分**
- 重构服务器部分,并整理项目目录
+2 -1
View File
@@ -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",
+289
View File
@@ -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();
+351
View File
@@ -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.");
}
}
+111 -64
View File
@@ -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}`);
}
+63
View File
@@ -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();
+11 -3
View File
@@ -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');
// ==================== 命令行参数处理 ====================
+1 -1
View File
@@ -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 模式配置
+2 -1
View File
@@ -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
+125
View File
@@ -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('服务器', '自检通过');
}