|
@@ -0,0 +1,551 @@
|
|
|
+const axios = require('axios');
|
|
|
+const yaml = require('yaml');
|
|
|
+const logger = require('../utils/logger');
|
|
|
+const ClashParser = require('./clashParser');
|
|
|
+const { Node, Subscription } = require('../models');
|
|
|
+
|
|
|
+class MultiSubscriptionManager {
|
|
|
+ constructor() {
|
|
|
+ this.updateInterval = parseInt(process.env.SUBSCRIPTION_UPDATE_INTERVAL) || 3600000; // 默认1小时
|
|
|
+ this.updateTimer = null;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取所有活跃的订阅
|
|
|
+ */
|
|
|
+ async getActiveSubscriptions() {
|
|
|
+ try {
|
|
|
+ return await Subscription.findAll({
|
|
|
+ where: { isActive: true },
|
|
|
+ order: [['createdAt', 'ASC']]
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('获取活跃订阅失败', { error: error.message });
|
|
|
+ return [];
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 从订阅地址获取配置
|
|
|
+ */
|
|
|
+ async fetchSubscription(subscription) {
|
|
|
+ if (!subscription.url) {
|
|
|
+ throw new Error('订阅地址为空');
|
|
|
+ }
|
|
|
+
|
|
|
+ try {
|
|
|
+ const response = await axios.get(subscription.url, {
|
|
|
+ timeout: 30000,
|
|
|
+ headers: {
|
|
|
+ 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36'
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ if (!response.data) {
|
|
|
+ throw new Error('订阅地址返回空内容');
|
|
|
+ }
|
|
|
+
|
|
|
+ let config;
|
|
|
+ let rawContent = response.data;
|
|
|
+
|
|
|
+ // 1. 先尝试YAML解析
|
|
|
+ let yamlParsed = false;
|
|
|
+ try {
|
|
|
+ config = yaml.parse(rawContent);
|
|
|
+ if (config && typeof config === 'object' && Array.isArray(config.proxies)) {
|
|
|
+ logger.info('成功解析为YAML格式', { subscriptionId: subscription.id });
|
|
|
+ yamlParsed = true;
|
|
|
+ } else {
|
|
|
+ logger.info('YAML解析结果不是有效的订阅对象,进入Base64解码流程', { subscriptionId: subscription.id });
|
|
|
+ throw new Error('不是有效的YAML订阅');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ if (!yamlParsed) logger.info('不是YAML格式,尝试Base64解码', { subscriptionId: subscription.id });
|
|
|
+
|
|
|
+ // 2. 不是YAML,尝试整体Base64解码
|
|
|
+ let decoded;
|
|
|
+ try {
|
|
|
+ decoded = Buffer.from(rawContent, 'base64').toString('utf8');
|
|
|
+ logger.info('Base64解码成功,内容长度:', decoded.length, { subscriptionId: subscription.id });
|
|
|
+
|
|
|
+ // 如果解码后包含ss://、vmess://、trojan://,说明是明文链接合集
|
|
|
+ if (/ss:\/\/|vmess:\/\/|trojan:\/\//.test(decoded)) {
|
|
|
+ logger.info('检测到代理链接,转换为Clash格式', { subscriptionId: subscription.id });
|
|
|
+ config = this.convertShadowsocksToClash(decoded);
|
|
|
+ } else {
|
|
|
+ logger.info('整体解码后不是代理链接,尝试多行Base64解码', { subscriptionId: subscription.id });
|
|
|
+ // 3. 如果整体解码后不是明文链接合集,尝试多行Base64(每行一个链接)
|
|
|
+ const lines = rawContent.split('\n').filter(line => line.trim());
|
|
|
+ logger.info('原始内容行数:', lines.length, { subscriptionId: subscription.id });
|
|
|
+
|
|
|
+ let decodedLines = [];
|
|
|
+ for (const line of lines) {
|
|
|
+ try {
|
|
|
+ const d = Buffer.from(line, 'base64').toString('utf8');
|
|
|
+ if (/ss:\/\/|vmess:\/\/|trojan:\/\//.test(d)) {
|
|
|
+ decodedLines.push(d);
|
|
|
+ logger.debug('成功解码一行:', d.substring(0, 50) + '...', { subscriptionId: subscription.id });
|
|
|
+ }
|
|
|
+ } catch (e) {
|
|
|
+ logger.debug('跳过无效的Base64行', { subscriptionId: subscription.id });
|
|
|
+ continue;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ logger.info('成功解码的行数:', decodedLines.length, { subscriptionId: subscription.id });
|
|
|
+ if (decodedLines.length > 0) {
|
|
|
+ config = this.convertShadowsocksToClash(decodedLines.join('\n'));
|
|
|
+ } else {
|
|
|
+ throw new Error('无法解析订阅内容,既不是有效的YAML也不是有效的Base64编码');
|
|
|
+ }
|
|
|
+ }
|
|
|
+ } catch (decodeError) {
|
|
|
+ logger.error('Base64解码失败:', decodeError.message, { subscriptionId: subscription.id });
|
|
|
+ throw new Error('无法解析订阅内容,既不是有效的YAML也不是有效的Base64编码');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!config.proxies || !Array.isArray(config.proxies)) {
|
|
|
+ logger.error('订阅配置中没有找到有效的代理节点', {
|
|
|
+ hasProxies: !!config.proxies,
|
|
|
+ isArray: Array.isArray(config.proxies),
|
|
|
+ configKeys: Object.keys(config || {}),
|
|
|
+ subscriptionId: subscription.id
|
|
|
+ });
|
|
|
+ throw new Error('订阅配置中没有找到有效的代理节点');
|
|
|
+ }
|
|
|
+
|
|
|
+ // 只在节点数量较多时显示前几个名称
|
|
|
+ const proxyNames = config.proxies.length > 10
|
|
|
+ ? config.proxies.slice(0, 3).map(p => p.name)
|
|
|
+ : config.proxies.map(p => p.name);
|
|
|
+
|
|
|
+ logger.info('订阅配置解析成功', {
|
|
|
+ proxyCount: config.proxies.length,
|
|
|
+ proxyNames: proxyNames,
|
|
|
+ subscriptionId: subscription.id
|
|
|
+ });
|
|
|
+
|
|
|
+ return config;
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('获取订阅配置失败', {
|
|
|
+ error: error.message,
|
|
|
+ url: subscription.url,
|
|
|
+ subscriptionId: subscription.id
|
|
|
+ });
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 更新单个订阅的节点列表
|
|
|
+ */
|
|
|
+ async updateSubscriptionNodes(subscription) {
|
|
|
+ try {
|
|
|
+ logger.info('开始更新订阅节点', {
|
|
|
+ subscriptionId: subscription.id,
|
|
|
+ subscriptionName: subscription.name
|
|
|
+ });
|
|
|
+
|
|
|
+ // 获取订阅配置
|
|
|
+ const config = await this.fetchSubscription(subscription);
|
|
|
+
|
|
|
+ // 解析节点
|
|
|
+ const parser = new ClashParser();
|
|
|
+ const newNodes = [];
|
|
|
+
|
|
|
+ for (const proxy of config.proxies) {
|
|
|
+ const node = parser.parseProxy(proxy);
|
|
|
+ if (node) {
|
|
|
+ newNodes.push(node);
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ if (newNodes.length === 0) {
|
|
|
+ logger.warn('订阅中没有找到有效的节点', { subscriptionId: subscription.id });
|
|
|
+ return { updated: 0, added: 0, removed: 0 };
|
|
|
+ }
|
|
|
+
|
|
|
+ // 获取现有节点
|
|
|
+ const existingNodes = await Node.findAll({
|
|
|
+ where: {
|
|
|
+ subscriptionId: subscription.id,
|
|
|
+ isActive: true
|
|
|
+ }
|
|
|
+ });
|
|
|
+
|
|
|
+ const existingNodeMap = new Map();
|
|
|
+ existingNodes.forEach(node => {
|
|
|
+ const key = `${node.name}-${node.server}-${node.port}`;
|
|
|
+ existingNodeMap.set(key, node);
|
|
|
+ });
|
|
|
+
|
|
|
+ let added = 0;
|
|
|
+ let updated = 0;
|
|
|
+ let removed = 0;
|
|
|
+
|
|
|
+ // 批量处理节点更新
|
|
|
+ const updatePromises = [];
|
|
|
+ const createPromises = [];
|
|
|
+ const deactivatePromises = [];
|
|
|
+
|
|
|
+ // 处理新节点
|
|
|
+ for (const nodeData of newNodes) {
|
|
|
+ const key = `${nodeData.name}-${nodeData.server}-${nodeData.port}`;
|
|
|
+ const existingNode = existingNodeMap.get(key);
|
|
|
+
|
|
|
+ if (existingNode) {
|
|
|
+ // 批量更新现有节点
|
|
|
+ updatePromises.push(
|
|
|
+ existingNode.update({
|
|
|
+ ...nodeData,
|
|
|
+ subscriptionId: subscription.id,
|
|
|
+ updatedAt: new Date()
|
|
|
+ })
|
|
|
+ );
|
|
|
+ updated++;
|
|
|
+ existingNodeMap.delete(key);
|
|
|
+ } else {
|
|
|
+ // 批量创建新节点
|
|
|
+ createPromises.push(
|
|
|
+ Node.create({
|
|
|
+ ...nodeData,
|
|
|
+ subscriptionId: subscription.id,
|
|
|
+ isActive: true,
|
|
|
+ status: 'offline'
|
|
|
+ })
|
|
|
+ );
|
|
|
+ added++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 批量标记不再存在的节点为非活跃
|
|
|
+ for (const [key, node] of existingNodeMap) {
|
|
|
+ deactivatePromises.push(node.update({ isActive: false }));
|
|
|
+ removed++;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 并行执行所有数据库操作
|
|
|
+ logger.info(`正在批量更新数据库,共${updatePromises.length + createPromises.length + deactivatePromises.length}个操作...`);
|
|
|
+ await Promise.all([...updatePromises, ...createPromises, ...deactivatePromises]);
|
|
|
+
|
|
|
+ // 更新订阅的节点数量
|
|
|
+ await subscription.update({
|
|
|
+ nodeCount: newNodes.length,
|
|
|
+ lastUpdateTime: new Date()
|
|
|
+ });
|
|
|
+
|
|
|
+ logger.info(`订阅节点更新完成 - 新增${added}个,更新${updated}个,移除${removed}个`, {
|
|
|
+ subscriptionId: subscription.id,
|
|
|
+ subscriptionName: subscription.name
|
|
|
+ });
|
|
|
+
|
|
|
+ // 如果有新增或更新的节点,触发测速
|
|
|
+ if (added > 0 || updated > 0) {
|
|
|
+ logger.info('检测到节点更新,准备触发测速...');
|
|
|
+ // 延迟3秒后触发测速,确保数据库操作完成
|
|
|
+ setTimeout(() => {
|
|
|
+ this.triggerSpeedTest();
|
|
|
+ }, 3000);
|
|
|
+ }
|
|
|
+
|
|
|
+ return { updated: newNodes.length, added, updated, removed };
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('更新订阅节点失败', {
|
|
|
+ error: error.message,
|
|
|
+ subscriptionId: subscription.id
|
|
|
+ });
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 更新所有订阅的节点
|
|
|
+ */
|
|
|
+ async updateAllSubscriptions() {
|
|
|
+ try {
|
|
|
+ const subscriptions = await this.getActiveSubscriptions();
|
|
|
+ logger.info(`开始更新所有订阅,共${subscriptions.length}个活跃订阅`);
|
|
|
+
|
|
|
+ const results = [];
|
|
|
+ for (const subscription of subscriptions) {
|
|
|
+ try {
|
|
|
+ const result = await this.updateSubscriptionNodes(subscription);
|
|
|
+ results.push({
|
|
|
+ subscriptionId: subscription.id,
|
|
|
+ subscriptionName: subscription.name,
|
|
|
+ ...result
|
|
|
+ });
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('更新订阅失败', {
|
|
|
+ subscriptionId: subscription.id,
|
|
|
+ subscriptionName: subscription.name,
|
|
|
+ error: error.message
|
|
|
+ });
|
|
|
+ results.push({
|
|
|
+ subscriptionId: subscription.id,
|
|
|
+ subscriptionName: subscription.name,
|
|
|
+ error: error.message
|
|
|
+ });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ // 检查是否有新增或更新的节点,如果有则触发测速
|
|
|
+ const hasUpdates = results.some(result =>
|
|
|
+ result.added > 0 || result.updated > 0
|
|
|
+ );
|
|
|
+
|
|
|
+ if (hasUpdates) {
|
|
|
+ logger.info('检测到节点更新,准备触发测速...');
|
|
|
+ // 延迟3秒后触发测速,确保数据库操作完成
|
|
|
+ setTimeout(() => {
|
|
|
+ this.triggerSpeedTest();
|
|
|
+ }, 3000);
|
|
|
+ }
|
|
|
+
|
|
|
+ return results;
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('更新所有订阅失败', { error: error.message });
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 启动自动更新
|
|
|
+ */
|
|
|
+ startAutoUpdate() {
|
|
|
+ logger.info('启动多订阅自动更新', {
|
|
|
+ interval: `${this.updateInterval / 1000}秒`
|
|
|
+ });
|
|
|
+
|
|
|
+ // 立即执行一次更新
|
|
|
+ this.updateAllSubscriptions().catch(error => {
|
|
|
+ logger.error('初始多订阅更新失败', { error: error.message });
|
|
|
+ });
|
|
|
+
|
|
|
+ // 设置定时更新
|
|
|
+ this.updateTimer = setInterval(() => {
|
|
|
+ this.updateAllSubscriptions().catch(error => {
|
|
|
+ logger.error('定时多订阅更新失败', { error: error.message });
|
|
|
+ });
|
|
|
+ }, this.updateInterval);
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 停止自动更新
|
|
|
+ */
|
|
|
+ stopAutoUpdate() {
|
|
|
+ if (this.updateTimer) {
|
|
|
+ clearInterval(this.updateTimer);
|
|
|
+ this.updateTimer = null;
|
|
|
+ logger.info('多订阅自动更新已停止');
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 手动更新所有订阅
|
|
|
+ */
|
|
|
+ async manualUpdate() {
|
|
|
+ try {
|
|
|
+ const results = await this.updateAllSubscriptions();
|
|
|
+ return {
|
|
|
+ success: true,
|
|
|
+ data: results
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ return {
|
|
|
+ success: false,
|
|
|
+ error: error.message
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 手动更新单个订阅
|
|
|
+ */
|
|
|
+ async manualUpdateSubscription(subscriptionId) {
|
|
|
+ try {
|
|
|
+ const subscription = await Subscription.findByPk(subscriptionId);
|
|
|
+ if (!subscription) {
|
|
|
+ throw new Error('订阅不存在');
|
|
|
+ }
|
|
|
+
|
|
|
+ const result = await this.updateSubscriptionNodes(subscription);
|
|
|
+ return {
|
|
|
+ success: true,
|
|
|
+ data: {
|
|
|
+ subscriptionId: subscription.id,
|
|
|
+ subscriptionName: subscription.name,
|
|
|
+ ...result
|
|
|
+ }
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ return {
|
|
|
+ success: false,
|
|
|
+ error: error.message
|
|
|
+ };
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 将Shadowsocks链接转换为Clash格式
|
|
|
+ */
|
|
|
+ convertShadowsocksToClash(shadowsocksContent) {
|
|
|
+ const lines = shadowsocksContent.split('\n').filter(line => line.trim());
|
|
|
+ const proxies = [];
|
|
|
+
|
|
|
+ logger.info(`开始解析Shadowsocks内容,共${lines.length}行`);
|
|
|
+
|
|
|
+ let ssCount = 0;
|
|
|
+ let vmessCount = 0;
|
|
|
+ let trojanCount = 0;
|
|
|
+ let errorCount = 0;
|
|
|
+
|
|
|
+ for (const line of lines) {
|
|
|
+ if (line.startsWith('ss://')) {
|
|
|
+ ssCount++;
|
|
|
+ try {
|
|
|
+ const proxy = this.parseShadowsocksUrl(line);
|
|
|
+ if (proxy) {
|
|
|
+ proxies.push(proxy);
|
|
|
+ } else {
|
|
|
+ errorCount++;
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ errorCount++;
|
|
|
+ }
|
|
|
+ } else if (line.startsWith('vmess://')) {
|
|
|
+ vmessCount++;
|
|
|
+ } else if (line.startsWith('trojan://')) {
|
|
|
+ trojanCount++;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ logger.info(`解析完成 - SS: ${ssCount}个, VMess: ${vmessCount}个, Trojan: ${trojanCount}个, 成功解析: ${proxies.length}个, 失败: ${errorCount}个`);
|
|
|
+
|
|
|
+ if (proxies.length === 0) {
|
|
|
+ logger.warn('没有找到任何有效的代理节点');
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ proxies: proxies,
|
|
|
+ proxyGroups: [
|
|
|
+ {
|
|
|
+ name: 'Proxy',
|
|
|
+ type: 'select',
|
|
|
+ proxies: proxies.map(p => p.name)
|
|
|
+ }
|
|
|
+ ]
|
|
|
+ };
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 解析Shadowsocks URL
|
|
|
+ */
|
|
|
+ parseShadowsocksUrl(url) {
|
|
|
+ try {
|
|
|
+ // 移除ss://前缀
|
|
|
+ const base64Part = url.substring(5);
|
|
|
+
|
|
|
+ // 分离服务器信息和备注
|
|
|
+ const parts = base64Part.split('#');
|
|
|
+ const serverInfo = parts[0];
|
|
|
+ const remark = parts[1] ? decodeURIComponent(parts[1]) : '';
|
|
|
+
|
|
|
+ // 分离服务器地址和认证信息
|
|
|
+ const atIndex = serverInfo.lastIndexOf('@');
|
|
|
+ if (atIndex === -1) {
|
|
|
+ logger.warn('Shadowsocks URL格式错误,缺少@符号', { url: url.substring(0, 50) + '...' });
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ const authPart = serverInfo.substring(0, atIndex);
|
|
|
+ const serverPart = serverInfo.substring(atIndex + 1);
|
|
|
+
|
|
|
+ // 解析服务器地址和端口
|
|
|
+ const colonIndex = serverPart.lastIndexOf(':');
|
|
|
+ if (colonIndex === -1) {
|
|
|
+ logger.warn('Shadowsocks URL格式错误,缺少端口号', { serverPart });
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ const server = serverPart.substring(0, colonIndex);
|
|
|
+ const port = parseInt(serverPart.substring(colonIndex + 1));
|
|
|
+
|
|
|
+ if (isNaN(port) || port <= 0 || port > 65535) {
|
|
|
+ logger.warn('Shadowsocks URL端口号无效', { port: serverPart.substring(colonIndex + 1) });
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ // 解析认证信息
|
|
|
+ const decodedAuth = Buffer.from(authPart, 'base64').toString('utf8');
|
|
|
+ const colonIndex2 = decodedAuth.indexOf(':');
|
|
|
+ if (colonIndex2 === -1) {
|
|
|
+ logger.warn('Shadowsocks认证信息格式错误', { decodedAuth });
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+
|
|
|
+ const method = decodedAuth.substring(0, colonIndex2);
|
|
|
+ const password = decodedAuth.substring(colonIndex2 + 1);
|
|
|
+
|
|
|
+ // 清理服务器地址(移除可能的\r字符)
|
|
|
+ const cleanServer = server.replace(/\r/g, '');
|
|
|
+ const cleanRemark = remark.replace(/\r/g, '');
|
|
|
+
|
|
|
+ return {
|
|
|
+ name: cleanRemark || `${cleanServer}:${port}`,
|
|
|
+ type: 'ss',
|
|
|
+ server: cleanServer,
|
|
|
+ port: port,
|
|
|
+ method: method,
|
|
|
+ password: password
|
|
|
+ };
|
|
|
+ } catch (error) {
|
|
|
+ logger.warn('解析Shadowsocks URL失败', { url: url.substring(0, 50) + '...', error: error.message });
|
|
|
+ return null;
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 触发测速
|
|
|
+ */
|
|
|
+ triggerSpeedTest() {
|
|
|
+ try {
|
|
|
+ // 通过事件触发测速,避免直接依赖
|
|
|
+ if (this.onSpeedTestTrigger) {
|
|
|
+ this.onSpeedTestTrigger();
|
|
|
+ } else {
|
|
|
+ logger.info('测速触发器未设置,跳过自动测速');
|
|
|
+ }
|
|
|
+ } catch (error) {
|
|
|
+ logger.error('触发测速失败', { error: error.message });
|
|
|
+ }
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 设置测速触发器
|
|
|
+ */
|
|
|
+ setSpeedTestTrigger(trigger) {
|
|
|
+ this.onSpeedTestTrigger = trigger;
|
|
|
+ }
|
|
|
+
|
|
|
+ /**
|
|
|
+ * 获取订阅状态
|
|
|
+ */
|
|
|
+ async getStatus() {
|
|
|
+ const subscriptions = await this.getActiveSubscriptions();
|
|
|
+ return {
|
|
|
+ activeSubscriptions: subscriptions.length,
|
|
|
+ updateInterval: this.updateInterval,
|
|
|
+ autoUpdateEnabled: !!this.updateTimer,
|
|
|
+ subscriptions: subscriptions.map(sub => ({
|
|
|
+ id: sub.id,
|
|
|
+ name: sub.name,
|
|
|
+ nodeCount: sub.nodeCount,
|
|
|
+ lastUpdateTime: sub.lastUpdateTime
|
|
|
+ }))
|
|
|
+ };
|
|
|
+ }
|
|
|
+}
|
|
|
+
|
|
|
+module.exports = MultiSubscriptionManager;
|