重构韩剧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:
1 parent
ae4f20c9c9
commit
b5d41fb42d
7 files changed
+738
-249
No files matched your search
@@ -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
@@ -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,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -752,4 +752,4 @@ export function decodeHtmlEntities(str) {
|
||||
.replace(/&#x([0-9a-fA-F]+);/g, (match, hex) => {
|
||||
return fromCodePoint(parseInt(hex, 16));
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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:") ? "极速版" : "韩小圈";
|
||||
}
|
||||
@@ -24,4 +24,4 @@ export function time_to_second(time) {
|
||||
seconds = parts[0];
|
||||
}
|
||||
return seconds;
|
||||
}
|
||||
}
|
||||
@@ -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 功能
|
||||
|
||||
Reference in new issue
Block a user