From 9a5e429dfd633717de597872789118a4d4ac7442 Mon Sep 17 00:00:00 2001 From: shaoxia Date: Sat, 9 May 2026 01:10:01 +0800 Subject: [PATCH 1/2] fix: improve bilibili danmaku download --- dist/bili-utils.user.js | 524 +++++++++++++++++++++++++++++++++------- src/bili.js | 488 +++++++++++++++++++++++++++++++------ vite.config.ts | 2 +- 3 files changed, 858 insertions(+), 156 deletions(-) diff --git a/dist/bili-utils.user.js b/dist/bili-utils.user.js index 327f5c3..65f28f9 100644 --- a/dist/bili-utils.user.js +++ b/dist/bili-utils.user.js @@ -1,7 +1,7 @@ // ==UserScript== // @name bilibili、腾讯视频弹幕下载 // @namespace https://github.com/LesslsMore/bili-utils -// @version 0.2.1 +// @version 0.2.2 // @author lesslsmore // @description bilibili、腾讯视频弹幕下载,支持各类视频弹幕下载,包括需要会员的视频以及需要大会员的番剧。B站使用分段Protobuf接口,下载全量弹幕。 // @license MIT @@ -17,12 +17,239 @@ 'use strict'; const DM_VIEW_API = "https://api.bilibili.com/x/v2/dm/web/view"; - const DM_SEG_API = "https://api.bilibili.com/x/v2/dm/web/seg.so"; + const DM_SEG_API = "https://api.bilibili.com/x/v2/dm/wbi/web/seg.so"; + const DM_SEG_FALLBACK_API = "https://api.bilibili.com/x/v2/dm/web/seg.so"; + const NAV_API = "https://api.bilibili.com/x/web-interface/nav"; + const VIDEO_VIEW_API = "https://api.bilibili.com/x/web-interface/view"; + const SEGMENT_SECONDS = 360; + const MIXIN_KEY_ENC_TAB = [ + 46, + 47, + 18, + 2, + 53, + 8, + 23, + 32, + 15, + 50, + 10, + 31, + 58, + 3, + 45, + 35, + 27, + 43, + 5, + 49, + 33, + 9, + 42, + 19, + 29, + 28, + 14, + 39, + 12, + 38, + 41, + 13, + 37, + 48, + 7, + 16, + 24, + 55, + 40, + 61, + 26, + 17, + 0, + 1, + 60, + 51, + 30, + 4, + 22, + 25, + 54, + 21, + 56, + 59, + 6, + 63, + 57, + 62, + 11, + 36, + 20, + 34, + 44, + 52 + ]; + let mixinKeyCache = ""; const setStatus = (onStatus, text, disabled = false) => { if (typeof onStatus === "function") { onStatus(text, disabled); } }; + function addUnsigned(x, y) { + return x + y >>> 0; + } + function rotateLeft(value, shift) { + return value << shift | value >>> 32 - shift; + } + function md5Round(func, a, b, c, d, x, shift, ac) { + return addUnsigned(rotateLeft(addUnsigned(addUnsigned(a, func(b, c, d)), addUnsigned(x, ac)), shift), b); + } + function md5(input) { + const bytes = new TextEncoder().encode(input); + const wordCount = ((bytes.length + 8 >>> 6) + 1) * 16; + const words = new Array(wordCount).fill(0); + for (let i = 0; i < bytes.length; i++) { + words[i >> 2] |= bytes[i] << i % 4 * 8; + } + words[bytes.length >> 2] |= 128 << bytes.length % 4 * 8; + words[wordCount - 2] = bytes.length * 8 >>> 0; + words[wordCount - 1] = Math.floor(bytes.length * 8 / 4294967296); + let a = 1732584193; + let b = 4023233417; + let c = 2562383102; + let d = 271733878; + const f = (x, y, z) => x & y | ~x & z; + const g = (x, y, z) => x & z | y & ~z; + const h = (x, y, z) => x ^ y ^ z; + const ii = (x, y, z) => y ^ (x | ~z); + const ff = (...args) => md5Round(f, ...args); + const gg = (...args) => md5Round(g, ...args); + const hh = (...args) => md5Round(h, ...args); + const iRound = (...args) => md5Round(ii, ...args); + for (let k = 0; k < words.length; k += 16) { + const aa = a; + const bb = b; + const cc = c; + const dd = d; + a = ff(a, b, c, d, words[k], 7, 3614090360); + d = ff(d, a, b, c, words[k + 1], 12, 3905402710); + c = ff(c, d, a, b, words[k + 2], 17, 606105819); + b = ff(b, c, d, a, words[k + 3], 22, 3250441966); + a = ff(a, b, c, d, words[k + 4], 7, 4118548399); + d = ff(d, a, b, c, words[k + 5], 12, 1200080426); + c = ff(c, d, a, b, words[k + 6], 17, 2821735955); + b = ff(b, c, d, a, words[k + 7], 22, 4249261313); + a = ff(a, b, c, d, words[k + 8], 7, 1770035416); + d = ff(d, a, b, c, words[k + 9], 12, 2336552879); + c = ff(c, d, a, b, words[k + 10], 17, 4294925233); + b = ff(b, c, d, a, words[k + 11], 22, 2304563134); + a = ff(a, b, c, d, words[k + 12], 7, 1804603682); + d = ff(d, a, b, c, words[k + 13], 12, 4254626195); + c = ff(c, d, a, b, words[k + 14], 17, 2792965006); + b = ff(b, c, d, a, words[k + 15], 22, 1236535329); + a = gg(a, b, c, d, words[k + 1], 5, 4129170786); + d = gg(d, a, b, c, words[k + 6], 9, 3225465664); + c = gg(c, d, a, b, words[k + 11], 14, 643717713); + b = gg(b, c, d, a, words[k], 20, 3921069994); + a = gg(a, b, c, d, words[k + 5], 5, 3593408605); + d = gg(d, a, b, c, words[k + 10], 9, 38016083); + c = gg(c, d, a, b, words[k + 15], 14, 3634488961); + b = gg(b, c, d, a, words[k + 4], 20, 3889429448); + a = gg(a, b, c, d, words[k + 9], 5, 568446438); + d = gg(d, a, b, c, words[k + 14], 9, 3275163606); + c = gg(c, d, a, b, words[k + 3], 14, 4107603335); + b = gg(b, c, d, a, words[k + 8], 20, 1163531501); + a = gg(a, b, c, d, words[k + 13], 5, 2850285829); + d = gg(d, a, b, c, words[k + 2], 9, 4243563512); + c = gg(c, d, a, b, words[k + 7], 14, 1735328473); + b = gg(b, c, d, a, words[k + 12], 20, 2368359562); + a = hh(a, b, c, d, words[k + 5], 4, 4294588738); + d = hh(d, a, b, c, words[k + 8], 11, 2272392833); + c = hh(c, d, a, b, words[k + 11], 16, 1839030562); + b = hh(b, c, d, a, words[k + 14], 23, 4259657740); + a = hh(a, b, c, d, words[k + 1], 4, 2763975236); + d = hh(d, a, b, c, words[k + 4], 11, 1272893353); + c = hh(c, d, a, b, words[k + 7], 16, 4139469664); + b = hh(b, c, d, a, words[k + 10], 23, 3200236656); + a = hh(a, b, c, d, words[k + 13], 4, 681279174); + d = hh(d, a, b, c, words[k], 11, 3936430074); + c = hh(c, d, a, b, words[k + 3], 16, 3572445317); + b = hh(b, c, d, a, words[k + 6], 23, 76029189); + a = hh(a, b, c, d, words[k + 9], 4, 3654602809); + d = hh(d, a, b, c, words[k + 12], 11, 3873151461); + c = hh(c, d, a, b, words[k + 15], 16, 530742520); + b = hh(b, c, d, a, words[k + 2], 23, 3299628645); + a = iRound(a, b, c, d, words[k], 6, 4096336452); + d = iRound(d, a, b, c, words[k + 7], 10, 1126891415); + c = iRound(c, d, a, b, words[k + 14], 15, 2878612391); + b = iRound(b, c, d, a, words[k + 5], 21, 4237533241); + a = iRound(a, b, c, d, words[k + 12], 6, 1700485571); + d = iRound(d, a, b, c, words[k + 3], 10, 2399980690); + c = iRound(c, d, a, b, words[k + 10], 15, 4293915773); + b = iRound(b, c, d, a, words[k + 1], 21, 2240044497); + a = iRound(a, b, c, d, words[k + 8], 6, 1873313359); + d = iRound(d, a, b, c, words[k + 15], 10, 4264355552); + c = iRound(c, d, a, b, words[k + 6], 15, 2734768916); + b = iRound(b, c, d, a, words[k + 13], 21, 1309151649); + a = iRound(a, b, c, d, words[k + 4], 6, 4149444226); + d = iRound(d, a, b, c, words[k + 11], 10, 3174756917); + c = iRound(c, d, a, b, words[k + 2], 15, 718787259); + b = iRound(b, c, d, a, words[k + 9], 21, 3951481745); + a = addUnsigned(a, aa); + b = addUnsigned(b, bb); + c = addUnsigned(c, cc); + d = addUnsigned(d, dd); + } + const wordToHex = (value) => { + let output = ""; + for (let i = 0; i <= 3; i++) { + output += `0${(value >>> i * 8 & 255).toString(16)}`.slice(-2); + } + return output; + }; + return `${wordToHex(a)}${wordToHex(b)}${wordToHex(c)}${wordToHex(d)}`; + } + async function fetchJson(url) { + const response = await fetch(url, { + credentials: "include" + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.json(); + } + async function getMixinKey() { + var _a, _b, _c, _d, _e; + if (mixinKeyCache) { + return mixinKeyCache; + } + const json = await fetchJson(NAV_API); + const wbiImg = (_a = json.data) == null ? undefined : _a.wbi_img; + const imgKey = ((_c = (_b = wbiImg == null ? undefined : wbiImg.img_url) == null ? undefined : _b.split("/").pop()) == null ? undefined : _c.split(".")[0]) || ""; + const subKey = ((_e = (_d = wbiImg == null ? undefined : wbiImg.sub_url) == null ? undefined : _d.split("/").pop()) == null ? undefined : _e.split(".")[0]) || ""; + const raw = `${imgKey}${subKey}`; + if (!raw) { + throw new Error("无法获取 WBI 图片密钥"); + } + mixinKeyCache = MIXIN_KEY_ENC_TAB.map((index) => raw[index] || "").join("").slice(0, 32); + return mixinKeyCache; + } + async function encodeWbiParams(params) { + const mixinKey = await getMixinKey(); + const signed = { + ...params, + wts: Math.floor(Date.now() / 1e3) + }; + if (!signed.web_location) { + signed.web_location = 1550101; + } + delete signed.w_rid; + const search = new URLSearchParams(); + for (const key of Object.keys(signed).sort()) { + search.append(key, String(signed[key]).replace(/[!'()*]/g, "")); + } + signed.w_rid = md5(`${search.toString()}${mixinKey}`); + return signed; + } function readVarint(buf, pos) { let result = 0n; let shift = 0n; @@ -30,7 +257,7 @@ const byte = BigInt(buf[pos++]); result |= (byte & 0x7fn) << shift; if ((byte & 0x80n) === 0n) { - break; + return { value: result, pos }; } shift += 7n; } @@ -40,9 +267,10 @@ const lenResult = readVarint(buf, pos); const len = Number(lenResult.value); const start = lenResult.pos; + const end = Math.min(start + len, buf.length); return { - value: buf.slice(start, start + len), - pos: start + len + value: buf.slice(start, end), + pos: end }; } function readString(buf, pos) { @@ -57,39 +285,50 @@ return readVarint(buf, pos).pos; } if (wireType === 1) { - return pos + 8; + return Math.min(pos + 8, buf.length); } if (wireType === 2) { return readBytes(buf, pos).pos; } if (wireType === 5) { - return pos + 4; + return Math.min(pos + 4, buf.length); } return buf.length; } function parseDmSegConfig(buf) { + const data = { + pageSize: 0, + total: 0 + }; let pos = 0; - let total = 0; while (pos < buf.length) { const tagResult = readVarint(buf, pos); pos = tagResult.pos; const tag = Number(tagResult.value); const fieldNum = tag >> 3; const wireType = tag & 7; - if (fieldNum === 2 && wireType === 0) { + if (wireType === 0 && (fieldNum === 1 || fieldNum === 2)) { const valueResult = readVarint(buf, pos); - total = Number(valueResult.value); + if (fieldNum === 1) { + data.pageSize = Number(valueResult.value); + } else { + data.total = Number(valueResult.value); + } pos = valueResult.pos; } else { pos = skipField(buf, pos, wireType); } } - return total; + return data; } function parseDmWebViewReply(arrayBuffer) { const buf = new Uint8Array(arrayBuffer); + const data = { + total: 0, + count: 0, + specialDmUrls: [] + }; let pos = 0; - let total = 0; while (pos < buf.length) { const tagResult = readVarint(buf, pos); pos = tagResult.pos; @@ -98,13 +337,21 @@ const wireType = tag & 7; if (fieldNum === 4 && wireType === 2) { const bytesResult = readBytes(buf, pos); - total = parseDmSegConfig(bytesResult.value); + data.total = parseDmSegConfig(bytesResult.value).total; pos = bytesResult.pos; + } else if (fieldNum === 6 && wireType === 2) { + const valueResult = readString(buf, pos); + data.specialDmUrls.push(valueResult.value); + pos = valueResult.pos; + } else if (fieldNum === 8 && wireType === 0) { + const valueResult = readVarint(buf, pos); + data.count = Number(valueResult.value); + pos = valueResult.pos; } else { pos = skipField(buf, pos, wireType); } } - return total; + return data; } function parseDanmakuElem(buf) { const elem = { @@ -116,8 +363,12 @@ midHash: "", content: "", ctime: 0n, + weight: -1, + action: "", pool: 0, - idStr: "" + idStr: "", + attr: -1, + uid: 0n }; let pos = 0; while (pos < buf.length) { @@ -135,7 +386,10 @@ else if (fieldNum === 4) elem.fontsize = Number(valueResult.value); else if (fieldNum === 5) elem.color = Number(valueResult.value); else if (fieldNum === 8) elem.ctime = valueResult.value; + else if (fieldNum === 9) elem.weight = Number(valueResult.value); else if (fieldNum === 11) elem.pool = Number(valueResult.value); + else if (fieldNum === 13) elem.attr = Number(valueResult.value); + else if (fieldNum === 14) elem.uid = valueResult.value; } else if (wireType === 2) { if (fieldNum === 6) { const valueResult = readString(buf, pos); @@ -145,6 +399,10 @@ const valueResult = readString(buf, pos); elem.content = valueResult.value; pos = valueResult.pos; + } else if (fieldNum === 10) { + const valueResult = readString(buf, pos); + elem.action = valueResult.value; + pos = valueResult.pos; } else if (fieldNum === 12) { const valueResult = readString(buf, pos); elem.idStr = valueResult.value; @@ -162,6 +420,9 @@ const buf = new Uint8Array(arrayBuffer); const elems = []; let pos = 0; + if (buf.length === 2 && buf[0] === 16 && buf[1] === 1) { + throw new Error("该视频已关闭弹幕"); + } while (pos < buf.length) { const tagResult = readVarint(buf, pos); pos = tagResult.pos; @@ -178,7 +439,7 @@ } return elems; } - async function fetchDmSegTotal(cid, aid) { + async function fetchDmView(cid, aid) { const params = new URLSearchParams({ type: "1", oid: String(cid) @@ -194,42 +455,98 @@ } return parseDmWebViewReply(await response.arrayBuffer()); } + async function fetchDmSegment(url, params) { + const response = await fetch(`${url}?${new URLSearchParams(params)}`, { + credentials: "include" + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return parseDmSegMobileReply(await response.arrayBuffer()); + } + async function fetchSignedDmSegment(params) { + try { + const signedParams = await encodeWbiParams(params); + return await fetchDmSegment(DM_SEG_API, signedParams); + } catch (error) { + console.warn("[弹幕下载] WBI 分片请求失败,回退旧接口", error); + mixinKeyCache = ""; + return fetchDmSegment(DM_SEG_FALLBACK_API, params); + } + } async function fetchAllSegments(cid, aid, totalSegments, onStatus) { const allElems = []; for (let i = 1; i <= totalSegments; i++) { setStatus(onStatus, `下载中 ${i}/${totalSegments}`, true); - const params = new URLSearchParams({ + const params = { type: "1", oid: String(cid), segment_index: String(i) - }); + }; if (aid) { - params.set("pid", String(aid)); + params.pid = String(aid); + } + try { + const elems = await fetchSignedDmSegment(params); + allElems.push(...elems); + console.log(`[弹幕下载] 第 ${i}/${totalSegments} 段获取到 ${elems.length} 条`); + } catch (error) { + console.error(`[弹幕下载] 第 ${i} 段失败`, error); } + } + return allElems; + } + async function fetchSpecialDanmakus(urls) { + const elems = []; + for (const url of urls) { try { - const response = await fetch(`${DM_SEG_API}?${params}`, { + const response = await fetch(url, { credentials: "include" }); if (!response.ok) { - console.warn(`[弹幕下载] 第 ${i} 段 HTTP ${response.status},已跳过`); - continue; + throw new Error(`HTTP ${response.status}`); } - const elems = parseDmSegMobileReply(await response.arrayBuffer()); - allElems.push(...elems); - console.log(`[弹幕下载] 第 ${i}/${totalSegments} 段获取到 ${elems.length} 条`); + elems.push(...parseDmSegMobileReply(await response.arrayBuffer()).map((elem) => ({ + ...elem, + mode: elem.mode || 9, + pool: elem.pool || 2 + }))); } catch (error) { - console.error(`[弹幕下载] 第 ${i} 段失败:`, error); + console.warn("[弹幕下载] 特殊弹幕获取失败", url, error); } } - return allElems; + return elems; } function escapeXml(value) { return String(value).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """).replace(/'/g, "'"); } - function elemsToXml(elems) { + function elemId(elem) { + if (elem.idStr) { + return elem.idStr; + } + if (elem.id) { + return elem.id.toString(); + } + return `${elem.progress}:${elem.mode}:${elem.content}`; + } + function dedupeDanmakus(elems) { + const seen = /* @__PURE__ */ new Set(); + const result = []; + for (const elem of elems) { + const key = elemId(elem); + if (seen.has(key)) { + continue; + } + seen.add(key); + result.push(elem); + } + return result.sort((a, b) => a.progress - b.progress); + } + function elemsToXml(elems, cid) { let xml = '\n\n'; xml += "chat.bilibili.com\n"; - xml += "0\n"; + xml += `${escapeXml(cid || 0)} +`; for (const elem of elems) { const timeSec = (elem.progress / 1e3).toFixed(5); const id = elem.idStr || elem.id; @@ -240,87 +557,123 @@ xml += ""; return xml; } - async function getText(url) { - const response = await fetch(url); - if (!response.ok) { - throw new Error(`HTTP ${response.status}`); + function getCurrentPageNumber() { + const page = Number(new URL(window.location.href).searchParams.get("p") || "1"); + return Number.isFinite(page) && page > 0 ? page : 1; + } + function infoFromInitialVideoState(bv) { + var _a, _b; + const state = window.__INITIAL_STATE__; + const videoData = state == null ? undefined : state.videoData; + if (!videoData) { + return null; } - return response.text(); + const pageNumber = getCurrentPageNumber(); + const page = ((_a = videoData.pages) == null ? undefined : _a[pageNumber - 1]) || ((_b = videoData.pages) == null ? undefined : _b.find((item) => item.cid === videoData.cid)); + return { + cid: (page == null ? undefined : page.cid) || videoData.cid, + aid: videoData.aid, + title: videoData.title || bv, + longTitle: (page == null ? undefined : page.part) || "", + duration: (page == null ? undefined : page.duration) || videoData.duration || 0 + }; } async function fetchVideoData(bv) { - const html = await getText(`https://www.bilibili.com/video/${bv}/`); - const match = html.match(/window\.__INITIAL_STATE__=(.*);\(function\(\){/); - if (!match) { - throw new Error("无法解析普通视频页面数据"); + var _a, _b; + const localInfo = infoFromInitialVideoState(bv); + if ((localInfo == null ? undefined : localInfo.cid) && (localInfo == null ? undefined : localInfo.aid)) { + return localInfo; } - const state = JSON.parse(match[1]); + const params = new URLSearchParams({ + bvid: bv + }); + const json = await fetchJson(`${VIDEO_VIEW_API}?${params}`); + if (json.code !== 0 || !json.data) { + throw new Error(json.message || "无法获取视频信息"); + } + const pageNumber = getCurrentPageNumber(); + const page = ((_a = json.data.pages) == null ? undefined : _a[pageNumber - 1]) || ((_b = json.data.pages) == null ? undefined : _b[0]) || {}; return { - cid: state.videoData.cid, - aid: state.videoData.aid, - title: state.videoData.title + cid: page.cid || json.data.cid, + aid: json.data.aid, + title: json.data.title || bv, + longTitle: page.part || "", + duration: page.duration || json.data.duration || 0 }; } + async function getText(url) { + const response = await fetch(url, { + credentials: "include" + }); + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + return response.text(); + } + function firstJsonScript(html, pattern) { + const match = html.match(pattern); + return match ? JSON.parse(match[1]) : null; + } async function fetchInfo(ep) { - var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j; + var _a, _b, _c, _d, _e, _f, _g, _h, _i, _j, _k, _l, _m, _n, _o; const html = await getText(`https://www.bilibili.com/bangumi/play/${ep}/`); try { - const match = html.match(/const playurlSSRData = (\{.*?\}\n)/s); - if (match) { - const json = JSON.parse(match[1]); - const epInfo = json.data.result.play_view_business_info.episode_info; - const ogvInfo = (_a = json.data.result.supplement) == null ? void 0 : _a.ogv_episode_info; + const json = firstJsonScript(html, /const playurlSSRData = (\{.*?\})\s*<\/script>/s) || firstJsonScript(html, /const playurlSSRData = (\{.*?\})\s*;/s); + const epInfo = (_c = (_b = (_a = json == null ? void 0 : json.data) == null ? void 0 : _a.result) == null ? void 0 : _b.play_view_business_info) == null ? void 0 : _c.episode_info; + const ogvInfo = (_f = (_e = (_d = json == null ? void 0 : json.data) == null ? void 0 : _d.result) == null ? void 0 : _e.supplement) == null ? void 0 : _f.ogv_episode_info; + if ((epInfo == null ? void 0 : epInfo.cid) && (epInfo == null ? void 0 : epInfo.aid)) { return { cid: epInfo.cid, aid: epInfo.aid, title: (ogvInfo == null ? void 0 : ogvInfo.index_title) || epInfo.index_title || ep, - longTitle: (ogvInfo == null ? void 0 : ogvInfo.long_title) || epInfo.long_title || "" + longTitle: (ogvInfo == null ? void 0 : ogvInfo.long_title) || epInfo.long_title || "", + duration: Number(epInfo.duration || (ogvInfo == null ? void 0 : ogvInfo.duration) || 0) }; } } catch (error) { console.warn("[弹幕下载] playurlSSRData 解析失败,尝试 __NEXT_DATA__", error); } try { - const match = html.match(/