360 lines
13 KiB
JavaScript
360 lines
13 KiB
JavaScript
/**
|
|
* Sub-Store 节点命名公共工具
|
|
*
|
|
* 远程引用地址:
|
|
* https://git.orionc.me/orion/script/raw/branch/main/substore/common.js?v=20260608-1
|
|
*/
|
|
(function (root) {
|
|
const VERSION = '20260608-1';
|
|
const regexSpecialChars = new Set(['.', '*', '+', '?', '^', '$', '{', '}', '(', ')', '|', '[', ']', '\\']);
|
|
const leadingFlagRegex = /^([\u{1F1E6}-\u{1F1FF}]{2}|🏴☠️)\s*/u;
|
|
const inlineFlagRegex = /^(.{1,24}?)([\u{1F1E6}-\u{1F1FF}]{2}|🏴☠️)\s*(.*)$/u;
|
|
const leadingMojibakeFlagRegex = /^((?:ð[\u0080-\u00BF]{3}){2})\s*/;
|
|
const regionSequenceRegexCache = Object.create(null);
|
|
const lineDescriptorRegexCache = Object.create(null);
|
|
|
|
const escapeRegex = value => [...String(value)]
|
|
.map(char => regexSpecialChars.has(char) ? `\\${char}` : char)
|
|
.join('');
|
|
|
|
const normalizeWhitespace = value => String(value)
|
|
.replace(/\s{2,}/g, ' ')
|
|
.trim();
|
|
|
|
const decodeMojibakeUtf8 = value => {
|
|
try {
|
|
const encoded = [...String(value)]
|
|
.map(char => `%${char.charCodeAt(0).toString(16).padStart(2, '0')}`)
|
|
.join('');
|
|
return decodeURIComponent(encoded);
|
|
} catch (error) {
|
|
return '';
|
|
}
|
|
};
|
|
|
|
const normalizeLeadingMojibakeFlag = value => String(value)
|
|
.replace(leadingMojibakeFlagRegex, (match, mojibakeFlag) => {
|
|
const decodedFlag = decodeMojibakeUtf8(mojibakeFlag);
|
|
return leadingFlagRegex.test(decodedFlag) ? `${decodedFlag} ` : '';
|
|
})
|
|
.trim();
|
|
|
|
const stripLeadingMojibakeFlag = value => String(value)
|
|
.replace(leadingMojibakeFlagRegex, '')
|
|
.trim();
|
|
|
|
const normalizeLeadingFlagSpacing = value => normalizeLeadingMojibakeFlag(value)
|
|
.replace(leadingFlagRegex, '$1 ')
|
|
.replace(/\s{2,}/g, ' ')
|
|
.trim();
|
|
|
|
const flagToRegionCode = (flag, overrides) => {
|
|
if (!flag) return '';
|
|
if (overrides && overrides[flag]) return overrides[flag];
|
|
|
|
const codePoints = [...flag].map(char => char.codePointAt(0));
|
|
if (
|
|
codePoints.length !== 2 ||
|
|
codePoints.some(codePoint => codePoint < 0x1F1E6 || codePoint > 0x1F1FF)
|
|
) {
|
|
return '';
|
|
}
|
|
|
|
const regionCode = codePoints
|
|
.map(codePoint => String.fromCharCode(codePoint - 0x1F1E6 + 65))
|
|
.join('');
|
|
return regionCode === 'GB' ? 'UK' : regionCode;
|
|
};
|
|
|
|
const regionCodeToFlag = regionCode => {
|
|
const flagCode = regionCode === 'UK' ? 'GB' : regionCode;
|
|
if (!/^[A-Z]{2}$/.test(flagCode)) return '';
|
|
|
|
return flagCode
|
|
.split('')
|
|
.map(char => String.fromCodePoint(char.charCodeAt(0) - 65 + 0x1F1E6))
|
|
.join('');
|
|
};
|
|
|
|
const splitLeadingFlag = name => {
|
|
const cleanName = normalizeLeadingMojibakeFlag(name);
|
|
const leadingFlagMatch = cleanName.match(leadingFlagRegex);
|
|
if (!leadingFlagMatch) return { flag: '', text: cleanName.trim() };
|
|
|
|
return {
|
|
flag: leadingFlagMatch[1],
|
|
text: cleanName.slice(leadingFlagMatch[0].length).trim()
|
|
};
|
|
};
|
|
|
|
const splitLeadingOrInlineFlag = name => {
|
|
const cleanName = normalizeLeadingMojibakeFlag(name);
|
|
const leadingFlagMatch = cleanName.match(leadingFlagRegex);
|
|
if (leadingFlagMatch) {
|
|
return {
|
|
flag: leadingFlagMatch[1],
|
|
text: cleanName.slice(leadingFlagMatch[0].length).trim()
|
|
};
|
|
}
|
|
|
|
const inlineFlagMatch = cleanName.match(inlineFlagRegex);
|
|
if (!inlineFlagMatch) return { flag: '', text: cleanName.trim() };
|
|
|
|
const prefix = inlineFlagMatch[1].trim();
|
|
const rest = inlineFlagMatch[3].trim();
|
|
return {
|
|
flag: inlineFlagMatch[2],
|
|
text: rest ? `${prefix} ${rest}`.trim() : prefix
|
|
};
|
|
};
|
|
|
|
const sortRegionAliases = aliases => aliases
|
|
.map(([alias, replacement]) => [alias, replacement, String(alias).toLowerCase()])
|
|
.sort((firstAlias, secondAlias) => secondAlias[0].length - firstAlias[0].length);
|
|
|
|
const createRegionResolver = aliases => {
|
|
const sortedAliases = sortRegionAliases(aliases);
|
|
const compiledAliases = sortedAliases.map(([alias, replacement, aliasLower]) => [
|
|
alias,
|
|
replacement,
|
|
aliasLower,
|
|
/^[A-Z]{2}$/.test(alias)
|
|
? new RegExp(`(^|[^A-Za-z])${escapeRegex(alias)}([^A-Za-z]|$)`, 'i')
|
|
: null
|
|
]);
|
|
|
|
const regionCodeOf = name => {
|
|
const cleanName = normalizeLeadingMojibakeFlag(name);
|
|
const leadingFlagMatch = cleanName.match(leadingFlagRegex);
|
|
if (leadingFlagMatch) return flagToRegionCode(leadingFlagMatch[1]);
|
|
|
|
const cleanNameLower = cleanName.toLowerCase();
|
|
for (const [alias, replacement, aliasLower, codeRegex] of compiledAliases) {
|
|
if (codeRegex) {
|
|
const codeMatch = cleanName.match(codeRegex);
|
|
if (codeMatch) return replacement;
|
|
continue;
|
|
}
|
|
|
|
if (cleanNameLower.includes(aliasLower)) return replacement;
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
const aliasRegionPrefixOf = name => {
|
|
const cleanNameLower = stripLeadingMojibakeFlag(name).toLowerCase();
|
|
for (const [, replacement, aliasLower] of compiledAliases) {
|
|
if (cleanNameLower.includes(aliasLower)) return replacement;
|
|
}
|
|
|
|
return '';
|
|
};
|
|
|
|
const normalizeLeadingRegion = name => {
|
|
const cleanName = stripLeadingMojibakeFlag(name);
|
|
const cleanNameLower = cleanName.toLowerCase();
|
|
|
|
for (const [alias, replacement, aliasLower] of compiledAliases) {
|
|
if (!cleanNameLower.startsWith(aliasLower)) continue;
|
|
|
|
const rest = cleanName.slice(alias.length);
|
|
if (/^[A-Za-z]/.test(alias) && rest && !/^[\s\d[|\-.x]/i.test(rest)) {
|
|
continue;
|
|
}
|
|
|
|
return joinAlias(replacement, rest);
|
|
}
|
|
|
|
return cleanName;
|
|
};
|
|
|
|
return {
|
|
sortedAliases,
|
|
regionCodeOf,
|
|
aliasRegionPrefixOf,
|
|
normalizeLeadingRegion
|
|
};
|
|
};
|
|
|
|
const joinAlias = (replacement, rest) => {
|
|
const cleanRest = String(rest || '').trim();
|
|
if (!cleanRest) return replacement;
|
|
if (/^[|/\-]/.test(cleanRest)) {
|
|
return `${replacement}${cleanRest}`;
|
|
}
|
|
return `${replacement} ${cleanRest}`;
|
|
};
|
|
|
|
const normalizeMultiplierText = value => String(value)
|
|
.replace(/-\s*(\d+(?:\.\d+)?)倍/gi, ' $1X')
|
|
.replace(/(\d+(?:\.\d+)?)倍/gi, '$1X')
|
|
.replace(/\b(\d+(?:\.\d+)?)x\b/gi, '$1X');
|
|
|
|
const moveMultiplierToEnd = name => {
|
|
const multipliers = [];
|
|
const nameWithoutMultiplier = String(name)
|
|
.replace(/\b(\d+(?:\.\d+)?X)\b/gi, multiplier => {
|
|
multipliers.push(multiplier.toUpperCase());
|
|
return ' ';
|
|
})
|
|
.replace(/\s+\[/g, ' [')
|
|
.replace(/\s{2,}/g, ' ')
|
|
.trim();
|
|
|
|
if (!multipliers.length) return name;
|
|
return `${nameWithoutMultiplier} ${multipliers.join(' ')}`.trim();
|
|
};
|
|
|
|
const normalizeRegionSequence = (name, options) => {
|
|
const allowCityCode = Boolean(options && options.allowCityCode);
|
|
const cacheKey = allowCityCode ? 'city' : 'country';
|
|
const cachedRegexes = regionSequenceRegexCache[cacheKey] || (
|
|
regionSequenceRegexCache[cacheKey] = (() => {
|
|
const regionPattern = allowCityCode
|
|
? '[A-Z]{2}(?:\\s+[A-Z]{3})?'
|
|
: '[A-Z]{2}';
|
|
|
|
return {
|
|
sequenceRegex: new RegExp(`(^|\\s)(${regionPattern})[\\s|\\-]*(\\d{1,2})(?=$|[\\s|\\-[\\]])`, 'g'),
|
|
multiplierHyphenRegex: new RegExp(`(\\b${regionPattern} \\d{2})-\\s*(?=\\d+(?:\\.\\d+)?X\\b)`, 'g')
|
|
};
|
|
})()
|
|
);
|
|
|
|
return String(name)
|
|
.replace(cachedRegexes.sequenceRegex, (
|
|
match,
|
|
prefix,
|
|
regionCode,
|
|
sequenceNumber,
|
|
offset,
|
|
input
|
|
) => {
|
|
if (
|
|
prefix === ' ' &&
|
|
offset > 1 &&
|
|
/^[A-Z]{2}$/.test(input.slice(offset - 2, offset))
|
|
) {
|
|
return match;
|
|
}
|
|
|
|
return `${prefix}${regionCode} ${sequenceNumber.padStart(2, '0')}`;
|
|
})
|
|
.replace(cachedRegexes.multiplierHyphenRegex, '$1 ')
|
|
.replace(/\s{2,}/g, ' ')
|
|
.trim();
|
|
};
|
|
|
|
const normalizeHyphenatedRegionFeatures = name => {
|
|
const misplacedSequenceMatch = String(name).match(/^([A-Z]{2}(?:\s+[A-Z]{3})?)\s+01\s+(\d{1,2})-(.+)$/);
|
|
if (misplacedSequenceMatch) {
|
|
return [
|
|
misplacedSequenceMatch[1],
|
|
misplacedSequenceMatch[2].padStart(2, '0'),
|
|
misplacedSequenceMatch[3].trim()
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.replace(/\s{2,}/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
const hyphenatedMatch = String(name).match(/^([A-Z]{2})(?:\s+([A-Z]{3}))?-(.+)$/);
|
|
if (!hyphenatedMatch) return name;
|
|
|
|
const countryCode = hyphenatedMatch[1];
|
|
let regionCode = hyphenatedMatch[2]
|
|
? `${countryCode} ${hyphenatedMatch[2]}`
|
|
: countryCode;
|
|
const tokens = hyphenatedMatch[3].split('-').map(token => token.trim()).filter(Boolean);
|
|
|
|
if (
|
|
!hyphenatedMatch[2] &&
|
|
tokens.length >= 2 &&
|
|
/^[A-Z]{3}$/.test(tokens[0]) &&
|
|
/^\d{1,2}$/.test(tokens[1])
|
|
) {
|
|
regionCode = `${countryCode} ${tokens.shift()}`;
|
|
}
|
|
|
|
const sequenceIndex = tokens.findIndex(token => /^\d{1,2}$/.test(token));
|
|
if (sequenceIndex === -1) {
|
|
return [regionCode, tokens.join('-')]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.replace(/\s{2,}/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
const sequenceNumber = tokens[sequenceIndex].padStart(2, '0');
|
|
const ingressTokens = tokens
|
|
.slice(0, sequenceIndex)
|
|
.map(token => token.toUpperCase());
|
|
const egressTokens = tokens.slice(sequenceIndex + 1);
|
|
const featureText = ingressTokens.length && egressTokens.length
|
|
? `${ingressTokens.join('-')}-${egressTokens.join('-')}`
|
|
: [...ingressTokens, ...egressTokens].join('-');
|
|
|
|
return [regionCode, sequenceNumber, featureText]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.replace(/\s{2,}/g, ' ')
|
|
.trim();
|
|
};
|
|
|
|
const stripLineDescriptors = (name, descriptorKeywords) => {
|
|
const descriptorCacheKey = descriptorKeywords.join('\u0000');
|
|
const descriptorRegexes = lineDescriptorRegexCache[descriptorCacheKey] || (
|
|
lineDescriptorRegexCache[descriptorCacheKey] = (() => {
|
|
const descriptorPattern = descriptorKeywords.map(escapeRegex).join('|');
|
|
return {
|
|
descriptorOnlyRegex: new RegExp(`^(?:${descriptorPattern})+$`, 'i'),
|
|
embeddedDescriptorRegex: new RegExp(`\\s*(?:${descriptorPattern})(?=\\s|\\d|$)`, 'gi'),
|
|
bracketDescriptorRegex: new RegExp(`\\s*\\[(?:${descriptorPattern})(?:\\s+(?:${descriptorPattern}))*]\\s*`, 'gi')
|
|
};
|
|
})()
|
|
);
|
|
|
|
const [head, ...segments] = stripLeadingMojibakeFlag(name).split('|');
|
|
const keptSegments = segments
|
|
.map(segment => segment.trim())
|
|
.filter(segment => segment && !descriptorRegexes.descriptorOnlyRegex.test(segment));
|
|
|
|
const cleanHead = head
|
|
.replace(descriptorRegexes.bracketDescriptorRegex, ' ')
|
|
.replace(descriptorRegexes.embeddedDescriptorRegex, '')
|
|
.replace(leadingFlagRegex, '$1 ')
|
|
.replace(/\s{2,}/g, ' ')
|
|
.trim();
|
|
|
|
return [cleanHead, ...keptSegments]
|
|
.filter(Boolean)
|
|
.join('|')
|
|
.replace(/\s+\|/g, '|')
|
|
.replace(/\|\s+/g, '|')
|
|
.trim();
|
|
};
|
|
|
|
root.SubStoreRenameUtils = {
|
|
createRegionResolver,
|
|
escapeRegex,
|
|
flagToRegionCode,
|
|
joinAlias,
|
|
moveMultiplierToEnd,
|
|
normalizeHyphenatedRegionFeatures,
|
|
normalizeLeadingFlagSpacing,
|
|
normalizeLeadingMojibakeFlag,
|
|
normalizeMultiplierText,
|
|
normalizeRegionSequence,
|
|
normalizeWhitespace,
|
|
regionCodeToFlag,
|
|
sortRegionAliases,
|
|
stripLeadingMojibakeFlag,
|
|
splitLeadingFlag,
|
|
splitLeadingOrInlineFlag,
|
|
stripLineDescriptors,
|
|
version: VERSION
|
|
};
|
|
})(globalThis);
|