From a5fb406596100ab9ec77666427da22928b160f30 Mon Sep 17 00:00:00 2001 From: Elegy233 <82034747+wan0ge@users.noreply.github.com> Date: Fri, 27 Mar 2026 23:46:38 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=BAdandan=E5=AE=9E=E6=97=B6=E6=8B=89?= =?UTF-8?q?=E5=8F=96=E5=8A=9F=E8=83=BD=E6=94=AF=E6=8C=81related=E7=9A=84?= =?UTF-8?q?=E5=90=88=E5=B9=B6=E5=88=86P=E6=A0=BC=E5=BC=8F=20(#256)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * bilibili.js:处理合并分P链接格式 https://www.bilibili.com/combine?cid32908181970=0-583&cid32908250608=0-378&cid32908314947=0-440 * dandan.js:修复实时拉取时b站分p参数被截断,并放行合并分P格式 * merge-util.js:同步更新dandan related 接口对接相关函数 * globals.js:1.17.7 --- danmu_api/configs/globals.js | 4 +- danmu_api/sources/bilibili.js | 150 ++++++++++++++++++++++++++-------- danmu_api/sources/dandan.js | 23 +++++- danmu_api/utils/merge-util.js | 16 +++- 4 files changed, 154 insertions(+), 39 deletions(-) diff --git a/danmu_api/configs/globals.js b/danmu_api/configs/globals.js index ed72fd0..d75d2ec 100644 --- a/danmu_api/configs/globals.js +++ b/danmu_api/configs/globals.js @@ -13,7 +13,7 @@ export const Globals = { accessedEnvVars: {}, // 静态常量 - VERSION: '1.17.6', + VERSION: '1.17.7', MAX_LOGS: 1000, // 日志存储,最多保存 1000 行 MAX_ANIMES: 100, MAX_RECORDS: 100, // 请求记录最大数量 @@ -211,4 +211,4 @@ export const globals = new Proxy({}, { getOwnPropertyDescriptor(target, prop) { return Object.getOwnPropertyDescriptor(Globals.getConfig(), prop); } -}); \ No newline at end of file +}); diff --git a/danmu_api/sources/bilibili.js b/danmu_api/sources/bilibili.js index 16ded42..d50e565 100644 --- a/danmu_api/sources/bilibili.js +++ b/danmu_api/sources/bilibili.js @@ -249,34 +249,34 @@ export default class BilibiliSource extends BaseSource { */ _extractMediaType(seasonTypeName) { const typeName = (seasonTypeName || "").toLowerCase(); - + // 电影类型 if (typeName.includes("电影") || typeName.includes("movie")) { return "电影"; } - + // 动漫类型(包括番剧和国创) if (typeName.includes("番剧") || typeName.includes("国创") || typeName.includes("动漫") || typeName.includes("anime")) { return "动漫"; } - + // 纪录片类型 if (typeName.includes("纪录片") || typeName.includes("documentary")) { return "纪录片"; } - + // 综艺类型 if (typeName.includes("综艺") || typeName.includes("variety")) { return "综艺"; } - + // 电视剧类型 if (typeName.includes("电视剧") || typeName.includes("剧集") || typeName.includes("drama") || typeName.includes("tv")) { return "电视剧"; } - + // 默认返回电视剧(最常见的类型) return "电视剧"; } @@ -339,9 +339,9 @@ export default class BilibiliSource extends BaseSource { "Cookie": globals.bilibliCookie || "" } }); - + const data = typeof response.data === "string" ? JSON.parse(response.data) : response.data; - + if (data.code === 0 && data.result) { // 优先从 main_section 获取分集,兼容 view 和 section 接口 rawEpisodes = data.result.main_section?.episodes || data.result.episodes || []; @@ -471,13 +471,13 @@ export default class BilibiliSource extends BaseSource { const epIndex = ep.title || ep.index_title || realVal; const longTitle = ep.long_title || ""; - + let displayTitle = /^\d+(\.\d+)?$/.test(epIndex) ? `第${epIndex}话` : epIndex; if (longTitle && longTitle !== epIndex) displayTitle += ` ${longTitle}`; const epId = ep.id || ep.param; let linkUrl = `https://www.bilibili.com/bangumi/play/ep${epId}?season_id=${anime.mediaId.substring(2)}`; - + // 传递区域标记,供后续提取视频信息使用 if (anime.isOversea) linkUrl += "&area=hkmt"; @@ -518,7 +518,7 @@ export default class BilibiliSource extends BaseSource { if (links.length === 0) return; const numericAnimeId = convertToAsciiSum(anime.mediaId); - + // 优先使用tmdb智能标题替换的标题,否则对原标题进行繁转简处理 const displayTitle = anime._displayTitle || simplized(anime.title); @@ -628,7 +628,7 @@ export default class BilibiliSource extends BaseSource { } else if (id.includes("bangumi/") && id.includes("ep")) { try { const epid = path.slice(-1)[0].slice(2); - + // 解析特殊参数:season_id 和 area 标记 const urlParams = id.split('?')[1] || ""; let seasonId = null, isOversea = false; @@ -637,7 +637,7 @@ export default class BilibiliSource extends BaseSource { if (k === 'season_id') seasonId = v; if (k === 'area' && v === 'hkmt') isOversea = true; }); - + let success = false; // 轨道一:直连模式 (非港澳台标记) @@ -664,7 +664,7 @@ export default class BilibiliSource extends BaseSource { if (ep) { cid = ep.cid; aid = ep.aid; duration = ep.duration / 1000; title = ep.long_title; success = true; } } } catch(e) {} - + // 尝试 Section 接口 (直连回退) if (!success) { try { @@ -756,14 +756,14 @@ export default class BilibiliSource extends BaseSource { // 分批并发请求,防止请求过多 const BATCH_SIZE = 6; let contents = []; - + for (let i = 0; i < segmentList.length; i += BATCH_SIZE) { const batch = segmentList.slice(i, i + BATCH_SIZE); const promises = batch.map(segment => this.getEpisodeSegmentDanmu(segment).then(d => ({status: 'ok', value: d})).catch(e => ({status: 'err', error: e}))); - + const results = await Promise.all(promises); let stop = false; - + for (const res of results) { if (res.status === 'ok' && res.value) { contents.push(...res.value); @@ -779,9 +779,55 @@ export default class BilibiliSource extends BaseSource { return contents; } + /** + * 获取视频的分段信息,支持解析常规视频(/video/BV)与合并分P请求(/combine?cid) + * 对于合并分P,将其拆解为标准分段任务队列并注入时间轴平移元数据 + */ async getEpisodeDanmuSegments(id) { log("info", "获取B站弹幕分段列表...", id); + // 解析合并分P请求,直接转化为标准分段列表返回,由后续并发池统一处理 + if (typeof id === 'string' && id.includes('/combine?')) { + const segmentList = []; + let currentOffset = 0; + let totalDuration = 0; + const urlStr = id.startsWith('http') ? id : `https://www.bilibili.com${id}`; + const urlObj = new URL(urlStr); + + for (const [key, value] of urlObj.searchParams.entries()) { + if (!key.startsWith('cid')) continue; + const cid = key.substring(3); + const [startStr, endStr] = value.split('-'); + const start = parseFloat(startStr) || 0; + const end = parseFloat(endStr) || 0; + const duration = end - start; + + if (duration <= 0) continue; + + totalDuration += duration; + const maxLen = Math.ceil(end / 360); + + for (let i = 0; i < maxLen; i++) { + // 采用 URL Hash 携带平移元数据,穿透防污染的模型序列化屏障 + const metadataHash = `#combine_start=${start}&combine_end=${end}&combine_offset=${currentOffset}`; + + segmentList.push({ + "type": "bilibili1", + "segment_start": i * 360, + "segment_end": Math.min((i + 1) * 360, end), + "url": `https://api.bilibili.com/x/v2/dm/web/seg.so?type=1&oid=${cid}&segment_index=${i + 1}${metadataHash}` + }); + } + currentOffset += duration; // 为下一个 CID 累积偏移时间 + } + + return new SegmentListResponse({ + "type": "bilibili1", + "duration": totalDuration, + "segmentList": segmentList + }); + } + // 提取视频信息 const videoInfo = await this._extractVideoInfo(id); if (!videoInfo) { @@ -827,9 +873,17 @@ export default class BilibiliSource extends BaseSource { }); } + /** + * 获取单段弹幕数据 + * 包含就地拦截元数据并进行时间轴平移与截取的能力 + */ async getEpisodeSegmentDanmu(segment) { try { - const response = await httpGet(segment.url, { + // 提取被附加到 URL hash 中的元数据 + const urlObj = new URL(segment.url); + const rawUrl = segment.url.split('#')[0]; + + const response = await httpGet(rawUrl, { headers: { "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", "Cookie": globals.bilibliCookie @@ -844,6 +898,36 @@ export default class BilibiliSource extends BaseSource { contents = parseDanmakuBase64(response.data); } + // 读取 URL Hash 中注入的元数据,就地执行区间截取与无缝时间轴拼接 + if (urlObj.hash && urlObj.hash.includes('combine_offset')) { + const hashParams = new URLSearchParams(urlObj.hash.substring(1)); + const start = parseFloat(hashParams.get('combine_start')) || 0; + const end = parseFloat(hashParams.get('combine_end')) || 0; + const offset = parseFloat(hashParams.get('combine_offset')) || 0; + + const filtered = []; + + for (const c of contents) { + let time = 0; + if (c.p && typeof c.p === 'string') time = parseFloat(c.p.split(',')[0]); + else if (c.t !== undefined) time = Number(c.t); + else if (c.progress !== undefined) time = c.progress / 1000; + + if (!isNaN(time) && time >= start && time <= end) { + const shiftedTime = (time - start) + offset; + if (c.p && typeof c.p === 'string') { + const parts = c.p.split(','); + parts[0] = shiftedTime.toFixed(5); + c.p = parts.join(','); + } + if (c.t !== undefined) c.t = shiftedTime; + if (c.progress !== undefined) c.progress = Math.round(shiftedTime * 1000); + filtered.push(c); + } + } + return filtered; + } + return contents; } catch (error) { // 抛出错误以触发外层的熔断机制 @@ -899,12 +983,12 @@ export default class BilibiliSource extends BaseSource { const params = { keyword, type: appType, area: 'tw', mobi_app: 'android', platform: 'android', build: '8140200', ts: Math.floor(Date.now()/1000), appkey: BilibiliSource.APP_KEY, access_key: akMatch[1], disable_rcmd: 1 }; const qs = Object.keys(params).sort().map(k => `${k}=${this._javaUrlEncode(String(params[k]))}`).join('&'); const sign = md5(qs + BilibiliSource.APP_SEC); - + const target = `https://app.bilibili.com/x/v2/search/type?${qs}&sign=${sign}`; const url = globals.makeProxyUrl(target); - + const data = await this._fetchAppSearchWithStream(url, { "User-Agent": "Mozilla/5.0 Android", "X-From-Biliroaming": "1.0.0" }, label, signal); - + if (data && data.code === 0) { // 兼容 items (影视/综艺) 和 result (番剧) 两种字段结构,提取返回的 org_title 字段 return (data.data?.items || data.data?.result || data.data || []) @@ -937,16 +1021,16 @@ export default class BilibiliSource extends BaseSource { try { const params = { keyword, search_type: webSearchType, area: 'tw', page: 1, order: 'totalrank', __refresh__: true, _timestamp: Date.now() }; const qs = Object.keys(params).map(k => `${encodeURIComponent(k)}=${encodeURIComponent(params[k])}`).join('&'); - + const target = `https://api.bilibili.com/x/web-interface/search/type?${qs}`; const url = globals.makeProxyUrl(target); - + const res = await httpGet(url, { headers: { "User-Agent": "Mozilla/5.0", "Cookie": globals.bilibliCookie||"", "X-From-Biliroaming": "1.0.0" }, signal: signal }); const data = typeof res.data==="string"?JSON.parse(res.data):res.data; - + if (data.code !== 0) { log("warn", `[Bilibili-Proxy] Web 接口返回错误 Code ${data.code}: ${data.message}`); return []; @@ -970,13 +1054,13 @@ export default class BilibiliSource extends BaseSource { // 综合港澳台搜索入口 async _searchOversea(keyword) { const tmdbAbortController = new AbortController(); - + // 定义搜索配置:同时搜索番剧(App:7, Web:media_bangumi)和影视(App:8, Web:media_ft) const searchConfigs = [ { appType: 7, webType: 'media_bangumi' }, { appType: 8, webType: 'media_ft' } ]; - + // 1. 原始关键词搜索 (并发执行所有类型,增加间隔延迟) const t1 = Promise.all(searchConfigs.map(async (conf, index) => { if (index > 0) await new Promise(r => setTimeout(r, index * 300)); // 错峰请求避免风控 @@ -988,23 +1072,23 @@ export default class BilibiliSource extends BaseSource { flatResults.forEach(i => i._originalQuery = keyword); return flatResults; }).catch(()=>[]); - + // 2. TMDB 辅助搜索 const t2 = globals.tmdbApiKey ? (new Promise(r=>setTimeout(r,100)).then(async ()=>{ // 获取 TMDB 原名及别名 const tmdbResult = await getTmdbJaOriginalTitle(keyword, tmdbAbortController.signal, "Bilibili"); - + if (tmdbResult && tmdbResult.title && tmdbResult.title !== keyword) { const { title: tmdbTitle, cnAlias } = tmdbResult; - + // 使用日语原名进行并发搜索 (包含番剧和影视,增加间隔延迟) const tmdbPromises = searchConfigs.map(async (conf, index) => { if (index > 0) await new Promise(r => setTimeout(r, index * 300)); // 错峰请求避免风控 return this._searchOverseaRequest(tmdbTitle, conf.appType, conf.webType, "TMDB", tmdbAbortController.signal); }); - + const results = (await Promise.all(tmdbPromises)).flat(); - + // 注入上下文信息,包括别名 results.forEach(r => { r._originalQuery = keyword; @@ -1015,14 +1099,14 @@ export default class BilibiliSource extends BaseSource { } return []; }).catch(()=>[])) : Promise.resolve([]); - + return (await Promise.all([t1, t2])).flat(); } // APP搜索流式嗅探,针对 B 站港澳台无结果时返回的大体积推荐数据 async _fetchAppSearchWithStream(url, headers, label, signal) { if (typeof httpGetWithStreamCheck !== 'function') return null; - + let trusted = false; let isNoResult = false; // 标记是否为"无结果"中断 diff --git a/danmu_api/sources/dandan.js b/danmu_api/sources/dandan.js index 9c8a88a..98d1790 100644 --- a/danmu_api/sources/dandan.js +++ b/danmu_api/sources/dandan.js @@ -454,8 +454,27 @@ export default class DandanSource extends BaseSource { const match = targetStr.match(/sn=(\d+)/) || targetStr.match(/\d+$/); return match ? (match[1] || match[0]) : targetStr; } - // 常规平台逻辑:统一剥离 http/https协议、www.前缀,并截断 ? 后面的所有查询参数 - return targetStr.replace(/^https?:\/\/(www\.)?/, '').split('?')[0]; + + // 常规平台逻辑:统一剥离 http/https协议、www.前缀 + let core = targetStr.replace(/^https?:\/\/(www\.)?/, ''); + + // 保留 B 站分 P 和合并分 P 关键参数供合并工具精确匹配 + if (sName === 'bilibili' || sName === 'bilibili1') { + // 1. 如果路径包含 /combine,保留问号及后面所有的查询参数(剥离可能的 hash) + if (/\/combine\?/.test(core)) { + return core.replace(/#.*/, ''); + } + // 2. 如果包含 p= 参数,精准提取 p 参数并拼接到纯净路径后 + const pMatch = core.match(/\b(p=\d+)\b/); + core = core.replace(/\?.*/, ''); // 先截断常规查询参数 + if (pMatch) { + core += `?${pMatch[1]}`; + } + return core; + } + + // 常规平台:截断“?”后面的所有查询参数 + return core.replace(/\?.*/, ''); }; for (const rel of relatedResp.data.relateds) { diff --git a/danmu_api/utils/merge-util.js b/danmu_api/utils/merge-util.js index 3843548..2b3dbf0 100644 --- a/danmu_api/utils/merge-util.js +++ b/danmu_api/utils/merge-util.js @@ -2596,13 +2596,25 @@ export function alignSourceTimelines(results, sourceNames, realIds, dandanShifts return results; } - // 核心标识提取函数,保持与 dandan 源内部一致的去噪匹配逻辑 + // 核心标识提取函数:滤除干扰因素,提取唯一特征以供比对 const getCoreIdentifier = (targetStr, sName) => { if (sName === 'bahamut') { const match = targetStr.match(/sn=(\d+)/) || targetStr.match(/\d+$/); return match ? (match[1] || match[0]) : targetStr; } - return targetStr.replace(/^https?:\/\/(www\.)?/, '').split('?')[0]; + let core = targetStr.replace(/^https?:\/\/(www\.)?/, ''); + if (sName === 'bilibili' || sName === 'bilibili1') { + if (/\/combine\?/.test(core)) { + return core.replace(/#.*/, ''); + } + const pMatch = core.match(/\b(p=\d+)\b/); + core = core.replace(/\?.*/, ''); + if (pMatch) { + core += `?${pMatch[1]}`; + } + return core; + } + return core.replace(/\?.*/, ''); }; // 遍历其他来源进行对齐