为dandan实时拉取功能支持related的合并分P格式 (#256)

* 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
This commit is contained in:
Elegy233 authored and GitHub committed 2026-03-27 23:46:38 +08:00
1 parent df9696515b
commit a5fb406596
4 files changed
+154 -39

No files matched your search

+2 -2
View File
@@ -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);
}
});
});
+117 -33
View File
@@ -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; // 标记是否为"无结果"中断
+21 -2
View File
@@ -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) {
+14 -2
View File
@@ -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(/\?.*/, '');
};
// 遍历其他来源进行对齐