diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index 3aa5548..99060f0 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -13,7 +13,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.19.14', + VERSION: '1.19.15', MAX_LOGS: 1000, // 日志存储,最多保存 1000 行 MAX_RECORDS: 100, // 请求记录最大数量 diff --git a/danmu_api/sources/hanjutv.js b/danmu_api/sources/hanjutv.js index 1c2ecd4..87e5885 100644 --- a/danmu_api/sources/hanjutv.js +++ b/danmu_api/sources/hanjutv.js @@ -39,7 +39,7 @@ export default class HanjutvSource extends BaseSource { this._mobileMakeHeaders = null; this._tvMakeHeaders = null; this._mobileWarmupPromise = null; - this._mobileWarmupAttempted = false; + this._mobileWarmedUid = null; } getDanmuHeaders() { @@ -212,13 +212,180 @@ export default class HanjutvSource extends BaseSource { return Array.from(map.values()); } - isMergeableSearchPair(leftItem, rightItem, keyword = "") { - if (!leftItem?.name || !rightItem?.name) return false; - if (keyword) { - if (!titleMatches(leftItem.name, keyword) || !titleMatches(rightItem.name, keyword)) return false; + // 双端身份键只做 Unicode 兼容归一与首尾去空白,不使用模糊标题清洗。 + normalizeSearchPairTitle(name = "") { + return String(name || "").normalize("NFKC").trim(); + } + + getSearchPairYear(item) { + const rawYear = item?.publishTime ?? item?.releaseTime ?? item?.year ?? null; + const yearText = String(rawYear ?? "").trim(); + const isReasonableYear = year => Number.isInteger(year) && year >= 1900 && year <= 2100; + const parseDateYear = value => { + const parsed = new Date(value); + const year = parsed.getUTCFullYear(); + return isReasonableYear(year) ? year : null; + }; + + if (/^(?:19|20)\d{2}$/.test(yearText)) return Number(yearText); + + const compactDateMatch = yearText.match(/^((?:19|20)\d{2})\d{4}$/); + if (compactDateMatch) return Number(compactDateMatch[1]); + + if (rawYear !== null && rawYear !== "") { + const numericValue = Number(rawYear); + if (Number.isFinite(numericValue)) { + if (numericValue !== 0) { + const absoluteValue = Math.abs(numericValue); + let timestamp = null; + if (absoluteValue >= 10_000_000_000) { + timestamp = numericValue; + } else if (absoluteValue >= 100_000_000) { + timestamp = numericValue * 1000; + } + + const year = timestamp === null ? null : parseDateYear(timestamp); + if (year !== null) return year; + } + } else if (yearText) { + const year = parseDateYear(yearText); + if (year !== null) return year; + } } - return titleMatches(leftItem.name, rightItem.name) && titleMatches(rightItem.name, leftItem.name); + const memoMatch = String(item?.searchMemo || "").match(/(?:19|20)\d{2}/); + return memoMatch ? Number(memoMatch[0]) : null; + } + + getSearchPairEpisodeCount(item) { + const value = Number(item?.lastSerialNo ?? item?.totalEpisode ?? item?.episodeCount); + return Number.isFinite(value) && value > 0 ? value : null; + } + + getSearchPairMetadata(item) { + const normalizeValue = value => { + if (value === undefined || value === null) return null; + const normalized = String(value).trim(); + return normalized || null; + }; + return { + playMode: normalizeValue(item?.playMode), + year: normalizeValue(this.getSearchPairYear(item)), + episodeCount: normalizeValue(this.getSearchPairEpisodeCount(item)), + category: normalizeValue(item?.category), + }; + } + + // titleMatches 只负责关键词相关性;S5↔TV 是否为同一实体必须满足精确标题与强元数据约束。 + isMergeableSearchPair(leftItem, rightItem) { + const leftTitle = this.normalizeSearchPairTitle(leftItem?.name); + const rightTitle = this.normalizeSearchPairTitle(rightItem?.name); + if (!leftTitle || !rightTitle || leftTitle !== rightTitle) return false; + + const leftMeta = this.getSearchPairMetadata(leftItem); + const rightMeta = this.getSearchPairMetadata(rightItem); + for (const field of ["playMode", "year", "category"]) { + if (leftMeta[field] !== null && rightMeta[field] !== null && leftMeta[field] !== rightMeta[field]) { + return false; + } + } + + return true; + } + + getSearchPairMatchScore(leftItem, rightItem) { + if (!this.isMergeableSearchPair(leftItem, rightItem)) return null; + const leftMeta = this.getSearchPairMetadata(leftItem); + const rightMeta = this.getSearchPairMetadata(rightItem); + const weights = { year: 8, playMode: 4, category: 2, episodeCount: 1 }; + let score = 0; + + for (const [field, weight] of Object.entries(weights)) { + if (leftMeta[field] !== null && rightMeta[field] !== null && leftMeta[field] === rightMeta[field]) { + score += weight; + } + } + + return score; + } + + selectUniqueBestSearchCandidate(item, candidates = []) { + const scored = candidates + .map(candidate => ({ candidate, score: this.getSearchPairMatchScore(item, candidate) })) + .filter(entry => entry.score !== null); + if (scored.length === 0) return null; + + const bestScore = Math.max(...scored.map(entry => entry.score)); + const best = scored.filter(entry => entry.score === bestScore); + return best.length === 1 ? best[0].candidate : null; + } + + pairSearchCandidateGroup(s5Items = [], tvItems = []) { + const remainingS5 = new Map(s5Items.map(item => [String(item.sid), item])); + const remainingTv = new Map(tvItems.map(item => [String(item.sid), item])); + const pairedTvByS5Sid = new Map(); + + while (remainingS5.size > 0 && remainingTv.size > 0) { + const currentS5 = Array.from(remainingS5.values()); + const currentTv = Array.from(remainingTv.values()); + const bestTvByS5Sid = new Map(); + const bestS5ByTvSid = new Map(); + + for (const s5Item of currentS5) { + const bestTv = this.selectUniqueBestSearchCandidate(s5Item, currentTv); + if (bestTv) bestTvByS5Sid.set(String(s5Item.sid), bestTv); + } + for (const tvItem of currentTv) { + const bestS5 = this.selectUniqueBestSearchCandidate(tvItem, currentS5); + if (bestS5) bestS5ByTvSid.set(String(tvItem.sid), bestS5); + } + + const mutualPairs = []; + for (const [s5Sid, tvItem] of bestTvByS5Sid) { + const tvSid = String(tvItem.sid); + const bestS5 = bestS5ByTvSid.get(tvSid); + if (bestS5 && String(bestS5.sid) === s5Sid) { + mutualPairs.push({ s5Sid, tvSid, tvItem }); + } + } + + if (mutualPairs.length === 0) break; + for (const pair of mutualPairs) { + pairedTvByS5Sid.set(pair.s5Sid, pair.tvItem); + remainingS5.delete(pair.s5Sid); + remainingTv.delete(pair.tvSid); + } + } + + return pairedTvByS5Sid; + } + + pairSearchCandidates(s5Items = [], tvItems = []) { + const groupByTitle = items => { + const groups = new Map(); + for (const item of items) { + const title = this.normalizeSearchPairTitle(item?.name); + if (!title) continue; + if (!groups.has(title)) groups.set(title, []); + groups.get(title).push(item); + } + return groups; + }; + + const s5Groups = groupByTitle(s5Items); + const tvGroups = groupByTitle(tvItems); + const pairedTvByS5Sid = new Map(); + + for (const [title, s5Group] of s5Groups) { + const tvGroup = tvGroups.get(title); + if (!tvGroup) continue; + const groupPairs = this.pairSearchCandidateGroup(s5Group, tvGroup); + for (const [s5Sid, tvItem] of groupPairs) { + pairedTvByS5Sid.set(s5Sid, tvItem); + } + } + + return pairedTvByS5Sid; } buildSearchCandidate(item, variant, linkedSid = "") { @@ -256,12 +423,12 @@ export default class HanjutvSource extends BaseSource { const hasMatched = s5.matched.length + tv.matched.length > 0; const resultList = []; - const usedTvSids = new Set(); + const pairedTvByS5Sid = this.pairSearchCandidates(s5.matched, tv.matched); + const usedTvSids = new Set(Array.from(pairedTvByS5Sid.values(), item => String(item.sid))); for (const item of s5.matched) { - const pairedTv = tv.matched.find(candidate => !usedTvSids.has(String(candidate.sid)) && this.isMergeableSearchPair(item, candidate, keyword)); + const pairedTv = pairedTvByS5Sid.get(String(item.sid)); if (pairedTv) { - usedTvSids.add(String(pairedTv.sid)); resultList.push(this.buildSearchCandidate(item, HANJUTV_VARIANTS.MERGED, pairedTv.sid)); } else { resultList.push(this.buildSearchCandidate(item, HANJUTV_VARIANTS.HXQ)); @@ -332,24 +499,34 @@ export default class HanjutvSource extends BaseSource { async warmupMobileIdentity(headers) { try { await httpGet(`${this.appHost}/api/common/configs`, { headers, timeout: 8000, retries: 0 }); + return true; } catch (_) { - // 暖身失败不阻断搜索 + // 暖身失败不阻断搜索,下一次搜索会再次尝试。 + return false; } } async ensureMobileIdentityWarmed() { - if (this._mobileWarmupAttempted) { - await this._mobileWarmupPromise; - return; - } + if (this._mobileWarmedUid) return true; + if (this._mobileWarmupPromise) return this._mobileWarmupPromise; - this._mobileWarmupAttempted = true; - this._mobileWarmupPromise = (async () => { + const warmupPromise = (async () => { const headerInfo = await this.buildMobileHeaders(); - await this.warmupMobileIdentity(headerInfo.headers); + if (this._mobileWarmedUid === headerInfo.uid) return true; + + const warmed = await this.warmupMobileIdentity(headerInfo.headers); + if (warmed) this._mobileWarmedUid = headerInfo.uid; + return warmed; })(); - await this._mobileWarmupPromise; + this._mobileWarmupPromise = warmupPromise; + try { + return await warmupPromise; + } finally { + if (this._mobileWarmupPromise === warmupPromise) { + this._mobileWarmupPromise = null; + } + } } async searchWithS5Api(keyword) { @@ -698,21 +875,30 @@ export default class HanjutvSource extends BaseSource { } } - await Promise.all( + const payloads = await Promise.all( filteredAnimes.map(async (anime) => { - try { - const payload = await this.buildAnimePayload(anime); - if (!payload || !payload.summary || !Array.isArray(payload.links) || payload.links.length === 0) return; - - tmpAnimes.push(payload.summary); - addAnime({ ...payload.summary, links: payload.links }, detailStore); - if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime(); - } catch (error) { - log("error", `[Hanjutv] Error processing anime: ${error.message}`); - } - }) + try { + const payload = await this.buildAnimePayload(anime); + if (!payload || !payload.summary || !Array.isArray(payload.links) || payload.links.length === 0) return null; + return payload; + } catch (error) { + log("error", `[Hanjutv] Error processing anime: ${error.message}`); + return null; + } + }) ); + for (const payload of payloads) { + if (!payload) continue; + try { + tmpAnimes.push(payload.summary); + addAnime({ ...payload.summary, links: payload.links }, detailStore); + if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime(); + } catch (error) { + log("error", `[Hanjutv] Error processing anime: ${error.message}`); + } + } + this.sortAndPushAnimesByYear(tmpAnimes, curAnimes); return tmpAnimes; } diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index e71d6e0..573ff41 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -1148,6 +1148,153 @@ test('worker.js API endpoints', async (t) => { // assert(res.length > 2, `Expected res.length > 2, but got ${res.length}`); // }); + await t.test('Hanjutv warmup should retry after failure and share concurrent promise', async () => { + const source = new HanjutvSource(); + let attempts = 0; + let finishFirst; + source.buildMobileHeaders = async () => ({ uid: 'stable-uid', headers: {} }); + source.warmupMobileIdentity = async () => { + attempts++; + if (attempts === 1) return new Promise(resolve => { finishFirst = resolve; }); + return true; + }; + + const concurrent = [source.ensureMobileIdentityWarmed(), source.ensureMobileIdentityWarmed()]; + await new Promise(resolve => setImmediate(resolve)); + assert.equal(attempts, 1); + finishFirst(false); + await Promise.all(concurrent); + await source.ensureMobileIdentityWarmed(); + await source.ensureMobileIdentityWarmed(); + assert.equal(attempts, 2); + }); + + await t.test('Hanjutv details should stay fully parallel and preserve candidate order', async () => { + const source = new HanjutvSource(); + const candidates = Array.from({ length: 6 }, (_, index) => ({ sid: `sid-${index}`, name: `顺序测试剧${index}` })); + const resolvers = new Map(); + const started = []; + const previous = { animes: Globals.animes, episodeIds: Globals.episodeIds, episodeNum: Globals.episodeNum }; + Globals.animes = []; + Globals.episodeIds = []; + Globals.episodeNum = 10001; + source.buildAnimePayload = anime => new Promise(resolve => { + started.push(anime.sid); + resolvers.set(anime.sid, resolve); + }); + source.sortAndPushAnimesByYear = (items, target) => target.push(...items); + + try { + const current = []; + const task = source.handleAnimes(candidates, '顺序测试剧', current, new Map()); + await new Promise(resolve => setImmediate(resolve)); + assert.deepEqual(started, candidates.map(item => item.sid)); + [...candidates].reverse().forEach(anime => { + const index = candidates.indexOf(anime); + resolvers.get(anime.sid)({ + summary: { animeId: 900000 + index, bangumiId: String(900000 + index), animeTitle: anime.name, type: '韩剧', typeDescription: '韩剧', imageUrl: '', startDate: '2025-01-01T00:00:00Z', episodeCount: 1, rating: 0, isFavorited: true, source: 'hanjutv' }, + links: [{ name: '第1集', url: `hxq:${anime.sid}`, title: '【hanjutv】 第1集' }], + }); + }); + const expected = candidates.map(item => item.name); + assert.deepEqual((await task).map(item => item.animeTitle), expected); + assert.deepEqual(current.map(item => item.animeTitle), expected); + assert.deepEqual(Globals.animes.map(item => item.animeTitle), expected); + } finally { + Globals.animes = previous.animes; + Globals.episodeIds = previous.episodeIds; + Globals.episodeNum = previous.episodeNum; + } + }); + + await t.test('Hanjutv should merge only exact titles and disambiguate duplicate names', async () => { + const source = new HanjutvSource(); + const getMergedPairs = (keyword, s5Items, tvItems) => source + .mergeSearchCandidates(keyword, s5Items, tvItems) + .resultList + .filter(item => item._variant === 'merged') + .map(item => [item.sid, item.tvSid]) + .sort((left, right) => left[0].localeCompare(right[0])); + + const taxi = source.mergeSearchCandidates('模范出租车', [ + { sid: 's3', name: '模范出租车3' }, + { sid: 's2', name: '模范出租车2' }, + ], [ + { sid: 't2', name: '模范出租车2' }, + { sid: 't3', name: '模范出租车3' }, + ]).resultList.filter(item => item._variant === 'merged'); + assert.deepEqual(taxi.map(item => [item.name, item.tvSid]), [ + ['模范出租车3', 't3'], + ['模范出租车2', 't2'], + ]); + + const duplicate = source.mergeSearchCandidates('配对游戏', [ + { sid: 's-new', name: '配对游戏', playMode: 100, publishTime: '2025-01-01', lastSerialNo: 6 }, + { sid: 's-old', name: '配对游戏', playMode: 101, publishTime: '2024-01-01', lastSerialNo: 63 }, + ], [ + { sid: 't-old', name: '配对游戏', playMode: 101, publishTime: '2024-01-01', lastSerialNo: 63 }, + { sid: 't-new', name: '配对游戏', playMode: 100, publishTime: '2025-01-01', lastSerialNo: 6 }, + ]).resultList.filter(item => item._variant === 'merged'); + assert.deepEqual(duplicate.map(item => item.tvSid), ['t-new', 't-old']); + + const partialS5 = [ + { sid: 's-unknown', name: '同名剧', playMode: 100, category: 1 }, + { sid: 's-2025', name: '同名剧', playMode: 100, publishTime: '2025-01-01', category: 1 }, + ]; + const datedTv = [ + { sid: 't-2025', name: '同名剧', playMode: 100, publishTime: '2025-01-01', category: 1 }, + ]; + for (const s5Order of [partialS5, [...partialS5].reverse()]) { + assert.deepEqual(getMergedPairs('同名剧', s5Order, datedTv), [['s-2025', 't-2025']]); + } + + const ambiguousS5 = [ + { sid: 's-a', name: '歧义剧', playMode: 100, category: 1 }, + { sid: 's-b', name: '歧义剧', playMode: 100, category: 1 }, + ]; + const ambiguousTv = [{ sid: 't-only', name: '歧义剧', playMode: 100, category: 1 }]; + assert.deepEqual(getMergedPairs('歧义剧', ambiguousS5, ambiguousTv), []); + assert.deepEqual(getMergedPairs('歧义剧', [...ambiguousS5].reverse(), ambiguousTv), []); + + assert.deepEqual(getMergedPairs('待播剧', [ + { sid: 's-upcoming', name: '待播剧', playMode: 100, category: 1 }, + ], [ + { sid: 't-upcoming', name: '待播剧', playMode: 100, category: 1 }, + ]), [['s-upcoming', 't-upcoming']]); + + const eliminationS5 = [ + { sid: 's-known', name: '排除剧', playMode: 100, publishTime: '2025-01-01', category: 1 }, + { sid: 's-left', name: '排除剧', playMode: 100, category: 1 }, + ]; + const eliminationTv = [ + { sid: 't-left', name: '排除剧', playMode: 100, category: 1 }, + { sid: 't-known', name: '排除剧', playMode: 100, publishTime: '2025-01-01', category: 1 }, + ]; + const expectedEliminationPairs = [['s-known', 't-known'], ['s-left', 't-left']]; + for (const s5Order of [eliminationS5, [...eliminationS5].reverse()]) { + for (const tvOrder of [eliminationTv, [...eliminationTv].reverse()]) { + assert.deepEqual(getMergedPairs('排除剧', s5Order, tvOrder), expectedEliminationPairs); + } + } + }); + + await t.test('Hanjutv should parse search-pair years without confusing seconds and milliseconds', () => { + const source = new HanjutvSource(); + assert.equal(source.getSearchPairYear({ publishTime: 888768000000 }), 1998); + assert.equal(source.getSearchPairYear({ publishTime: '956678400000' }), 2000); + assert.equal(source.getSearchPairYear({ publishTime: 1735689600 }), 2025); + assert.equal(source.getSearchPairYear({ publishTime: '20250101' }), 2025); + assert.equal(source.getSearchPairYear({ releaseTime: '2025-07-11T00:00:00Z' }), 2025); + assert.equal(source.getSearchPairYear({ publishTime: 0, searchMemo: '1998·韩剧·敬请期待' }), 1998); + assert.equal(source.getSearchPairYear({ publishTime: 'not-a-date' }), null); + assert.equal(source.getSearchPairYear({ publishTime: 253402300800000 }), null); + + assert.equal(source.isMergeableSearchPair( + { name: '千禧剧', publishTime: 974788882000 }, + { name: '千禧剧', publishTime: 974820151000 }, + ), true); + }); + // await t.test('GET hanjutv search', async () => { // const res = await hanjutvSource.search("犯罪现场Zero"); // assert(res.length > 0, `Expected res.length > 0, but got ${res.length}`);