优化hanjutv源代码 (#222)

This commit is contained in:
huangxd- authored and GitHub committed 2026-03-04 22:39:46 +08:00
1 parent 124e72a436
commit 0616a03d86
4 files changed
+244 -444

No files matched your search

+1 -1
View File
@@ -13,7 +13,7 @@ export const Globals = {
accessedEnvVars: {},
// 静态常量
VERSION: '1.15.5',
VERSION: '1.15.6',
MAX_LOGS: 1000, // 日志存储,最多保存 1000 行
MAX_ANIMES: 100,
MAX_RECORDS: 100, // 请求记录最大数量
+235 -361
View File
@@ -9,6 +9,9 @@ import { titleMatches } from "../utils/common-util.js";
import { SegmentListResponse } from '../models/dandan-model.js';
import { createHanjutvUid, createHanjutvSearchHeaders, decodeHanjutvEncryptedPayload, buildLiteHeaders } from "../utils/hanjutv-util.js";
const CATE_MAP = { 1: "韩剧", 2: "综艺", 3: "电影", 4: "日剧", 5: "美剧", 6: "泰剧", 7: "国产剧" };
const MAX_AXIS = 100000000;
// =====================
// 获取韩剧TV弹幕
// =====================
@@ -42,6 +45,57 @@ export default class HanjutvSource extends BaseSource {
};
}
getCategory(key) {
return CATE_MAP[key] || "其他";
}
/**
* 构建 TV 端请求头,返回 { headers, uid }
*/
async buildTvHeaders() {
const makeHeaders = await buildLiteHeaders(Date.now());
return makeHeaders(Date.now()); // { headers, uid }
}
/**
* 向 TV 端发起 GET 请求并自动解密响应
*/
async tvGet(path, options = {}) {
const headerInfo = await this.buildTvHeaders();
const resp = await httpGet(`${this.tvHost}${path}`, {
headers: headerInfo.headers,
timeout: 10000,
retries: 1,
...options,
});
return decodeHanjutvEncryptedPayload(resp?.data, headerInfo.uid);
}
/**
* 统一的错误日志格式
*/
logError(tag, error) {
log("error", `${tag}:`, {
message: error.message,
name: error.name,
stack: error.stack,
});
}
/**
* 安全执行异步操作,失败时返回 fallback 值并可选打印警告
*/
async tryGet(fn, fallback, warnTag) {
try {
return await fn();
} catch (error) {
if (warnTag) log("warn", `${warnTag}: ${error.message}`);
return fallback;
}
}
// ── 数据规范化 ──────────────────────────────────────────────
normalizeSearchItems(items = []) {
if (!Array.isArray(items)) return [];
@@ -59,10 +113,7 @@ export default class HanjutvSource extends BaseSource {
...item,
sid: String(sid),
name: String(name),
image: {
...imageObj,
thumb,
},
image: { ...imageObj, thumb },
};
})
.filter(Boolean);
@@ -96,6 +147,8 @@ export default class HanjutvSource extends BaseSource {
return this.normalizeSearchItems(list);
}
// ── 搜索候选合并 ─────────────────────────────────────────────
dedupeBySid(items = []) {
const map = new Map();
for (const item of items) {
@@ -115,28 +168,21 @@ export default class HanjutvSource extends BaseSource {
}
mergeSearchCandidates(keyword, s5List = [], webList = []) {
const s5Candidates = this.dedupeBySid(s5List);
const webCandidates = this.dedupeBySid(webList);
const partition = (candidates) => {
const matched = [], unmatched = [];
for (const item of this.dedupeBySid(candidates)) {
(titleMatches(item?.name || "", keyword) ? matched : unmatched).push(item);
}
return { matched, unmatched };
};
const s5Matched = [];
const s5Unmatched = [];
for (const item of s5Candidates) {
if (titleMatches(item?.name || "", keyword)) s5Matched.push(item);
else s5Unmatched.push(item);
}
const s5 = partition(s5List);
const web = partition(webList);
const webMatched = [];
const webUnmatched = [];
for (const item of webCandidates) {
if (titleMatches(item?.name || "", keyword)) webMatched.push(item);
else webUnmatched.push(item);
}
// 优先返回可命中标题的结果,避免 s5 非空但无效时阻断 legacy。
const hasMatched = (s5Matched.length + webMatched.length) > 0;
const hasMatched = (s5.matched.length + web.matched.length) > 0;
const orderedCandidates = hasMatched
? [...s5Matched, ...webMatched, ...s5Unmatched, ...webUnmatched]
: [...s5Candidates, ...webCandidates];
? [...s5.matched, ...web.matched, ...s5.unmatched, ...web.unmatched]
: [...this.dedupeBySid(s5List), ...this.dedupeBySid(webList)];
const resultList = [];
const sidSet = new Set();
@@ -146,65 +192,65 @@ export default class HanjutvSource extends BaseSource {
sidSet.add(sid);
resultList.push(item);
}
const pluckNames = (list) => list?.map(item => item.name) || [];
const names = (list) => list?.map(item => item.name) || [];
return {
resultList,
stats: {
s5Total: s5Candidates.length,
s5Matched: s5Matched.length,
webTotal: webCandidates.length,
webMatched: webMatched.length,
s5MatchedList: pluckNames(s5Matched),
s5UnmatchedList: pluckNames(s5Unmatched),
webMatchedList: pluckNames(webMatched),
webUnmatchedList: pluckNames(webUnmatched)
}
s5Total: s5.matched.length + s5.unmatched.length,
s5Matched: s5.matched.length,
webTotal: web.matched.length + web.unmatched.length,
webMatched: web.matched.length,
s5MatchedList: names(s5.matched),
s5UnmatchedList: names(s5.unmatched),
webMatchedList: names(web.matched),
webUnmatchedList: names(web.unmatched),
},
};
}
async searchWithS5Api(keyword) {
const uid = createHanjutvUid();
const headers = await createHanjutvSearchHeaders(uid);
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,
timeout: 10000,
retries: 1,
});
const payload = resp?.data;
if (!payload || typeof payload !== "object") {
throw new Error("s5 响应为空");
}
/**
* 从响应 payload 中提取搜索结果;支持加密与明文两种格式
*/
async extractFromPayload(payload, uid, tag) {
if (!payload || typeof payload !== "object") throw new Error(`${tag} 响应为空`);
if (typeof payload.data === "string" && payload.data.length > 0) {
let decoded;
try {
decoded = await decodeHanjutvEncryptedPayload(payload, uid);
} catch (error) {
throw new Error(`s5 响应解密失败: ${error.message}`);
throw new Error(`${tag} 响应解密失败: ${error.message}`);
}
const items = this.extractSearchItems(decoded);
if (items.length === 0) throw new Error("s5 解密后无有效结果");
if (items.length === 0) throw new Error(`${tag} 解密后无有效结果`);
return items;
}
const plainItems = this.extractSearchItems(payload);
if (plainItems.length === 0) throw new Error("s5 无有效结果");
return plainItems;
const items = this.extractSearchItems(payload);
if (items.length === 0) throw new Error(`${tag} 无有效结果`);
return items;
}
async searchWithS5Api(keyword) {
const uid = createHanjutvUid();
const headers = await createHanjutvSearchHeaders(uid);
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,
timeout: 10000,
retries: 1,
});
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: {
"Content-Type": "application/json",
"User-Agent": "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",
},
headers: this.getWebHeaders(),
timeout: 10000,
retries: 1,
});
@@ -213,37 +259,13 @@ export default class HanjutvSource extends BaseSource {
async searchWithTvApi(keyword) {
const q = encodeURIComponent(keyword);
const makeHeaders = await buildLiteHeaders(Date.now());
const headerInfo = await makeHeaders(Date.now());
const headers = headerInfo.headers;
const headerInfo = await this.buildTvHeaders();
const resp = await httpGet(`https://api.xiawen.tv/api/v1/aggregate/search?key=${q}&scope=101&page=1`, {
headers,
headers: headerInfo.headers,
timeout: 10000,
retries: 1,
});
const payload = resp?.data;
if (!payload || typeof payload !== "object") {
throw new Error("tv 响应为空");
}
if (typeof payload.data === "string" && payload.data.length > 0) {
let decoded;
try {
decoded = await decodeHanjutvEncryptedPayload(payload, headerInfo.uid);
} catch (error) {
throw new Error(`tv 响应解密失败: ${error.message}`);
}
const items = this.extractSearchItems(decoded);
if (items.length === 0) throw new Error("tv 解密后无有效结果");
return items;
}
const plainItems = this.extractSearchItems(payload);
if (plainItems.length === 0) throw new Error("tv 无有效结果");
return plainItems;
return this.extractFromPayload(resp?.data, headerInfo.uid, "tv");
}
async search(keyword) {
@@ -251,46 +273,21 @@ export default class HanjutvSource extends BaseSource {
const key = String(keyword || "").trim();
if (!key) return [];
let s5List = [];
// s5 → legacy → tv 三级降级搜索
let s5List = await this.tryGet(() => this.searchWithS5Api(key), [], `[Hanjutv] s5 搜索失败,降级旧接口`);
let webList = [];
let tvList = [];
let s5Error = null;
let webError = null;
try {
s5List = await this.searchWithS5Api(key);
} catch (error) {
s5Error = error;
log("warn", `[Hanjutv] s5 搜索失败,降级旧接口: ${error.message}`);
}
const needFallback = (list) => list.length === 0 || this.countMatchedItems(list, key) === 0;
const s5MatchedCount = this.countMatchedItems(s5List, key);
const needLegacySearch = s5List.length === 0 || s5MatchedCount === 0;
if (needLegacySearch) {
if (!s5Error && s5List.length > 0 && s5MatchedCount === 0) {
log("warn", `[Hanjutv] s5 返回 ${s5List.length} 条但标题零命中,触发 legacy 补偿检索`);
}
try {
webList = await this.searchWithLegacyApi(key);
} catch (error) {
webError = error;
log("warn", `[Hanjutv] 旧搜索接口失败: ${error.message}`);
}
if (needFallback(s5List)) {
if (s5List.length > 0) log("warn", `[Hanjutv] s5 返回 ${s5List.length} 条但标题零命中,触发 legacy 补偿检索`);
webList = await this.tryGet(() => this.searchWithLegacyApi(key), [], `[Hanjutv] 旧搜索接口失败`);
const webMatchedCount = this.countMatchedItems(webList, key);
const needTvSearch = webList.length === 0 || webMatchedCount === 0;
if (needTvSearch) {
if (!webError && webList.length > 0 && webMatchedCount === 0) {
log("warn", `[Hanjutv] web 返回 ${webList.length} 条但标题零命中,触发 TV 补偿检索`);
}
// 如果web接口也失败,则尝试TV端接口作为最终降级方案
try {
log("info", `[Hanjutv] 尝试TV端搜索接口作为降级方案`);
tvList = await this.searchWithTvApi(key);
} catch (tvError) {
log("warn", `[Hanjutv] TV端搜索接口也失败: ${tvError.message}`);
}
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端搜索接口也失败`);
}
}
@@ -301,86 +298,46 @@ export default class HanjutvSource extends BaseSource {
return [];
}
log("info", `[Hanjutv] 搜索候选统计 s5MatchedList=${JSON.stringify(stats.s5MatchedList)}, s5UnmatchedList=${JSON.stringify(stats.s5UnmatchedList)}, webMatchedList=${JSON.stringify(stats.webMatchedList)}, webMatchedList=${JSON.stringify(stats.webUnmatchedList)}`);
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})`);
log("info", `[Hanjutv] 搜索找到 ${resultList.length} 个有效结果`);
return resultList.map((anime) => {
const animeId = convertToAsciiSum(anime.sid);
return { ...anime, animeId };
});
return resultList.map((anime) => ({ ...anime, animeId: convertToAsciiSum(anime.sid) }));
} catch (error) {
log("error", "getHanjutvAnimes error:", {
message: error.message,
name: error.name,
stack: error.stack,
});
this.logError("getHanjutvAnimes error", error);
return [];
}
}
// ── 详情 & 剧集 ──────────────────────────────────────────────
async getDetail(id) {
try {
const sid = String(id || "").trim();
if (!sid) return [];
let detail = null;
try {
const appResp = await httpGet(`${this.appHost}/api/series/detail?sid=${sid}`, {
headers: this.getAppHeaders(),
timeout: 10000,
retries: 1,
});
detail = appResp?.data?.series || null;
} catch {
}
if (!detail) {
try {
const webResp = await httpGet(`${this.webHost}/wapi/series/series/detail?sid=${sid}`, {
headers: this.getWebHeaders(),
timeout: 10000,
retries: 1,
});
detail = webResp?.data?.series || null;
} catch {
}
}
if (!detail) {
const makeHeaders = await buildLiteHeaders(Date.now());
const headerInfo = await makeHeaders(Date.now());
const headers = headerInfo.headers;
try {
const tvResp = await httpGet(`${this.tvHost}/api/v1/series/detail/query?sid=${sid}`, {
headers: headers,
timeout: 10000,
retries: 1,
});
const decoded = await decodeHanjutvEncryptedPayload(tvResp?.data, headerInfo.uid);
detail = decoded?.series || null;
} catch (error) {
log("error", "getHanjutvDetail error:", {
message: error.message,
name: error.name,
stack: error.stack,
});
}
}
// 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 [];
}
return detail;
} catch (error) {
log("error", "getHanjutvDetail error:", {
message: error.message,
name: error.name,
stack: error.stack,
});
this.logError("getHanjutvDetail error", error);
return [];
}
}
@@ -390,81 +347,39 @@ export default class HanjutvSource extends BaseSource {
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 : []);
},
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 || []);
},
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 : []),
]);
},
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 = [];
try {
const detailResp = await httpGet(`${this.appHost}/api/series/detail?sid=${sid}`, {
headers: this.getAppHeaders(),
timeout: 10000,
retries: 1,
});
const detailData = detailResp?.data;
const playItems = Array.isArray(detailData?.playItems) ? detailData.playItems : [];
episodes = this.normalizeEpisodes(playItems);
} catch {
}
if (episodes.length === 0) {
try {
const epResp = await httpGet(`${this.appHost}/api/series2/episodes?sid=${sid}&refer=${encodeURIComponent(this.defaultRefer)}`, {
headers: this.getAppHeaders(),
timeout: 10000,
retries: 1,
});
const epData = epResp?.data;
episodes = this.normalizeEpisodes(epData?.programs || epData?.episodes || epData?.qxkPrograms || []);
} catch {
}
}
if (episodes.length === 0) {
try {
const pResp = await httpGet(`${this.appHost}/api/series/programs_v2?sid=${sid}`, {
headers: this.getAppHeaders(),
timeout: 10000,
retries: 1,
});
const pData = pResp?.data;
const programs = [
...(Array.isArray(pData?.programs) ? pData.programs : []),
...(Array.isArray(pData?.qxkPrograms) ? pData.qxkPrograms : []),
];
episodes = this.normalizeEpisodes(programs);
} catch {
}
}
if (episodes.length === 0) {
try {
const webResp = await httpGet(`${this.webHost}/wapi/series/series/detail?sid=${sid}`, {
headers: this.getWebHeaders(),
timeout: 10000,
retries: 1,
});
episodes = this.normalizeEpisodes(webResp?.data?.episodes || []);
} catch {
}
}
if (episodes.length === 0) {
const makeHeaders = await buildLiteHeaders(Date.now());
const headerInfo = await makeHeaders(Date.now());
const headers = headerInfo.headers;
try {
const tvResp = await httpGet(`${this.tvHost}/api/v1/series/detail/query?sid=${sid}`, {
headers: headers,
timeout: 10000,
retries: 1,
});
const decoded = await decodeHanjutvEncryptedPayload(tvResp?.data, headerInfo.uid);
episodes = this.normalizeEpisodes(decoded?.episodes || []);
} catch {
log("error", "getHanjutvDetail episodes:", {
message: error.message,
name: error.name,
stack: error.stack,
});
}
for (const attempt of attempts) {
if (episodes.length > 0) break;
episodes = await this.tryGet(attempt, []);
}
if (episodes.length === 0) {
@@ -474,56 +389,50 @@ export default class HanjutvSource extends BaseSource {
return episodes.sort((a, b) => a.serialNo - b.serialNo);
} catch (error) {
log("error", "getHanjutvEposides error:", {
message: error.message,
name: error.name,
stack: error.stack,
});
this.logError("getHanjutvEposides error", error);
return [];
}
}
// ── 番剧处理 ─────────────────────────────────────────────────
async handleAnimes(sourceAnimes, queryTitle, curAnimes) {
const cateMap = {1: "韩剧", 2: "综艺", 3: "电影", 4: "日剧", 5: "美剧", 6: "泰剧", 7: "国产剧"}
function getCategory(key) {
return cateMap[key] || "其他";
}
const tmpAnimes = [];
// 添加错误处理,确保sourceAnimes是数组
if (!sourceAnimes || !Array.isArray(sourceAnimes)) {
if (!Array.isArray(sourceAnimes)) {
log("error", "[Hanjutv] sourceAnimes is not a valid array");
return [];
}
// 使用 map 和 async 时需要返回 Promise 数组,并等待所有 Promise 完成
const processHanjutvAnimes = await Promise.all(sourceAnimes
.filter(s => titleMatches(s.name, queryTitle))
.map(async (anime) => {
try {
const detail = await this.getDetail(anime.sid);
const eps = await this.getEpisodes(anime.sid);
let links = [];
for (const ep of eps) {
const epTitle = ep.title && ep.title.trim() !== "" ? `第${ep.serialNo}集:${ep.title}` : `第${ep.serialNo}集`;
links.push({
"name": epTitle,
"url": ep.pid,
"title": `【hanjutv】 ${epTitle}`
});
}
const tmpAnimes = [];
if (links.length > 0) {
let transformedAnime = {
const processHanjutvAnimes = 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 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}(${new Date(anime.updateTime).getFullYear()})【${getCategory(detail.category)}】from hanjutv`,
type: getCategory(detail.category),
typeDescription: getCategory(detail.category),
animeTitle: `${anime.name}(${year})【${category}】from hanjutv`,
type: category,
typeDescription: category,
imageUrl: anime.image.thumb,
startDate: generateValidStartDate(new Date(anime.updateTime).getFullYear()),
startDate: generateValidStartDate(year),
episodeCount: links.length,
rating: detail.rank,
isFavorited: true,
@@ -531,116 +440,81 @@ export default class HanjutvSource extends BaseSource {
};
tmpAnimes.push(transformedAnime);
addAnime({...transformedAnime, links: links});
addAnime({ ...transformedAnime, links });
if (globals.animes.length > globals.MAX_ANIMES) removeEarliestAnime();
} catch (error) {
log("error", `[Hanjutv] Error processing anime: ${error.message}`);
}
} catch (error) {
log("error", `[Hanjutv] Error processing anime: ${error.message}`);
}
})
})
);
this.sortAndPushAnimesByYear(tmpAnimes, curAnimes);
return processHanjutvAnimes;
}
// ── 弹幕 ─────────────────────────────────────────────────────
async getEpisodeDanmu(id) {
let allDanmus = [];
let prevId = 0;
let fromAxis = 0;
let pageCount = 0;
const maxAxis = 100000000;
const maxPages = 120;
try {
while (fromAxis < maxAxis) {
const resp = await httpGet(`https://hxqapi.zmdcq.com/api/danmu/playItem/list?fromAxis=${fromAxis}&pid=${id}&toAxis=${maxAxis}`, {
headers: {
"Content-Type": "application/json",
"User-Agent": "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",
},
// 尝试旧弹幕接口(分页轮询)
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,
});
// 将当前请求的 episodes 拼接到总数组
if (resp.data && resp.data.danmus) {
allDanmus.push(...resp.data.danmus);
}
if (resp?.data?.danmus) allDanmus.push(...resp.data.danmus);
// 获取 nextAxis,更新 fromAxis
const nextAxis = resp.data.nextAxis || maxAxis;
if (nextAxis >= maxAxis) {
break; // 如果 nextAxis 达到或超过最大值,退出循环
}
if (nextAxis <= fromAxis) {
break; // 如果 nextAxis 未前进,退出循环,避免卡死
}
const nextAxis = resp?.data?.nextAxis ?? MAX_AXIS;
if (nextAxis >= MAX_AXIS || nextAxis <= fromAxis) break;
fromAxis = nextAxis;
}
// 若旧接口无数据,降级到 TV 端弹幕接口
if (allDanmus.length === 0) {
while (fromAxis < maxAxis && pageCount < maxPages) {
const makeHeaders = await buildLiteHeaders(Date.now());
const headerInfo = await makeHeaders(Date.now());
const headers = headerInfo.headers;
let prevId = 0;
fromAxis = 0;
let pageCount = 0;
const maxPages = 120;
while (fromAxis < MAX_AXIS && pageCount < maxPages) {
try {
const tvResp = await httpGet(`${this.tvHost}/api/v1/bulletchat/episode/get?eid=${id}&prevId=${prevId}&fromAxis=${fromAxis}&toAxis=${maxAxis}&offset=0`, {
headers: headers,
timeout: 10000,
retries: 1,
});
const data = await decodeHanjutvEncryptedPayload(tvResp?.data, headerInfo.uid);
pageCount += 1;
const data = await this.tvGet(`/api/v1/bulletchat/episode/get?eid=${id}&prevId=${prevId}&fromAxis=${fromAxis}&toAxis=${MAX_AXIS}&offset=0`);
pageCount++;
allDanmus.push(...data.bulletchats);
const hasMore = Number(data.more ?? 0) === 1 || data.more === true || data.more === "1";
const nextAxis = Number(data.nextAxis ?? maxAxis);
const nextAxis = Number(data.nextAxis ?? MAX_AXIS);
const lastId = Number(data.lastId ?? prevId);
if (Number.isFinite(lastId) && lastId > prevId) prevId = lastId;
if (!Number.isFinite(nextAxis) || nextAxis <= fromAxis) break;
if (nextAxis >= maxAxis) break;
if (!Number.isFinite(nextAxis) || nextAxis <= fromAxis || nextAxis >= MAX_AXIS) break;
fromAxis = nextAxis;
if (!hasMore) prevId = 0;
} catch {
log("error", "fetchHanjutvEpisodeDanmu:", {
message: error.message,
name: error.name,
stack: error.stack,
});
} catch (error) {
this.logError("fetchHanjutvEpisodeDanmu", error);
break;
}
}
}
return allDanmus;
} catch (error) {
// 捕获请求中的错误
log("error", "fetchHanjutvEpisodeDanmu error:", {
message: error.message,
name: error.name,
stack: error.stack,
});
return allDanmus; // 返回已收集的 episodes
this.logError("fetchHanjutvEpisodeDanmu error", error);
}
return allDanmus;
}
async getEpisodeDanmuSegments(id) {
log("info", "获取韩剧TV弹幕分段列表...", id);
return new SegmentListResponse({
"type": "hanjutv",
"segmentList": [{
"type": "hanjutv",
"segment_start": 0,
"segment_end": 30000,
"url": id
}]
type: "hanjutv",
segmentList: [{ type: "hanjutv", segment_start: 0, segment_end: 30000, url: id }],
});
}
@@ -654,7 +528,7 @@ export default class HanjutvSource extends BaseSource {
p: `${(c.t / 1000).toFixed(2)},${c.tp === 2 ? 5 : c.tp},${Number(c.sc)},[hanjutv]`,
m: c.con,
t: Math.round(c.t / 1000),
like: c.lc
like: c.lc,
}));
}
}
}
+5 -5
View File
@@ -335,12 +335,12 @@ function rotWord(word){
}
// 字节代换
function subWord(word){
export function subWord(word){
return Uint8Array.from(word.map(b=>SBOX[b]));
}
// 扩展密钥 16 字节 -> 176 字节
function keyExpansion(key) {
export function keyExpansion(key) {
const Nk = 4, Nb=4, Nr=10;
const w = new Array(Nb*(Nr+1));
for(let i=0;i<Nk;i++){
@@ -372,7 +372,7 @@ function aesDecryptBlock(input, w) {
}
// AES 辅助函数
function addRoundKey(state, w){
export function addRoundKey(state, w){
const out = new Uint8Array(16);
for(let c=0;c<4;c++)
for(let r=0;r<4;r++)
@@ -380,13 +380,13 @@ function addRoundKey(state, w){
return out;
}
function invSubBytes(state){
export function invSubBytes(state){
const INV_SBOX = new Array(256);
for(let i=0;i<256;i++) INV_SBOX[SBOX[i]]=i;
return Uint8Array.from(state.map(b=>INV_SBOX[b]));
}
function invShiftRows(state){
export function invShiftRows(state){
const out = new Uint8Array(16);
for(let r=0;r<4;r++)
for(let c=0;c<4;c++)
+3 -77
View File
@@ -1,4 +1,4 @@
import { md5, stringToUtf8Bytes, utf8BytesToString, bytesToBase64, base64ToBytes } from "./codec-util.js";
import { md5, stringToUtf8Bytes, utf8BytesToString, bytesToBase64, base64ToBytes, invSubBytes, subWord, keyExpansion, invShiftRows } from "./codec-util.js";
// 移动端参数
const HANJUTV_VERSION = "6.5.3";
@@ -15,33 +15,6 @@ const UK_IV = "d3w8hf94fidk38lk";
const RESPONSE_SECRET = "34F9Q53w/HJW8E6Q";
const SAID = "fb3597b87601d5a7";
const SBOX = [
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16,
];
const INV_SBOX = (() => {
const table = new Uint8Array(256);
for (let i = 0; i < 256; i++) table[SBOX[i]] = i;
return table;
})();
const RCON = [0x00, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36];
function utf8Encode(text) {
if (typeof TextEncoder !== "undefined") return new TextEncoder().encode(text);
return stringToUtf8Bytes(text);
@@ -71,35 +44,6 @@ function stripControlChars(text) {
return text.replace(/[\u0000-\u001f\u007f-\u009f]/g, "");
}
function rotWord(word) {
return Uint8Array.from([word[1], word[2], word[3], word[0]]);
}
function subWord(word) {
return Uint8Array.from(word.map((b) => SBOX[b]));
}
function keyExpansion(key) {
const Nk = 4;
const Nb = 4;
const Nr = 10;
const w = new Array(Nb * (Nr + 1));
for (let i = 0; i < Nk; i++) {
w[i] = key.slice(4 * i, 4 * i + 4);
}
for (let i = Nk; i < Nb * (Nr + 1); i++) {
let temp = w[i - 1];
if (i % Nk === 0) {
temp = xorBytes(subWord(rotWord(temp)), Uint8Array.from([RCON[i / Nk], 0, 0, 0]));
}
w[i] = xorBytes(w[i - Nk], temp);
}
return w;
}
function addRoundKey(state, w, round) {
const out = new Uint8Array(16);
for (let c = 0; c < 4; c++) {
@@ -110,14 +54,6 @@ function addRoundKey(state, w, round) {
return out;
}
function subBytes(state) {
return Uint8Array.from(state.map((b) => SBOX[b]));
}
function invSubBytes(state) {
return Uint8Array.from(state.map((b) => INV_SBOX[b]));
}
function shiftRows(state) {
const out = new Uint8Array(16);
for (let r = 0; r < 4; r++) {
@@ -128,16 +64,6 @@ function shiftRows(state) {
return out;
}
function invShiftRows(state) {
const out = new Uint8Array(16);
for (let r = 0; r < 4; r++) {
for (let c = 0; c < 4; c++) {
out[r + 4 * c] = state[r + 4 * ((c - r + 4) % 4)];
}
}
return out;
}
function gfMul(a, b) {
let p = 0;
let aa = a;
@@ -181,13 +107,13 @@ function aesEncryptBlock(input, w) {
state = addRoundKey(state, w, 0);
for (let round = 1; round <= 9; round++) {
state = subBytes(state);
state = subWord(state);
state = shiftRows(state);
state = mixColumns(state);
state = addRoundKey(state, w, round);
}
state = subBytes(state);
state = subWord(state);
state = shiftRows(state);
state = addRoundKey(state, w, 10);
return state;