From b5d41fb42d18eff2605cd75018db467d1be02e14 Mon Sep 17 00:00:00 2001 From: Wo254992 <110774618+Wo254992@users.noreply.github.com> Date: Sun, 29 Mar 2026 23:53:50 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=9F=A9=E5=89=A7TV=E5=BC=B9?= =?UTF-8?q?=E5=B9=95=E6=BA=90=E7=9A=84=E6=A0=B8=E5=BF=83=E9=80=BB=E8=BE=91?= =?UTF-8?q?=EF=BC=8C=E4=BC=98=E5=8C=96=E5=8F=8C=E9=93=BE=E8=B7=AF=EF=BC=88?= =?UTF-8?q?=E9=9F=A9=E5=B0=8F=E5=9C=88=20HXQ=20+=20=E6=9E=81=E9=80=9F?= =?UTF-8?q?=E7=89=88=20TV=EF=BC=89=E6=9E=B6=E6=9E=84=E7=9A=84=E7=A8=B3?= =?UTF-8?q?=E5=AE=9A=E6=80=A7=E5=92=8C=E4=BB=A3=E7=A0=81=E8=B4=A8=E9=87=8F?= =?UTF-8?q?=EF=BC=8C=E4=BF=AE=E5=A4=8D=E5=A4=9A=E4=B8=AA=E5=AE=9E=E9=99=85?= =?UTF-8?q?=E8=BF=90=E8=A1=8C=E4=B8=AD=E7=9A=84=20bug=20(#261)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(hanjutv): 重构韩剧TV源为双链路架构(韩小圈+极速版) - 搜索:并行调用 s5(韩小圈)和 TV(极速版)接口,按标题匹配自动合并为 merged 候选,未匹配的保留为独立变体 - 详情/剧集:merged 变体并行拉取双端剧集列表,按集号合并;支持 3 层降级(merged → hxq → tv) - 弹幕:merged 剧集 ID 编码为 base64url,拉取时按 ref 分别请求并打上细粒度来源标签(韩小圈/极速版) - 弹幕分页:韩小圈端使用 60s 窗口滑动 + 多 host 容灾,TV 端使用全量范围分页 - 加密:统一 AES-CBC 加密参数,新增 TV 端请求头构建(buildLiteHeaders)和响应解密 - 搜索身份:闭包工厂模式缓存 mobile/tv 请求头,暖身请求实例内只执行一次 - 外层合并:fetchMergedComments 保留韩剧TV内部细粒度标签,仅在缺失时补充源标签 - 点赞阈值:handleDanmusLike 支持韩小圈/极速版变体标签的低阈值判定 - 公共工具:base64url 编解码移至 codec-util,正时间戳归一化移至 time-util * 将韩剧TV专用函数从通用工具移到hanjutv-util encodeBase64UrlText、decodeBase64UrlText、normalizePositiveTimestamp 三个函数只有hanjutv-util在用,放在通用工具里会导致部分运行环境 文件体积超限无法加载,移到hanjutv-util作为本地函数 --- danmu_api/apis/dandan-api.js | 58 ++- danmu_api/sources/hanjutv.js | 695 +++++++++++++++++++++++--------- danmu_api/utils/codec-util.js | 2 +- danmu_api/utils/danmu-util.js | 13 +- danmu_api/utils/hanjutv-util.js | 193 +++++++-- danmu_api/utils/time-util.js | 2 +- danmu_api/worker.test.js | 24 ++ 7 files changed, 738 insertions(+), 249 deletions(-) diff --git a/danmu_api/apis/dandan-api.js b/danmu_api/apis/dandan-api.js index a1593c5..d4d0372 100644 --- a/danmu_api/apis/dandan-api.js +++ b/danmu_api/apis/dandan-api.js @@ -17,6 +17,7 @@ import { } from "../utils/common-util.js"; import { getTMDBChineseTitle } from "../utils/tmdb-util.js"; import { applyMergeLogic, mergeDanmakuList, MERGE_DELIMITER, alignSourceTimelines } from "../utils/merge-util.js"; +import { getHanjutvSourceLabel } from "../utils/hanjutv-util.js"; import AIClient from '../utils/ai-util.js'; import Kan360Source from "../sources/kan360.js"; import VodSource from "../sources/vod.js"; @@ -1356,8 +1357,36 @@ function buildBangumiData(anime, idParam = "") { */ async function fetchMergedComments(url, animeTitle, commentId) { const parts = url.split(MERGE_DELIMITER); - const sourceNames = parts.map(part => part.split(':')[0]).filter(Boolean); - const sourceTag = sourceNames.join('&'); + const partMetas = parts.map((part) => { + const firstColonIndex = part.indexOf(':'); + if (firstColonIndex === -1) { + return { + realId: '', + logicalSource: '', + sourceLabel: '', + }; + } + + const sourceName = part.substring(0, firstColonIndex); + const realId = part.substring(firstColonIndex + 1); + + if (sourceName !== 'hanjutv') { + return { + realId, + logicalSource: sourceName, + sourceLabel: sourceName, + }; + } + + return { + realId, + logicalSource: 'hanjutv', + sourceLabel: getHanjutvSourceLabel(realId), + }; + }); + const sourceNames = partMetas.map(meta => meta.logicalSource).filter(Boolean); + const realIds = partMetas.map(meta => meta.realId); + const sourceTag = partMetas.map(meta => meta.sourceLabel).filter(Boolean).join('&'); log("info", `[Merge] 开始获取 [${sourceTag}] 聚合弹幕...`); @@ -1371,12 +1400,10 @@ async function fetchMergedComments(url, animeTitle, commentId) { const stats = {}; // 2. 并行获取所有源的弹幕 - const tasks = parts.map(async (part) => { - const firstColonIndex = part.indexOf(':'); - if (firstColonIndex === -1) return []; - - const sourceName = part.substring(0, firstColonIndex); - const realId = part.substring(firstColonIndex + 1); + const tasks = partMetas.map(async (meta) => { + const sourceName = meta.logicalSource; + const sourceLabel = meta.sourceLabel || meta.logicalSource; + const realId = meta.realId; if (!sourceName || !realId) return []; @@ -1424,7 +1451,9 @@ async function fetchMergedComments(url, animeTitle, commentId) { // 给合并工具里的每一条弹幕打上独立的原始源标签 if (formatted && Array.isArray(formatted)) { - formatted.forEach(item => item._sourceLabel = sourceName); + formatted.forEach(item => { + if (!item._sourceLabel) item._sourceLabel = sourceLabel; + }); } // 提取并挂载 dandan 源传出的精确偏移量 @@ -1432,11 +1461,11 @@ async function fetchMergedComments(url, animeTitle, commentId) { formatted.relatedShifts = raw.relatedShifts; } - stats[sourceName] = formatted.length; + stats[sourceLabel] = formatted.length; return formatted; } catch (e) { - log("error", `[Merge] 获取 ${sourceName} 失败: ${e.message}`); - stats[sourceName] = 0; + log("error", `[Merge] 获取 ${sourceLabel} 失败: ${e.message}`); + stats[sourceLabel] = 0; return []; } } @@ -1459,11 +1488,6 @@ async function fetchMergedComments(url, animeTitle, commentId) { // 跨源时间轴对齐(仅当存在 dandan 源时执行) if (sourceNames.includes('dandan')) { - const realIds = parts.map(part => { - const firstColonIndex = part.indexOf(':'); - return firstColonIndex === -1 ? '' : part.substring(firstColonIndex + 1); - }); - // 提取精确偏移集合 const dandanIndex = sourceNames.indexOf('dandan'); const dandanShifts = (results[dandanIndex] && results[dandanIndex].relatedShifts) ? results[dandanIndex].relatedShifts : {}; diff --git a/danmu_api/sources/hanjutv.js b/danmu_api/sources/hanjutv.js index 6cf5dee..682e62b 100644 --- a/danmu_api/sources/hanjutv.js +++ b/danmu_api/sources/hanjutv.js @@ -7,30 +7,45 @@ import { generateValidStartDate } from "../utils/time-util.js"; import { addAnime, removeEarliestAnime } from "../utils/cache-util.js"; import { titleMatches } from "../utils/common-util.js"; import { SegmentListResponse } from '../models/dandan-model.js'; -import { createHanjutvUid, createHanjutvSearchHeaders, decodeHanjutvEncryptedPayload, buildLiteHeaders } from "../utils/hanjutv-util.js"; +import { + buildHanjutvSearchHeaders, + decodeHanjutvEncryptedPayload, + buildLiteHeaders, + encodeMergedHanjutvEpisodeDanmuId, + parseHanjutvEpisodeDanmuId, + getHanjutvSourceLabel, +} from "../utils/hanjutv-util.js"; const CATE_MAP = { 1: "韩剧", 2: "综艺", 3: "电影", 4: "日剧", 5: "美剧", 6: "泰剧", 7: "国产剧" }; const MAX_AXIS = 100000000; +const DANMU_WINDOW_MS = 60000; +const HANJUTV_VARIANTS = Object.freeze({ + HXQ: "hxq", + TV: "tv", + MERGED: "merged", +}); -// ===================== // 获取韩剧TV弹幕 -// ===================== export default class HanjutvSource extends BaseSource { constructor() { super(); - this.webHost = "https://hxqapi.hiyun.tv"; this.appHost = "https://hxqapi.hiyun.tv"; this.tvHost = "https://api.xiawen.tv"; - this.oldDanmuHost = "https://hxqapi.zmdcq.com"; + this.fallbackDanmuHost = "https://hxqapi.zmdcq.com"; + this.danmuHosts = Array.from(new Set([this.appHost, this.fallbackDanmuHost])); this.defaultRefer = "2JGztvGjRVpkxcr0T4ZWG2k+tOlnHmDGUNMwAGSeq548YV2FMbs0h0bXNi6DJ00L"; - this.webUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"; + this.danmuUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"; this.appUserAgent = "HanjuTV/6.8.2 (Redmi Note 12; Android 14; Scale/2.00)"; + this._mobileMakeHeaders = null; + this._tvMakeHeaders = null; + this._mobileWarmupPromise = null; + this._mobileWarmupAttempted = false; } - getWebHeaders() { + getDanmuHeaders() { return { "Content-Type": "application/json", - "User-Agent": this.webUserAgent, + "User-Agent": this.danmuUserAgent, }; } @@ -53,8 +68,17 @@ export default class HanjutvSource extends BaseSource { * 构建 TV 端请求头,返回 { headers, uid } */ async buildTvHeaders() { - const makeHeaders = await buildLiteHeaders(Date.now()); - return makeHeaders(Date.now()); // { headers, uid } + if (!this._tvMakeHeaders) { + this._tvMakeHeaders = await buildLiteHeaders(Date.now()); + } + return this._tvMakeHeaders(Date.now()); + } + + async buildMobileHeaders() { + if (!this._mobileMakeHeaders) { + this._mobileMakeHeaders = await buildHanjutvSearchHeaders(Date.now()); + } + return this._mobileMakeHeaders(Date.now()); } /** @@ -82,6 +106,13 @@ export default class HanjutvSource extends BaseSource { }); } + attachDanmuSourceLabel(comments = [], rawId = "") { + const sourceLabel = getHanjutvSourceLabel(rawId); + return Array.isArray(comments) + ? comments.map(item => ({ ...item, _sourceLabel: sourceLabel })) + : []; + } + /** * 安全执行异步操作,失败时返回 fallback 值并可选打印警告 */ @@ -125,15 +156,19 @@ export default class HanjutvSource extends BaseSource { return items .map((item, index) => { if (!item || typeof item !== "object") return null; - const pid = item.pid || item.id || item.programId || item.episodeId; - if (!pid) return null; + const episodeId = item.pid || item.eid || item.id || item.programId || item.episodeId; + if (!episodeId) return null; const serialCandidate = item.serialNo ?? item.serial_no ?? item.sort ?? item.sortNo ?? item.num ?? item.episodeNo ?? (index + 1); const serialNo = Number(serialCandidate); + const pid = item.pid || item.programId || item.episodeId || item.id || ""; + const eid = item.eid || item.id || item.episodeId || ""; return { ...item, - pid: String(pid), + episodeId: String(episodeId), + pid: pid ? String(pid) : "", + eid: eid ? String(eid) : "", serialNo: Number.isFinite(serialNo) && serialNo > 0 ? serialNo : (index + 1), title: item.title || item.name || item.programName || item.episodeTitle || "", }; @@ -142,6 +177,24 @@ export default class HanjutvSource extends BaseSource { .sort((a, b) => a.serialNo - b.serialNo); } + normalizeHxqEpisodes(items = []) { + return this.normalizeEpisodes(items) + .filter(item => item.pid) + .map(item => ({ + ...item, + pid: String(item.pid), + })); + } + + normalizeTvEpisodes(items = []) { + return this.normalizeEpisodes(items) + .filter(item => item.eid || item.episodeId) + .map(item => ({ + ...item, + eid: String(item.eid || item.episodeId), + })); + } + extractSearchItems(data) { const list = data?.seriesData?.seriesList || data?.seriesList || data?.seriesData?.series || []; return this.normalizeSearchItems(list); @@ -159,53 +212,94 @@ export default class HanjutvSource extends BaseSource { return Array.from(map.values()); } - countMatchedItems(items = [], keyword = "") { - if (!Array.isArray(items) || !keyword) return 0; - return items.reduce((count, item) => { - const name = item?.name ? String(item.name) : ""; - return count + (titleMatches(name, keyword) ? 1 : 0); - }, 0); + isMergeableSearchPair(leftItem, rightItem, keyword = "") { + if (!leftItem?.name || !rightItem?.name) return false; + if (keyword) { + if (!titleMatches(leftItem.name, keyword) || !titleMatches(rightItem.name, keyword)) return false; + } + + return titleMatches(leftItem.name, rightItem.name) && titleMatches(rightItem.name, leftItem.name); } - mergeSearchCandidates(keyword, s5List = [], webList = []) { - const partition = (candidates) => { - const matched = [], unmatched = []; - for (const item of this.dedupeBySid(candidates)) { + buildSearchCandidate(item, variant, linkedSid = "") { + if (!item?.sid) return null; + + const primarySid = String(item.sid); + const normalizedLinkedSid = String(linkedSid || "").trim(); + const animeId = variant === HANJUTV_VARIANTS.MERGED + ? convertToAsciiSum(`hxq:${primarySid}|tv:${normalizedLinkedSid}`) + : convertToAsciiSum(primarySid); + + return { + ...item, + animeId, + _variant: variant, + ...(normalizedLinkedSid ? { tvSid: normalizedLinkedSid } : {}), + }; + } + + mergeSearchCandidates(keyword, s5List = [], tvList = []) { + const s5Unique = this.dedupeBySid(s5List); + const tvUnique = this.dedupeBySid(tvList); + + const partition = (items) => { + const matched = []; + const unmatched = []; + for (const item of items) { (titleMatches(item?.name || "", keyword) ? matched : unmatched).push(item); } return { matched, unmatched }; }; - const s5 = partition(s5List); - const web = partition(webList); - - const hasMatched = (s5.matched.length + web.matched.length) > 0; - const orderedCandidates = hasMatched - ? [...s5.matched, ...web.matched, ...s5.unmatched, ...web.unmatched] - : [...this.dedupeBySid(s5List), ...this.dedupeBySid(webList)]; + const s5 = partition(s5Unique); + const tv = partition(tvUnique); + const hasMatched = s5.matched.length + tv.matched.length > 0; const resultList = []; - const sidSet = new Set(); - for (const item of orderedCandidates) { - const sid = item?.sid ? String(item.sid) : ""; - if (!sid || sidSet.has(sid)) continue; - sidSet.add(sid); - resultList.push(item); + const usedTvSids = new Set(); + + for (const item of s5.matched) { + const pairedTv = tv.matched.find(candidate => !usedTvSids.has(String(candidate.sid)) && this.isMergeableSearchPair(item, candidate, keyword)); + 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)); + } } - const names = (list) => list?.map(item => item.name) || []; + tv.matched + .filter(item => !usedTvSids.has(String(item.sid))) + .forEach(item => { + resultList.push(this.buildSearchCandidate(item, HANJUTV_VARIANTS.TV)); + }); + + if (hasMatched) { + resultList.push( + ...s5.unmatched.map(item => this.buildSearchCandidate(item, HANJUTV_VARIANTS.HXQ)).filter(Boolean), + ...tv.unmatched.map(item => this.buildSearchCandidate(item, HANJUTV_VARIANTS.TV)).filter(Boolean), + ); + } else { + resultList.push( + ...s5Unique.map(item => this.buildSearchCandidate(item, HANJUTV_VARIANTS.HXQ)).filter(Boolean), + ...tvUnique.map(item => this.buildSearchCandidate(item, HANJUTV_VARIANTS.TV)).filter(Boolean), + ); + } + + const names = (list) => list.map(item => item.name); return { resultList, stats: { - s5Total: s5.matched.length + s5.unmatched.length, + s5Total: s5Unique.length, s5Matched: s5.matched.length, - webTotal: web.matched.length + web.unmatched.length, - webMatched: web.matched.length, + tvTotal: tvUnique.length, + tvMatched: tv.matched.length, + mergedCount: hasMatched ? resultList.filter(item => item?._variant === HANJUTV_VARIANTS.MERGED).length : 0, s5MatchedList: names(s5.matched), s5UnmatchedList: names(s5.unmatched), - webMatchedList: names(web.matched), - webUnmatchedList: names(web.unmatched), + tvMatchedList: names(tv.matched), + tvUnmatchedList: names(tv.unmatched), }, }; } @@ -235,9 +329,33 @@ export default class HanjutvSource extends BaseSource { return items; } + async warmupMobileIdentity(headers) { + try { + await httpGet(`${this.appHost}/api/common/configs`, { headers, timeout: 8000, retries: 0 }); + } catch (_) { + // 暖身失败不阻断搜索 + } + } + + async ensureMobileIdentityWarmed() { + if (this._mobileWarmupAttempted) { + await this._mobileWarmupPromise; + return; + } + + this._mobileWarmupAttempted = true; + this._mobileWarmupPromise = (async () => { + const headerInfo = await this.buildMobileHeaders(); + await this.warmupMobileIdentity(headerInfo.headers); + })(); + + await this._mobileWarmupPromise; + } + async searchWithS5Api(keyword) { - const uid = createHanjutvUid(); - const headers = await createHanjutvSearchHeaders(uid); + await this.ensureMobileIdentityWarmed(); + + const { uid, headers } = await this.buildMobileHeaders(); const q = encodeURIComponent(keyword); const resp = await httpGet(`https://hxqapi.hiyun.tv/api/search/s5?k=${q}&srefer=search_input&type=0&page=1`, { headers, @@ -247,16 +365,6 @@ export default class HanjutvSource extends BaseSource { return this.extractFromPayload(resp?.data, uid, "s5"); } - async searchWithLegacyApi(keyword) { - const q = encodeURIComponent(keyword); - const resp = await httpGet(`https://hxqapi.hiyun.tv/wapi/search/aggregate/search?keyword=${q}&scope=101&page=1`, { - headers: this.getWebHeaders(), - timeout: 10000, - retries: 1, - }); - return this.extractSearchItems(resp?.data); - } - async searchWithTvApi(keyword) { const q = encodeURIComponent(keyword); const headerInfo = await this.buildTvHeaders(); @@ -273,36 +381,29 @@ export default class HanjutvSource extends BaseSource { const key = String(keyword || "").trim(); if (!key) return []; - // s5 → legacy → tv 三级降级搜索 - let s5List = await this.tryGet(() => this.searchWithS5Api(key), [], `[Hanjutv] s5 搜索失败,降级旧接口`); - let webList = []; - let tvList = []; + const [s5List, tvList] = await Promise.all([ + this.tryGet(() => this.searchWithS5Api(key), [], `[Hanjutv] s5 搜索失败`), + this.tryGet(() => this.searchWithTvApi(key), [], `[Hanjutv] TV 搜索失败`), + ]); - const needFallback = (list) => list.length === 0 || this.countMatchedItems(list, key) === 0; + const { resultList, stats } = this.mergeSearchCandidates(key, s5List, tvList); + const totalMatched = stats.s5Matched + stats.tvMatched; - if (needFallback(s5List)) { - if (s5List.length > 0) log("warn", `[Hanjutv] s5 返回 ${s5List.length} 条但标题零命中,触发 legacy 补偿检索`); - webList = await this.tryGet(() => this.searchWithLegacyApi(key), [], `[Hanjutv] 旧搜索接口失败`); - - if (needFallback(webList)) { - if (webList.length > 0) log("warn", `[Hanjutv] web 返回 ${webList.length} 条但标题零命中,触发 TV 补偿检索`); - log("info", `[Hanjutv] 尝试TV端搜索接口作为降级方案`); - tvList = await this.tryGet(() => this.searchWithTvApi(key), [], `[Hanjutv] TV端搜索接口也失败`); - } - } - - const { resultList, stats } = this.mergeSearchCandidates(key, s5List, [...webList, ...tvList]); - - if (resultList.length === 0) { - log("info", "hanjutvSearchresp: s5、旧接口和TV端接口均无有效结果"); + if (resultList.length > 0 && totalMatched === 0) { + log("warn", `[Hanjutv] 所有候选均未命中关键词,丢弃疑似推荐流结果: ${key}`); return []; } - log("info", `[Hanjutv] 搜索候选统计 s5MatchedList=${JSON.stringify(stats.s5MatchedList)}, s5UnmatchedList=${JSON.stringify(stats.s5UnmatchedList)}, webMatchedList=${JSON.stringify(stats.webMatchedList)}, webUnmatchedList=${JSON.stringify(stats.webUnmatchedList)}`); - log("info", `[Hanjutv] 搜索候选统计 s5=${stats.s5Total}(命中${stats.s5Matched}), web=${stats.webTotal}(命中${stats.webMatched})`); + if (resultList.length === 0) { + log("info", "hanjutvSearchresp: s5 与 TV 接口均无有效结果"); + return []; + } + + log("info", `[Hanjutv] 搜索候选统计 s5MatchedList=${JSON.stringify(stats.s5MatchedList)}, s5UnmatchedList=${JSON.stringify(stats.s5UnmatchedList)}, tvMatchedList=${JSON.stringify(stats.tvMatchedList)}, tvUnmatchedList=${JSON.stringify(stats.tvUnmatchedList)}`); + log("info", `[Hanjutv] 搜索候选统计 s5=${stats.s5Total}(命中${stats.s5Matched}), tv=${stats.tvTotal}(命中${stats.tvMatched}), merged=${stats.mergedCount}`); log("info", `[Hanjutv] 搜索找到 ${resultList.length} 个有效结果`); - return resultList.map((anime) => ({ ...anime, animeId: convertToAsciiSum(anime.sid) })); + return resultList.filter(Boolean); } catch (error) { this.logError("getHanjutvAnimes error", error); return []; @@ -311,69 +412,67 @@ export default class HanjutvSource extends BaseSource { // ── 详情 & 剧集 ────────────────────────────────────────────── - async getDetail(id) { + async getSeriesDetail(id, loader, missingLogTag, errorTag) { try { const sid = String(id || "").trim(); - if (!sid) return []; - - // App → Web → TV 三级降级 - let detail = - await this.tryGet(async () => { - const r = await httpGet(`${this.appHost}/api/series/detail?sid=${sid}`, { headers: this.getAppHeaders(), timeout: 10000, retries: 1 }); - return r?.data?.series ?? null; - }, null) ?? - await this.tryGet(async () => { - const r = await httpGet(`${this.webHost}/wapi/series/series/detail?sid=${sid}`, { headers: this.getWebHeaders(), timeout: 10000, retries: 1 }); - return r?.data?.series ?? null; - }, null) ?? - await this.tryGet(async () => { - const decoded = await this.tvGet(`/api/v1/series/detail/query?sid=${sid}`); - return decoded?.series ?? null; - }, null, "getHanjutvDetail error"); - - if (!detail) { - log("info", "getHanjutvDetail: series 不存在"); - return []; - } + if (!sid) return null; + const detail = await this.tryGet(() => loader(sid), null, errorTag); + if (!detail) { log("info", `${missingLogTag}: series 不存在`); return null; } return detail; - } catch (error) { - this.logError("getHanjutvDetail error", error); - return []; - } + } catch (error) { this.logError(errorTag, error); return null; } } - async getEpisodes(id) { + async getHxqDetail(id) { + return this.getSeriesDetail(id, async (sid) => { + const r = await httpGet(`${this.appHost}/api/series/detail?sid=${sid}`, { + headers: this.getAppHeaders(), timeout: 10000, retries: 1, + }); + return r?.data?.series ?? null; + }, "getHanjutvHxqDetail", "getHanjutvHxqDetail error"); + } + + async getTvDetail(id) { + return this.getSeriesDetail(id, async (sid) => { + const decoded = await this.tvGet(`/api/v1/series/detail/query?sid=${sid}`); + return decoded?.series ?? null; + }, "getHanjutvTvDetail", "getHanjutvTvDetail error"); + } + + async getHxqEpisodes(id) { try { const sid = String(id || "").trim(); if (!sid) return []; - // 依次尝试各接口,直到拿到非空剧集列表 const attempts = [ async () => { - const r = await httpGet(`${this.appHost}/api/series/detail?sid=${sid}`, { headers: this.getAppHeaders(), timeout: 10000, retries: 1 }); - return this.normalizeEpisodes(Array.isArray(r?.data?.playItems) ? r.data.playItems : []); + const r = await httpGet(`${this.appHost}/api/series/detail?sid=${sid}`, { + headers: this.getAppHeaders(), + timeout: 10000, + retries: 1, + }); + return this.normalizeHxqEpisodes(Array.isArray(r?.data?.playItems) ? r.data.playItems : []); }, async () => { - const r = await httpGet(`${this.appHost}/api/series2/episodes?sid=${sid}&refer=${encodeURIComponent(this.defaultRefer)}`, { headers: this.getAppHeaders(), timeout: 10000, retries: 1 }); - const d = r?.data; - return this.normalizeEpisodes(d?.programs || d?.episodes || d?.qxkPrograms || []); + const r = await httpGet(`${this.appHost}/api/series2/episodes?sid=${sid}&refer=${encodeURIComponent(this.defaultRefer)}`, { + headers: this.getAppHeaders(), + timeout: 10000, + retries: 1, + }); + const data = r?.data; + return this.normalizeHxqEpisodes(data?.programs || data?.episodes || data?.qxkPrograms || []); }, async () => { - const r = await httpGet(`${this.appHost}/api/series/programs_v2?sid=${sid}`, { headers: this.getAppHeaders(), timeout: 10000, retries: 1 }); - const d = r?.data; - return this.normalizeEpisodes([ - ...(Array.isArray(d?.programs) ? d.programs : []), - ...(Array.isArray(d?.qxkPrograms) ? d.qxkPrograms : []), + const r = await httpGet(`${this.appHost}/api/series/programs_v2?sid=${sid}`, { + headers: this.getAppHeaders(), + timeout: 10000, + retries: 1, + }); + const data = r?.data; + return this.normalizeHxqEpisodes([ + ...(Array.isArray(data?.programs) ? data.programs : []), + ...(Array.isArray(data?.qxkPrograms) ? data.qxkPrograms : []), ]); }, - async () => { - const r = await httpGet(`${this.webHost}/wapi/series/series/detail?sid=${sid}`, { headers: this.getWebHeaders(), timeout: 10000, retries: 1 }); - return this.normalizeEpisodes(r?.data?.episodes || []); - }, - async () => { - const decoded = await this.tvGet(`/api/v1/series/detail/query?sid=${sid}`); - return this.normalizeEpisodes(decoded?.episodes || []); - }, ]; let episodes = []; @@ -383,20 +482,186 @@ export default class HanjutvSource extends BaseSource { } if (episodes.length === 0) { - log("info", "getHanjutvEposides: episodes 不存在"); + log("info", "getHanjutvHxqEpisodes: episodes 不存在"); return []; } return episodes.sort((a, b) => a.serialNo - b.serialNo); } catch (error) { - this.logError("getHanjutvEposides error", error); + this.logError("getHanjutvHxqEpisodes error", error); return []; } } + async getTvEpisodes(id) { + try { + const sid = String(id || "").trim(); + if (!sid) return []; + + const episodes = await this.tryGet(async () => { + const decoded = await this.tvGet(`/api/v1/series/detail/query?sid=${sid}`); + return this.normalizeTvEpisodes(decoded?.episodes || []); + }, [], "getHanjutvTvEpisodes error"); + + if (episodes.length === 0) { + log("info", "getHanjutvTvEpisodes: episodes 不存在"); + return []; + } + + return episodes.sort((a, b) => a.serialNo - b.serialNo); + } catch (error) { + this.logError("getHanjutvTvEpisodes error", error); + return []; + } + } + + resolveAnimeYear(anime, ...details) { + const candidates = [ + anime?.updateTime, + anime?.publishTime, + ...details.map(item => item?.updateTime), + ...details.map(item => item?.publishTime), + ]; + + for (const candidate of candidates) { + if (!candidate) continue; + const parsed = new Date(candidate); + const year = parsed.getFullYear(); + if (Number.isFinite(year) && year > 1900) return year; + } + + return new Date().getFullYear(); + } + + buildEpisodeTitle(serialNo, rawTitle = "") { + const title = String(rawTitle || "").trim(); + return title ? `第${serialNo}集:${title}` : `第${serialNo}集`; + } + + buildEpisodeLink(hxqEpisode, tvEpisode) { + const serialNo = hxqEpisode?.serialNo ?? tvEpisode?.serialNo ?? 1; + const title = this.buildEpisodeTitle(serialNo, hxqEpisode?.title || tvEpisode?.title || ""); + + let url = ""; + if (hxqEpisode?.pid && tvEpisode?.eid) { + const mergedId = encodeMergedHanjutvEpisodeDanmuId(hxqEpisode.pid, tvEpisode.eid); + url = mergedId ? `hanjutv:${mergedId}` : ""; + } else if (hxqEpisode?.pid) { + url = `hxq:${hxqEpisode.pid}`; + } else if (tvEpisode?.eid) { + url = `tv:${tvEpisode.eid}`; + } + + if (!url) return null; + + return { name: title, url, title: `【hanjutv】 ${title}` }; + } + + mergeVariantEpisodes(hxqEpisodes = [], tvEpisodes = []) { + const hxqMap = new Map(hxqEpisodes.map(item => [Number(item.serialNo), item])); + const tvMap = new Map(tvEpisodes.map(item => [Number(item.serialNo), item])); + const serialNos = Array.from(new Set([ + ...hxqMap.keys(), + ...tvMap.keys(), + ])).filter(Number.isFinite).sort((a, b) => a - b); + + return serialNos + .map(serialNo => this.buildEpisodeLink(hxqMap.get(serialNo), tvMap.get(serialNo))) + .filter(Boolean); + } + + buildAnimeSummary(anime, detail, links, animeId) { + const category = this.getCategory(detail?.category ?? anime?.category); + const year = this.resolveAnimeYear(anime, detail); + return { + animeId, + bangumiId: String(animeId), + animeTitle: `${anime.name}(${year})【${category}】from hanjutv`, + type: category, + typeDescription: category, + imageUrl: anime?.image?.thumb || "", + startDate: generateValidStartDate(year), + episodeCount: links.length, + rating: Number(detail?.rank ?? 0), + isFavorited: true, + source: "hanjutv", + }; + } + + async getHxqBundle(sid) { + if (!sid) return { detail: null, episodes: [] }; + const [detail, episodes] = await Promise.all([this.getHxqDetail(sid), this.getHxqEpisodes(sid)]); + return { detail, episodes }; + } + + async getTvBundle(sid) { + if (!sid) return { detail: null, episodes: [] }; + const [detail, episodes] = await Promise.all([this.getTvDetail(sid), this.getTvEpisodes(sid)]); + return { detail, episodes }; + } + + async buildAnimePayload(anime) { + const variant = anime?._variant || HANJUTV_VARIANTS.HXQ; + const hxqSid = String(anime?.sid || "").trim(); + const tvSid = String(anime?.tvSid || "").trim(); + const tvLookupSid = tvSid || (variant === HANJUTV_VARIANTS.TV ? hxqSid : ""); + const parsedAnimeId = Number(anime?.animeId); + const stableMergedAnimeId = variant === HANJUTV_VARIANTS.MERGED && hxqSid && tvSid + ? ((Number.isFinite(parsedAnimeId) && parsedAnimeId > 0) ? parsedAnimeId : convertToAsciiSum(`hxq:${hxqSid}|tv:${tvSid}`)) + : null; + const needsHxqBundle = (variant === HANJUTV_VARIANTS.HXQ || variant === HANJUTV_VARIANTS.MERGED) && hxqSid; + const needsTvBundle = (variant === HANJUTV_VARIANTS.TV || variant === HANJUTV_VARIANTS.MERGED) && tvLookupSid; + const [hxqBundle, tvBundle] = await Promise.all([ + needsHxqBundle ? this.getHxqBundle(hxqSid) : Promise.resolve({ detail: null, episodes: [] }), + needsTvBundle ? this.getTvBundle(tvLookupSid) : Promise.resolve({ detail: null, episodes: [] }), + ]); + + if (variant === HANJUTV_VARIANTS.MERGED && hxqSid && tvSid) { + const links = this.mergeVariantEpisodes(hxqBundle.episodes, tvBundle.episodes); + if (links.length > 0) { + const detail = hxqBundle.detail?.category + ? hxqBundle.detail + : (tvBundle.detail?.category ? tvBundle.detail : (hxqBundle.detail || tvBundle.detail)); + return { summary: this.buildAnimeSummary(anime, detail, links, stableMergedAnimeId), links }; + } + } + + if ((variant === HANJUTV_VARIANTS.HXQ || variant === HANJUTV_VARIANTS.MERGED) && hxqSid) { + const links = hxqBundle.episodes.map(ep => this.buildEpisodeLink(ep, null)).filter(Boolean); + if (links.length > 0) { + return { + summary: this.buildAnimeSummary( + anime, + hxqBundle.detail, + links, + stableMergedAnimeId ?? convertToAsciiSum(hxqSid), + ), + links, + }; + } + } + + if ((variant === HANJUTV_VARIANTS.TV || variant === HANJUTV_VARIANTS.MERGED) && tvLookupSid) { + const links = tvBundle.episodes.map(ep => this.buildEpisodeLink(null, ep)).filter(Boolean); + if (links.length > 0) { + return { + summary: this.buildAnimeSummary( + anime, + tvBundle.detail, + links, + stableMergedAnimeId ?? convertToAsciiSum(tvLookupSid), + ), + links, + }; + } + } + + return null; + } + // ── 番剧处理 ───────────────────────────────────────────────── - async handleAnimes(sourceAnimes, queryTitle, curAnimes, detailStore = null) { + async handleAnimes(sourceAnimes, queryTitle, curAnimes, extra = null, detailStore = null) { if (!Array.isArray(sourceAnimes)) { log("error", "[Hanjutv] sourceAnimes is not a valid array"); return []; @@ -404,43 +669,16 @@ export default class HanjutvSource extends BaseSource { const tmpAnimes = []; - const processHanjutvAnimes = await Promise.all( + await Promise.all( sourceAnimes .filter(s => titleMatches(s.name, queryTitle)) .map(async (anime) => { try { - const [detail, eps] = await Promise.all([ - this.getDetail(anime.sid), - this.getEpisodes(anime.sid), - ]); + const payload = await this.buildAnimePayload(anime); + if (!payload || !payload.summary || !Array.isArray(payload.links) || payload.links.length === 0) return; - const links = eps.map((ep) => { - const epTitle = ep.title?.trim() - ? `第${ep.serialNo}集:${ep.title}` - : `第${ep.serialNo}集`; - return { name: epTitle, url: ep.pid, title: `【hanjutv】 ${epTitle}` }; - }); - - if (links.length === 0) return; - - const category = this.getCategory(detail.category); - const year = new Date(anime.updateTime).getFullYear(); - const transformedAnime = { - animeId: anime.animeId, - bangumiId: String(anime.animeId), - animeTitle: `${anime.name}(${year})【${category}】from hanjutv`, - type: category, - typeDescription: category, - imageUrl: anime.image.thumb, - startDate: generateValidStartDate(year), - episodeCount: links.length, - rating: detail.rank, - isFavorited: true, - source: "hanjutv", - }; - - tmpAnimes.push(transformedAnime); - addAnime({ ...transformedAnime, links }, detailStore); + 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}`); @@ -449,72 +687,132 @@ export default class HanjutvSource extends BaseSource { ); this.sortAndPushAnimesByYear(tmpAnimes, curAnimes); - return processHanjutvAnimes; + return tmpAnimes; } // ── 弹幕 ───────────────────────────────────────────────────── - async getEpisodeDanmu(id) { + async fetchEpisodeDanmuByRef(episodeRef) { + const episodeId = String(episodeRef?.id || "").trim(); + if (!episodeId) return []; + let allDanmus = []; - try { - // 尝试旧弹幕接口(分页轮询) - let fromAxis = 0; - while (fromAxis < MAX_AXIS) { - const resp = await httpGet(`https://hxqapi.zmdcq.com/api/danmu/playItem/list?fromAxis=${fromAxis}&pid=${id}&toAxis=${MAX_AXIS}`, { - headers: this.getWebHeaders(), - retries: 1, - }); + if (!episodeRef.preferTv) { + const headers = this.getDanmuHeaders(); - if (resp?.data?.danmus) allDanmus.push(...resp.data.danmus); + for (const danmuHost of this.danmuHosts) { + const hostDanmus = []; + try { + let prevId = 0; + let fromAxis = 0; + let toAxis = DANMU_WINDOW_MS; + let pageCount = 0; + const maxPages = 240; - const nextAxis = resp?.data?.nextAxis ?? MAX_AXIS; - if (nextAxis >= MAX_AXIS || nextAxis <= fromAxis) break; - fromAxis = nextAxis; - } - - // 若旧接口无数据,降级到 TV 端弹幕接口 - if (allDanmus.length === 0) { - let prevId = 0; - fromAxis = 0; - let pageCount = 0; - const maxPages = 120; - - while (fromAxis < MAX_AXIS && pageCount < maxPages) { - try { - const data = await this.tvGet(`/api/v1/bulletchat/episode/get?eid=${id}&prevId=${prevId}&fromAxis=${fromAxis}&toAxis=${MAX_AXIS}&offset=0`); + while (fromAxis < MAX_AXIS && pageCount < maxPages) { + const resp = await httpGet(`${danmuHost}/api/danmu/playItem/list?pid=${episodeId}&prevId=${prevId}&fromAxis=${fromAxis}&toAxis=${toAxis}&offset=0`, { + headers, + timeout: 10000, + retries: 1, + }); pageCount++; - allDanmus.push(...data.bulletchats); + const pageDanmus = Array.isArray(resp?.data?.danmus) ? resp.data.danmus : []; + if (pageDanmus.length > 0) hostDanmus.push(...pageDanmus); - const hasMore = Number(data.more ?? 0) === 1 || data.more === true || data.more === "1"; - const nextAxis = Number(data.nextAxis ?? MAX_AXIS); - const lastId = Number(data.lastId ?? prevId); + const hasMore = Number(resp?.data?.more ?? 0) === 1 || resp?.data?.more === true || resp?.data?.more === "1"; + const nextAxis = Number(resp?.data?.nextAxis ?? MAX_AXIS); + const lastId = Number(resp?.data?.lastId ?? prevId); - if (Number.isFinite(lastId) && lastId > prevId) prevId = lastId; if (!Number.isFinite(nextAxis) || nextAxis <= fromAxis || nextAxis >= MAX_AXIS) break; + if (Number.isFinite(lastId) && lastId > prevId) prevId = lastId; fromAxis = nextAxis; - if (!hasMore) prevId = 0; - } catch (error) { - this.logError("fetchHanjutvEpisodeDanmu", error); + + if (hasMore) { + continue; + } + + if (pageDanmus.length === 0) break; + toAxis = fromAxis + DANMU_WINDOW_MS; + } + + if (hostDanmus.length > 0) { + allDanmus = hostDanmus; + break; + } + } catch (error) { + this.logError(`fetchHanjutvEpisodeDanmu(韩小圈弹幕:${danmuHost})`, error); + if (hostDanmus.length > 0) { + allDanmus = hostDanmus; break; } } } - } catch (error) { - this.logError("fetchHanjutvEpisodeDanmu error", error); + } + + if (allDanmus.length === 0) { + let prevId = 0; + let fromAxis = 0; + let pageCount = 0; + const maxPages = 120; + + while (fromAxis < MAX_AXIS && pageCount < maxPages) { + try { + const data = await this.tvGet(`/api/v1/bulletchat/episode/get?eid=${episodeId}&prevId=${prevId}&fromAxis=${fromAxis}&toAxis=${MAX_AXIS}&offset=0`); + + pageCount++; + const pageDanmus = Array.isArray(data?.bulletchats) ? data.bulletchats : []; + if (pageDanmus.length > 0) allDanmus.push(...pageDanmus); + + const hasMore = Number(data.more ?? 0) === 1 || data.more === true || data.more === "1"; + const nextAxis = Number(data.nextAxis ?? MAX_AXIS); + const lastId = Number(data.lastId ?? prevId); + + if (!Number.isFinite(nextAxis) || nextAxis <= fromAxis || nextAxis >= MAX_AXIS) break; + + if (Number.isFinite(lastId) && lastId > prevId) prevId = lastId; + fromAxis = nextAxis; + if (!hasMore) prevId = 0; + if (pageDanmus.length === 0 && !hasMore) break; + } catch (error) { + this.logError("fetchHanjutvEpisodeDanmu(TV端)", error); + break; + } + } } return allDanmus; } + async getEpisodeDanmu(id) { + const episodeRef = parseHanjutvEpisodeDanmuId(id); + const refs = Array.isArray(episodeRef?.refs) ? episodeRef.refs.filter(item => item?.id) : []; + if (refs.length === 0) return []; + + if (refs.length === 1) { + const comments = await this.fetchEpisodeDanmuByRef(refs[0]); + return this.attachDanmuSourceLabel(comments, refs[0].rawId); + } + + const results = await Promise.all( + refs.map(async (ref) => { + const comments = await this.fetchEpisodeDanmuByRef(ref); + return this.attachDanmuSourceLabel(comments, ref.rawId); + }) + ); + return results.flat(); + } + async getEpisodeDanmuSegments(id) { log("info", "获取韩剧TV弹幕分段列表...", id); + // 韩剧TV 当前没有可复用的分片清单接口,统一走整集拉取。 return new SegmentListResponse({ type: "hanjutv", - segmentList: [{ type: "hanjutv", segment_start: 0, segment_end: 30000, url: id }], + duration: 0, + segmentList: [], }); } @@ -525,10 +823,11 @@ export default class HanjutvSource extends BaseSource { formatComments(comments) { return comments.map(c => ({ cid: Number(c.did), - p: `${(c.t / 1000).toFixed(2)},${c.tp === 2 ? 5 : c.tp},${Number(c.sc)},[hanjutv]`, + p: `${(c.t / 1000).toFixed(2)},${c.tp === 2 ? 5 : c.tp},${Number(c.sc)},[${c._sourceLabel || "hanjutv"}]`, m: c.con, - t: Math.round(c.t / 1000), + t: c.t / 1000, like: c.lc, + _sourceLabel: c._sourceLabel, })); } -} \ No newline at end of file +} diff --git a/danmu_api/utils/codec-util.js b/danmu_api/utils/codec-util.js index d0d6531..2bba119 100644 --- a/danmu_api/utils/codec-util.js +++ b/danmu_api/utils/codec-util.js @@ -752,4 +752,4 @@ export function decodeHtmlEntities(str) { .replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => { return fromCodePoint(parseInt(hex, 16)); }); -} \ No newline at end of file +} diff --git a/danmu_api/utils/danmu-util.js b/danmu_api/utils/danmu-util.js index 338a1b3..c22450a 100644 --- a/danmu_api/utils/danmu-util.js +++ b/danmu_api/utils/danmu-util.js @@ -115,15 +115,22 @@ export function handleDanmusLike(groupedDanmus) { if (!globals.likeSwitch) { return groupedDanmus; } + const lowThresholdSources = new Set([ + '[hanjutv]', + '[sohu]', + '[bilibili1]', + '[migu]', + ]); return groupedDanmus.map(item => { // 如果item没有like字段或者like值小于5,则不处理 if (!item.like || item.like < 5) { return item; } - // 获取弹幕来源信息,判断是否为需要特殊处理的源(低阈值) - const lowThresholdSources = ['[hanjutv]', '[sohu]', '[bilibili1]', '[migu]']; - const isLowThresholdSource = lowThresholdSources.some(source => item.p.includes(source)); + // 韩剧TV 双链路标签可能继续扩展,按来源标签内容判断更稳。 + const sourceTag = item.p.match(/,(\[[^\]]+\])$/)?.[1] || ''; + const isHanjutvVariantTag = sourceTag.includes('韩小圈') || sourceTag.includes('极速版'); + const isLowThresholdSource = isHanjutvVariantTag || lowThresholdSources.has(sourceTag); // 确定阈值:特定源中>=100用🔥,其他>=1000用🔥 const threshold = isLowThresholdSource ? 100 : 1000; diff --git a/danmu_api/utils/hanjutv-util.js b/danmu_api/utils/hanjutv-util.js index 9259a9e..920902b 100644 --- a/danmu_api/utils/hanjutv-util.js +++ b/danmu_api/utils/hanjutv-util.js @@ -1,5 +1,27 @@ import { md5, stringToUtf8Bytes, utf8BytesToString, bytesToBase64, base64ToBytes, invSubBytes, subWord, keyExpansion, invShiftRows } from "./codec-util.js"; +function normalizePositiveTimestamp(value, fallbackValue = Date.now()) { + const ts = Number(value); + return Number.isFinite(ts) && ts > 0 ? Math.trunc(ts) : Math.trunc(Number(fallbackValue) || Date.now()); +} + +function encodeBase64UrlText(text = "") { + return bytesToBase64(stringToUtf8Bytes(text)) + .replace(/\+/g, "-") + .replace(/\//g, "_") + .replace(/=+$/g, ""); +} + +function decodeBase64UrlText(text = "") { + const normalized = String(text || "").trim(); + if (!normalized) return ""; + const base64 = normalized + .replace(/-/g, "+") + .replace(/_/g, "/") + .padEnd(Math.ceil(normalized.length / 4) * 4, "="); + return utf8BytesToString(base64ToBytes(base64)); +} + // 移动端参数 const HANJUTV_VERSION = "6.8.2"; const HANJUTV_VC = "a_8280"; @@ -9,15 +31,14 @@ const HANJUTV_MAKER = "Xiaomi"; const HANJUTV_OSV = "14"; const HANJUTV_UA = `HanjuTV/${HANJUTV_VERSION} (${HANJUTV_MODEL}; Android ${HANJUTV_OSV}; Scale/2.00)`; const HANJUTV_INSTALL_AGE_MS = 14 * 24 * 60 * 60 * 1000; -const HANJUTV_UK_KEY = "f349wghhe784tqwh"; -const HANJUTV_UK_IV = "d3w8hf94fidk38lk"; -const HANJUTV_RESPONSE_SECRET = "34F9Q53w/HJW8E6Q"; const UID_CHARSET = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"; -// TV版本参数 +// 共享加密参数 const UK_KEY = "f349wghhe784tqwh"; const UK_IV = "d3w8hf94fidk38lk"; const RESPONSE_SECRET = "34F9Q53w/HJW8E6Q"; + +// TV版本参数 const SAID = "fb3597b87601d5a7"; function utf8Encode(text) { @@ -202,12 +223,6 @@ function randomInt(max) { return Math.floor(Math.random() * max); } -function buildSearchSignPayload(uid, timestamp) { - const ts = Number(timestamp); - const installTs = Math.max(0, ts - HANJUTV_INSTALL_AGE_MS); - return JSON.stringify({ emu: 0, ou: 0, it: installTs, iit: installTs, bs: 0, uid, pc: 0, tm: 81, d8m: "0,0,0,0,0,0,0,4", md: HANJUTV_MODEL, maker: HANJUTV_MAKER, osv: HANJUTV_OSV, br: 95, rpc: 0, scc: 2, plc: 6, toc: 19, tsc: 10, ts, pa: 1, crec: 0, nw: 2, px: "0", isp: "", ai: "", oa: "", dpc: 0, dsc: 0, qpc: 0, apad: 0, pk: "com.babycloud.hanju" }); -} - export function createHanjutvUid(length = 20) { let uid = ""; for (let i = 0; i < length; i++) uid += UID_CHARSET[randomInt(UID_CHARSET.length)]; @@ -216,28 +231,49 @@ export function createHanjutvUid(length = 20) { function randomFrom(chars, len) { let s = ""; - for (let i = 0; i < len; i++) s += chars[Math.floor(Math.random() * chars.length)]; + for (let i = 0; i < len; i++) s += chars[randomInt(chars.length)]; return s; } -// 移动端headers -export async function createHanjutvSearchHeaders(uid, timestamp = Date.now()) { - const ts = Number(timestamp); - const uidMd5 = md5(uid); - const signPayload = buildSearchSignPayload(uid, ts); - const sign = await aesCbcEncryptToBase64(signPayload, uidMd5.slice(0, 16), uidMd5.slice(16, 32)); - const uk = await aesCbcEncryptToBase64(uid, HANJUTV_UK_KEY, HANJUTV_UK_IV); - +function createSearchContext(uid, sessionInitTs = Date.now()) { + const initTs = normalizePositiveTimestamp(sessionInitTs); return { - app: "hj", - ch: HANJUTV_CH, - uk, - vn: HANJUTV_VERSION, - sign, - "User-Agent": HANJUTV_UA, - vc: HANJUTV_VC, - "Accept-Encoding": "gzip", - Connection: "Keep-Alive", + uid: uid || createHanjutvUid(), + said: randomFrom("0123456789abcdef", 16), + oa: randomFrom("0123456789abcdef", 16), + installTs: Math.max(0, initTs - HANJUTV_INSTALL_AGE_MS), + }; +} + +function buildSearchSignPayload(context, timestamp) { + const ts = normalizePositiveTimestamp(timestamp, context.installTs + HANJUTV_INSTALL_AGE_MS); + return JSON.stringify({ emu: 0, ou: 0, it: context.installTs, iit: context.installTs, bs: 0, uid: context.uid, pc: 0, tm: 81, d8m: "0,0,0,0,0,0,0,4", md: HANJUTV_MODEL, maker: HANJUTV_MAKER, osv: HANJUTV_OSV, br: 95, rpc: 0, scc: 2, plc: 6, toc: 19, tsc: 10, ts, pa: 1, crec: 0, nw: 2, px: "0", isp: "", ai: context.said, oa: context.oa, dpc: 0, dsc: 0, qpc: 0, apad: 0, pk: "com.babycloud.hanju" }); +} + +export async function buildHanjutvSearchHeaders(sessionInitTs = Date.now(), uid = createHanjutvUid()) { + const searchContext = createSearchContext(uid, sessionInitTs); + const uidMd5 = md5(searchContext.uid); + const uk = await aesCbcEncryptToBase64(searchContext.uid, UK_KEY, UK_IV); + + return async function makeHeaders(reqTs = Date.now()) { + const signPayload = buildSearchSignPayload(searchContext, reqTs); + const sign = await aesCbcEncryptToBase64(signPayload, uidMd5.slice(0, 16), uidMd5.slice(16, 32)); + + return { + uid: searchContext.uid, + headers: { + app: "hj", + ch: HANJUTV_CH, + said: searchContext.said, + uk, + vn: HANJUTV_VERSION, + sign, + "User-Agent": HANJUTV_UA, + vc: HANJUTV_VC, + "Accept-Encoding": "gzip", + Connection: "Keep-Alive", + }, + }; }; } @@ -287,10 +323,109 @@ export async function decodeHanjutvEncryptedPayload(payload, uid = "") { if (!key && uid && ts !== "") key = md5(`${uid}${ts}`); if (!key) throw new Error("缺少解密 key,且无法通过 uid+ts 推导"); - const mix = md5(`${key}${HANJUTV_RESPONSE_SECRET}`); + const mix = md5(`${key}${RESPONSE_SECRET}`); const aesKey = mix.slice(0, 16); const iv = mix.slice(16, 32); const plainText = await aesCbcDecryptBase64NoPadding(payload.data, aesKey, iv); const cleanedText = stripControlChars(plainText).trim(); return JSON.parse(cleanedText); } + +function normalizeHanjutvEpisodeIdText(rawId = "") { + const idText = String(rawId || "").trim(); + return idText.startsWith("hanjutv:") ? idText.slice("hanjutv:".length) : idText; +} + +function buildHanjutvEpisodeRef(id = "", preferTv = false) { + const cleanId = String(id || "").trim(); + if (!cleanId) return null; + + return { + id: cleanId, + preferTv, + rawId: `${preferTv ? "tv" : "hxq"}:${cleanId}`, + }; +} + +function decodeMergedHanjutvEpisodeId(normalizedId = "") { + if (!normalizedId.startsWith("merge:")) return null; + + try { + const payloadText = decodeBase64UrlText(normalizedId.slice("merge:".length)); + const payload = JSON.parse(payloadText); + const hxqId = String(payload?.hxq || "").trim(); + const tvId = String(payload?.tv || "").trim(); + + if (!hxqId || !tvId) return null; + return { hxqId, tvId }; + } catch (_) { + return null; + } +} + +export function encodeMergedHanjutvEpisodeDanmuId(hxqId = "", tvId = "") { + const cleanHxqId = String(hxqId || "").trim(); + const cleanTvId = String(tvId || "").trim(); + if (!cleanHxqId || !cleanTvId) return ""; + + return `merge:${encodeBase64UrlText(JSON.stringify({ + hxq: cleanHxqId, + tv: cleanTvId, + }))}`; +} + +export function parseHanjutvEpisodeDanmuId(rawId = "") { + const normalizedId = normalizeHanjutvEpisodeIdText(rawId); + if (!normalizedId) { + return { id: "", preferTv: false, rawId: "", refs: [] }; + } + + const mergedIds = decodeMergedHanjutvEpisodeId(normalizedId); + if (mergedIds) { + const refs = [ + buildHanjutvEpisodeRef(mergedIds.hxqId, false), + buildHanjutvEpisodeRef(mergedIds.tvId, true), + ].filter(Boolean); + + return { + id: refs[0]?.id || "", + preferTv: false, + rawId: normalizedId, + refs, + }; + } + + if (normalizedId.startsWith("tv:")) { + const ref = buildHanjutvEpisodeRef(normalizedId.slice(3), true); + return { + id: ref?.id || "", + preferTv: true, + rawId: ref?.rawId || "", + refs: ref ? [ref] : [], + }; + } + + if (normalizedId.startsWith("hxq:")) { + const ref = buildHanjutvEpisodeRef(normalizedId.slice(4), false); + return { + id: ref?.id || "", + preferTv: false, + rawId: ref?.rawId || "", + refs: ref ? [ref] : [], + }; + } + + const ref = buildHanjutvEpisodeRef(normalizedId, false); + return { + id: ref?.id || "", + preferTv: false, + rawId: ref?.rawId || "", + refs: ref ? [ref] : [], + }; +} + +export function getHanjutvSourceLabel(rawId = "") { + const normalizedId = normalizeHanjutvEpisodeIdText(rawId); + if (normalizedId.startsWith("merge:")) return "韩小圈&极速版"; + return normalizedId.startsWith("tv:") ? "极速版" : "韩小圈"; +} diff --git a/danmu_api/utils/time-util.js b/danmu_api/utils/time-util.js index f860d88..14a0328 100644 --- a/danmu_api/utils/time-util.js +++ b/danmu_api/utils/time-util.js @@ -24,4 +24,4 @@ export function time_to_second(time) { seconds = parts[0]; } return seconds; -} \ No newline at end of file +} diff --git a/danmu_api/worker.test.js b/danmu_api/worker.test.js index c730f00..6bf2c7f 100644 --- a/danmu_api/worker.test.js +++ b/danmu_api/worker.test.js @@ -35,6 +35,8 @@ import { CloudflareHandler } from "./configs/handlers/cloudflare-handler.js"; import { EdgeoneHandler } from "./configs/handlers/edgeone-handler.js"; import { Globals } from "./configs/globals.js"; import { addAnime, addEpisode } from "./utils/cache-util.js"; +import { convertToAsciiSum } from "./utils/codec-util.js"; +import { handleDanmusLike } from "./utils/danmu-util.js"; import { Segment, SegmentListResponse } from "./models/dandan-model.js" // Mock Request class for testing @@ -57,6 +59,27 @@ async function parseResponse(response) { } } +function mockJsonResponse(data, url) { + return { + ok: true, + status: 200, + url, + headers: new Headers({ 'content-type': 'application/json' }), + text: async () => JSON.stringify(data), + }; +} + +async function withMockFetch(mockFetch, run) { + const originalFetch = globalThis.fetch; + globalThis.fetch = mockFetch; + try { + return await run(); + } finally { + if (originalFetch === undefined) delete globalThis.fetch; + else globalThis.fetch = originalFetch; + } +} + function createSearchResult(anime) { return { animeId: anime.animeId, @@ -1467,6 +1490,7 @@ test('worker.js API endpoints', async (t) => { // const res = await handler.deploy(); // assert(res, `Expected res is true, but got ${res}`); // }); + }); // // 测试本地 Redis 功能