/** * @fileoverview 元数据行清理器 * * 用于清理歌词中开头和结尾的元数据行,例如: * * (歌曲名) - (歌手名) * 词:... * 曲:... * 编曲:... * 和声编唱:... * 人声编辑:... * 混音:... * 母带:... * 监制:... * 出品:... * 真正的歌词行 1 * 真正的歌词行 2 * * @see https://github.com/apoint123/Unilyric/blob/afd351c54eca7137cf8ee4ea5652d9ee55c20e32/lyrics_helper_rs/src/converter/processors/metadata_stripper.rs */ import type { LyricLine } from '@applemusic-like-lyrics/lyric' const STRICT_MATCH_SEPARATORS = [ ':', ':', ',', ',', '.', '。', '!', '!', '-', '_', '(', '(', '[', '【', '{', '『', '「' ] /** * 扫描限制配置 */ export interface ScanLimitConfig { /** * 扫描比例 (0.0 - 1.0) */ ratio: number /** * 最小扫描行数 */ minLines: number /** * 最大扫描行数 */ maxLines: number } /** * 歌词清理配置 */ export interface StripOptions { /** * 要匹配的关键词列表 */ keywords: string[] /** * 要匹配的正则字符串列表 */ regexPatterns: string[] /** * 这里的正则表达式会被视为弱匹配 */ softMatchRegexes?: string[] /** * 歌曲元数据,用于检查第一行是否为 "歌曲 - 歌手" 格式 */ matchMetadata?: { title?: string artists?: string[] } } const DEFAULT_HEADER_LIMIT: ScanLimitConfig = { ratio: 0.2, minLines: 20, maxLines: 70 } const DEFAULT_FOOTER_LIMIT: ScanLimitConfig = { ratio: 0.2, minLines: 20, maxLines: 50 } function calculateScanLimit(config: ScanLimitConfig, totalLines: number): number { const proportional = Math.ceil(totalLines * config.ratio) return Math.min(Math.min(Math.max(proportional, config.minLines), config.maxLines), totalLines) } function getLineText(line: LyricLine): string { if (!line || !line.words) return '' return line.words .map((w) => w.word) .join('') .trim() } /** * 清理文本以便检查 * * 移除行首尾的括号,例如 (作曲: xxx) -> 作曲: xxx */ function cleanTextForCheck(text: string): string { let processed = text.trim() const brackets = [ ['(', ')'], ['(', ')'], ['【', '】'], ['[', ']'], ['{', '}'], ['『', '』'], ['「', '」'] ] let changed = true let loopCount = 0 while (changed && loopCount < 5) { changed = false loopCount++ for (const [open, close] of brackets) { if (processed.startsWith(open)) { // 类似于这样的格式:(作曲:周杰伦) if (processed.endsWith(close)) { processed = processed.slice(open.length, processed.length - close.length).trim() changed = true break } // 类似于这样的格式:(Live) 作曲:周杰伦 const closeIdx = processed.indexOf(close) if (closeIdx > -1) { const contentAfter = processed.slice(closeIdx + close.length).trim() if (contentAfter.length > 0) { processed = contentAfter changed = true break } } } } } return processed } // 强匹配行:匹配关键词加冒号,或者匹配正则表达式的行 // 弱匹配行:带有冒号,但不匹配关键词或正则表达式的行。如果夹在强匹配行之间,多半是元数据行但是没有对应的规则。但也有可能是演唱者标识,“男:...”这样的,为了避免误删,如果后面全是弱匹配行,就不删它们 // 真正的歌词行:既不匹配规则,又没有冒号的行,作为防火墙来阻止对之后行的移除。避免元数据在歌词中间,把中间的歌词也移除了 function isStrictMatch(text: string, keywords: string[], regexes: RegExp[]): boolean { const cleaned = cleanTextForCheck(text) // 转小写并移除空格进行匹配 const normalizedText = cleaned.toLowerCase().replace(/\s+/g, '') for (const kw of keywords) { const normalizedKw = kw.toLowerCase().replace(/\s+/g, '') if (normalizedText.startsWith(normalizedKw)) { const remainder = normalizedText.slice(normalizedKw.length) // 如果剩余部分为空,说明完全匹配 if (remainder.length === 0) { return true } // 检查分隔符 // 允许的分隔符包括:冒号、逗号、句号、感叹号、连字符、括号等 // 注意:normalizedText 已经移除了空格 if (STRICT_MATCH_SEPARATORS.includes(remainder.charAt(0))) { return true } } } for (const reg of regexes) { if (reg.test(text)) { return true } } return false } function looksLikeMetadata(text: string, softRegexes: RegExp[]): boolean { const cleaned = cleanTextForCheck(text) if ( cleaned.includes(':') || cleaned.includes(':') || // 第一行的 歌曲名 - 歌手名 这样的格式 cleaned.includes('-') ) { return true } for (const reg of softRegexes) { if (reg.test(text)) { return true } } return false } /** * 扫描头部,寻找正文开始的位置 */ function findHeaderCutoff( lines: readonly LyricLine[], startIndex: number, keywords: string[], regexes: RegExp[], softRegexes: RegExp[], limit: number ): number { let lastValidMetadataIndex = startIndex - 1 console.groupCollapsed(`[LyricStripper] ⬇️ 开始头部扫描 (Start: ${startIndex}, Limit: ${limit})`) for (let i = startIndex; i < limit; i++) { if (i >= lines.length) break const text = getLineText(lines[i]) if (!text) { continue } const strict = isStrictMatch(text, keywords, regexes) const weak = looksLikeMetadata(text, softRegexes) let status = '❌ NONE' if (strict) status = '✅ STRICT' else if (weak) status = '⚠️ WEAK' console.log(`Line [${i}]: "${text}" | Result: ${status}`) if (!strict && !weak) { break } if (strict) { lastValidMetadataIndex = i } } console.groupEnd() return lastValidMetadataIndex + 1 } /** * 扫描尾部,寻找正文结束的位置 */ function findFooterCutoff( lines: readonly LyricLine[], startIndex: number, keywords: string[], regexes: RegExp[], softRegexes: RegExp[], limit: number ): number { if (startIndex >= lines.length) return startIndex const scanEnd = Math.max(startIndex, lines.length - limit) let firstValidFooterIndex = lines.length console.groupCollapsed(`[LyricStripper] ⬆️ 开始尾部扫描 (Limit: ${limit})`) for (let i = lines.length - 1; i >= scanEnd; i--) { const text = getLineText(lines[i]) if (!text) { continue } const strict = isStrictMatch(text, keywords, regexes) const weak = looksLikeMetadata(text, softRegexes) let status = '❌ NONE' if (strict) status = '✅ STRICT' else if (weak) status = '⚠️ WEAK' console.log(`Line [${i}]: "${text}" | Result: ${status}`) if (!strict && !weak) { break } if (strict) { firstValidFooterIndex = i } } console.groupEnd() return firstValidFooterIndex } /** * 剥离歌词中的元数据行 * @param lines 原始歌词行数组 * @param options 包含关键词和正则的配置 * @returns 清理后的新歌词行数组 */ export function stripLyricMetadata( lines: readonly LyricLine[], options: StripOptions ): LyricLine[] { if (!lines || lines.length === 0) return [] let scanStartIndex = 0 if (options.matchMetadata) { const { title, artists } = options.matchMetadata const firstLineText = getLineText(lines[0]) if (title && artists && artists.length > 0 && firstLineText) { const lowerText = firstLineText.toLowerCase() const lowerTitle = title.toLowerCase() if (lowerText.includes(lowerTitle)) { const hasAnyArtist = artists.some((artist) => lowerText.includes(artist.toLowerCase())) if (hasAnyArtist) { console.log( `[LyricStripper] 在第一行匹配到歌曲元数据: "${firstLineText}" (Title: ${title}, Artists: ${artists.join(', ')})` ) scanStartIndex = 1 } } } } if ( scanStartIndex === 0 && (!options.keywords || options.keywords.length === 0) && (!options.regexPatterns || options.regexPatterns.length === 0) && (!options.softMatchRegexes || options.softMatchRegexes.length === 0) ) { return [...lines] } const regexes: RegExp[] = [] if (options.regexPatterns) { options.regexPatterns.forEach((p) => { try { if (p.trim()) { regexes.push(new RegExp(p, 'i')) // 忽略大小写 } } catch (e) { console.warn(`[LyricStripper] 无效的正则表达式: ${p}`, e) } }) } const softRegexes: RegExp[] = [] if (options.softMatchRegexes) { options.softMatchRegexes.forEach((p) => { try { if (p.trim()) softRegexes.push(new RegExp(p, 'i')) } catch (e) { console.warn(`[LyricStripper] 无效的正则表达式: ${p}`, e) } }) } const keywords = options.keywords || [] const totalLines = lines.length const headerConfig = DEFAULT_HEADER_LIMIT const headerLimit = calculateScanLimit(headerConfig, totalLines) const startIdx = findHeaderCutoff( lines, scanStartIndex, keywords, regexes, softRegexes, headerLimit ) const footerConfig = DEFAULT_FOOTER_LIMIT const footerLimit = calculateScanLimit(footerConfig, totalLines) const endIdx = findFooterCutoff(lines, startIdx, keywords, regexes, softRegexes, footerLimit) if (startIdx === 0 && endIdx === lines.length) { return [...lines] } const newLength = endIdx - startIdx console.log(`[LyricStripper] 清理完成,总行数从 ${totalLines} 变为 ${newLength}`) return lines.slice(startIdx, endIdx) }