start
This commit is contained in:
5
.dockerignore
Normal file
5
.dockerignore
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
data/
|
||||||
|
.git
|
||||||
|
*.md
|
||||||
4
.gitignore
vendored
Normal file
4
.gitignore
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
.env
|
||||||
|
data/
|
||||||
34
Dockerfile
Normal file
34
Dockerfile
Normal 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
36
docker-compose.yml
Normal 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
13
entrypoint.sh
Normal 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
295
package-lock.json
generated
Normal 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
19
package.json
Normal 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
26
src/browser.ts
Normal 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
94
src/index.ts
Normal 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
181
src/novavps.ts
Normal 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
412
src/xray-panel.ts
Normal 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
15
tsconfig.json
Normal 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/**/*"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user