start
This commit is contained in:
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();
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user