🏠 

bilibilidanmu

在哔哩哔哩视频标题下方增加弹幕查看和下载


安装此脚本?
  1. // ==UserScript==
  2. // @name bilibilidanmu
  3. // @name:zh-CN 哔哩哔哩弹幕姬
  4. // @namespace https://github.com/sakuyaa/gm_scripts
  5. // @author sakuyaa
  6. // @description 在哔哩哔哩视频标题下方增加弹幕查看和下载
  7. // @include http*://www.bilibili.com/video/av*
  8. // @include http*://www.bilibili.com/video/BV*
  9. // @include http*://www.bilibili.com/watchlater/#/av*
  10. // @include http*://www.bilibili.com/watchlater/#/BV*
  11. // @include http*://www.bilibili.com/medialist/play/*/*
  12. // @include http*://www.bilibili.com/bangumi/play/*
  13. // @include http*://www.baidu.com/bangumi/play/*
  14. // @include http*://www.google.com/bangumi/play/*
  15. // @version 2024.01.02
  16. // @compatible firefox 52
  17. // @grant none
  18. // @run-at document-end
  19. // ==/UserScript==
  20. (function() {
  21. let view, download, downloadAll, downloadPast, subSpan, downloadSub, convertSub;
  22. //拦截pushState和replaceState事件
  23. let historyFunc = type => {
  24. let origin = history[type];
  25. return function() {
  26. let e = new Event(type);
  27. e.arguments = arguments;
  28. window.dispatchEvent(e);
  29. return origin.apply(history, arguments);
  30. };
  31. };
  32. history.pushState = historyFunc('pushState');
  33. history.replaceState = historyFunc('replaceState');
  34. let sleep = time => {
  35. return new Promise(resolve => setTimeout(resolve, time));
  36. };
  37. let fetchFunc = (url, type) => {
  38. let init = {};
  39. if (url.indexOf('.bilibili.com/') > 0) {
  40. init.credentials = 'include';
  41. }
  42. return fetch(url, init).then(response => {
  43. if (!response.ok) {
  44. throw new Error(`bilibiliDanmaku${response.status} ${response.statusText}\n无法加载:${url}`);
  45. }
  46. switch (type) {
  47. case 'blob':
  48. return response.blob();
  49. case 'json':
  50. return response.json();
  51. default:
  52. return response.text();
  53. }
  54. });
  55. };
  56. //获取视频发布日期
  57. let fetchPubDate = async () => {
  58. let response = await fetchFunc(`https://api.bilibili.com/x/web-interface/view?${window.bvid ? 'bvid=' + window.bvid : 'aid=' + window.aid}`, 'json');
  59. if (response.data.pubdate) {
  60. let pubDate = new Date(response.data.pubdate * 1000);
  61. if (!isNaN(pubDate)) {
  62. return pubDate;
  63. }
  64. }
  65. return null;
  66. };
  67. //获取CC字幕列表
  68. let fetchSubtitles = async () => {
  69. let response = await fetchFunc(`https://api.bilibili.com/x/web-interface/view?${window.bvid ? 'bvid=' + window.bvid : 'aid=' + window.aid}`, 'json');
  70. if (response.data.subtitle.list) {
  71. return response.data.subtitle.list;
  72. }
  73. return [];
  74. };
  75. //秒转化为时分秒
  76. let formatSeconds = seconds => {
  77. let h = Math.floor(seconds / 3600);
  78. if (h < 10) {
  79. h = '0' + h;
  80. }
  81. let m = Math.floor((seconds / 60 % 60));
  82. if (m < 10) {
  83. m = '0' + m;
  84. }
  85. let s = Math.floor((seconds % 60));
  86. if (s < 10) {
  87. s = '0' + s;
  88. }
  89. let ms = '00' + Math.floor(seconds * 1000 % 1000);
  90. return `${h}:${m}:${s}.${ms.substr(-3)}`;
  91. }
  92. let danmakuFunc = async () => {
  93. //查看弹幕
  94. view.setAttribute('href', `https://comment.bilibili.com/${window.cid}.xml`);
  95. //下载弹幕
  96. download.removeAttribute('download');
  97. download.setAttribute('href', 'javascript:;');
  98. download.onclick = async () => {
  99. let danmaku = await fetchFunc(`https://api.bilibili.com/x/v1/dm/list.so?oid=${window.cid}&bilibiliDanmaku=1`, 'blob');
  100. download.onclick = null;
  101. download.setAttribute('download', document.title.split('_')[0] + '.xml');
  102. download.setAttribute('href', URL.createObjectURL(danmaku));
  103. download.dispatchEvent(new MouseEvent('click'));
  104. };
  105. //全弹幕下载
  106. downloadAll.removeAttribute('download');
  107. downloadAll.setAttribute('href', 'javascript:;');
  108. downloadAll.onclick = async () => {
  109. try {
  110. //加载当前弹幕池
  111. let danmakuMap = new Map();
  112. let danmaku = await fetchFunc(`https://api.bilibili.com/x/v1/dm/list.so?oid=${window.cid}&bilibiliDanmaku=1`);
  113. let danmakuAll = danmaku.substring(0, danmaku.indexOf('<d p='));
  114. let exp = new RegExp('<d p="([^,]+)[^"]+,(\\d+)">.+?</d>', 'g');
  115. while ((match = exp.exec(danmaku)) != null) {
  116. danmakuMap.set(parseInt(match[2]), [parseFloat(match[1]), match[0]]);
  117. }
  118. //获取视频发布日期
  119. let now = new Date();
  120. let pubDate, year, month;
  121. let dateNode = document.querySelector('.video-data span:nth-child(2)');
  122. if (dateNode) {
  123. pubDate = new Date(dateNode.textContent);
  124. if (isNaN(pubDate)) {
  125. pubDate = await fetchPubDate();
  126. }
  127. } else {
  128. pubDate = await fetchPubDate();
  129. }
  130. if (!pubDate) {
  131. alert('获取视频投稿时间失败!');
  132. return;
  133. }
  134. year = pubDate.getFullYear();
  135. month = pubDate.getMonth() + 1;
  136. //计算历史月份
  137. let monthArray = [];
  138. while (year * 100 + month <= now.getFullYear() * 100 + now.getMonth() + 1) {
  139. monthArray.push(`https://api.bilibili.com/x/v2/dm/history/index?type=1&oid=${window.cid}&month=${year + '-' + ('0' + month).substr(-2)}`);
  140. if (++month > 12) {
  141. month = 1;
  142. year++;
  143. }
  144. }
  145. //增加延迟
  146. let delay;
  147. if((delay = prompt('由于网站弹幕接口改版新的API限制获取速度,全弹幕下载需要有获取间隔,会导致该功能需要很长很长时间进行弹幕获取(视投稿时间而定,每天都有历史数据的话获取一个月大概需要20多秒),请输入获取间隔(若仍出现获取速度过快请适当加大间隔,单位:毫秒)', 299)) == null) {
  148. return;
  149. }
  150. if(isNaN(delay)) {
  151. alert('输入值不是数值!');
  152. return;
  153. }
  154. //进度条
  155. let progress = document.createElement('progress');
  156. progress.setAttribute('max', monthArray.length * 1000);
  157. progress.setAttribute('value', 0);
  158. progress.style.position = 'fixed';
  159. progress.style.margin = 'auto';
  160. progress.style.left = progress.style.right = 0;
  161. progress.style.top = progress.style.bottom = 0;
  162. progress.style.zIndex = 99; //进度条置顶
  163. document.body.appendChild(progress);
  164. //获取历史弹幕日期
  165. let data;
  166. for (let i = 0; i < monthArray.length;) {
  167. data = await fetchFunc(monthArray[i], 'json');
  168. if (data.code) {
  169. throw new Error('bilibiliDanmaku,API接口返回错误:' + data.message);
  170. }
  171. if (data.data) {
  172. for (let j = 0; j < data.data.length; j++) {
  173. progress.setAttribute('value', i * 1000 + 1000 / data.data.length * j);
  174. await sleep(delay); //避免网站API调用速度过快导致错误
  175. danmaku = await fetchFunc(`https://api.bilibili.com/x/v2/dm/history?type=1&oid=${window.cid}&date=${data.data[j]}&bilibiliDanmaku=1`);
  176. if ((match = (new RegExp('^\{"code":[^,]+,"message":"([^"]+)","ttl":[^\}]+\}$',)).exec(danmaku)) != null) {
  177. throw new Error('bilibiliDanmaku,API接口返回错误:' + match[1]);
  178. }
  179. exp = new RegExp('<d p="([^,]+)[^"]+,(\\d+)">.+?</d>', 'g');
  180. while ((match = exp.exec(danmaku)) != null) {
  181. if (!danmakuMap.has(parseInt(match[2]))) { //跳过重复的项目
  182. danmakuMap.set(parseInt(match[2]), [parseFloat(match[1]), match[0]]);
  183. }
  184. }
  185. }
  186. }
  187. progress.setAttribute('value', ++i * 1000);
  188. }
  189. //按弹幕播放时间排序
  190. let danmakuArray = [];
  191. for (let value of danmakuMap.values()) {
  192. danmakuArray.push(value);
  193. }
  194. danmakuArray.sort((a, b) => a[0] - b[0]);
  195. //合成弹幕
  196. document.body.removeChild(progress);
  197. for (let pair of danmakuArray) {
  198. danmakuAll += pair[1];
  199. }
  200. danmakuAll += '</i>';
  201. //设置下载链接
  202. downloadAll.onclick = null;
  203. downloadAll.setAttribute('download', document.title.split('_')[0] + '.xml');
  204. downloadAll.setAttribute('href', URL.createObjectURL(new Blob([danmakuAll])));
  205. downloadAll.dispatchEvent(new MouseEvent('click'));
  206. } catch(e) {
  207. alert(e);
  208. }
  209. };
  210. //历史弹幕下载
  211. downloadPast.onclick = async () => {
  212. //获取视频发布日期
  213. let date;
  214. let dateNode = document.querySelector('.video-data span:nth-child(2)');
  215. if (dateNode) {
  216. date = new Date(dateNode.textContent);
  217. if (isNaN(date)) {
  218. date = await fetchPubDate();
  219. }
  220. } else {
  221. date = await fetchPubDate();
  222. }
  223. if (!date) { //获取视频投稿时间失败,默认设置为当天
  224. date = new Date();
  225. }
  226. if((date = prompt('请按此格式输入想要下载历史弹幕的日期', date.getFullYear() + '-' + ('0' + (date.getMonth() + 1)).substr(-2) + '-' + ('0' + date.getDate()).substr(-2))) == null) {
  227. return;
  228. }
  229. let danmaku = await fetchFunc(`https://api.bilibili.com/x/v2/dm/history?type=1&oid=${window.cid}&date=${date}&bilibiliDanmaku=1`);
  230. let aLink = document.createElement('a');
  231. aLink.setAttribute('download', document.title.split('_')[0] + '_' + date + '.xml');
  232. aLink.setAttribute('href', URL.createObjectURL(new Blob([danmaku])));
  233. aLink.dispatchEvent(new MouseEvent('click'));
  234. };
  235. //获取CC字幕列表
  236. let subList = [];
  237. let notFound = true;
  238. if (window.eventLogText) {
  239. for (let i = window.eventLogText.length - 1; i >= 0; i--) {
  240. let eventLog = window.eventLogText[i];
  241. if (eventLog.indexOf('<subtitle>') > 0) {
  242. notFound = false;
  243. try {
  244. subList = JSON.parse(eventLog.substring(eventLog.indexOf('<subtitle>') + 10,
  245. eventLog.indexOf('</subtitle>'))).subtitles;
  246. } catch(e) {
  247. console.log(e);
  248. notFound = true;
  249. }
  250. break;
  251. }
  252. }
  253. }
  254. if (notFound) {
  255. subList = await fetchSubtitles();
  256. }
  257. if (subList.length == 0) { //没有CC字幕则隐藏相关按钮
  258. subSpan.setAttribute('hidden', 'hidden');
  259. downloadSub.onclick = null;
  260. convertSub.onclick = null;
  261. return;
  262. } else {
  263. subSpan.removeAttribute('hidden');
  264. }
  265. //下载CC字幕
  266. downloadSub.onclick = async () => {
  267. let aLink = document.createElement('a');
  268. for (let sub of subList) {
  269. let subtitle = await fetchFunc(sub.subtitle_url.replace(/^http:/, ''), 'blob'); //避免混合内容
  270. aLink.setAttribute('download', sub.lan + '_' + document.title.split('_')[0] + '.json');
  271. aLink.setAttribute('href', URL.createObjectURL(subtitle));
  272. aLink.dispatchEvent(new MouseEvent('click'));
  273. }
  274. };
  275. //生成SRT字幕
  276. convertSub.onclick = async () => {
  277. let aLink = document.createElement('a');
  278. for (let sub of subList) {
  279. let subtitle = await fetchFunc(sub.subtitle_url.replace(/^http:/, ''), 'json'); //避免混合内容
  280. let srt = '', index = 0;
  281. for (let content of subtitle.body) {
  282. srt += `${index++}\n${formatSeconds(content.from)} --> ${formatSeconds(content.to)}\n${content.content.replace(/\n/g,'<br>')}\n\n`;
  283. }
  284. aLink.setAttribute('download', sub.lan + '_' + document.title.split('_')[0] + '.srt');
  285. aLink.setAttribute('href', URL.createObjectURL(new Blob([srt])));
  286. aLink.dispatchEvent(new MouseEvent('click'));
  287. }
  288. };
  289. };
  290. let findInsertPos = () => {
  291. let node;
  292. if (location.href.indexOf('www.bilibili.com/bangumi/play') > 0) { //番剧
  293. node = document.querySelector('.media-right');
  294. if (node && node.querySelector('.media-count').textContent.indexOf('弹幕') == -1) {
  295. return null; //避免信息栏未加载出来时插入链接导致错误
  296. }
  297. } else if (location.href.indexOf('www.bilibili.com/watchlater') > 0) { //稍后再看
  298. node = document.querySelector('.tminfo');
  299. if (node) {
  300. node.lastElementChild.style.marginRight = '32px';
  301. }
  302. } else if (location.href.indexOf('www.bilibili.com/medialist/play') > 0) { //新的稍后再看页面、收藏页面
  303. node = document.querySelector('.play-data');
  304. if (node) {
  305. node.lastElementChild.style.marginRight = '16px';
  306. }
  307. //新的稍后再看页面没有aid、bvid、cid,需要特殊处理
  308. let videoMessage = window.player.getVideoMessage();
  309. if (videoMessage) {
  310. window.aid = videoMessage.aid;
  311. window.cid = videoMessage.cid;
  312. } else {
  313. return null;
  314. }
  315. } else {
  316. node = document.getElementById('viewbox_report');
  317. if (node) {
  318. if (!document.querySelector('.bilibili-player-video-info-people-number')) {
  319. return null; //避免信息栏未加载出来时插入链接导致错误
  320. }
  321. node = node.querySelector('.video-data');
  322. node.lastElementChild.style.marginRight = '16px';
  323. }
  324. }
  325. return node;
  326. };
  327. let createNode = () => {
  328. view = document.createElement('a');
  329. download = document.createElement('a');
  330. downloadAll = document.createElement('a');
  331. downloadPast = document.createElement('a');
  332. downloadSub = document.createElement('a');
  333. convertSub = document.createElement('a');
  334. view.setAttribute('target', '_blank');
  335. downloadPast.setAttribute('href', 'javascript:;');
  336. downloadSub.setAttribute('href', 'javascript:;');
  337. convertSub.setAttribute('href', 'javascript:;');
  338. view.textContent = '查看弹幕';
  339. download.textContent = '下载弹幕';
  340. downloadAll.textContent = '全弹幕下载';
  341. downloadPast.textContent = '历史弹幕下载';
  342. downloadSub.textContent = '下载CC字幕';
  343. convertSub.textContent = '生成SRT字幕';
  344. view.style.color = '#999';
  345. download.style.color = '#999';
  346. downloadAll.style.color = '#999';
  347. downloadPast.style.color = '#999';
  348. downloadSub.style.color = '#999';
  349. convertSub.style.color = '#999';
  350. let span = document.createElement('span');
  351. span.id = 'bilibiliDanmaku';
  352. span.appendChild(view);
  353. span.appendChild(document.createTextNode(' | '));
  354. span.appendChild(download);
  355. span.appendChild(document.createTextNode(' | '));
  356. span.appendChild(downloadAll);
  357. span.appendChild(document.createTextNode(' | '));
  358. span.appendChild(downloadPast);
  359. subSpan = document.createElement('span');
  360. subSpan.setAttribute('hidden', 'hidden');
  361. subSpan.style.marginLeft = '16px'; //弹幕与字幕功能分开
  362. subSpan.appendChild(downloadSub);
  363. subSpan.appendChild(document.createTextNode(' | '));
  364. subSpan.appendChild(convertSub);
  365. span.appendChild(subSpan);
  366. return span;
  367. };
  368. let insertNode = () => {
  369. let code = setInterval(() => {
  370. if (location.href.indexOf('www.bilibili.com/medialist/play') > 0) {
  371. if (!window.player) { //新的稍后再看页面、收藏页面没有cid
  372. return;
  373. }
  374. } else if (!window.cid) {
  375. return;
  376. }
  377. if (document.getElementById('bilibiliDanmaku')) { //节点已存在
  378. clearInterval(code);
  379. danmakuFunc();
  380. } else {
  381. let node = findInsertPos();
  382. if (node) {
  383. clearInterval(code);
  384. node.appendChild(createNode());
  385. danmakuFunc();
  386. }
  387. }
  388. }, 2196);
  389. };
  390. insertNode();
  391. addEventListener('hashchange', insertNode);
  392. addEventListener('pushState', insertNode);
  393. addEventListener('replaceState', insertNode);
  394. })();