重构韩剧TV弹幕源的核心逻辑,优化双链路(韩小圈 HXQ + 极速版 TV)架构的稳定性和代码质量,修复多个实际运行中的 bug (#261)

* 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作为本地函数
This commit is contained in:
Wo254992 authored and GitHub committed 2026-03-29 23:53:50 +08:00
1 parent ae4f20c9c9
commit b5d41fb42d
7 files changed
+738 -249

No files matched your search

+41 -17
View File
@@ -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 : {};
+497 -198
View File
@@ -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,
}));
}
}
}
+1 -1
View File
@@ -752,4 +752,4 @@ export function decodeHtmlEntities(str) {
.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {
return fromCodePoint(parseInt(hex, 16));
});
}
}
+10 -3
View File
@@ -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;
+164 -29
View File
@@ -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:") ? "极速版" : "韩小圈";
}
+1 -1
View File
@@ -24,4 +24,4 @@ export function time_to_second(time) {
seconds = parts[0];
}
return seconds;
}
}
+24
View File
@@ -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 功能