diff --git a/danmu_api/utils/bangumi-data-util.js b/danmu_api/utils/bangumi-data-util.js index f9f2f938c..54caa2d00 100644 --- a/danmu_api/utils/bangumi-data-util.js +++ b/danmu_api/utils/bangumi-data-util.js @@ -773,7 +773,44 @@ export async function searchBangumiData(keyword, siteKeys) { results.push(...additionalDubs); } } - return results; + + return dedupeBangumiSearchResults(results, keyword); +} + +// 对搜索结果精确匹配优先排序并按源去重,同源多条目保留标题精确命中检索词的一条、其余标题并入别名;tmdb 不分季且按标题消费,不参与去重 +export function dedupeBangumiSearchResults(results, keyword) { + let exactTerms = [keyword]; + try { + exactTerms = [...new Set([keyword, simplized(keyword), traditionalized(keyword)])]; + } catch (e) {} + results.sort((a, b) => { + const aExact = a.titles.some(t => t && exactTerms.includes(t)); + const bExact = b.titles.some(t => t && exactTerms.includes(t)); + if (aExact && !bExact) return -1; + if (!aExact && bExact) return 1; + return 0; + }); + + const dedupedResults = []; + const seenSourceIds = new Map(); + for (const r of results) { + if (r.matchedSiteKey === 'tmdb') { + dedupedResults.push(r); + continue; + } + const dedupeKey = `${r.matchedSiteKey}:${r.siteId}`; + const keepIdx = seenSourceIds.get(dedupeKey); + if (keepIdx === undefined) { + seenSourceIds.set(dedupeKey, dedupedResults.length); + dedupedResults.push(r); + } else { + const kept = dedupedResults[keepIdx]; + for (const t of r.titles) { + if (t && !kept.titles.includes(t)) kept.titles.push(t); + } + } + } + return dedupedResults; } /** diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index 575463a12..c8b6f1a16 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -49,7 +49,7 @@ import { previewJsContent } from './ui/js/preview.js'; import { convertToAsciiSum } from "./utils/codec-util.js"; import { convertToDanmakuJson, handleDanmusLike } from "./utils/danmu-util.js"; import { Segment, SegmentListResponse } from "./models/dandan-model.js" -import { initBangumiData, searchBangumiData, clearBangumiDataCache } from "./utils/bangumi-data-util.js"; +import { initBangumiData, searchBangumiData, clearBangumiDataCache, dedupeBangumiSearchResults } from "./utils/bangumi-data-util.js"; import { generateNipaplaySignature, parseNipaplayRelatedLinks, resolveNipaplayLink, applyShiftToDanmu } from "./utils/nipaplay-util.js"; // Mock Request class for testing @@ -1125,6 +1125,34 @@ test('worker.js API endpoints', async (t) => { }); }); + // 测试 Bangumi Data 本地检索结果的同源去重 + await t.test('dedupeBangumiSearchResults should dedupe same-source results and skip tmdb', () => { + const makeResult = (siteKey, siteId, titles) => ({ matchedSiteKey: siteKey, siteId, titles }); + + // 同源多条目:保留标题精确命中检索词的一条,其余标题并入别名 + const merged = dedupeBangumiSearchResults([ + makeResult('anidb', '19242', ['Re:从零开始的异世界生活 第四季 丧失篇(2026)']), + makeResult('anidb', '19242', ['Re:从零开始的异世界生活 第四季 夺还篇(2026)']), + ], 'Re:从零开始的异世界生活 第四季 夺还篇(2026)'); + assert.equal(merged.length, 1, `Expected merged.length === 1, but got ${merged.length}`); + assert.equal(merged[0].titles[0], 'Re:从零开始的异世界生活 第四季 夺还篇(2026)'); + assert.ok(merged[0].titles.includes('Re:从零开始的异世界生活 第四季 丧失篇(2026)'), 'Expected merged titles to include the secondary title'); + + // tmdb 不参与去重,同 id 多条均保留 + const tmdbOnly = dedupeBangumiSearchResults([ + makeResult('tmdb', '123', ['标题A']), + makeResult('tmdb', '123', ['标题B']), + ], '检索词'); + assert.equal(tmdbOnly.length, 2, `Expected tmdbOnly.length === 2, but got ${tmdbOnly.length}`); + + // 不同源 id 空间重叠(同 siteId 不同 matchedSiteKey)不合并 + const crossSite = dedupeBangumiSearchResults([ + makeResult('anidb', '19242', ['丧失篇']), + makeResult('bangumi', '19242', ['夺还篇']), + ], '检索词'); + assert.equal(crossSite.length, 2, `Expected crossSite.length === 2, but got ${crossSite.length}`); + }); + }); // await t.test('GET /api/v2/comment/:id?format=json&duration=true should return segment duration and reuse comment cache', async () => {