diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index c25884e..36423d9 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -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, // 请求记录最大数量 diff --git a/danmu_api/sources/hanjutv.js b/danmu_api/sources/hanjutv.js index 60088ca..3337a1a 100644 --- a/danmu_api/sources/hanjutv.js +++ b/danmu_api/sources/hanjutv.js @@ -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, })); } -} +} \ No newline at end of file diff --git a/danmu_api/utils/codec-util.js b/danmu_api/utils/codec-util.js index 1756091..d0d6531 100644 --- a/danmu_api/utils/codec-util.js +++ b/danmu_api/utils/codec-util.js @@ -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;iINV_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++) diff --git a/danmu_api/utils/hanjutv-util.js b/danmu_api/utils/hanjutv-util.js index aafdf80..3621de8 100644 --- a/danmu_api/utils/hanjutv-util.js +++ b/danmu_api/utils/hanjutv-util.js @@ -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;