初次提交
This commit is contained in:
@@ -0,0 +1,411 @@
|
||||
/**
|
||||
* @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)
|
||||
}
|
||||
Reference in New Issue
Block a user