fix(parser): gdmusic 音源增加搜索结果校验,避免解析出货不对版的歌曲

此前 gdmusic 无条件取搜索第一条结果,版权下架歌曲(如周杰伦)会解析出
翻唱/同名不同曲并直接播放(#704)。现改为取前 5 条候选做归一化匹配:
- 歌名必须匹配(忽略大小写/标点/括号内 Live 等备注)
- 候选带歌手信息时歌手也必须匹配,宁可解析失败也不返回错歌
- 版权导致的全音源无资源属外部问题,代码层保证不再放错歌

Ref #704
This commit is contained in:
alger
2026-07-05 14:29:40 +08:00
parent 07d4204b5a
commit 2d693cee17
+108 -13
View File
@@ -56,17 +56,18 @@ export const parseFromGDMusic = async (
}
const songName = data.name || '';
let artistNames = '';
let artistList: string[] = [];
// 处理不同的艺术家字段结构
if (data.artists && Array.isArray(data.artists)) {
artistNames = data.artists.map((artist) => artist.name).join(' ');
artistList = data.artists.map((artist) => artist?.name).filter(Boolean);
} else if (data.ar && Array.isArray(data.ar)) {
artistNames = data.ar.map((artist) => artist.name).join(' ');
} else if (data.artist) {
artistNames = typeof data.artist === 'string' ? data.artist : '';
artistList = data.ar.map((artist) => artist?.name).filter(Boolean);
} else if (data.artist && typeof data.artist === 'string') {
artistList = [data.artist];
}
const artistNames = artistList.join(' ');
const searchQuery = `${songName} ${artistNames}`.trim();
if (!searchQuery || searchQuery.length < 2) {
@@ -82,7 +83,12 @@ export const parseFromGDMusic = async (
// 依次尝试所有音源
for (const source of allSources) {
try {
const result = await searchAndGetUrl(source, searchQuery, quality);
const result = await searchAndGetUrl(
source,
searchQuery,
{ name: songName, artists: artistList },
quality
);
if (result) {
console.log(`GD音乐台成功通过 ${result.source} 解析音乐!`);
// 返回符合原API格式的数据
@@ -132,35 +138,124 @@ interface GDMusicUrlResult {
source: string;
}
type GDSearchItem = {
id: string | number;
name?: string;
artist?: unknown;
source?: string;
};
type ExpectedSong = {
name: string;
artists: string[];
};
const baseUrl = 'https://music-api.gdstudio.xyz/api.php';
/**
* 归一化文本用于匹配:去掉括号备注(Live/翻唱/伴奏等)、空白与常见标点,转小写
*/
const normalizeText = (text: string): string => {
const stripped = text
.toLowerCase()
.replace(/[(【[].*?[))】\]]/g, '')
.replace(/[\s\-—_·・'"‘’“”!?.,,。&+]/g, '');
// 整个歌名都在括号里时退化为仅去标点,避免归一化成空串
return stripped || text.toLowerCase().replace(/[\s\-—_·・'"‘’“”!?.,,。&+]/g, '');
};
const getCandidateArtistText = (artist: unknown): string => {
if (Array.isArray(artist)) {
return artist
.map((item) => (typeof item === 'string' ? item : (item as any)?.name || ''))
.join(' ');
}
return typeof artist === 'string' ? artist : '';
};
const isNameMatched = (expectedName: string, candidateName: string): boolean => {
const expected = normalizeText(expectedName);
const candidate = normalizeText(candidateName);
if (!expected || !candidate) return false;
return expected === candidate || candidate.includes(expected) || expected.includes(candidate);
};
/**
* 从候选中挑选与原曲匹配的结果。
* 此前无条件取第一条搜索结果,版权下架歌曲(如周杰伦)会解析出翻唱/同名歌,
* 即"货不对版"(#704)。校验策略:歌名必须匹配;候选带歌手信息时歌手也必须匹配,
* 宁可解析失败也不返回错误的歌。
*/
const pickBestCandidate = (
candidates: GDSearchItem[],
expected: ExpectedSong
): GDSearchItem | null => {
let best: GDSearchItem | null = null;
let bestScore = 0;
for (const item of candidates) {
if (!item || !item.id) continue;
if (!isNameMatched(expected.name, item.name || '')) continue;
const candidateArtist = normalizeText(getCandidateArtistText(item.artist));
let score: number;
if (expected.artists.length === 0) {
// 原曲无歌手信息,歌名匹配即可
score = 2;
} else if (!candidateArtist) {
// 候选缺少歌手信息:保留为低优先级候选
score = 1;
} else {
const artistMatched = expected.artists.some((name) => {
const normalized = normalizeText(name);
return (
!!normalized &&
(candidateArtist.includes(normalized) || normalized.includes(candidateArtist))
);
});
// 有歌手信息但对不上 → 拒绝,这正是"货不对版"的来源
if (!artistMatched) continue;
score = 3;
}
if (score > bestScore) {
best = item;
bestScore = score;
}
}
return best;
};
/**
* 在指定音源搜索歌曲并获取URL
* @param source 音源
* @param searchQuery 搜索关键词
* @param expected 原曲信息(用于校验搜索结果,避免货不对版)
* @param quality 音质
* @returns 音乐URL结果
*/
async function searchAndGetUrl(
source: MusicSourceType,
searchQuery: string,
expected: ExpectedSong,
quality: string
): Promise<GDMusicUrlResult | null> {
// 1. 搜索歌曲
const searchUrl = `${baseUrl}?types=search&source=${source}&name=${encodeURIComponent(searchQuery)}&count=1&pages=1`;
// 1. 搜索歌曲(取前5条做校验,而不是盲取第一条)
const searchUrl = `${baseUrl}?types=search&source=${source}&name=${encodeURIComponent(searchQuery)}&count=5&pages=1`;
console.log(`GD音乐台尝试音源 ${source} 搜索:`, searchUrl);
const searchResponse = await axios.get(searchUrl, { timeout: 5000 });
if (searchResponse.data && Array.isArray(searchResponse.data) && searchResponse.data.length > 0) {
const firstResult = searchResponse.data[0];
if (!firstResult || !firstResult.id) {
console.log(`GD音乐台 ${source} 搜索结果无效`);
const matchedResult = pickBestCandidate(searchResponse.data as GDSearchItem[], expected);
if (!matchedResult) {
console.log(`GD音乐台 ${source} 搜索结果与原曲不匹配,已拒绝(避免货不对版)`);
return null;
}
const trackId = firstResult.id;
const trackSource = firstResult.source || source;
const trackId = matchedResult.id;
const trackSource = matchedResult.source || source;
// 2. 获取歌曲URL
const songUrl = `${baseUrl}?types=url&source=${trackSource}&id=${trackId}&br=${quality}`;