Files
script/substore/common.js
T

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);