Files
clash-rules/config-sub-converter/scripts/external-proxies-sanitizer.js

339 lines
11 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Sub-Store operator: Normalize + tag + country detect + per-country numbering
*
* Output format (default):
* 🇺🇸 USA-01 🔒 📺 ❻ ▫ws/vless/443
*
* Notes:
* - Numbering is computed per-country AFTER grouping the full list.
* - Tags (icons) do NOT affect numbering order.
* - GeoIP is optional and only used when server is an IP and utils.geoip.lookup exists.
*/
///////////////////////
// CONFIG (EDIT ME)
///////////////////////
const DEBUG_APPEND_ORIGINAL_NAME = true; // set true to enable debug mode (appends original name as comment)
// 1) Remove these patterns (marketing noise, brackets, separators, etc.)
const NOISE_PATTERNS = [
/\[[^\]]*]/g, // [ ... ]
/\([^)]*\)/g, // ( ... )
/\{[^}]*}/g, // { ... }
/\btraffic\b/gi,
/\bfree\b/gi,
/\bwebsite\b/gi,
/\bexpire\b/gi,
/\blow\s*ping\b/gi,
/\bai\s*studio\b/gi,
/\bno\s*p2p\b/gi,
/\b10\s*gbit\b/gi,
/\bvless\b/gi, // you said you don't want it in the visible name
/\bvmess\b/gi,
/\bssr?\b/gi,
/\btrojan\b/gi,
/\bhysteria2?\b/gi,
/\btuic\b/gi,
/[|]/g,
/[_]+/g,
/[-]{2,}/g
];
// 2) Keyword -> icon tags (if found in original name, icon is added; the keyword is removed from base name)
const ICON_RULES = [
{ regex: /\bYT\b/gi, icon: "📺" },
{ regex: /\bIPv6\b/gi, icon: "❻" },
{ regex: /\bNetflix\b|\bNF\b/gi, icon: "🎬" },
{ regex: /\bDisney\+?\b|\bDSNY\b/gi, icon: "🏰" },
{ regex: /\bHBO\b/gi, icon: "📼" },
{ regex: /\bPrime\b|\bAmazon\b/gi, icon: "📦" },
{ regex: /\bChatGPT\b|\bOpenAI\b/gi, icon: "🤖" },
{ regex: /\bSteam\b/gi, icon: "🎮" },
];
// 3) Optional “network” tag rules based on NAME text (not $server.network)
// (Useful if providers shove "BGP/IPLC" into the node name)
const NAME_NETWORK_TAGS = [
{ regex: /\bIPLC\b/gi, tag: "🛰️" },
{ regex: /\bBGP\b/gi, tag: "🧭" },
{ regex: /\bAnycast\b/gi, tag: "🌍" }
];
// 4) Country detection rules by NAME (regex). First match wins (priority = lower is earlier)
const COUNTRY_RULES = [
// USA
{ regex: /\b(USA|US|UNITED\s*STATES|AMERICA|NEW\s*YORK|NYC|LOS\s*ANGELES|LA|DALLAS|CHI(CAGO)?)\b/i, iso3: "USA", flag: "🇺🇸", priority: 10 },
// Germany
{ regex: /\b(DE|DEU|GER(MANY)?|FRANKFURT|BERLIN|MUNICH|MÜNCHEN)\b/i, iso3: "DEU", flag: "🇩🇪", priority: 20 },
// Netherlands
{ regex: /\b(NL|NLD|NETHERLANDS|HOLLAND|AMSTERDAM)\b/i, iso3: "NLD", flag: "🇳🇱", priority: 30 },
// UK
{ regex: /\b(UK|GB|GBR|UNITED\s*KINGDOM|LONDON|MANCHESTER)\b/i, iso3: "GBR", flag: "🇬🇧", priority: 40 },
// France
{ regex: /\b(FR|FRA|FRANCE|PARIS|MARSEILLE)\b/i, iso3: "FRA", flag: "🇫🇷", priority: 50 },
// Poland
{ regex: /\b(PL|POL|POLAND|WARSAW|WARSZAWA)\b/i, iso3: "POL", flag: "🇵🇱", priority: 60 },
// Finland
{ regex: /\b(FI|FIN|FINLAND|HELSINKI)\b/i, iso3: "FIN", flag: "🇫🇮", priority: 70 },
// Sweden
{ regex: /\b(SE|SWE|SWEDEN|STOCKHOLM)\b/i, iso3: "SWE", flag: "🇸🇪", priority: 80 },
// Norway
{ regex: /\b(NO|NOR|NORWAY|OSLO)\b/i, iso3: "NOR", flag: "🇳🇴", priority: 90 },
// Switzerland
{ regex: /\b(CH|CHE|SWITZERLAND|ZURICH|GENEVA)\b/i, iso3: "CHE", flag: "🇨🇭", priority: 100 },
// Estonia / Latvia / Lithuania
{ regex: /\b(EE|EST|ESTONIA|TALLINN)\b/i, iso3: "EST", flag: "🇪🇪", priority: 110 },
{ regex: /\b(LV|LVA|LATVIA|RIGA)\b/i, iso3: "LVA", flag: "🇱🇻", priority: 120 },
{ regex: /\b(LT|LTU|LITHUANIA|VILNIUS)\b/i, iso3: "LTU", flag: "🇱🇹", priority: 130 },
// Turkey
{ regex: /\b(TR|TUR|TURKEY|ISTANBUL)\b/i, iso3: "TUR", flag: "🇹🇷", priority: 140 },
// Singapore / Japan / Korea / Hong Kong
{ regex: /\b(SG|SGP|SINGAPORE)\b/i, iso3: "SGP", flag: "🇸🇬", priority: 200 },
{ regex: /\b(JP|JPN|JAPAN|TOKYO|OSAKA)\b/i, iso3: "JPN", flag: "🇯🇵", priority: 210 },
{ regex: /\b(KR|KOR|KOREA|SEOUL)\b/i, iso3: "KOR", flag: "🇰🇷", priority: 220 },
{ regex: /\b(HK|HKG|HONG\s*KONG)\b/i, iso3: "HKG", flag: "🇭🇰", priority: 230 },
];
// 5) GeoIP mapping (ISO2 -> ISO3 + flag) used only if utils.geoip.lookup(ip) returns ISO2
const ISO2_TO_ISO3 = {
US: { iso3: "USA", flag: "🇺🇸" },
DE: { iso3: "DEU", flag: "🇩🇪" },
NL: { iso3: "NLD", flag: "🇳🇱" },
GB: { iso3: "GBR", flag: "🇬🇧" },
FR: { iso3: "FRA", flag: "🇫🇷" },
PL: { iso3: "POL", flag: "🇵🇱" },
FI: { iso3: "FIN", flag: "🇫🇮" },
SE: { iso3: "SWE", flag: "🇸🇪" },
NO: { iso3: "NOR", flag: "🇳🇴" },
CH: { iso3: "CHE", flag: "🇨🇭" },
EE: { iso3: "EST", flag: "🇪🇪" },
LV: { iso3: "LVA", flag: "🇱🇻" },
LT: { iso3: "LTU", flag: "🇱🇹" },
TR: { iso3: "TUR", flag: "🇹🇷" },
SG: { iso3: "SGP", flag: "🇸🇬" },
JP: { iso3: "JPN", flag: "🇯🇵" },
KR: { iso3: "KOR", flag: "🇰🇷" },
HK: { iso3: "HKG", flag: "🇭🇰" },
};
// 6) Protocol icons (based on proxy.type)
const PROTOCOL_ICONS = {
ss: "🔒",
ssr: "☂️",
vmess: "🪁",
vless: "🌌",
trojan: "🐎",
http: "🌐",
socks5: "🧦",
snell: "🐌",
wireguard: "🐲",
hysteria: "🤪",
hysteria2: "⚡",
tuic: "🚅"
};
///////////////////////
// HELPERS
///////////////////////
function isIPv4(str) {
if (typeof str !== "string") return false;
const m = str.match(/^(\d{1,3})(\.\d{1,3}){3}$/);
if (!m) return false;
return str.split(".").every(oct => {
const n = Number(oct);
return n >= 0 && n <= 255 && String(n) === oct.replace(/^0+(\d)/, "$1"); // avoids "001" weirdness
});
}
function uniq(arr) {
return [...new Set(arr.filter(Boolean))];
}
function sanitizeBaseName(name) {
let s = String(name || "");
// Remove noise patterns
for (const re of NOISE_PATTERNS) s = s.replace(re, " ");
// Collapse spaces
s = s.replace(/\s+/g, " ").trim();
return s;
}
function extractIconTagsAndStrip(name) {
let s = String(name || "");
const tags = [];
for (const r of ICON_RULES) {
if (r.regex.test(s)) {
tags.push(r.icon);
s = s.replace(r.regex, " ");
}
}
for (const t of NAME_NETWORK_TAGS) {
if (t.regex.test(s)) {
tags.push(t.tag);
s = s.replace(t.regex, " ");
}
}
return { stripped: s.replace(/\s+/g, " ").trim(), tags: uniq(tags) };
}
function detectCountryByName(name) {
const n = String(name || "");
// Order by priority, then first match wins
const sorted = COUNTRY_RULES.slice().sort((a, b) => a.priority - b.priority);
for (const c of sorted) {
if (c.regex.test(n)) return { iso3: c.iso3, flag: c.flag, priority: c.priority, source: "name" };
}
return null;
}
function detectCountryByGeoIP(server, utils) {
if (!isIPv4(server)) return null;
if (!utils || !utils.geoip || typeof utils.geoip.lookup !== "function") return null;
try {
const geo = utils.geoip.lookup(server);
const iso2 = geo && (geo.country || geo.country_code || geo.iso_code);
if (!iso2 || typeof iso2 !== "string") return null;
const key = iso2.toUpperCase();
const mapped = ISO2_TO_ISO3[key];
if (mapped) return { iso3: mapped.iso3, flag: mapped.flag, priority: 900, source: "geoip" };
// Unknown ISO2: keep something sane
return { iso3: key, flag: "🏳️", priority: 950, source: "geoip" };
} catch (e) {
return null;
}
}
function pad2(n) {
const x = Number(n);
return x < 10 ? `0${x}` : String(x);
}
function safeStr(v) {
return (v === undefined || v === null) ? "" : String(v);
}
///////////////////////
// OPERATOR
///////////////////////
function operator(proxies, targetPlatform, utils) {
// Sub-Store sometimes passes utils as global $utils; sometimes as 3rd arg; sometimes not at all.
// We'll accept any of them without whining.
const U = utils || (typeof $utils !== "undefined" ? $utils : null);
const buckets = Object.create(null);
for (const proxy of proxies) {
const originalName = safeStr(proxy && proxy.name);
// 1) Extract tags (icons) from ORIGINAL name, then strip those keywords out
const iconStage = extractIconTagsAndStrip(originalName);
// 2) Sanitize remaining base name (remove marketing trash, brackets, etc.)
const cleanBase = sanitizeBaseName(iconStage.stripped);
// 3) Detect country (name first, then GeoIP)
const byName = detectCountryByName(originalName);
const byGeo = detectCountryByGeoIP(proxy && proxy.server, U);
const country = byName || byGeo || { iso3: "UNK", flag: "🏴‍☠️", priority: 9999, source: "fallback" };
// 4) Protocol icon (based on type)
const proto = PROTOCOL_ICONS[(proxy && proxy.type) || ""] || "🔌";
// 5) Network/type/port tag (from proxy fields)
const net = safeStr(proxy && proxy.network) || "net?";
const typ = safeStr(proxy && proxy.type) || "type?";
const port = safeStr(proxy && proxy.port) || "port?";
const metaTag = `▫️${net}/${typ}/${port}`;
// 6) Prepare bucket key
const key = country.iso3;
if (!buckets[key]) {
buckets[key] = {
country,
list: []
};
}
// Keep meta used for sorting and final formatting
buckets[key].list.push({
proxy,
_meta: {
originalName,
cleanBase,
iconTags: iconStage.tags,
proto,
metaTag
}
});
}
// 7) Sort buckets by priority
const bucketKeys = Object.keys(buckets).sort((a, b) => {
return (buckets[a].country.priority || 9999) - (buckets[b].country.priority || 9999);
});
// 8) Sort inside each country bucket and rename with per-country numbering
const result = [];
for (const key of bucketKeys) {
const group = buckets[key];
group.list.sort((A, B) => {
// Tags do not affect numbering: sort only by sanitized base + server:port as tie-breaker
const an = A._meta.cleanBase.toLowerCase();
const bn = B._meta.cleanBase.toLowerCase();
if (an !== bn) return an.localeCompare(bn);
const as = `${safeStr(A.proxy.server)}:${safeStr(A.proxy.port)}`;
const bs = `${safeStr(B.proxy.server)}:${safeStr(B.proxy.port)}`;
return as.localeCompare(bs);
});
for (let i = 0; i < group.list.length; i++) {
const item = group.list[i];
const p = item.proxy;
const num = pad2(i + 1);
const debugSuffix = DEBUG_APPEND_ORIGINAL_NAME
? `${item._meta.originalName}`
: "";
const tagStr = item._meta.iconTags.length ? ` ${item._meta.iconTags.join(" ")}` : "";
// Final name format:
// 🇩🇪 DEU-03 🌌 📺 ❻ ▫ws/vless/443
p.name = `${group.country.flag} ${group.country.iso3}-${num} ${item._meta.proto}${tagStr} ${item._meta.metaTag}${debugSuffix}`
.replace(/\s+/g, " ")
.trim();
result.push(p);
}
}
return result;
}