Compare commits

...

31 Commits

Author SHA1 Message Date
xream
2d5ed65f8d feat: VMess 支持 kcp/quic 2025-05-27 00:34:31 +08:00
xream
9e0028219d feat: Shadowrocket 支持 anytls
Some checks failed
build / build (push) Has been cancelled
2025-05-26 17:24:39 +08:00
xream
54750d552b feat: 为 env 响应增加如何使用前端搭配后端的引导说明
Some checks failed
build / build (push) Has been cancelled
2025-05-25 00:58:06 +08:00
xream
0e7561a069 feat: Node.js 环境中 JSON 数据文件校验失败后会备份原文件, 创建新文件
Some checks failed
build / build (push) Has been cancelled
2025-05-24 18:40:30 +08:00
xream
6804c6368a fix: 修复 QX VLESS TLS
Some checks failed
build / build (push) Has been cancelled
2025-05-23 22:36:08 +08:00
xream
9c5d6e9a10 feat: 单条订阅和文件支持链接参数 produceType raw, 此时返回原始数据的数组
Some checks failed
build / build (push) Has been cancelled
2025-05-22 16:09:35 +08:00
xream
ef2d6be8eb feat: 预处理支持 Base64 兜底 2025-05-22 15:17:38 +08:00
xream
04e12a4836 fix: 修复 SOCKS5 URI
Some checks failed
build / build (push) Has been cancelled
2025-05-21 01:39:30 +08:00
xream
f94cf7185a feat: 日志增加 body JSON limit 2025-05-20 21:16:30 +08:00
xream
fa7df51f8c feat: Shadowrocket 支持前置代理. 补充 demo.js 说明
Some checks failed
build / build (push) Has been cancelled
2025-05-18 17:21:54 +08:00
xream
18659d1cc8 feat: Node.js 环境下 API / 路由不自动跳转到 sub-store.vercel.app
Some checks failed
build / build (push) Has been cancelled
2025-05-17 22:49:12 +08:00
xream
1d12dc55bd feat: 单条订阅和文件支持链接参数 produceType raw, 此时返回原始数据的数组 2025-05-17 20:22:24 +08:00
xream
af9a2c86c1 fix: 修复 Surge/Loon VMess aead
Some checks failed
build / build (push) Has been cancelled
2025-05-12 12:26:14 +08:00
xream
98892fa100 fix: 修复 QX VMess aead 2025-05-12 11:06:13 +08:00
xream
6e2411e2c2 doc: demo.js 2025-05-12 01:42:42 +08:00
xream
b3f6876bbd feat: 兼容 xishang0128/sparkle 的 JavaScript 覆写; ProxyUtils 新增 Buffer, Base64
Some checks failed
build / build (push) Has been cancelled
2025-05-11 16:21:28 +08:00
xream
d2c3956884 feat: QX 正式支持 SS2022
Some checks failed
build / build (push) Has been cancelled
2025-05-07 02:59:19 +08:00
xream
21c1e11976 feat: 兼容非标 Shadowsocks URI 输入
Some checks failed
build / build (push) Has been cancelled
2025-04-28 13:55:15 +08:00
xream
e0f6b3e692 feat: sing-box Hysteria up/down 跟文档不一致, 但是懒得全转, 只处理最常见的 Mbps 2025-04-28 10:23:30 +08:00
xream
0d2920fadd feat: 兼容 Shadowrocket 非标 VMess URI 输入中的 peer(sni)
Some checks failed
build / build (push) Has been cancelled
2025-04-27 09:45:14 +08:00
xream
da9b1d8795 feat: 输出到 Clash/Stash/Shadowrocket 时, 会过滤掉配置了前置代理的节点, 并提示使用对应的功能
Some checks failed
build / build (push) Has been cancelled
2025-04-26 13:46:58 +08:00
xream
4c4bda563a feat: Stash 输出中过滤掉有前置代理的节点, 并在日志中提示
Some checks failed
build / build (push) Has been cancelled
2025-04-22 09:42:32 +08:00
xream
95f181351a feat: 忽略失败的远程选择支持开启通知(前端 >= 2.15.17)
Some checks failed
build / build (push) Has been cancelled
2025-04-21 19:28:58 +08:00
xream
3b85063f73 feat: 简单实现了 SUB_STORE_MMDB_CRON 定时更新 MMDB. ASN: SUB_STORE_MMDB_ASN_PATH, SUB_STORE_MMDB_ASN_URL. COUNTRY: SUB_STORE_MMDB_COUNTRY_PATH, SUB_STORE_MMDB_COUNTRY_URL; 脚本中新增 ProxyUtils.downloadFile 方便下载二进制文件. 2025-04-21 18:25:00 +08:00
xream
7f691c8511 fix: SS 解析增加默认节点名
Some checks failed
build / build (push) Has been cancelled
2025-04-20 20:10:08 +08:00
xream
55cc7dcd16 fix: 修复 URI 输出
Some checks failed
build / build (push) Has been cancelled
2025-04-19 16:44:40 +08:00
xream
4f745b0232 feat: sing-box 输出支持 brutal
Some checks failed
build / build (push) Has been cancelled
2025-04-18 22:49:19 +08:00
xream
28b233b62c fix: 修复 URI 输出
Some checks failed
build / build (push) Has been cancelled
2025-04-18 15:04:06 +08:00
xream
44d72523ce feat: AnyTLS URI 支持 UDP 参数
Some checks failed
build / build (push) Has been cancelled
2025-04-18 12:24:31 +08:00
xream
b60995f7ac feat: Loon 输入输出正式支持 VLESS XTLS/REALITY, VMess REALITY
Some checks failed
build / build (push) Has been cancelled
2025-04-17 09:57:56 +08:00
xream
a262dfbbe8 fix: 修复 Loon block-quic 参数
Some checks failed
build / build (push) Has been cancelled
2025-04-16 07:28:28 +08:00
27 changed files with 503 additions and 174 deletions

View File

@@ -1,6 +1,6 @@
{
"name": "sub-store",
"version": "2.19.22",
"version": "2.19.53",
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and Shadowrocket.",
"main": "src/main.js",
"scripts": {

View File

@@ -1,7 +1,8 @@
import { Base64 } from 'js-base64';
import { Buffer } from 'buffer';
import rs from '@/utils/rs';
import YAML from '@/utils/yaml';
import download from '@/utils/download';
import download, { downloadFile } from '@/utils/download';
import {
isIPv4,
isIPv6,
@@ -330,8 +331,11 @@ export const ProxyUtils = {
MMDB,
Gist,
download,
downloadFile,
isValidUUID,
doh,
Buffer,
Base64,
};
function tryParse(parser, line) {

View File

@@ -128,13 +128,13 @@ function URI_SS() {
// parse url
let content = line.split('ss://')[1];
let name = line.split('#')[1];
const proxy = {
name: decodeURIComponent(line.split('#')[1]),
type: 'ss',
};
content = content.split('#')[0]; // strip proxy name
// handle IPV4 and IPV6
let serverAndPortArray = content.match(/@([^/]*)(\/|$)/);
let serverAndPortArray = content.match(/@([^/?]*)(\/|\?|$)/);
let rawUserInfoStr = decodeURIComponent(content.split('@')[0]); // 其实应该分隔之后, 用户名和密码再 decodeURIComponent. 但是问题不大
let userInfoStr;
@@ -260,6 +260,10 @@ function URI_SS() {
if (/(&|\?)tfo=(1|true)/i.test(query)) {
proxy.tfo = true;
}
if (name != null) {
name = decodeURIComponent(name);
}
proxy.name = name ?? `SS ${proxy.server}:${proxy.port}`;
return proxy;
};
return { name, test, parse };
@@ -452,8 +456,12 @@ function URI_VMess() {
);
}
// https://github.com/2dust/v2rayN/wiki/%E5%88%86%E4%BA%AB%E9%93%BE%E6%8E%A5%E6%A0%BC%E5%BC%8F%E8%AF%B4%E6%98%8E(ver-2)
if (proxy.tls && params.sni && params.sni !== '') {
proxy.sni = params.sni;
if (proxy.tls) {
if (params.sni && params.sni !== '') {
proxy.sni = params.sni;
} else if (params.peer && params.peer !== '') {
proxy.sni = params.peer;
}
}
let httpupgrade = false;
// handle obfs
@@ -465,8 +473,8 @@ function URI_VMess() {
['http'].includes(params.type)
) {
proxy.network = 'http';
} else if (['grpc'].includes(params.net)) {
proxy.network = 'grpc';
} else if (['grpc', 'kcp', 'quic'].includes(params.net)) {
proxy.network = params.net;
} else if (
params.net === 'httpupgrade' ||
proxy.network === 'httpupgrade'
@@ -534,6 +542,10 @@ function URI_VMess() {
}
proxy[`${proxy.network}-opts`] = opts;
}
} else if (['kcp', 'quic'].includes(proxy.network)) {
proxy[`${proxy.network}-opts`] = {
[`_${proxy.network}-type`]: getIfNotBlank(params.type),
};
} else {
delete proxy.network;
}
@@ -744,6 +756,8 @@ function URI_AnyTLS() {
proxy[key] = value ? value.split(',') : undefined;
} else if (['insecure'].includes(key)) {
proxy['skip-cert-verify'] = /(TRUE)|1/i.test(value);
} else if (['udp'].includes(key)) {
proxy[key] = /(TRUE)|1/i.test(value);
} else {
proxy[key] = value;
}

View File

@@ -86,10 +86,10 @@ vmess = "vmess" equals address
(uuid/method/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/obfs/obfs_host/obfs_uri/udp_relay/udp_over_tcp/fast_open/aead/server_check_url/others)* {
proxy.type = "vmess";
proxy.cipher = proxy.cipher || "none";
if (proxy.aead) {
proxy.alterId = 0;
if (proxy.aead === false) {
proxy.alterId = 1;
} else {
proxy.alterId = proxy.alterId || 0;
proxy.alterId = 0;
}
handleObfs();
}

View File

@@ -84,10 +84,10 @@ vmess = "vmess" equals address
(uuid/method/over_tls/tls_host/tls_pubkey_sha256/tls_alpn/tls_no_session_ticket/tls_no_session_reuse/tls_fingerprint/tls_verification/tag/obfs/obfs_host/obfs_uri/udp_relay/udp_over_tcp/fast_open/aead/server_check_url/others)* {
proxy.type = "vmess";
proxy.cipher = proxy.cipher || "none";
if (proxy.aead) {
proxy.alterId = 0;
if (proxy.aead === false) {
proxy.alterId = 1;
} else {
proxy.alterId = proxy.alterId || 0;
proxy.alterId = 0;
}
handleObfs();
}

View File

@@ -55,10 +55,11 @@ shadowsocks = tag equals "ss" address (method/passwordk/obfs/obfs_host/obfs_uri/
vmess = tag equals "vmess" address (vmess_uuid/vmess_aead/ws/ws_path/ws_headers/method/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls/sni/tls_fingerprint/tls_verification/fast_open/tfo/udp_relay/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
proxy.type = "vmess";
proxy.cipher = proxy.cipher || "none";
// Surfboard 与 Surge 默认不一致, 不管 Surfboard https://getsurfboard.com/docs/profile-format/proxy/external-proxy/vmess
if (proxy.aead) {
proxy.alterId = 0;
} else {
proxy.alterId = proxy.alterId || 0;
proxy.alterId = 1;
}
handleWebsocket();
handleShadowTLS();

View File

@@ -53,10 +53,11 @@ shadowsocks = tag equals "ss" address (method/passwordk/obfs/obfs_host/obfs_uri/
vmess = tag equals "vmess" address (vmess_uuid/vmess_aead/ws/ws_path/ws_headers/method/ip_version/underlying_proxy/tos/allow_other_interface/interface/test_url/test_udp/test_timeout/hybrid/no_error_alert/tls/sni/tls_fingerprint/tls_verification/fast_open/tfo/udp_relay/shadow_tls_version/shadow_tls_sni/shadow_tls_password/block_quic/others)* {
proxy.type = "vmess";
proxy.cipher = proxy.cipher || "none";
// Surfboard 与 Surge 默认不一致, 不管 Surfboard https://getsurfboard.com/docs/profile-format/proxy/external-proxy/vmess
if (proxy.aead) {
proxy.alterId = 0;
} else {
proxy.alterId = proxy.alterId || 0;
proxy.alterId = 1;
}
handleWebsocket();
handleShadowTLS();

View File

@@ -50,6 +50,26 @@ function Base64Encoded() {
return { name, test, parse };
}
function fallbackBase64Encoded() {
const name = 'Fallback Base64 Pre-processor';
const test = function (raw) {
return true;
};
const parse = function (raw) {
const decoded = Base64.decode(raw);
if (!/^\w+(:\/\/|\s*?=\s*?)\w+/m.test(decoded)) {
$.error(
`Fallback Base64 Pre-processor error: decoded line does not start with protocol`,
);
return raw;
}
return decoded;
};
return { name, test, parse };
}
function Clash() {
const name = 'Clash Pre-processor';
const test = function (raw) {
@@ -163,4 +183,11 @@ function FullConfig() {
return { name, test, parse };
}
export default [HTML(), Clash(), Base64Encoded(), SSD(), FullConfig()];
export default [
HTML(),
Clash(),
Base64Encoded(),
SSD(),
FullConfig(),
fallbackBase64Encoded(),
];

View File

@@ -1141,6 +1141,10 @@ function createDynamicFunction(name, script, $arguments, $options) {
'$httpClient',
'$notification',
'ProxyUtils',
'yaml',
'Buffer',
'b64d',
'b64e',
'scriptResourceCache',
'flowUtils',
'produceArtifact',
@@ -1158,6 +1162,10 @@ function createDynamicFunction(name, script, $arguments, $options) {
// eslint-disable-next-line no-undef
$notification,
ProxyUtils,
ProxyUtils.yaml,
ProxyUtils.Buffer,
ProxyUtils.Base64.decode,
ProxyUtils.Base64.encode,
scriptResourceCache,
flowUtils,
produceArtifact,
@@ -1170,6 +1178,10 @@ function createDynamicFunction(name, script, $arguments, $options) {
'$substore',
'lodash',
'ProxyUtils',
'yaml',
'Buffer',
'b64d',
'b64e',
'scriptResourceCache',
'flowUtils',
'produceArtifact',
@@ -1181,6 +1193,10 @@ function createDynamicFunction(name, script, $arguments, $options) {
$,
lodash,
ProxyUtils,
ProxyUtils.yaml,
ProxyUtils.Buffer,
ProxyUtils.Base64.decode,
ProxyUtils.Base64.encode,
scriptResourceCache,
flowUtils,
produceArtifact,

View File

@@ -1,4 +1,5 @@
import { isPresent } from '@/core/proxy-utils/producers/utils';
import $ from '@/core/app';
export default function Clash_Producer() {
const type = 'ALL';
@@ -46,6 +47,11 @@ export default function Clash_Producer() {
proxy['reality-opts']))
) {
return false;
} else if (proxy['underlying-proxy'] || proxy['dialer-proxy']) {
$.error(
`Clash 不支持前置代理字段. 已过滤节点 ${proxy.name}`,
);
return false;
}
return true;
})
@@ -152,11 +158,6 @@ export default function Clash_Producer() {
}
delete proxy['tls-fingerprint'];
if (proxy['underlying-proxy']) {
proxy['dialer-proxy'] = proxy['underlying-proxy'];
}
delete proxy['underlying-proxy'];
if (isPresent(proxy, 'tls') && typeof proxy.tls !== 'boolean') {
delete proxy.tls;
}

View File

@@ -151,7 +151,7 @@ function shadowsocks(proxy) {
return result.toString();
}
function shadowsocksr(proxy, includeUnsupportedProxy) {
function shadowsocksr(proxy) {
const result = new Result(proxy);
result.append(
`${proxy.name}=shadowsocksr,${proxy.server},${proxy.port},${proxy.cipher},"${proxy.password}"`,
@@ -210,6 +210,13 @@ function shadowsocksr(proxy, includeUnsupportedProxy) {
// tfo
result.appendIfPresent(`,fast-open=${proxy.tfo}`, 'tfo');
// block-quic
if (proxy['block-quic'] === 'on') {
result.append(',block-quic=true');
} else if (proxy['block-quic'] === 'off') {
result.append(',block-quic=false');
}
// udp
if (proxy.udp) {
result.append(`,udp=true`);
@@ -266,6 +273,13 @@ function trojan(proxy) {
// tfo
result.appendIfPresent(`,fast-open=${proxy.tfo}`, 'tfo');
// block-quic
if (proxy['block-quic'] === 'on') {
result.append(',block-quic=true');
} else if (proxy['block-quic'] === 'off') {
result.append(',block-quic=false');
}
// udp
if (proxy.udp) {
result.append(`,udp=true`);
@@ -276,17 +290,9 @@ function trojan(proxy) {
return result.toString();
}
function vmess(proxy, includeUnsupportedProxy) {
if (!includeUnsupportedProxy && proxy['reality-opts']) {
throw new Error(`VMess REALITY is not supported`);
}
function vmess(proxy) {
const isReality = !!proxy['reality-opts'];
let isReality = false;
if (includeUnsupportedProxy) {
if (proxy['reality-opts']) {
isReality = true;
}
}
const result = new Result(proxy);
result.append(
`${proxy.name}=vmess,${proxy.server},${proxy.port},${proxy.cipher},"${proxy.uuid}"`,
@@ -359,7 +365,7 @@ function vmess(proxy, includeUnsupportedProxy) {
// AEAD
if (isPresent(proxy, 'aead')) {
result.append(`,alterId=0`);
result.append(`,alterId=${proxy.aead ? 0 : 1}`);
} else {
result.append(`,alterId=${proxy.alterId}`);
}
@@ -383,28 +389,18 @@ function vmess(proxy, includeUnsupportedProxy) {
return result.toString();
}
function vless(proxy, includeUnsupportedProxy) {
if (
!includeUnsupportedProxy &&
(typeof proxy.flow !== 'undefined' || proxy['reality-opts'])
) {
throw new Error(`VLESS XTLS/REALITY is not supported`);
}
function vless(proxy) {
let isXtls = false;
let isReality = false;
if (includeUnsupportedProxy) {
if (proxy['reality-opts']) {
isReality = true;
}
const isReality = !!proxy['reality-opts'];
if (typeof proxy.flow !== 'undefined') {
if (['xtls-rprx-vision'].includes(proxy.flow)) {
isXtls = true;
} else {
throw new Error(`VLESS flow(${proxy.flow}) is not supported`);
}
if (typeof proxy.flow !== 'undefined') {
if (['xtls-rprx-vision'].includes(proxy.flow)) {
isXtls = true;
} else {
throw new Error(`VLESS flow(${proxy.flow}) is not supported`);
}
}
const result = new Result(proxy);
result.append(
`${proxy.name}=vless,${proxy.server},${proxy.port},"${proxy.uuid}"`,

View File

@@ -7,7 +7,7 @@ export default function QX_Producer() {
const produce = (proxy, type, opts = {}) => {
switch (proxy.type) {
case 'ss':
return shadowsocks(proxy, opts['include-unsupported-proxy']);
return shadowsocks(proxy);
case 'ssr':
return shadowsocksr(proxy);
case 'trojan':
@@ -28,7 +28,7 @@ export default function QX_Producer() {
return { produce };
}
function shadowsocks(proxy, includeUnsupportedProxy) {
function shadowsocks(proxy) {
const result = new Result(proxy);
const append = result.append.bind(result);
const appendIfPresent = result.appendIfPresent.bind(result);
@@ -58,9 +58,8 @@ function shadowsocks(proxy, includeUnsupportedProxy) {
'aes-256-gcm',
'chacha20-ietf-poly1305',
'xchacha20-ietf-poly1305',
...(includeUnsupportedProxy
? ['2022-blake3-aes-128-gcm', '2022-blake3-aes-256-gcm']
: []),
'2022-blake3-aes-128-gcm',
'2022-blake3-aes-256-gcm',
].includes(proxy.cipher)
) {
throw new Error(`cipher ${proxy.cipher} is not supported`);
@@ -406,6 +405,8 @@ function vless(proxy) {
else append(`,obfs=ws`);
} else if (proxy.network === 'http') {
append(`,obfs=http`);
} else if (['tcp'].includes(proxy.network)) {
if (proxy.tls) append(`,obfs=over-tls`);
} else if (!['tcp'].includes(proxy.network)) {
throw new Error(`network ${proxy.network} is unsupported`);
}

View File

@@ -1,4 +1,5 @@
import { isPresent } from '@/core/proxy-utils/producers/utils';
import $ from '@/core/app';
export default function Shadowrocket_Producer() {
const type = 'ALL';
@@ -8,7 +9,7 @@ export default function Shadowrocket_Producer() {
if (opts['include-unsupported-proxy']) return true;
if (proxy.type === 'snell' && String(proxy.version) === '4') {
return false;
} else if (['mieru', 'anytls'].includes(proxy.type)) {
} else if (['mieru'].includes(proxy.type)) {
return false;
}
return true;

View File

@@ -31,6 +31,21 @@ const smuxParser = (smux, proxy) => {
if (smux['min-streams'])
proxy.multiplex.min_streams = parseInt(`${smux['min-streams']}`, 10);
if (smux.padding) proxy.multiplex.padding = true;
if (smux['brutal-opts']?.up || smux['brutal-opts']?.down) {
proxy.multiplex.brutal = {
enabled: true,
};
if (smux['brutal-opts']?.up)
proxy.multiplex.brutal.up_mbps = parseInt(
`${smux['brutal-opts']?.up}`,
10,
);
if (smux['brutal-opts']?.down)
proxy.multiplex.brutal.down_mbps = parseInt(
`${smux['brutal-opts']?.down}`,
10,
);
}
};
const wsParser = (proxy, parsedProxy) => {
@@ -552,12 +567,13 @@ const hysteriaParser = (proxy = {}) => {
if (proxy['fast-open']) parsedProxy.udp_fragment = true;
// eslint-disable-next-line no-control-regex
const reg = new RegExp('^[0-9]+[ \t]*[KMGT]*[Bb]ps$');
if (reg.test(`${proxy.up}`)) {
// sing-box 跟文档不一致, 但是懒得全转, 只处理最常见的 Mbps
if (reg.test(`${proxy.up}`) && !`${proxy.up}`.endsWith('Mbps')) {
parsedProxy.up = `${proxy.up}`;
} else {
parsedProxy.up_mbps = parseInt(`${proxy.up}`, 10);
}
if (reg.test(`${proxy.down}`)) {
if (reg.test(`${proxy.down}`) && !`${proxy.down}`.endsWith('Mbps')) {
parsedProxy.down = `${proxy.down}`;
} else {
parsedProxy.down_mbps = parseInt(`${proxy.down}`, 10);

View File

@@ -1,4 +1,5 @@
import { isPresent } from '@/core/proxy-utils/producers/utils';
import $ from '@/core/app';
export default function Stash_Producer() {
const type = 'ALL';
@@ -50,6 +51,11 @@ export default function Stash_Producer() {
: proxy.type === 'vless' && proxy['reality-opts'])
) {
return false;
} else if (proxy['underlying-proxy'] || proxy['dialer-proxy']) {
$.error(
`Stash 暂不支持前置代理字段. 已过滤节点 ${proxy.name}. 请使用 代理的转发链 https://stash.wiki/proxy-protocols/proxy-groups#relay`,
);
return false;
}
return true;
})
@@ -257,11 +263,6 @@ export default function Stash_Producer() {
}
delete proxy['tls-fingerprint'];
if (proxy['underlying-proxy']) {
proxy['dialer-proxy'] = proxy['underlying-proxy'];
}
delete proxy['underlying-proxy'];
if (isPresent(proxy, 'tls') && typeof proxy.tls !== 'boolean') {
delete proxy.tls;
}

View File

@@ -12,7 +12,7 @@ export default function URI_Producer() {
delete proxy.resolved;
delete proxy['no-resolve'];
for (const key in proxy) {
if (proxy[key] == null || /^_/i.test(key)) {
if (proxy[key] == null) {
delete proxy[key];
}
}
@@ -33,7 +33,9 @@ export default function URI_Producer() {
switch (proxy.type) {
case 'socks5':
result = `socks://${encodeURIComponent(
Base64.encode(`${proxy.username}:${proxy.password}`),
Base64.encode(
`${proxy.username ?? ''}:${proxy.password ?? ''}`,
),
)}@${proxy.server}:${proxy.port}#${proxy.name}`;
break;
case 'ss':
@@ -154,6 +156,16 @@ export default function URI_Producer() {
'gun';
result.host =
proxy[`${proxy.network}-opts`]?.['_grpc-authority'];
} else if (['kcp', 'quic'].includes(proxy.network)) {
result.path =
proxy[`${proxy.network}-opts`]?.[
'grpc-service-name'
];
// https://github.com/XTLS/Xray-core/issues/91
result.type =
proxy[`${proxy.network}-opts`]?.[
`_${proxy.network}-type`
] || 'none';
}
}
result = 'vmess://' + Base64.encode(JSON.stringify(result));
@@ -478,7 +490,7 @@ export default function URI_Producer() {
hysteriaParams.push(`obfsParam=${proxy[key]}`);
} else if (['sni'].includes(key)) {
hysteriaParams.push(`peer=${proxy[key]}`);
} else if (proxy[key]) {
} else if (proxy[key] && !/^_/i.test(key)) {
hysteriaParams.push(
`${i}=${encodeURIComponent(proxy[key])}`,
);
@@ -541,7 +553,7 @@ export default function URI_Producer() {
tuicParams.push(
`congestion_control=${proxy[key]}`,
);
} else if (proxy[key]) {
} else if (proxy[key] && !/^_/i.test(key)) {
tuicParams.push(
`${i.replace(
/-/g,
@@ -589,7 +601,11 @@ export default function URI_Producer() {
if (proxy[key]) {
anytlsParams.push(`insecure=1`);
}
} else if (proxy[key]) {
} else if (['udp'].includes(key)) {
if (proxy[key]) {
anytlsParams.push(`udp=1`);
}
} else if (proxy[key] && !/^_/i.test(key)) {
anytlsParams.push(
`${i.replace(/-/g, '_')}=${encodeURIComponent(
proxy[key],
@@ -626,7 +642,7 @@ export default function URI_Producer() {
if (proxy[key]) {
wireguardParams.push(`${key}=1`);
}
} else if (proxy[key]) {
} else if (proxy[key] && !/^_/i.test(key)) {
wireguardParams.push(
`${key}=${encodeURIComponent(proxy[key])}`,
);

View File

@@ -64,6 +64,7 @@ async function getFile(req, res) {
ignoreFailedRemoteFile,
proxy,
noCache,
produceType,
} = req.query;
let $options = {
_req: {
@@ -128,6 +129,10 @@ async function getFile(req, res) {
if (noCache) {
$.info(`指定不使用缓存: ${noCache}`);
}
if (produceType) {
produceType = decodeURIComponent(produceType);
$.info(`指定生产类型: ${produceType}`);
}
const allFiles = $.read(FILES_KEY);
const file = findByName(allFiles, name);
@@ -144,6 +149,7 @@ async function getFile(req, res) {
$options,
proxy,
noCache,
produceType,
});
try {

View File

@@ -1,7 +1,7 @@
import express from '@/vendor/express';
import $ from '@/core/app';
import migrate from '@/utils/migration';
import download from '@/utils/download';
import download, { downloadFile } from '@/utils/download';
import { syncArtifacts, produceArtifact } from '@/restful/sync';
import { gistBackupAction } from '@/restful/miscs';
import { TOKENS_KEY } from '@/constants';
@@ -35,7 +35,7 @@ export default function serve() {
const fe_be_path = eval('process.env.SUB_STORE_FRONTEND_BACKEND_PATH');
const fe_path = eval('process.env.SUB_STORE_FRONTEND_PATH');
if (be_prefix || be_merge) {
if(!fe_be_path.startsWith('/')){
if (!fe_be_path.startsWith('/')) {
throw new Error(
'SUB_STORE_FRONTEND_BACKEND_PATH should start with /',
);
@@ -48,15 +48,20 @@ export default function serve() {
$app.use((req, res, next) => {
if (req.path.startsWith(fe_be_path)) {
req.url = req.url.replace(fe_be_path, '') || '/';
if(be_merge && req.url.startsWith('/api/')){
if (be_merge && req.url.startsWith('/api/')) {
req.query['share'] = 'true';
}
next();
return;
}
const pathname = decodeURIComponent(req._parsedUrl.pathname) || '/';
if(be_merge && req.path.startsWith('/share/') && req.query.token){
if (req.method.toLowerCase() !== 'get'){
const pathname =
decodeURIComponent(req._parsedUrl.pathname) || '/';
if (
be_merge &&
req.path.startsWith('/share/') &&
req.query.token
) {
if (req.method.toLowerCase() !== 'get') {
res.status(405).send('Method not allowed');
return;
}
@@ -67,14 +72,14 @@ export default function serve() {
`/share/${t.type}/${t.name}` === pathname &&
(t.exp == null || t.exp > Date.now()),
);
if (token){
if (token) {
next();
return;
}
}
if (be_merge && fe_path && req.path.indexOf('/',1) == -1) {
if (req.path.indexOf('.') == -1){
req.url = "/index.html"
if (be_merge && fe_path && req.path.indexOf('/', 1) == -1) {
if (req.path.indexOf('.') == -1) {
req.url = '/index.html';
}
const express_ = eval(`require("express")`);
const mime_ = eval(`require("mime-types")`);
@@ -85,7 +90,7 @@ export default function serve() {
if (type) {
res.set('Content-Type', type);
}
}
},
});
staticFileMiddleware(req, res, next);
return;
@@ -230,6 +235,60 @@ export default function serve() {
// 'Asia/Shanghai' // timeZone
);
}
const mmdb_cron = eval('process.env.SUB_STORE_MMDB_CRON');
const countryFile = eval('process.env.SUB_STORE_MMDB_COUNTRY_PATH');
const countryUrl = eval('process.env.SUB_STORE_MMDB_COUNTRY_URL');
const asnFile = eval('process.env.SUB_STORE_MMDB_ASN_PATH');
const asnUrl = eval('process.env.SUB_STORE_MMDB_ASN_URL');
if (mmdb_cron && ((countryFile && countryUrl) || (asnFile && asnUrl))) {
$.info(`[MMDB CRON] ${mmdb_cron} enabled`);
const { CronJob } = eval(`require("cron")`);
new CronJob(
mmdb_cron,
async function () {
try {
$.info(`[MMDB CRON] ${mmdb_cron} started`);
if (countryFile && countryUrl) {
try {
$.info(
`[MMDB CRON] downloading ${countryUrl} to ${countryFile}`,
);
await downloadFile(countryUrl, countryFile);
} catch (e) {
$.error(
`[MMDB CRON] ${countryUrl} download failed: ${
e.message ?? e
}`,
);
}
}
if (asnFile && asnUrl) {
try {
$.info(
`[MMDB CRON] downloading ${asnUrl} to ${asnFile}`,
);
await downloadFile(asnUrl, asnFile);
} catch (e) {
$.error(
`[MMDB CRON] ${asnUrl} download failed: ${
e.message ?? e
}`,
);
}
}
$.info(`[MMDB CRON] ${mmdb_cron} finished`);
} catch (e) {
$.error(
`[MMDB CRON] ${mmdb_cron} error: ${e.message ?? e}`,
);
}
}, // onTick
null, // onComplete
true, // start
// 'Asia/Shanghai' // timeZone
);
}
const path = eval(`require("path")`);
const fs = eval(`require("fs")`);
const data_url = eval('process.env.SUB_STORE_DATA_URL');

View File

@@ -49,11 +49,17 @@ export default function register($app) {
success(res);
});
// Redirect sub.store to vercel webpage
$app.get('/', async (req, res) => {
// 302 redirect
res.set('location', 'https://sub-store.vercel.app/').status(302).end();
});
if (ENV().isNode) {
$app.get('/', getEnv);
} else {
// Redirect sub.store to vercel webpage
$app.get('/', async (req, res) => {
// 302 redirect
res.set('location', 'https://sub-store.vercel.app/')
.status(302)
.end();
});
}
// handle preflight request for QX
if (ENV().isQX) {
@@ -71,7 +77,19 @@ function getEnv(req, res) {
if (req.query.share) {
env.feature.share = true;
}
success(res, env);
res.set('Content-Type', 'application/json;charset=UTF-8').send(
JSON.stringify(
{
status: 'success',
data: {
guide: '⚠️⚠️⚠️ 您当前看到的是后端的响应. 若想配合前端使用, 可访问官方前端 https://sub-store.vercel.app 后自行配置后端地址, 或一键配置后端 https://sub-store.vercel.app?api=https://a.com/xxx (假设 https://a.com 是你后端的域名, /xxx 是自定义路径). 需注意 HTTPS 前端无法请求非本地的 HTTP 后端(部分浏览器上也无法访问本地 HTTP 后端). 请配置反代或在局域网自建 HTTP 前端. 如果还有问题, 可查看此排查说明: https://t.me/zhetengsha/1068',
...env,
},
},
null,
2,
),
);
}
async function refresh(_, res) {

View File

@@ -47,15 +47,22 @@ async function previewFile(req, res) {
}),
);
if (
!file.ignoreFailedRemoteFile &&
Object.keys(errors).length > 0
) {
throw new Error(
`文件 ${file.name} 的远程文件 ${Object.keys(
errors,
).join(', ')} 发生错误, 请查看日志`,
);
if (Object.keys(errors).length > 0) {
if (!file.ignoreFailedRemoteFile) {
throw new Error(
`文件 ${file.name} 的远程文件 ${Object.keys(
errors,
).join(', ')} 发生错误, 请查看日志`,
);
} else if (file.ignoreFailedRemoteFile === 'enabled') {
$.notify(
`🌍 Sub-Store 预览文件失败`,
`${file.name}`,
`远程文件 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
}
}
if (file.mergeSources === 'localFirst') {
content.unshift(file.content);
@@ -136,12 +143,22 @@ async function compareSub(req, res) {
}),
);
if (!sub.ignoreFailedRemoteSub && Object.keys(errors).length > 0) {
throw new Error(
`订阅 ${sub.name} 的远程订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
if (Object.keys(errors).length > 0) {
if (!sub.ignoreFailedRemoteSub) {
throw new Error(
`订阅 ${sub.name} 的远程订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
} else if (sub.ignoreFailedRemoteSub === 'enabled') {
$.notify(
`🌍 Sub-Store 预览订阅失败`,
`${sub.name}`,
`远程订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
}
}
if (sub.mergeSources === 'localFirst') {
content.unshift(sub.content);
@@ -244,15 +261,25 @@ async function compareCollection(req, res) {
}
}),
);
if (
!sub.ignoreFailedRemoteSub &&
Object.keys(errors).length > 0
) {
throw new Error(
`订阅 ${sub.name} 的远程订阅 ${Object.keys(
errors,
).join(', ')} 发生错误, 请查看日志`,
);
if (Object.keys(errors).length > 0) {
if (!sub.ignoreFailedRemoteSub) {
throw new Error(
`订阅 ${sub.name} 的远程订阅 ${Object.keys(
errors,
).join(', ')} 发生错误, 请查看日志`,
);
} else if (
sub.ignoreFailedRemoteSub === 'enabled'
) {
$.notify(
`🌍 Sub-Store 预览订阅失败`,
`${sub.name}`,
`远程订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
}
}
if (sub.mergeSources === 'localFirst') {
raw.unshift(sub.content);
@@ -284,20 +311,28 @@ async function compareCollection(req, res) {
errors[name] = err;
$.error(
`❌ 处理组合订阅 ${collection.name} 中的子订阅: ${sub.name}时出现错误:${err}`,
`❌ 处理组合订阅 ${collection.name} 中的子订阅: ${sub.name} 时出现错误:${err}`,
);
}
}),
);
if (
!collection.ignoreFailedRemoteSub &&
Object.keys(errors).length > 0
) {
throw new Error(
`组合订阅 ${collection.name} 中的子订阅 ${Object.keys(
errors,
).join(', ')} 发生错误, 请查看日志`,
);
if (Object.keys(errors).length > 0) {
if (!collection.ignoreFailedRemoteSub) {
throw new Error(
`组合订阅 ${collection.name} 的子订阅 ${Object.keys(
errors,
).join(', ')} 发生错误, 请查看日志`,
);
} else if (collection.ignoreFailedRemoteSub === 'enabled') {
$.notify(
`🌍 Sub-Store 预览组合订阅失败`,
`${collection.name}`,
`子订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
}
}
// merge proxies with the original order
const original = Array.prototype.concat.apply(

View File

@@ -89,12 +89,23 @@ async function produceArtifact({
if (ignoreFailedRemoteSub != null && ignoreFailedRemoteSub !== '') {
subIgnoreFailedRemoteSub = ignoreFailedRemoteSub;
}
if (!subIgnoreFailedRemoteSub && Object.keys(errors).length > 0) {
throw new Error(
`订阅 ${sub.name} 的远程订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
if (Object.keys(errors).length > 0) {
if (!subIgnoreFailedRemoteSub) {
throw new Error(
`订阅 ${sub.name} 的远程订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
} else if (subIgnoreFailedRemoteSub === 'enabled') {
$.notify(
`🌍 Sub-Store 处理订阅失败`,
`${sub.name}`,
`远程订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
}
}
if (mergeSources === 'localFirst') {
raw.unshift(content);
@@ -138,12 +149,23 @@ async function produceArtifact({
if (ignoreFailedRemoteSub != null && ignoreFailedRemoteSub !== '') {
subIgnoreFailedRemoteSub = ignoreFailedRemoteSub;
}
if (!subIgnoreFailedRemoteSub && Object.keys(errors).length > 0) {
throw new Error(
`订阅 ${sub.name} 的远程订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
if (Object.keys(errors).length > 0) {
if (!subIgnoreFailedRemoteSub) {
throw new Error(
`订阅 ${sub.name} 的远程订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
} else if (subIgnoreFailedRemoteSub === 'enabled') {
$.notify(
`🌍 Sub-Store 处理订阅失败`,
`${sub.name}`,
`远程订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
}
}
if (sub.mergeSources === 'localFirst') {
raw.unshift(sub.content);
@@ -151,6 +173,9 @@ async function produceArtifact({
raw.push(sub.content);
}
}
if (produceType === 'raw') {
return JSON.stringify((Array.isArray(raw) ? raw : [raw]).flat());
}
// parse proxies
let proxies = (Array.isArray(raw) ? raw : [raw])
.map((i) => ProxyUtils.parse(i))
@@ -264,15 +289,25 @@ async function produceArtifact({
}
}),
);
if (
!sub.ignoreFailedRemoteSub &&
Object.keys(errors).length > 0
) {
throw new Error(
`订阅 ${sub.name} 的远程订阅 ${Object.keys(
errors,
).join(', ')} 发生错误, 请查看日志`,
);
if (Object.keys(errors).length > 0) {
if (!sub.ignoreFailedRemoteSub) {
throw new Error(
`订阅 ${sub.name} 的远程订阅 ${Object.keys(
errors,
).join(', ')} 发生错误, 请查看日志`,
);
} else if (
sub.ignoreFailedRemoteSub === 'enabled'
) {
$.notify(
`🌍 Sub-Store 处理订阅失败`,
`${sub.name}`,
`远程订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
}
}
if (sub.mergeSources === 'localFirst') {
raw.unshift(sub.content);
@@ -327,15 +362,23 @@ async function produceArtifact({
if (ignoreFailedRemoteSub != null && ignoreFailedRemoteSub !== '') {
collectionIgnoreFailedRemoteSub = ignoreFailedRemoteSub;
}
if (
!collectionIgnoreFailedRemoteSub &&
Object.keys(errors).length > 0
) {
throw new Error(
`组合订阅 ${name} 中的子订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
if (Object.keys(errors).length > 0) {
if (!collectionIgnoreFailedRemoteSub) {
throw new Error(
`组合订阅 ${collection.name} 的子订阅 ${Object.keys(
errors,
).join(', ')} 发生错误, 请查看日志`,
);
} else if (collectionIgnoreFailedRemoteSub === 'enabled') {
$.notify(
`🌍 Sub-Store 处理组合订阅失败`,
`${collection.name}`,
`子订阅 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
}
}
// merge proxies with the original order
@@ -505,15 +548,23 @@ async function produceArtifact({
) {
fileIgnoreFailedRemoteFile = ignoreFailedRemoteFile;
}
if (
!fileIgnoreFailedRemoteFile &&
Object.keys(errors).length > 0
) {
throw new Error(
`文件 ${file.name} 的远程文件 ${Object.keys(
errors,
).join(', ')} 发生错误, 请查看日志`,
);
if (Object.keys(errors).length > 0) {
if (!fileIgnoreFailedRemoteFile) {
throw new Error(
`文件 ${file.name} 的远程文件 ${Object.keys(
errors,
).join(', ')} 发生错误, 请查看日志`,
);
} else if (fileIgnoreFailedRemoteFile === 'enabled') {
$.notify(
`🌍 Sub-Store 处理文件失败`,
`${file.name}`,
`远程文件 ${Object.keys(errors).join(
', ',
)} 发生错误, 请查看日志`,
);
}
}
if (file.mergeSources === 'localFirst') {
raw.unshift(file.content);
@@ -522,6 +573,9 @@ async function produceArtifact({
}
}
}
if (produceType === 'raw') {
return JSON.stringify((Array.isArray(raw) ? raw : [raw]).flat());
}
const files = (Array.isArray(raw) ? raw : [raw]).flat();
let filesContent = files
.filter((i) => i != null && i !== '')

View File

@@ -273,3 +273,25 @@ export default async function download(
}
return result;
}
export async function downloadFile(url, file) {
const undici = eval("require('undici')");
const fs = eval("require('fs')");
const { pipeline } = eval("require('stream/promises')");
const { Agent, interceptors, request } = undici;
$.info(`Downloading file...\nURL: ${url}\nFile: ${file}`);
const { body, statusCode } = await request(url, {
dispatcher: new Agent().compose(
interceptors.redirect({
maxRedirections: 3,
throwOnRedirect: true,
}),
),
});
if (statusCode !== 200)
throw new Error(`Failed to download file from ${url}`);
const fileStream = fs.createWriteStream(file);
await pipeline(body, fileStream);
$.info(`File downloaded from ${url} to ${file}`);
return file;
}

View File

@@ -34,4 +34,6 @@ export default {
load,
safeDump,
dump,
parse: safeLoad,
stringify: safeDump,
};

View File

@@ -17,10 +17,12 @@ export default function express({ substore: $, port, host }) {
const express_ = eval(`require("express")`);
const bodyParser = eval(`require("body-parser")`);
const app = express_();
const limit = eval('process.env.SUB_STORE_BODY_JSON_LIMIT') || '1mb';
$.info(`[BACKEND] body JSON limit: ${limit}`);
app.use(
bodyParser.json({
verify: rawBodySaver,
limit: eval('process.env.SUB_STORE_BODY_JSON_LIMIT') || '1mb',
limit,
}),
);
app.use(
@@ -36,7 +38,7 @@ export default function express({ substore: $, port, host }) {
app.start = () => {
const listener = app.listen(port, host, () => {
const { address, port } = listener.address();
$.info(`[BACKEND] ${address}:${port}`);
$.info(`[BACKEND] listening on ${address}:${port}`);
});
};
return app;

View File

@@ -10,6 +10,14 @@ const isEgern = 'object' == typeof egern;
const isLanceX = 'undefined' != typeof $native;
const isGUIforCores = typeof $Plugins !== 'undefined';
function isPlainObject(obj) {
return (
obj !== null &&
typeof obj === 'object' &&
[null, Object.prototype].includes(Object.getPrototypeOf(obj))
);
}
export class OpenAPI {
constructor(name = 'untitled', debug = false) {
this.name = name;
@@ -62,29 +70,50 @@ export class OpenAPI {
const basePath =
eval('process.env.SUB_STORE_DATA_BASE_PATH') || '.';
let rootPath = `${basePath}/root.json`;
const backupRootPath = `${basePath}/root_${Date.now()}.json`;
this.log(`Root path: ${rootPath}`);
if (!this.node.fs.existsSync(rootPath)) {
if (this.node.fs.existsSync(rootPath)) {
try {
this.root = JSON.parse(
this.node.fs.readFileSync(`${rootPath}`),
);
} catch (e) {
this.node.fs.copyFileSync(rootPath, backupRootPath);
this.error(
`Failed to parse ${rootPath}: ${e.message}. Backup created at ${backupRootPath}`,
);
}
}
if (!isPlainObject(this.root)) {
this.node.fs.writeFileSync(rootPath, JSON.stringify({}), {
flag: 'wx',
flag: 'w',
});
this.root = {};
} else {
this.root = JSON.parse(
this.node.fs.readFileSync(`${rootPath}`),
);
}
// create a json file with the given name if not exists
let fpath = `${basePath}/${this.name}.json`;
const backupPath = `${basePath}/${this.name}_${Date.now()}.json`;
this.log(`Data path: ${fpath}`);
if (!this.node.fs.existsSync(fpath)) {
if (this.node.fs.existsSync(fpath)) {
try {
this.cache = JSON.parse(
this.node.fs.readFileSync(`${fpath}`),
);
} catch (e) {
this.node.fs.copyFileSync(fpath, backupPath);
this.error(
`Failed to parse ${fpath}: ${e.message}. Backup created at ${backupPath}`,
);
}
}
if (!isPlainObject(this.cache)) {
this.node.fs.writeFileSync(fpath, JSON.stringify({}), {
flag: 'wx',
flag: 'w',
});
this.cache = {};
} else {
this.cache = JSON.parse(this.node.fs.readFileSync(`${fpath}`));
}
}
}

View File

View File

@@ -14,9 +14,12 @@ function operator(proxies = [], targetPlatform, context) {
// 6. `_collectionName` 为组合订阅名, `_collectionDisplayName` 为组合订阅显示名
// 7. `tls-fingerprint` 为 tls 指纹
// 8. `underlying-proxy` 为前置代理, 不同平台会自动转换
// 例如 $server['underlying-proxy'] = '名称'
// 只给 mihomo 输出的话, `dialer-proxy` 也行
// 只给 sing-box 输出的话, `detour` 也行
// 只给 egern 输出的话, `prev_hop` 也行
// 只给 Egern 输出的话, `prev_hop` 也行
// 只给 Shadowrocket 输出的话, `chain` 也行
// 输出到 Clash/Stash 时, 会过滤掉配置了前置代理的节点, 并提示使用对应的功能.
// 9. `trojan`, `tuic`, `hysteria`, `hysteria2`, `juicity` 会在解析时设置 `tls`: true (会使用 tls 类协议的通用逻辑), 输出时删除
// 10. `sni` 在某些协议里会自动与 `servername` 转换
// 11. 读取节点的 ca-str 和 _ca (后端文件路径) 字段, 自动计算 fingerprint (参考 https://t.me/zhetengsha/1512)
@@ -116,9 +119,13 @@ function operator(proxies = [], targetPlatform, context) {
// getISO, // 获取 ISO 3166-1 alpha-2 代码
// Gist, // Gist 类
// download, // 内部的下载方法, 见 backend/src/utils/download.js
// downloadFile, // 下载二进制文件, 见 backend/src/utils/download.js
// MMDB, // Node.js 环境 可用于模拟 Surge/Loon 的 $utils.ipasn, $utils.ipaso, $utils.geoip. 具体见 https://t.me/zhetengsha/1269
// isValidUUID, // 辅助判断是否为有效的 UUID
// Buffer, // https://github.com/feross/buffer
// Base64, // https://github.com/dankogai/js-base64
// }
// 为兼容 https://github.com/xishang0128/sparkle 的 JavaScript 覆写, 也可以直接使用 `b64d`(Base64 解码), `b64e`(Base64 编码), `Buffer`, `yaml`(简单兼容了下 `yaml.parse` 和 `yaml.stringify`)
// 如果只是为了快速修改或者筛选 可以参考 脚本操作支持节点快捷脚本 https://t.me/zhetengsha/970 和 脚本筛选支持节点快捷脚本 https://t.me/zhetengsha/1009
// ⚠️ 注意: 函数式(即本文件这样的 function operator() {}) 和快捷操作(下面使用 $server) 只能二选一