Fix(hanjutv): 修复韩剧TV搜索候选错配并提升结果稳定性 (#367)

* 优化韩剧TV暖身恢复与结果顺序

暖身仅在成功后记录身份,失败后允许下一次搜索重试,并保持并发请求共享同一暖身任务。

详情请求继续全部并行,完成后按候选顺序写入结果与缓存,同时保留单候选异常隔离;最小回归测试并入worker.test.js,版本升级至1.19.15。

* 修复韩剧TV搜索候选跨季错配

S5与极速版候选仅在NFKC与首尾空白规范化后的剧名完全一致时合并,删除双向模糊标题和季号叠加判断。

同名多条目使用播放模式、发布年份、集数和分类唯一消歧;仍有歧义时保持独立,最小回归测试并入worker.test.js。

* 修复韩剧TV同名候选配对与年份解析

将S5与TV同名候选改为双向唯一最佳匹配,保留无年份一对一合并并消除返回顺序导致的错配。

修正秒、毫秒、字符串时间戳和紧凑日期的年份解析,补充歧义、顺序不变与旧年份回归测试。
This commit is contained in:
Wo254992 authored and GitHub committed 2026-07-14 22:26:01 +08:00
1 parent 617ae3b230
commit 6ad5a2d5bb
3 files changed
+364 -31

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ export const Globals = {
accessedEnvVars: {},
// 静态常量
VERSION: '1.19.14',
VERSION: '1.19.15',
MAX_LOGS: 1000, // 日志存储,最多保存 1000 行
MAX_RECORDS: 100, // 请求记录最大数量
+216 -30
View File
@@ -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;
}
+147
View File
@@ -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}`);