bilibiliDanmaku

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

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