/** * Sub-Store 节点命名公共工具 * * 远程引用地址: * https://git.orionc.me/orion/script/raw/branch/main/substore/common.js?v=20260702-1 */ (function (root) { const VERSION = '20260702-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 multiplierOf = name => { const match = String(name).match(/\b(\d+(?:\.\d+)?)(?:\s*[xX]|倍)\b/i); return match ? `${match[1]}X` : ''; }; const multiplierValueOf = multiplier => ( multiplier ? Number(String(multiplier).replace(/X$/i, '')) : Number.NEGATIVE_INFINITY ); const stripSubscriptionSuffix = (name, subName) => { const cleanSubName = String(subName || '').trim(); if (!cleanSubName) return String(name).trim(); return String(name) .replace(new RegExp(`\\s+-\\s*${escapeRegex(cleanSubName)}(?:\\s+\\d{2})?\\s*$`, 'i'), '') .trim(); }; const leadingRegionPrefixOf = name => { const match = String(name).match(/^([A-Z]{2})(?:\s+([A-Z]{3}))?(?=\s|$|-|\d|\|)/); if (!match) return ''; return match[2] ? `${match[1]} ${match[2]}` : match[1]; }; const createSequenceCounter = () => { const counts = Object.create(null); return key => { counts[key] = (counts[key] || 0) + 1; return String(counts[key]).padStart(2, '0'); }; }; 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, leadingRegionPrefixOf, moveMultiplierToEnd, multiplierOf, multiplierValueOf, normalizeHyphenatedRegionFeatures, normalizeLeadingFlagSpacing, normalizeLeadingMojibakeFlag, normalizeMultiplierText, normalizeRegionSequence, normalizeWhitespace, regionCodeToFlag, sortRegionAliases, stripLeadingMojibakeFlag, splitLeadingFlag, splitLeadingOrInlineFlag, createSequenceCounter, stripSubscriptionSuffix, stripLineDescriptors, version: VERSION }; })(globalThis);