diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index cc34706..c25884e 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -13,7 +13,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.15.4', + VERSION: '1.15.5', 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 fbae830..60088ca 100644 --- a/danmu_api/sources/hanjutv.js +++ b/danmu_api/sources/hanjutv.js @@ -7,7 +7,7 @@ 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 } from "../utils/hanjutv-util.js"; +import { createHanjutvUid, createHanjutvSearchHeaders, decodeHanjutvEncryptedPayload, buildLiteHeaders } from "../utils/hanjutv-util.js"; // ===================== // 获取韩剧TV弹幕 @@ -17,6 +17,7 @@ export default class HanjutvSource extends BaseSource { 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.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"; @@ -91,7 +92,7 @@ export default class HanjutvSource extends BaseSource { } extractSearchItems(data) { - const list = data?.seriesData?.seriesList || data?.seriesList || []; + const list = data?.seriesData?.seriesList || data?.seriesList || data?.seriesData?.series || []; return this.normalizeSearchItems(list); } @@ -210,6 +211,41 @@ export default class HanjutvSource extends BaseSource { return this.extractSearchItems(resp?.data); } + async searchWithTvApi(keyword) { + const q = encodeURIComponent(keyword); + const makeHeaders = await buildLiteHeaders(Date.now()); + const headerInfo = await makeHeaders(Date.now()); + const headers = headerInfo.headers; + + const resp = await httpGet(`https://api.xiawen.tv/api/v1/aggregate/search?key=${q}&scope=101&page=1`, { + 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; + } + async search(keyword) { try { const key = String(keyword || "").trim(); @@ -217,7 +253,9 @@ export default class HanjutvSource extends BaseSource { let s5List = []; let webList = []; + let tvList = []; let s5Error = null; + let webError = null; try { s5List = await this.searchWithS5Api(key); @@ -235,14 +273,31 @@ export default class HanjutvSource extends BaseSource { try { webList = await this.searchWithLegacyApi(key); } catch (error) { + webError = error; log("warn", `[Hanjutv] 旧搜索接口失败: ${error.message}`); } + + 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}`); + } + } } - const { resultList, stats } = this.mergeSearchCandidates(key, s5List, webList); + const { resultList, stats } = this.mergeSearchCandidates(key, s5List, [...webList, ...tvList]); if (resultList.length === 0) { - log("info", "hanjutvSearchresp: s5 与旧接口均无有效结果"); + log("info", "hanjutvSearchresp: s5、旧接口和TV端接口均无有效结果"); return []; } @@ -293,6 +348,27 @@ export default class HanjutvSource extends BaseSource { } } + 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, + }); + } + } + if (!detail) { log("info", "getHanjutvDetail: series 不存在"); return []; @@ -370,6 +446,27 @@ export default class HanjutvSource extends BaseSource { } } + 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, + }); + } + } + if (episodes.length === 0) { log("info", "getHanjutvEposides: episodes 不存在"); return []; @@ -452,8 +549,11 @@ export default class HanjutvSource extends BaseSource { async getEpisodeDanmu(id) { let allDanmus = []; + let prevId = 0; let fromAxis = 0; + let pageCount = 0; const maxAxis = 100000000; + const maxPages = 120; try { while (fromAxis < maxAxis) { @@ -481,6 +581,43 @@ export default class HanjutvSource extends BaseSource { fromAxis = nextAxis; } + 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; + 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; + + allDanmus.push(...data.bulletchats); + + const hasMore = Number(data.more ?? 0) === 1 || data.more === true || data.more === "1"; + const nextAxis = Number(data.nextAxis ?? maxAxis); + 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; + + fromAxis = nextAxis; + if (!hasMore) prevId = 0; + } catch { + log("error", "fetchHanjutvEpisodeDanmu:", { + message: error.message, + name: error.name, + stack: error.stack, + }); + } + } + } + return allDanmus; } catch (error) { // 捕获请求中的错误 diff --git a/danmu_api/utils/hanjutv-util.js b/danmu_api/utils/hanjutv-util.js index 0c69292..aafdf80 100644 --- a/danmu_api/utils/hanjutv-util.js +++ b/danmu_api/utils/hanjutv-util.js @@ -1,5 +1,6 @@ import { md5, stringToUtf8Bytes, utf8BytesToString, bytesToBase64, base64ToBytes } from "./codec-util.js"; +// 移动端参数 const HANJUTV_VERSION = "6.5.3"; const HANJUTV_VC = "a_7980"; const HANJUTV_UA = "HanjuTV/6.5.3 (Pixel 2 XL; Android 11; Scale/2.00)"; @@ -8,6 +9,12 @@ 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"; +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, @@ -305,6 +312,13 @@ export function createHanjutvUid(length = 20) { return uid; } +function randomFrom(chars, len) { + let s = ""; + for (let i = 0; i < len; i++) s += chars[Math.floor(Math.random() * chars.length)]; + return s; +} + +// 移动端headers export async function createHanjutvSearchHeaders(uid, timestamp = Date.now()) { const ts = Number(timestamp); const uidMd5 = md5(uid); @@ -327,6 +341,43 @@ export async function createHanjutvSearchHeaders(uid, timestamp = Date.now()) { }; } +// TV端headers +export async function buildLiteHeaders(sessionInitTs = Date.now()) { + const uid = randomFrom("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz", 20); + const oa = randomFrom("0123456789abcdef", 16); + + return async function makeHeaders(reqTs = Date.now()) { + const uidMd5 = md5(uid); + const rpPayload = JSON.stringify({ + emu: 0, ou: 0, it: sessionInitTs, iit: sessionInitTs, bs: 0, uid, + isp: "", pc: 0, tm: 50, d8m: "0,0,0,0,0,0,14,7", md: "23127PN0CC", + dn: "", osv: "16", br: 50, rpc: 0, scc: 1, plc: 1, toc: 5, tsc: 7, + ts: reqTs, nw: 2, px: "0", ai: SAID, oa, dpc: 0, dsc: 0, qpc: 0, apad: 0, + }); + + const di = await aesCbcEncryptToBase64(uid, UK_KEY, UK_IV); + const rp = await aesCbcEncryptToBase64(rpPayload, uidMd5.slice(0, 16), uidMd5.slice(16, 32)); + + return { + uid, + headers: { + version: "a_22570", + "version-name": "1.7.2", + channel: "xiaomi", + "app-type": "ztv", + "User-Agent": "ZTV/1.7.2 (23127PN0CC; Android 16; Scale/2.00)", + said: SAID, + di, + token: "", + uid: "", + rp, + "Accept-Encoding": "gzip", + Connection: "Keep-Alive", + }, + }; + }; +} + export async function decodeHanjutvEncryptedPayload(payload, uid = "") { if (!payload || typeof payload !== "object" || Array.isArray(payload)) return payload; if (typeof payload.data !== "string" || payload.data.length === 0) return payload;