This commit is contained in:
2026-06-04 00:43:53 +05:00
parent 85afc04ce2
commit c5d4ec68c4
3 changed files with 381 additions and 7 deletions

View File

@@ -1,10 +1,39 @@
import path from "path";
import fs from "fs/promises";
import https from "https";
import http from "http";
import { newContext } from "./browser";
const DATA_DIR = path.resolve(__dirname, "..", "data");
const OUTPUT_FILE = path.join(DATA_DIR, "novavps-connections.json");
interface HttpResult { status: number; body: string; contentType: string }
function httpGet(url: string): Promise<HttpResult> {
return new Promise((resolve, reject) => {
const mod = url.startsWith("https") ? https : http;
const req = mod.get(url, {
headers: {
"User-Agent": "v2rayN/1.0",
"Accept": "*/*",
},
timeout: 30000,
}, (res) => {
const chunks: Buffer[] = [];
res.on("data", (chunk: Buffer) => chunks.push(chunk));
res.on("end", () => {
const body = Buffer.concat(chunks);
const contentType = res.headers["content-type"] || "";
const charsetMatch = contentType.match(/charset=([\w-]+)/i);
const encoding = charsetMatch ? charsetMatch[1] : "utf-8";
resolve({ status: res.statusCode || 0, body: body.toString(encoding as BufferEncoding), contentType });
});
});
req.on("error", reject);
req.on("timeout", () => { req.destroy(); reject(new Error("Request timeout")); });
});
}
export interface NovavpsConnection {
name: string;
protocol: string;
@@ -136,8 +165,12 @@ async function save(data: NovavpsConnection[]): Promise<void> {
console.log(`[novavps] Saved ${data.length} connections to ${OUTPUT_FILE}`);
}
export async function parseNovavps(url: string): Promise<NovavpsConnection[]> {
console.log(`[novavps] Opening page: ${url}`);
function isDirectSubscriptionUrl(url: string): boolean {
return !url.includes("novavps.app");
}
async function parseNovavpsPage(url: string): Promise<NovavpsConnection[]> {
console.log(`[novavps] Opening novavps page: ${url}`);
const context = await newContext();
const page = await context.newPage();
@@ -171,11 +204,92 @@ export async function parseNovavps(url: string): Promise<NovavpsConnection[]> {
const links: string[] = JSON.parse(linksJson);
console.log(`[novavps] Found ${links.length} links`);
const connections = links.map(parseProxyLink);
await save(connections);
return connections;
return links.map(parseProxyLink);
} finally {
await page.close();
await context.close();
}
}
async function parseDirectSubscription(url: string): Promise<NovavpsConnection[]> {
console.log(`[sub] Fetching subscription: ${url}`);
let result: HttpResult;
try {
result = await httpGet(url);
} catch (err) {
console.error(`[sub] HTTP error: ${err instanceof Error ? err.message : String(err)}`);
return [];
}
if (result.status < 200 || result.status >= 300) {
console.error(`[sub] HTTP ${result.status}: ${result.body.slice(0, 200)}`);
return [];
}
// Reject HTML responses (error pages, etc.)
if (result.contentType.includes("text/html") || /^\s*</.test(result.body.trim())) {
console.error(`[sub] Got HTML response instead of subscription data (content-type: ${result.contentType})`);
return [];
}
const body = result.body;
if (!body || body.length < 10) {
console.log("[sub] Empty or too short response");
return [];
}
// Try base64 decode first (standard V2Ray subscription format)
let text: string;
const b64Pattern = /^[A-Za-z0-9+/]*={0,2}$/;
const stripped = body.replace(/\s/g, "");
if (stripped.length > 20 && b64Pattern.test(stripped)) {
try {
text = Buffer.from(stripped, "base64").toString("utf-8");
console.log("[sub] Successfully decoded base64 subscription");
} catch {
text = body;
}
} else {
text = body;
}
// If decoded text looks like HTML, reject it
if (/^\s*</.test(text.trim()) || text.includes("<html") || text.includes("<HTML")) {
console.error("[sub] Decoded content is HTML, not a valid subscription");
return [];
}
const lines = text.split(/\r?\n/).map((l) => l.trim()).filter((l) => l.length > 0);
// Filter out HTML/error lines and only keep known proxy link prefixes
const proxyLines = lines.filter((l) =>
/^(vmess|vless|trojan|ss|hysteria2|hy2|tuic|wireguard|wg):\/\//.test(l)
);
if (proxyLines.length === 0) {
console.log(`[sub] No valid proxy links found (${lines.length} total lines, none matched known formats)`);
if (lines.length > 0) {
console.log(`[sub] First line preview: ${lines[0].slice(0, 200)}`);
}
return [];
}
console.log(`[sub] Found ${proxyLines.length} proxy links`);
return proxyLines.map(parseProxyLink);
}
export async function parseNovavps(url: string): Promise<NovavpsConnection[]> {
let connections: NovavpsConnection[];
if (isDirectSubscriptionUrl(url)) {
connections = await parseDirectSubscription(url);
} else {
connections = await parseNovavpsPage(url);
}
if (connections.length > 0) {
await save(connections);
}
return connections;
}

View File

@@ -9,7 +9,7 @@ import fs from "fs/promises";
import promClient from 'prom-client';
import { parseNovavps, getValidConnections, NovavpsConnection } from "./novavps";
import { parseXrayPanel, syncNovavpsToXray } from "./xray-panel";
import { parseXrayPanel, syncNovavpsToXray, healthCheckXrayConnection } from "./xray-panel";
import { closeBrowser } from "./browser";
import { tcpPing } from "./ping";
import {
@@ -167,10 +167,33 @@ async function syncPanelWithPing(
await saveState(state);
}
let isHealthChecking = false;
async function runHealthCheck(): Promise<void> {
if (isHealthChecking || isRunning) return;
isHealthChecking = true;
const panel = getPanel();
if (!panel) {
console.log("[hc] Нет панели Xray — пропускаю health check");
isHealthChecking = false;
return;
}
try {
const result = await healthCheckXrayConnection(panel.url, panel.username, panel.password, panel.label);
console.log(`[hc] Результат: ${result.message}`);
} catch (err) {
console.error("[hc] Ошибка:", err instanceof Error ? err.message : String(err));
} finally {
isHealthChecking = false;
}
}
let isRunning = false;
async function runParser(): Promise<void> {
if (isRunning) return;
if (isRunning || isHealthChecking) return;
isRunning = true;
novavpsConnectionsCount.set(0);
@@ -455,6 +478,7 @@ app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
});
const PARSE_INTERVAL_MS = (parseInt(process.env.PARSE_INTERVAL || "1800", 10)) * 1000;
const HC_INTERVAL_MS = 5 * 60 * 1000;
setTimeout(() => {
console.log(`[server] Автозапуск парсера через 5с...`);
@@ -465,4 +489,14 @@ app.use((err: Error, _req: Request, res: Response, _next: NextFunction) => {
console.log(`[server] Плановый запуск парсера (интервал ${PARSE_INTERVAL_MS / 1000}с)...`);
runParser();
}, PARSE_INTERVAL_MS);
setTimeout(() => {
console.log(`[server] Первая проверка health check через 30с...`);
runHealthCheck();
}, 30000);
setInterval(() => {
console.log(`[server] Плановая проверка health check (интервал ${HC_INTERVAL_MS / 1000}с)...`);
runHealthCheck();
}, HC_INTERVAL_MS);
})();

View File

@@ -5,6 +5,7 @@ import type { Page } from "playwright";
import { newContext, getBrowser } from "./browser";
import type { NovavpsConnection } from "./novavps";
import { getConnectionNamePrefix } from "./config";
import { tcpPing } from "./ping";
const DATA_DIR = path.resolve(__dirname, "..", "data");
const BACKUP_DIR = path.join(DATA_DIR, "xray-backups");
@@ -319,8 +320,11 @@ export async function syncNovavpsToXray(
const existingNovavpsTags = outbounds.filter((o) => String(o.tag || "").startsWith(namePrefix));
console.log(`[${panelLabel}] Из них с тегом ${namePrefix}*: ${existingNovavpsTags.length}`);
const syncedTags = new Set<string>();
connections.forEach((conn, idx) => {
const tag = `${namePrefix}${startIndex + idx}`;
syncedTags.add(tag);
const newOutbound = buildVlessOutbound(conn, tag);
const existingIdx = outbounds.findIndex((o) => o.tag === tag);
@@ -333,6 +337,15 @@ export async function syncNovavpsToXray(
}
});
// Удаление устаревших outbound'ов (подписка уменьшилась)
for (let i = outbounds.length - 1; i >= 0; i--) {
const tag = String(outbounds[i].tag || "");
if (tag.startsWith(namePrefix) && !syncedTags.has(tag)) {
console.log(`[${panelLabel}] Удаляю устаревший ${tag}`);
outbounds.splice(i, 1);
}
}
config.outbounds = outbounds;
const modifiedJson = JSON.stringify(config, null, 2);
const novavpsCountAfter = (outbounds.filter((o) => String(o.tag || "").startsWith(namePrefix))).length;
@@ -441,3 +454,216 @@ export async function syncNovavpsToXray(
await context.close();
}
}
// ---------- helpers for editor read/write ----------
async function readConfigFromEditor(page: Page): Promise<string | null> {
return 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 || null;
});
}
async function writeConfigToEditor(page: Page, json: string): Promise<void> {
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 }));
}
});
}, json);
}
async function clickSaveButton(page: Page): Promise<boolean> {
return 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;
});
}
async function clickRestartButton(page: Page, baseUrl: string): Promise<void> {
await page.goto(`${baseUrl}/panel/`, { waitUntil: "load", timeout: 60000 });
await page.waitForTimeout(5000);
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(`[xray] Нажата кнопка: ${restarted}`);
await page.waitForTimeout(5000);
}
}
function getOutboundAddress(outbound: Record<string, unknown>): { address: string; port: number } | null {
const settings = outbound.settings as Record<string, unknown> | undefined;
const vnext = settings?.vnext as Record<string, unknown>[] | undefined;
if (!vnext || vnext.length === 0) return null;
return {
address: String(vnext[0].address || ""),
port: parseInt(String(vnext[0].port)) || 443,
};
}
// ---------- health check ----------
export async function healthCheckXrayConnection(
url: string,
username: string,
password: string,
panelLabel: string
): Promise<{ changed: boolean; message: string }> {
const namePrefix = getConnectionNamePrefix();
const context = await newContext();
const page = await context.newPage();
try {
await loginAndNavigate(page, url, username, password);
const baseUrl = new URL(url).origin;
await page.goto(`${baseUrl}/panel/xray`, { waitUntil: "load", timeout: 60000 });
await page.waitForTimeout(3000);
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) {
return { changed: false, message: "Вкладка 'Расширенный шаблон' не найдена" };
}
await page.waitForTimeout(4000);
const configJson = await readConfigFromEditor(page);
if (!configJson) {
return { changed: false, message: "Не удалось прочитать конфиг" };
}
let config: Record<string, unknown>;
try {
config = JSON.parse(configJson);
} catch {
return { changed: false, message: "Ошибка парсинга JSON конфига" };
}
const outbounds = (config.outbounds as Record<string, unknown>[]) || [];
const ourOutbounds = outbounds
.map((o, i) => ({ outbound: o, index: i, tag: String(o.tag || "") }))
.filter((x) => x.tag.startsWith(namePrefix))
.sort((a, b) => a.tag.localeCompare(b.tag));
if (ourOutbounds.length === 0) {
return { changed: false, message: `Нет outbound'ов с тегом ${namePrefix}*` };
}
const primaryTag = `${namePrefix}0`;
const primaryEntry = ourOutbounds.find((x) => x.tag === primaryTag);
if (!primaryEntry) {
return { changed: false, message: `${primaryTag} не найден` };
}
const primaryAddr = getOutboundAddress(primaryEntry.outbound);
if (!primaryAddr || !primaryAddr.address) {
return { changed: false, message: `${primaryTag} не содержит адреса` };
}
console.log(`[hc] Пингую ${primaryTag} (${primaryAddr.address}:${primaryAddr.port})...`);
const primaryLatency = await tcpPing(primaryAddr.address, primaryAddr.port);
if (primaryLatency !== null) {
console.log(`[hc] ${primaryTag} отвечает (${primaryLatency}ms) — здоровье OK`);
return { changed: false, message: `${primaryTag} отвечает (${primaryLatency}ms)` };
}
console.log(`[hc] ${primaryTag} не отвечает — проверяю все ${namePrefix}*...`);
const results: Array<{ tag: string; index: number; latency: number | null; address: string; port: number; outbound: Record<string, unknown> }> = [];
for (const entry of ourOutbounds) {
const addr = getOutboundAddress(entry.outbound);
if (!addr || !addr.address) continue;
console.log(`[hc] Пингую ${entry.tag} (${addr.address}:${addr.port})...`);
const lat = await tcpPing(addr.address, addr.port);
results.push({ ...entry, ...addr, latency: lat, outbound: entry.outbound });
}
const alive = results.filter((r) => r.latency !== null).sort((a, b) => (a.latency || Infinity) - (b.latency || Infinity));
if (alive.length === 0) {
console.log(`[hc] Ни один ${namePrefix}* не отвечает`);
return { changed: false, message: `Нет доступных ${namePrefix}* подключений` };
}
const best = alive[0];
console.log(`[hc] Лучший: ${best.tag} (${best.latency}ms) — делаю ${primaryTag}`);
// Remove the best outbound from its current position, make it primary at same index
const bestOutbound = outbounds.splice(best.index, 1)[0];
bestOutbound.tag = primaryTag;
// Reinsert primary at the original index of the old primary
outbounds.splice(primaryEntry.index, 0, bestOutbound);
config.outbounds = outbounds;
const modifiedJson = JSON.stringify(config, null, 2);
await backupConfig(configJson);
await writeConfigToEditor(page, modifiedJson);
await page.waitForTimeout(3000);
const saved = await clickSaveButton(page);
if (saved) {
console.log(`[hc] Конфиг сохранён`);
await page.waitForTimeout(5000);
}
await clickRestartButton(page, baseUrl);
return { changed: true, message: `Переназначил ${best.tag}${primaryTag} (${best.latency}ms)` };
} finally {
await page.close();
await context.close();
}
}