mirror of
https://github.com/foxhui/WebAI2API.git
synced 2026-06-16 21:03:59 +08:00
feat: 适配地理伪装,优化初始化,增加自检
This commit is contained in:
@@ -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
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
@@ -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
@@ -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}`);
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
@@ -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');
|
||||
|
||||
// ==================== 命令行参数处理 ====================
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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('服务器', '自检通过');
|
||||
}
|
||||
Reference in New Issue
Block a user