This commit is contained in:
2026-05-18 08:49:45 +05:00
commit c4e8344e50
12 changed files with 1134 additions and 0 deletions

5
.dockerignore Normal file
View File

@@ -0,0 +1,5 @@
node_modules/
dist/
data/
.git
*.md

4
.gitignore vendored Normal file
View File

@@ -0,0 +1,4 @@
node_modules/
dist/
.env
data/

34
Dockerfile Normal file
View File

@@ -0,0 +1,34 @@
FROM node:20-slim
WORKDIR /app
# Системные зависимости для Chromium (Playwright)
RUN apt-get update && apt-get install -y \
libnss3 \
libnspr4 \
libatk1.0-0 \
libatk-bridge2.0-0 \
libcups2 \
libdrm2 \
libdbus-1-3 \
libxkbcommon0 \
libxcomposite1 \
libxdamage1 \
libxfixes3 \
libxrandr2 \
libgbm1 \
libpango-1.0-0 \
libcairo2 \
libasound2 \
&& rm -rf /var/lib/apt/lists/*
COPY package*.json ./
RUN npm install && npx playwright install chromium && npx playwright install-deps chromium
COPY tsconfig.json ./
COPY src/ ./src/
COPY entrypoint.sh ./
RUN chmod +x entrypoint.sh
CMD ["./entrypoint.sh"]

36
docker-compose.yml Normal file
View File

@@ -0,0 +1,36 @@
services:
vpn-parser:
build: .
restart: always
environment:
- TZ=Asia/Yekaterinburg
- PARSE_INTERVAL=3600
# ---------- Парсер novavps ----------
- NOVAVPS_URL=
# ---------- Панель Xray 1 ----------
- XRAY_URL=
- XRAY_USERNAME=
- XRAY_PASSWORD=
# ---------- Панель Xray 2 ----------
- XRAY_URL2=
- XRAY_USERNAME2=
- XRAY_PASSWORD2=
networks:
- applications
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- ./data:/app/data
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
applications:
external: true

13
entrypoint.sh Normal file
View File

@@ -0,0 +1,13 @@
#!/bin/sh
set -e
INTERVAL="${PARSE_INTERVAL:-3600}"
echo "[entrypoint] PARSE_INTERVAL=${INTERVAL}s"
while true; do
echo "[entrypoint] Запуск парсера..."
npx ts-node src/index.ts
echo "[entrypoint] Следующий запуск через ${INTERVAL} секунд"
sleep "${INTERVAL}"
done

295
package-lock.json generated Normal file
View File

@@ -0,0 +1,295 @@
{
"name": "vpn-parser",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "vpn-parser",
"version": "1.0.0",
"dependencies": {
"dotenv": "^16.4.7",
"playwright": "^1.51.0"
},
"devDependencies": {
"@types/node": "^22.13.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.3"
}
},
"node_modules/@cspotcode/source-map-support": {
"version": "0.8.1",
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
"integrity": "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.9"
},
"engines": {
"node": ">=12"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.9",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz",
"integrity": "sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.0.3",
"@jridgewell/sourcemap-codec": "^1.4.10"
}
},
"node_modules/@tsconfig/node10": {
"version": "1.0.12",
"resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.12.tgz",
"integrity": "sha512-UCYBaeFvM11aU2y3YPZ//O5Rhj+xKyzy7mvcIoAjASbigy8mHMryP5cK7dgjlz2hWxh1g5pLw084E0a/wlUSFQ==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node12": {
"version": "1.0.11",
"resolved": "https://registry.npmjs.org/@tsconfig/node12/-/node12-1.0.11.tgz",
"integrity": "sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node14": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@tsconfig/node14/-/node14-1.0.3.tgz",
"integrity": "sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow==",
"dev": true,
"license": "MIT"
},
"node_modules/@tsconfig/node16": {
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@tsconfig/node16/-/node16-1.0.4.tgz",
"integrity": "sha512-vxhUy4J8lyeyinH7Azl1pdd43GJhZH/tP2weN8TntQblOY+A0XbT8DJk1/oCPuOOyg/Ja757rG0CgHcWC8OfMA==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "22.19.19",
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz",
"integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~6.21.0"
}
},
"node_modules/acorn": {
"version": "8.16.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz",
"integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==",
"dev": true,
"license": "MIT",
"bin": {
"acorn": "bin/acorn"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/acorn-walk": {
"version": "8.3.5",
"resolved": "https://registry.npmjs.org/acorn-walk/-/acorn-walk-8.3.5.tgz",
"integrity": "sha512-HEHNfbars9v4pgpW6SO1KSPkfoS0xVOM/9UzkJltjlsHZmJasxg8aXkuZa7SMf8vKGIBhpUsPluQSqhJFCqebw==",
"dev": true,
"license": "MIT",
"dependencies": {
"acorn": "^8.11.0"
},
"engines": {
"node": ">=0.4.0"
}
},
"node_modules/arg": {
"version": "4.1.3",
"resolved": "https://registry.npmjs.org/arg/-/arg-4.1.3.tgz",
"integrity": "sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA==",
"dev": true,
"license": "MIT"
},
"node_modules/create-require": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/create-require/-/create-require-1.1.1.tgz",
"integrity": "sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ==",
"dev": true,
"license": "MIT"
},
"node_modules/diff": {
"version": "4.0.4",
"resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz",
"integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.3.1"
}
},
"node_modules/dotenv": {
"version": "16.6.1",
"resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz",
"integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==",
"license": "BSD-2-Clause",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://dotenvx.com"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/make-error": {
"version": "1.3.6",
"resolved": "https://registry.npmjs.org/make-error/-/make-error-1.3.6.tgz",
"integrity": "sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==",
"dev": true,
"license": "ISC"
},
"node_modules/playwright": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.60.0.tgz",
"integrity": "sha512-hheHdokM8cdqCb0lcE3s+zT4t4W+vvjpGxsZlDnikarzx8tSzMebh3UiFtgqwFwnTnjYQcsyMF8ei2mCO/tpeA==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.60.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.60.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.60.0.tgz",
"integrity": "sha512-9bW6zvX/m0lEbgTKJ6YppOKx8H3VOPBMOCFh2irXFOT4BbHgrx5hPjwJYLT40Lu+4qtD36qKc/Hn56StUW57IA==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
},
"node_modules/ts-node": {
"version": "10.9.2",
"resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.2.tgz",
"integrity": "sha512-f0FFpIdcHgn8zcPSbf1dRevwt047YMnaiJM3u2w2RewrB+fob/zePZcrOyQoLMMO7aBIddLcQIEK5dYjkLnGrQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@cspotcode/source-map-support": "^0.8.0",
"@tsconfig/node10": "^1.0.7",
"@tsconfig/node12": "^1.0.7",
"@tsconfig/node14": "^1.0.0",
"@tsconfig/node16": "^1.0.2",
"acorn": "^8.4.1",
"acorn-walk": "^8.1.1",
"arg": "^4.1.0",
"create-require": "^1.1.0",
"diff": "^4.0.1",
"make-error": "^1.1.1",
"v8-compile-cache-lib": "^3.0.1",
"yn": "3.1.1"
},
"bin": {
"ts-node": "dist/bin.js",
"ts-node-cwd": "dist/bin-cwd.js",
"ts-node-esm": "dist/bin-esm.js",
"ts-node-script": "dist/bin-script.js",
"ts-node-transpile-only": "dist/bin-transpile.js",
"ts-script": "dist/bin-script-deprecated.js"
},
"peerDependencies": {
"@swc/core": ">=1.2.50",
"@swc/wasm": ">=1.2.50",
"@types/node": "*",
"typescript": ">=2.7"
},
"peerDependenciesMeta": {
"@swc/core": {
"optional": true
},
"@swc/wasm": {
"optional": true
}
}
},
"node_modules/typescript": {
"version": "5.9.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz",
"integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
},
"engines": {
"node": ">=14.17"
}
},
"node_modules/undici-types": {
"version": "6.21.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
"integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==",
"dev": true,
"license": "MIT"
},
"node_modules/v8-compile-cache-lib": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz",
"integrity": "sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg==",
"dev": true,
"license": "MIT"
},
"node_modules/yn": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/yn/-/yn-3.1.1.tgz",
"integrity": "sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6"
}
}
}
}

19
package.json Normal file
View File

@@ -0,0 +1,19 @@
{
"name": "vpn-parser",
"version": "1.0.0",
"private": true,
"scripts": {
"start": "ts-node src/index.ts",
"build": "tsc",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"dotenv": "^16.4.7",
"playwright": "^1.51.0"
},
"devDependencies": {
"@types/node": "^22.13.0",
"ts-node": "^10.9.2",
"typescript": "^5.7.3"
}
}

26
src/browser.ts Normal file
View File

@@ -0,0 +1,26 @@
import { chromium, Browser, BrowserContext } from "playwright";
let browser: Browser | null = null;
export async function getBrowser(): Promise<Browser> {
if (!browser) {
browser = await chromium.launch({ headless: true });
const exitHandler = () => browser?.close().catch(() => {});
process.on("exit", exitHandler);
process.on("SIGINT", exitHandler);
process.on("SIGTERM", exitHandler);
}
return browser;
}
export async function newContext(): Promise<BrowserContext> {
const b = await getBrowser();
return b.newContext();
}
export async function closeBrowser(): Promise<void> {
if (browser) {
await browser.close();
browser = null;
}
}

94
src/index.ts Normal file
View File

@@ -0,0 +1,94 @@
import dotenv from "dotenv";
import { parseNovavps, getValidConnections } from "./novavps";
import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel";
import { closeBrowser } from "./browser";
dotenv.config();
interface PanelConfig {
url: string;
username: string;
password: string;
label: string;
}
function getPanels(): PanelConfig[] {
const panels: PanelConfig[] = [];
const url1 = process.env.XRAY_URL;
const user1 = process.env.XRAY_USERNAME;
const pass1 = process.env.XRAY_PASSWORD;
if (url1 && user1 && pass1) {
panels.push({ url: url1, username: user1, password: pass1, label: "xray1" });
}
const url2 = process.env.XRAY_URL2;
const user2 = process.env.XRAY_USERNAME2;
const pass2 = process.env.XRAY_PASSWORD2;
if (url2 && user2 && pass2) {
panels.push({ url: url2, username: user2, password: pass2, label: "xray2" });
}
return panels;
}
async function main() {
const novavpsUrl = process.env.NOVAVPS_URL;
if (!novavpsUrl) {
console.error("NOVAVPS_URL not set in .env");
process.exit(1);
}
const panels = getPanels();
if (panels.length === 0) {
console.error("Нет настроенных Xray панелей (XRAY_URL или XRAY_URL2)");
process.exit(1);
}
console.log("=== VPN Connection Parser ===\n");
let novavpsConnections: Awaited<ReturnType<typeof parseNovavps>> = [];
try {
novavpsConnections = await parseNovavps(novavpsUrl);
console.log(`[result] novavps: ${novavpsConnections.length} connections`);
} catch (err) {
console.error("[novavps] Error:", err instanceof Error ? err.message : String(err));
}
const valid = getValidConnections(novavpsConnections);
console.log(`[result] valid (без auto): ${valid.length} connections\n`);
for (const panel of panels) {
console.log(`--- ${panel.label}: ${panel.url} ---`);
try {
const xray = await parseXrayPanel(panel.url, panel.username, panel.password);
console.log(`[${panel.label}] Найдено outbounds: ${xray.length}`);
} catch (err) {
console.error(`[${panel.label}] Ошибка парсинга:`, err instanceof Error ? err.message : String(err));
}
console.log();
if (valid.length > 0) {
try {
await syncNovavpsToXray(valid, panel.url, panel.username, panel.password, panel.label);
console.log(`[${panel.label}] Синхронизация завершена`);
} catch (err) {
console.error(`[${panel.label}] Ошибка синхронизации:`, err instanceof Error ? err.message : String(err));
}
}
console.log();
}
await closeBrowser();
console.log("Done.");
}
main().catch((err) => {
console.error("Fatal error:", err);
closeBrowser().catch(() => {});
process.exit(1);
});

181
src/novavps.ts Normal file
View File

@@ -0,0 +1,181 @@
import path from "path";
import fs from "fs/promises";
import { newContext } from "./browser";
const DATA_DIR = path.resolve(__dirname, "..", "data");
const OUTPUT_FILE = path.join(DATA_DIR, "novavps-connections.json");
export interface NovavpsConnection {
name: string;
protocol: string;
address: string;
port: string;
uuid?: string;
security?: string;
flow?: string;
encryption?: string;
sni?: string;
fingerprint?: string;
publicKey?: string;
shortId?: string;
networkType?: string;
serviceName?: string;
rawLink: string;
}
export function getValidConnections(connections: NovavpsConnection[]): NovavpsConnection[] {
return connections.filter((c) => {
const addr = c.address.toLowerCase();
if (addr === "auto" || addr === "" || addr === "0.0.0.0") return false;
return true;
});
}
function parseProxyLink(link: string): NovavpsConnection {
const base: NovavpsConnection = {
name: "",
protocol: "",
address: "",
port: "",
rawLink: link,
};
try {
if (link.startsWith("vmess://")) {
base.protocol = "vmess";
const b64 = link.replace("vmess://", "").split("#")[0];
const config = JSON.parse(Buffer.from(b64, "base64").toString("utf-8"));
base.name = config.ps || "";
base.address = config.add || "";
base.port = String(config.port || "");
base.uuid = config.id || "";
base.security = config.security || "";
base.encryption = config.scy || "";
} else if (link.startsWith("vless://")) {
base.protocol = "vless";
const u = new URL(link);
base.name = decodeURIComponent(u.hash.replace("#", ""));
base.address = u.hostname;
base.port = u.port;
base.uuid = u.username;
base.security = u.searchParams.get("security") || "";
base.flow = u.searchParams.get("flow") || "";
base.encryption = u.searchParams.get("encryption") || "";
base.sni = u.searchParams.get("sni") || "";
base.fingerprint = u.searchParams.get("fp") || "";
base.publicKey = u.searchParams.get("pbk") || "";
base.shortId = u.searchParams.get("sid") || "";
base.networkType = u.searchParams.get("type") || "tcp";
base.serviceName = u.searchParams.get("serviceName") || "";
} else if (link.startsWith("trojan://")) {
base.protocol = "trojan";
const u = new URL(link);
base.name = decodeURIComponent(u.hash.replace("#", ""));
base.address = u.hostname;
base.port = u.port;
base.uuid = u.username;
base.security = u.searchParams.get("security") || "";
base.sni = u.searchParams.get("sni") || "";
} else if (link.startsWith("ss://")) {
base.protocol = "ss";
const hashSplit = link.split("#");
const withoutHash = hashSplit[0];
base.name = hashSplit.length > 1 ? decodeURIComponent(hashSplit[1]) : "";
const atIdx = withoutHash.lastIndexOf("@");
if (atIdx > 0) {
const methodPassB64 = withoutHash.replace("ss://", "").slice(0, atIdx - "ss://".length);
const hostPart = withoutHash.slice(atIdx + 1);
const colonIdx = hostPart.lastIndexOf(":");
if (colonIdx > 0) {
base.address = hostPart.slice(0, colonIdx);
base.port = hostPart.slice(colonIdx + 1).split("#")[0];
}
try {
const decoded = Buffer.from(methodPassB64, "base64").toString("utf-8");
const [method, password] = decoded.split(":");
base.encryption = method || "";
base.uuid = password || "";
} catch { /* ignore */ }
}
} else if (link.startsWith("hysteria2://") || link.startsWith("hy2://")) {
base.protocol = "hysteria2";
const u = new URL(link);
base.name = decodeURIComponent(u.hash.replace("#", ""));
base.address = u.hostname;
base.port = u.port;
base.uuid = u.username;
base.sni = u.searchParams.get("sni") || "";
} else if (link.startsWith("tuic://")) {
base.protocol = "tuic";
const u = new URL(link);
base.name = decodeURIComponent(u.hash.replace("#", ""));
base.address = u.hostname;
base.port = u.port;
base.uuid = u.username;
} else if (link.startsWith("wireguard://") || link.startsWith("wg://")) {
base.protocol = "wireguard";
base.name = link;
} else if (link.startsWith("http")) {
base.protocol = "subscription";
base.name = "Subscription link";
base.address = link;
} else {
base.protocol = "unknown";
base.name = link;
}
} catch (err) {
base.name = `parse_error: ${err instanceof Error ? err.message : err}`;
}
return base;
}
async function save(data: NovavpsConnection[]): Promise<void> {
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(OUTPUT_FILE, JSON.stringify(data, null, 2), "utf-8");
console.log(`[novavps] Saved ${data.length} connections to ${OUTPUT_FILE}`);
}
export async function parseNovavps(url: string): Promise<NovavpsConnection[]> {
console.log(`[novavps] Opening page: ${url}`);
const context = await newContext();
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(3000);
const linksJson = await page.evaluate(() => {
let result: string | null = null;
const scripts = document.querySelectorAll("script");
scripts.forEach((script) => {
if (result) return;
const text = script.textContent || "";
if (!text.includes("panelData")) return;
const match = text.match(/JSON\.parse\(atob\('([^']+)'\)\)/);
if (!match) return;
try {
const data = JSON.parse(atob(match[1]));
result = JSON.stringify(data.response.links);
} catch { /* skip */ }
});
return result;
});
if (!linksJson) {
console.log("[novavps] Could not extract links from page");
return [];
}
const links: string[] = JSON.parse(linksJson);
console.log(`[novavps] Found ${links.length} links`);
const connections = links.map(parseProxyLink);
await save(connections);
return connections;
} finally {
await page.close();
await context.close();
}
}

412
src/xray-panel.ts Normal file
View File

@@ -0,0 +1,412 @@
import path from "path";
import fs from "fs/promises";
import { readdirSync, unlinkSync } from "fs";
import type { Page } from "playwright";
import { newContext, getBrowser } from "./browser";
import type { NovavpsConnection } from "./novavps";
const DATA_DIR = path.resolve(__dirname, "..", "data");
const BACKUP_DIR = path.join(DATA_DIR, "xray-backups");
const OUTBOUNDS_FILE = path.join(DATA_DIR, "xray-outbounds.json");
const MAX_BACKUPS = 45;
export interface XrayOutbound {
remark: string;
protocol: string;
address: string;
port: string;
details: Record<string, string>;
}
async function saveOutboundsFile(data: XrayOutbound[]): Promise<void> {
await fs.mkdir(DATA_DIR, { recursive: true });
await fs.writeFile(OUTBOUNDS_FILE, JSON.stringify(data, null, 2), "utf-8");
console.log(`[xray] Сохранено ${data.length} outbounds в ${OUTBOUNDS_FILE}`);
}
async function backupConfig(configJson: string): Promise<string> {
await fs.mkdir(BACKUP_DIR, { recursive: true });
const now = new Date();
const pad = (n: number) => String(n).padStart(2, "0");
const ts = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())}T${pad(now.getHours())}-${pad(now.getMinutes())}-${pad(now.getSeconds())}`;
const filename = `config-${ts}.json`;
const filepath = path.join(BACKUP_DIR, filename);
await fs.writeFile(filepath, configJson, "utf-8");
console.log(`[xray] Бэкап сохранён: ${filename}`);
// Ротация — оставляем только MAX_BACKUPS последних
try {
const files = readdirSync(BACKUP_DIR)
.filter((f) => f.startsWith("config-") && f.endsWith(".json"))
.sort()
.reverse();
if (files.length > MAX_BACKUPS) {
for (const old of files.slice(MAX_BACKUPS)) {
unlinkSync(path.join(BACKUP_DIR, old));
}
console.log(`[xray] Ротация: удалено ${files.length - MAX_BACKUPS} старых бэкапов`);
}
} catch { /* ignore rotation errors */ }
return filepath;
}
function buildVlessOutbound(conn: NovavpsConnection, tag: string): Record<string, unknown> {
const outbound: Record<string, unknown> = {
tag,
protocol: "vless",
settings: {
vnext: [
{
address: conn.address,
port: parseInt(conn.port) || 443,
users: [
{
id: conn.uuid || "",
encryption: conn.encryption || "none",
flow: conn.flow || "",
},
],
},
],
},
streamSettings: {
network: conn.networkType || "tcp",
security: conn.security || "none",
},
};
if (conn.security === "reality") {
(outbound.streamSettings as Record<string, unknown>).realitySettings = {
serverName: conn.sni || conn.address,
fingerprint: conn.fingerprint || "chrome",
publicKey: conn.publicKey || "",
shortId: conn.shortId || "",
};
}
if (conn.networkType === "grpc" && conn.serviceName) {
(outbound.streamSettings as Record<string, unknown>).grpcSettings = {
serviceName: conn.serviceName,
};
}
return outbound;
}
async function loginAndNavigate(page: Page, url: string, username: string, password: string): Promise<void> {
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(2000);
const loginInput = await page.$('input[type="text"], input[name="username"], input[name="login"]');
const passInput = await page.$('input[type="password"]');
if (loginInput && passInput) {
await loginInput.fill(username);
await passInput.fill(password);
const submitBtn = await page.$('button[type="submit"], input[type="submit"]');
if (submitBtn) await submitBtn.click();
else await page.keyboard.press("Enter");
await page.waitForTimeout(3000);
await page.waitForLoadState("networkidle");
}
}
export async function parseXrayPanel(
url: string,
username: string,
password: string
): Promise<XrayOutbound[]> {
console.log(`[xray] Открываю панель: ${url}`);
const context = await newContext();
const page = await context.newPage();
try {
await page.goto(url, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(2000);
const loginInput = await page.$('input[type="text"], input[name="username"], input[name="login"]');
const passInput = await page.$('input[type="password"]');
if (loginInput && passInput) {
console.log("[xray] Логинюсь...");
await loginInput.fill(username);
await passInput.fill(password);
const submitBtn = await page.$('button[type="submit"], input[type="submit"]');
if (submitBtn) await submitBtn.click();
else await page.keyboard.press("Enter");
await page.waitForTimeout(3000);
await page.waitForLoadState("networkidle");
}
const baseUrl = new URL(url).origin;
const xrayUrl = `${baseUrl}/panel/xray`;
await page.goto(xrayUrl, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(3000);
// Кликаем на вкладку "Исходящие подключения"
await page.evaluate(() => {
let found = false;
document.querySelectorAll("button, a, span, div, li").forEach((el) => {
if (found) return;
const text = (el.textContent || "").trim();
if (text === "Исходящие подключения" || text === "Outbounds") {
(el as HTMLElement).click();
found = true;
}
});
});
await page.waitForTimeout(2000);
await page.waitForLoadState("networkidle");
const outbounds = await page.evaluate(() => {
const rows: Array<{
remark: string;
protocol: string;
address: string;
port: string;
details: Record<string, string>;
}> = [];
const tables = document.querySelectorAll("table");
tables.forEach((table) => {
const trs = table.querySelectorAll("tbody tr, tr");
trs.forEach((tr) => {
const tds = tr.querySelectorAll("td");
if (tds.length < 2) return;
const cells = Array.from(tds).map((td) => td.textContent?.trim() ?? "");
const text = cells.join(" ").toLowerCase();
const protocols = ["vless", "vmess", "shadowsocks", "trojan", "socks", "http", "wireguard"];
if (!protocols.some((p) => text.includes(p))) return;
const row = {
remark: cells[1] || cells[0] || "",
protocol: "",
address: "",
port: "",
details: {} as Record<string, string>,
};
cells.forEach((cell, idx) => {
const lower = cell.toLowerCase();
if (protocols.some((p) => lower.includes(p))) {
row.protocol = cell;
} else if (idx === 3 && cell.includes(":")) {
const [h, ...rest] = cell.split(":");
row.address = h;
row.port = rest.join(":");
} else if (/^\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}/.test(lower) || /^[a-z0-9]([a-z0-9-]*[a-z0-9])?(\.[a-z0-9]([a-z0-9-]*[a-z0-9])?)*\.[a-z]{2,}/.test(lower)) {
if (!row.address) row.address = cell;
} else if (/^\d+$/.test(cell) && parseInt(cell) > 0 && parseInt(cell) < 65536) {
if (!row.port) row.port = cell;
}
if (idx > 0) row.details[`col${idx}`] = cell;
});
if (!row.protocol) {
for (const p of protocols) {
if (text.includes(p)) {
row.protocol = p.toUpperCase();
break;
}
}
}
rows.push(row);
});
});
return rows;
});
await saveOutboundsFile(outbounds);
return outbounds;
} finally {
await page.close();
await context.close();
}
}
export async function syncNovavpsToXray(
connections: NovavpsConnection[],
url: string,
username: string,
password: string,
panelLabel = "xray"
): Promise<void> {
if (connections.length === 0) {
console.log(`[${panelLabel}] Нет подключений для синхронизации`);
return;
}
console.log(`[${panelLabel}] Синхронизирую ${connections.length} novavps подключений...`);
const context = await newContext();
const page = await context.newPage();
try {
await loginAndNavigate(page, url, username, password);
const baseUrl = new URL(url).origin;
// Переход на страницу настроек Xray
await page.goto(`${baseUrl}/panel/xray`, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(2000);
// Клик на вкладку "Расширенный шаблон"
const templateClicked = await page.evaluate(() => {
let found = false;
document.querySelectorAll("button, a, span, div, li").forEach((el) => {
if (found) return;
const text = (el.textContent || "").trim();
if (text === "Расширенный шаблон" || text === "Extended Template") {
(el as HTMLElement).click();
found = true;
}
});
return found;
});
if (!templateClicked) {
console.log(`[${panelLabel}] Вкладка 'Расширенный шаблон' не найдена`);
return;
}
await page.waitForTimeout(2000);
await page.waitForLoadState("networkidle");
// Читаем JSON через CodeMirror API
const configJson = await page.evaluate(() => {
const cm = document.querySelector(".CodeMirror") as unknown as { CodeMirror?: { getValue: () => string } };
if (cm?.CodeMirror) return cm.CodeMirror.getValue();
const textarea = document.querySelector("textarea");
if (textarea && textarea.value.length > 100) return textarea.value;
let foundVal = "";
document.querySelectorAll("textarea").forEach((ta) => {
if (!foundVal && ta.value && ta.value.length > 100) foundVal = ta.value;
});
return foundVal;
});
if (!configJson) {
console.log(`[${panelLabel}] Не удалось прочитать JSON конфиг из редактора`);
return;
}
await backupConfig(configJson);
let config: Record<string, unknown>;
try {
config = JSON.parse(configJson);
} catch (e) {
console.log(`[${panelLabel}] Ошибка парсинга JSON: ${e}`);
return;
}
const outbounds = (config.outbounds as Record<string, unknown>[]) || [];
connections.forEach((conn, idx) => {
const tag = `novavps${idx + 1}`;
const newOutbound = buildVlessOutbound(conn, tag);
const existingIdx = outbounds.findIndex((o) => o.tag === tag);
if (existingIdx >= 0) {
outbounds[existingIdx] = newOutbound;
console.log(`[${panelLabel}] Outbound ${tag} обновлён`);
} else {
outbounds.push(newOutbound);
console.log(`[${panelLabel}] Outbound ${tag} добавлен`);
}
});
config.outbounds = outbounds;
const modifiedJson = JSON.stringify(config, null, 2);
await page.evaluate((json) => {
const cm = document.querySelector(".CodeMirror") as unknown as { CodeMirror?: { setValue: (v: string) => void } };
if (cm?.CodeMirror) {
cm.CodeMirror.setValue(json);
return;
}
document.querySelectorAll("textarea").forEach((ta) => {
if (ta.value && ta.value.length > 100) {
ta.value = json;
ta.dispatchEvent(new Event("input", { bubbles: true }));
ta.dispatchEvent(new Event("change", { bubbles: true }));
}
});
}, modifiedJson);
await page.waitForTimeout(500);
const saved = await page.evaluate(() => {
let clicked = false;
document.querySelectorAll("button").forEach((btn) => {
if (clicked) return;
const text = (btn.textContent || "").trim();
if (text === "Сохранить" || text === "Save") {
btn.click();
clicked = true;
}
});
return clicked;
});
if (saved) {
console.log(`[${panelLabel}] Конфиг сохранён`);
await page.waitForTimeout(2000);
await page.waitForLoadState("networkidle");
} else {
console.log(`[${panelLabel}] Кнопка 'Сохранить' не найдена`);
}
await page.goto(`${baseUrl}/panel/`, { waitUntil: "networkidle", timeout: 30000 });
await page.waitForTimeout(3000);
const dashboardText = await page.evaluate(() => document.body.innerText);
console.log(`[${panelLabel}] Текст дашборда (первые 500 символов):`);
console.log(dashboardText.slice(0, 500));
const restarted = await page.evaluate(() => {
const texts = ["Перезапустить", "Restart", "Остановить", "Stop", "Запустить", "Start"];
const allEls = document.querySelectorAll("button, a, span, div, li");
let result = "";
allEls.forEach((el) => {
if (result) return;
const t = (el.textContent || "").trim();
for (const target of texts) {
if (t === target || t.includes(target)) {
(el as HTMLElement).click();
result = `clicked: ${target}`;
return;
}
}
});
return result || "not_found";
});
if (restarted.startsWith("clicked")) {
console.log(`[${panelLabel}] Нажата кнопка: ${restarted}`);
await page.waitForTimeout(3000);
} else {
console.log(`[${panelLabel}] Кнопка не найдена (${restarted})`);
}
} finally {
await page.close();
await context.close();
}
}

15
tsconfig.json Normal file
View File

@@ -0,0 +1,15 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022", "dom"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true
},
"include": ["src/**/*"]
}