Compare commits

..

16 Commits

Author SHA1 Message Date
xream
6f7fe8204b feat: 支持完整导出和导入 Sub-Store 单条订阅数据 2024-04-22 15:15:57 +08:00
xream
bafaf07743 Merge pull request #314 from eric-gitta-moore/master
feat: script ip-flag for node
2024-04-22 13:09:33 +08:00
Eric Moore
9962eb0947 feat: script ip-flag for node 2024-04-22 12:59:06 +08:00
xream
ac5232a7bc ci: restore the functionality of generating conventional changelog 2024-04-22 04:25:19 +08:00
xream
2301ccbfb5 fix: 修复对不规范的节点名称的处理 2024-04-22 02:51:44 +08:00
xream
0b5761e5fc feat: QX 输出正式支持 VLESS 2024-04-22 02:15:54 +08:00
xream
3ab21b0e26 feat: 支持 Loon SOCKS5/SOCKS5-TLS 2024-04-21 12:36:11 +08:00
xream
89ab72e46c doc: README 2024-04-21 11:32:23 +08:00
xream
18bd6526d0 chore: 增加探针版本(没有自定义的必要吧 默认为 0.0.1) 2024-04-20 07:45:49 +08:00
xream
c7329c32eb feat: 哪吒探针网络监控接口支持用参数传入检测次数; 节点字段上自定义的多个次数, 只取最大值 2024-04-19 06:27:45 +08:00
xream
4819ae95e4 feat: 哪吒探针网络监控接口提示不兼容的节点, 支持传入节点名 2024-04-19 05:57:26 +08:00
xream
370d228b04 feat: 订阅兼容哪吒探针网络监控接口(Loon/Surge 可输出节点延迟) 2024-04-19 05:16:24 +08:00
xream
d092916168 fix: sing-box wireguard 2024-04-17 11:36:23 +08:00
xream
0c93de48ab feat: GEO 增加 TYO 2024-04-17 05:50:50 +08:00
xream
274aa50373 ci: GitHub Actions: Transitioning from Node 16 to Node 20 2024-04-17 01:12:07 +08:00
xream
e24de8d0b6 feat: 支持 WireGuard URI 输入和输出 2024-04-17 00:56:53 +08:00
16 changed files with 419 additions and 34 deletions

View File

@@ -21,11 +21,11 @@ jobs:
- name: Set up Node.js
uses: actions/setup-node@v3
with:
node-version: "16"
node-version: "20"
- name: Install dependencies
run: |
npm install -g pnpm
cd backend && pnpm i
cd backend && pnpm i --no-frozen-lockfile
- name: Test
run: |
cd backend
@@ -44,14 +44,20 @@ jobs:
cd backend
SUBSTORE_RELEASE=`node --eval="process.stdout.write(require('./package.json').version)"`
echo "release_tag=$SUBSTORE_RELEASE" >> $GITHUB_OUTPUT
- name: Prepare release
run: |
cd backend
pnpm i -D conventional-changelog-cli
pnpm run changelog
- name: Release
uses: softprops/action-gh-release@v1
if: ${{ success() }}
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
body_path: ./backend/CHANGELOG.md
tag_name: ${{ steps.tag.outputs.release_tag }}
generate_release_notes: true
# generate_release_notes: true
files: |
./backend/sub-store.min.js
./backend/dist/sub-store-0.min.js

View File

@@ -26,11 +26,11 @@ Core functionalities:
### Supported Input Formats
- [x] URI(SS, SSR, VMess, VLESS, Trojan, Hysteria, Hysteria 2, TUIC v5)
- [x] URI(SS, SSR, VMess, VLESS, Trojan, Hysteria, Hysteria 2, TUIC v5, WireGuard)
- [x] Clash Proxies YAML
- [x] Clash Proxy JSON(single line)
- [x] QX (SS, SSR, VMess, Trojan, HTTP, SOCKS5, VLESS)
- [x] Loon (SS, SSR, VMess, Trojan, HTTP, SOCKS5, WireGuard, VLESS, Hysteria 2)
- [x] Loon (SS, SSR, VMess, Trojan, HTTP, SOCKS5, SOCKS5-TLS, WireGuard, VLESS, Hysteria 2)
- [x] Surge (SS, VMess, Trojan, HTTP, SOCKS5, SOCKS5-TLS, TUIC, Snell, Hysteria 2, SSH(Password authentication only), SSR(external, only for macOS), External Proxy Program(only for macOS), WireGuard(Surge to Surge))
- [x] Surfboard (SS, VMess, Trojan, HTTP, SOCKS5, SOCKS5-TLS, WireGuard(Surfboard to Surfboard))
- [x] Shadowrocket (SS, SSR, VMess, Trojan, HTTP, SOCKS5, Snell, VLESS, WireGuard, Hysteria, Hysteria 2, TUIC)

View File

@@ -1,6 +1,6 @@
{
"name": "sub-store",
"version": "2.14.282",
"version": "2.14.298",
"description": "Advanced Subscription Manager for QX, Loon, Surge, Stash and ShadowRocket.",
"main": "src/main.js",
"scripts": {
@@ -11,7 +11,8 @@
"dev:esbuild": "nodemon -w src -w package.json dev-esbuild.js",
"dev:run": "nodemon -w sub-store.min.js sub-store.min.js",
"build": "gulp",
"bundle": "node bundle.js"
"bundle": "node bundle.js",
"changelog": "conventional-changelog -p cli -i CHANGELOG.md -s"
},
"author": "Peng-YM",
"license": "GPL-3.0",

View File

@@ -404,15 +404,19 @@ function lastParse(proxy) {
}
}
if (typeof proxy.name !== 'string') {
try {
if (proxy.name?.data) {
proxy.name = Buffer.from(proxy.name.data).toString('utf8');
} else {
proxy.name = utf8ArrayToStr(proxy.name);
if (/^\d+$/.test(proxy.name)) {
proxy.name = `${proxy.name}`;
} else {
try {
if (proxy.name?.data) {
proxy.name = Buffer.from(proxy.name.data).toString('utf8');
} else {
proxy.name = utf8ArrayToStr(proxy.name);
}
} catch (e) {
$.error(`proxy.name decode failed\nReason: ${e}`);
proxy.name = `${proxy.type} ${proxy.server}:${proxy.port}`;
}
} catch (e) {
$.error(`proxy.name decode failed\nReason: ${e}`);
proxy.name = `${proxy.type} ${proxy.server}:${proxy.port}`;
}
}
if (['', 'off'].includes(proxy.sni)) {

View File

@@ -674,6 +674,89 @@ function URI_TUIC() {
};
return { name, test, parse };
}
function URI_WireGuard() {
const name = 'URI WireGuard Parser';
const test = (line) => {
return /^(wireguard|wg):\/\//.test(line);
};
const parse = (line) => {
line = line.split(/(wireguard|wg):\/\//)[2];
/* eslint-disable no-unused-vars */
let [
__,
___,
privateKey,
server,
____,
port,
_____,
addons = '',
name,
] = /^((.*?)@)?(.*?)(:(\d+))?\/?(\?(.*?))?(?:#(.*?))?$/.exec(line);
/* eslint-enable no-unused-vars */
port = parseInt(`${port}`, 10);
if (isNaN(port)) {
port = 51820;
}
privateKey = decodeURIComponent(privateKey);
if (name != null) {
name = decodeURIComponent(name);
}
name = name ?? `WireGuard ${server}:${port}`;
const proxy = {
type: 'wireguard',
name,
server,
port,
'private-key': privateKey,
udp: true,
};
for (const addon of addons.split('&')) {
let [key, value] = addon.split('=');
key = key.replace(/_/, '-');
value = decodeURIComponent(value);
if (['reserved'].includes(key)) {
const parsed = value
.split(',')
.map((i) => parseInt(i.trim(), 10))
.filter((i) => Number.isInteger(i));
if (parsed.length === 3) {
proxy[key] = parsed;
}
} else if (['address', 'ip'].includes(key)) {
value.split(',').map((i) => {
const ip = i
.trim()
.replace(/\/\d+$/, '')
.replace(/^\[/, '')
.replace(/\]$/, '');
if (isIPv4(ip)) {
proxy.ip = ip;
} else if (isIPv6(ip)) {
proxy.ipv6 = ip;
}
});
} else if (['mtu'].includes(key)) {
const parsed = parseInt(value.trim(), 10);
if (Number.isInteger(parsed)) {
proxy[key] = parsed;
}
} else if (/publickey/i.test(key)) {
proxy['public-key'] = value;
} else if (/privatekey/i.test(key)) {
proxy['private-key'] = value;
} else if (['udp'].includes(key)) {
proxy[key] = /(TRUE)|1/i.test(value);
} else if (!['flag'].includes(key)) {
proxy[key] = value;
}
}
return proxy;
};
return { name, test, parse };
}
// Trojan URI format
function URI_Trojan() {
@@ -913,6 +996,15 @@ function Loon_Http() {
const parse = (line) => getLoonParser().parse(line);
return { name, test, parse };
}
function Loon_Socks5() {
const name = 'Loon SOCKS5 Parser';
const test = (line) => {
return /^.*=\s*socks5/i.test(line.split(',')[0]);
};
const parse = (line) => getLoonParser().parse(line);
return { name, test, parse };
}
function Loon_WireGuard() {
const name = 'Loon WireGuard Parser';
@@ -1196,6 +1288,7 @@ export default [
URI_VMess(),
URI_VLESS(),
URI_TUIC(),
URI_WireGuard(),
URI_Hysteria(),
URI_Hysteria2(),
URI_Trojan(),
@@ -1218,6 +1311,7 @@ export default [
Loon_Hysteria2(),
Loon_Trojan(),
Loon_Http(),
Loon_Socks5(),
Loon_WireGuard(),
QX_SS(),
QX_SSR(),

View File

@@ -35,7 +35,7 @@ const grammars = String.raw`
}
}
start = (shadowsocksr/shadowsocks/vmess/vless/trojan/https/http/hysteria2) {
start = (shadowsocksr/shadowsocks/vmess/vless/trojan/https/http/socks5/hysteria2) {
return proxy;
}
@@ -78,6 +78,9 @@ https = tag equals "https"i address (username password)? (tls_host/tls_verificat
http = tag equals "http"i address (username password)? (fast_open/udp_relay/others)* {
proxy.type = "http";
}
socks5 = tag equals "socks5"i address (username password)? (over_tls/tls_host/tls_verification/fast_open/udp_relay/others)* {
proxy.type = "socks5";
}
address = comma server:server comma port:port {
proxy.server = server;
@@ -167,7 +170,7 @@ ssr_protocol_param = comma "protocol-param" equals param:$[^=,]+ { proxy["protoc
vmess_alterId = comma "alterId" equals alterId:$[0-9]+ { proxy.alterId = parseInt(alterId); }
over_tls = comma "over-tls" equals flag:bool { proxy.tls = flag; }
tls_host = comma "tls-name" equals host:domain { proxy.sni = host; }
tls_host = comma sni:("tls-name"/"sni") equals host:domain { proxy.sni = host; }
tls_verification = comma "skip-cert-verify" equals flag:bool { proxy["skip-cert-verify"] = flag; }
fast_open = comma "fast-open" equals flag:bool { proxy.tfo = flag; }

View File

@@ -33,7 +33,7 @@
}
}
start = (shadowsocksr/shadowsocks/vmess/vless/trojan/https/http/hysteria2) {
start = (shadowsocksr/shadowsocks/vmess/vless/trojan/https/http/socks5/hysteria2) {
return proxy;
}
@@ -76,6 +76,9 @@ https = tag equals "https"i address (username password)? (tls_host/tls_verificat
http = tag equals "http"i address (username password)? (fast_open/udp_relay/others)* {
proxy.type = "http";
}
socks5 = tag equals "socks5"i address (username password)? (over_tls/tls_host/tls_verification/fast_open/udp_relay/others)* {
proxy.type = "socks5";
}
address = comma server:server comma port:port {
proxy.server = server;
@@ -165,7 +168,7 @@ ssr_protocol_param = comma "protocol-param" equals param:$[^=,]+ { proxy["protoc
vmess_alterId = comma "alterId" equals alterId:$[0-9]+ { proxy.alterId = parseInt(alterId); }
over_tls = comma "over-tls" equals flag:bool { proxy.tls = flag; }
tls_host = comma "tls-name" equals host:domain { proxy.sni = host; }
tls_host = comma sni:("tls-name"/"sni") equals host:domain { proxy.sni = host; }
tls_verification = comma "skip-cert-verify" equals flag:bool { proxy["skip-cert-verify"] = flag; }
fast_open = comma "fast-open" equals flag:bool { proxy.tfo = flag; }

View File

@@ -22,6 +22,9 @@ function Base64Encoded() {
'aHR0c', // htt
'dmxlc3M=', // vless
'aHlzdGVyaWEy', // hysteria2
'd2lyZWd1YXJkOi8v', // wireguard://
'd2c6Ly8=', // wg://
'dHVpYzovLw==', // tuic://
];
const test = function (raw) {

View File

@@ -18,6 +18,8 @@ export default function Loon_Producer() {
return vless(proxy);
case 'http':
return http(proxy);
case 'socks5':
return socks5(proxy);
case 'wireguard':
return wireguard(proxy);
case 'hysteria2':
@@ -316,6 +318,29 @@ function http(proxy) {
return result.toString();
}
function socks5(proxy) {
const result = new Result(proxy);
result.append(`${proxy.name}=socks5,${proxy.server},${proxy.port}`);
result.appendIfPresent(`,${proxy.username}`, 'username');
result.appendIfPresent(`,"${proxy.password}"`, 'password');
// tls
result.appendIfPresent(`,over-tls=${proxy.tls}`, 'tls');
// sni
result.appendIfPresent(`,sni=${proxy.sni}`, 'sni');
// tls verification
result.appendIfPresent(
`,skip-cert-verify=${proxy['skip-cert-verify']}`,
'skip-cert-verify',
);
// tfo
result.appendIfPresent(`,tfo=${proxy.tfo}`, 'tfo');
return result.toString();
}
function wireguard(proxy) {
if (Array.isArray(proxy.peers) && proxy.peers.length > 0) {

View File

@@ -3,6 +3,7 @@ import { isPresent, Result } from './utils';
const targetPlatform = 'QX';
export default function QX_Producer() {
// eslint-disable-next-line no-unused-vars
const produce = (proxy, type, opts = {}) => {
switch (proxy.type) {
case 'ss':
@@ -18,13 +19,7 @@ export default function QX_Producer() {
case 'socks5':
return socks5(proxy);
case 'vless':
if (opts['include-unsupported-proxy']) {
return vless(proxy);
} else {
throw new Error(
`Platform ${targetPlatform}(App Store Release) does not support proxy type: ${proxy.type}`,
);
}
return vless(proxy);
}
throw new Error(
`Platform ${targetPlatform} does not support proxy type: ${proxy.type}`,

View File

@@ -1,5 +1,6 @@
import ClashMeta_Producer from './clashmeta';
import $ from '@/core/app';
import { isIPv4, isIPv6 } from '@/utils';
const detourParser = (proxy, parsedProxy) => {
if (proxy['dialer-proxy']) parsedProxy.detour = proxy['dialer-proxy'];
@@ -620,8 +621,11 @@ const tuic5Parser = (proxy = {}) => {
const wireguardParser = (proxy = {}) => {
const local_address = ['ip', 'ipv6']
.map((i) => proxy[i])
.filter((i) => i)
.map((i) => (/\\/.test(i) ? i : `${i}/32`));
.map((i) => {
if (isIPv4(i)) return `${i}/32`;
if (isIPv6(i)) return `${i}/128`;
})
.filter((i) => i);
const parsedProxy = {
tag: proxy.name,
type: 'wireguard',

View File

@@ -411,8 +411,51 @@ export default function URI_Producer() {
}?${tuicParams.join('&')}#${encodeURIComponent(
proxy.name,
)}`;
break;
}
break;
case 'wireguard':
let wireguardParams = [];
Object.keys(proxy).forEach((key) => {
if (
![
'name',
'type',
'server',
'port',
'ip',
'ipv6',
'private-key',
].includes(key)
) {
if (['public-key'].includes(key)) {
wireguardParams.push(`publickey=${proxy[key]}`);
} else if (['udp'].includes(key)) {
if (proxy[key]) {
wireguardParams.push(`${key}=1`);
}
} else if (proxy[key]) {
wireguardParams.push(
`${key}=${encodeURIComponent(proxy[key])}`,
);
}
}
});
if (proxy.ip && proxy.ipv6) {
wireguardParams.push(
`address=${proxy.ip}/32,${proxy.ipv6}/128`,
);
} else if (proxy.ip) {
wireguardParams.push(`address=${proxy.ip}/32`);
} else if (proxy.ipv6) {
wireguardParams.push(`address=${proxy.ipv6}/128`);
}
result = `wireguard://${encodeURIComponent(
proxy['private-key'],
)}@${proxy.server}:${proxy.port}/?${wireguardParams.join(
'&',
)}#${encodeURIComponent(proxy.name)}`;
break;
}
return result;
};

View File

@@ -1,4 +1,5 @@
import { getPlatformFromHeaders } from '@/utils/user-agent';
import { ProxyUtils } from '@/core/proxy-utils';
import { COLLECTIONS_KEY, SUBS_KEY } from '@/constants';
import { findByName } from '@/utils/database';
import { getFlowHeaders } from '@/utils/flow';
@@ -29,11 +30,27 @@ export default function register($app) {
req.query.resultFormat = 'nezha';
await downloadSubscription(req, res);
});
$app.get(
'/download/collection/:name/api/v1/monitor/:nezhaIndex',
async (req, res) => {
req.query.platform = 'JSON';
req.query.produceType = 'internal';
req.query.resultFormat = 'nezha-monitor';
await downloadCollection(req, res);
},
);
$app.get('/download/:name/api/v1/monitor/:nezhaIndex', async (req, res) => {
req.query.platform = 'JSON';
req.query.produceType = 'internal';
req.query.resultFormat = 'nezha-monitor';
await downloadSubscription(req, res);
});
}
async function downloadSubscription(req, res) {
let { name } = req.params;
let { name, nezhaIndex } = req.params;
name = decodeURIComponent(name);
nezhaIndex = decodeURIComponent(nezhaIndex);
const platform =
req.query.target || getPlatformFromHeaders(req.headers) || 'JSON';
@@ -155,6 +172,15 @@ async function downloadSubscription(req, res) {
if (platform === 'JSON') {
if (resultFormat === 'nezha') {
output = nezhaTransform(output);
} else if (resultFormat === 'nezha-monitor') {
nezhaIndex = /^\d+$/.test(nezhaIndex)
? parseInt(nezhaIndex, 10)
: output.findIndex((i) => i.name === nezhaIndex);
output = await nezhaMonitor(
output[nezhaIndex],
nezhaIndex,
req.query,
);
}
res.set('Content-Type', 'application/json;charset=utf-8').send(
output,
@@ -192,8 +218,9 @@ async function downloadSubscription(req, res) {
}
async function downloadCollection(req, res) {
let { name } = req.params;
let { name, nezhaIndex } = req.params;
name = decodeURIComponent(name);
nezhaIndex = decodeURIComponent(nezhaIndex);
const platform =
req.query.target || getPlatformFromHeaders(req.headers) || 'JSON';
@@ -300,6 +327,15 @@ async function downloadCollection(req, res) {
if (platform === 'JSON') {
if (resultFormat === 'nezha') {
output = nezhaTransform(output);
} else if (resultFormat === 'nezha-monitor') {
nezhaIndex = /^\d+$/.test(nezhaIndex)
? parseInt(nezhaIndex, 10)
: output.findIndex((i) => i.name === nezhaIndex);
output = await nezhaMonitor(
output[nezhaIndex],
nezhaIndex,
req.query,
);
}
res.set('Content-Type', 'application/json;charset=utf-8').send(
output,
@@ -338,6 +374,85 @@ async function downloadCollection(req, res) {
}
}
async function nezhaMonitor(proxy, index, query) {
const result = {
code: 0,
message: 'success',
result: [],
};
try {
const { isLoon, isSurge } = $.env;
if (!isLoon && !isSurge)
throw new Error('仅支持 Loon 和 Surge(ability=http-client-policy)');
const node = ProxyUtils.produce([proxy], isLoon ? 'Loon' : 'Surge');
if (!node) throw new Error('当前客户端不兼容此节点');
const monitors = proxy._monitors || [
{
name: 'Cloudflare',
url: 'http://cp.cloudflare.com/generate_204',
method: 'HEAD',
number: 3,
timeout: 2000,
},
{
name: 'Google',
url: 'http://www.google.com/generate_204',
method: 'HEAD',
number: 3,
timeout: 2000,
},
];
const number =
query.number || Math.max(...monitors.map((i) => i.number)) || 3;
for (const monitor of monitors) {
const interval = 10 * 60 * 1000;
const data = {
monitor_id: monitors.indexOf(monitor),
server_id: index,
monitor_name: monitor.name,
server_name: proxy.name,
created_at: [],
avg_delay: [],
};
for (let index = 0; index < number; index++) {
const startedAt = Date.now();
try {
await $.http[(monitor.method || 'HEAD').toLowerCase()]({
timeout: monitor.timeout || 2000,
url: monitor.url,
'policy-descriptor': node,
node,
});
const latency = Date.now() - startedAt;
$.info(`${monitor.name} latency: ${latency}`);
data.avg_delay.push(latency);
} catch (e) {
$.error(e);
data.avg_delay.push(0);
}
data.created_at.push(
Date.now() - interval * (monitor.number - index - 1),
);
}
result.result.push(data);
}
} catch (e) {
$.error(e);
result.result.push({
monitor_id: 0,
server_id: 0,
monitor_name: `${e.message ?? e}`,
server_name: proxy.name,
created_at: [Date.now()],
avg_delay: [0],
});
}
return JSON.stringify(result, null, 2);
}
function nezhaTransform(output) {
const result = {
code: 0,
@@ -376,7 +491,7 @@ function nezhaTransform(output) {
Virtualization: '',
BootTime: time,
CountryCode, // 目前需要
Version: '',
Version: '0.0.1',
},
status: {
CPU: 0,

View File

@@ -181,11 +181,21 @@ function createSubscription(req, res) {
function getSubscription(req, res) {
let { name } = req.params;
let { raw } = req.query;
name = decodeURIComponent(name);
const allSubs = $.read(SUBS_KEY);
const sub = findByName(allSubs, name);
if (sub) {
success(res, sub);
if (raw) {
res.set('content-type', 'application/json')
.set(
'content-disposition',
`attachment; filename="${encodeURIComponent(name)}.json"`,
)
.send(JSON.stringify(sub));
} else {
success(res, sub);
}
} else {
failed(
res,

View File

@@ -40,7 +40,7 @@ const ISOFlags = {
'🇭🇷': ['HR', 'HRV'],
'🇭🇺': ['HU', 'HUN'],
'🇯🇴': ['JO', 'JOR'],
'🇯🇵': ['JP', 'JPN'],
'🇯🇵': ['JP', 'JPN', 'TYO'],
'🇰🇪': ['KE', 'KEN'],
'🇰🇬': ['KG', 'KGZ'],
'🇰🇭': ['KH', 'KGZ'],

79
scripts/ip-flag-node.js Normal file
View File

@@ -0,0 +1,79 @@
const $ = $substore;
const {onlyFlagIP = true} = $arguments
async function operator(proxies) {
const BATCH_SIZE = 10;
let i = 0;
while (i < proxies.length) {
const batch = proxies.slice(i, i + BATCH_SIZE);
await Promise.all(batch.map(async proxy => {
if (onlyFlagIP && !ProxyUtils.isIP(proxy.server)) return;
try {
// remove the original flag
let proxyName = removeFlag(proxy.name);
// query ip-api
const countryCode = await queryIpApi(proxy);
proxyName = getFlagEmoji(countryCode) + ' ' + proxyName;
proxy.name = proxyName;
} catch (err) {
// TODO:
}
}));
await sleep(1000);
i += BATCH_SIZE;
}
return proxies;
}
async function queryIpApi(proxy) {
const ua = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10.10; rv:78.0) Gecko/20100101 Firefox/78.0";
const headers = {
"User-Agent": ua
};
const result = new Promise((resolve, reject) => {
const url =
`http://ip-api.com/json/${encodeURIComponent(proxy.server)}?lang=zh-CN`;
$.http.get({
url,
headers,
}).then(resp => {
const data = JSON.parse(resp.body);
if (data.status === "success") {
resolve(data.countryCode);
} else {
reject(new Error(data.message));
}
}).catch(err => {
console.log(err);
reject(err);
});
});
return result;
}
function getFlagEmoji(countryCode) {
const codePoints = countryCode
.toUpperCase()
.split('')
.map(char => 127397 + char.charCodeAt());
return String
.fromCodePoint(...codePoints)
.replace(/🇹🇼/g, '🇨🇳');
}
function removeFlag(str) {
return str
.replace(/[\uD83C][\uDDE6-\uDDFF][\uD83C][\uDDE6-\uDDFF]/g, '')
.trim();
}
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}