为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:
1 parent
df9696515b
commit
a5fb406596
4 files changed
+154
-39
No files matched your search
@@ -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
@@ -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; // 标记是否为"无结果"中断
|
||||
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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(/\?.*/, '');
|
||||
};
|
||||
|
||||
// 遍历其他来源进行对齐
|
||||
|
||||
Reference in new issue
Block a user