bilibili-mobile-comment-module

Comment module for https://greatest.deepsurf.us/scripts/497732

بۇ قوليازمىنى بىۋاسىتە قاچىلاشقا بولمايدۇ. بۇ باشقا قوليازمىلارنىڭ ئىشلىتىشى ئۈچۈن تەمىنلەنگەن ئامبار بولۇپ، ئىشلىتىش ئۈچۈن مېتا كۆرسەتمىسىگە قىستۇرىدىغان كود: // @require https://update.greatest.deepsurf.us/scripts/524844/1586579/bilibili-mobile-comment-module.js

  1. /*
  2. * Comment module for https://greatest.deepsurf.us/scripts/497732
  3. */
  4.  
  5. ;const MobileCommentModule = (function() {
  6. 'use strict';
  7.  
  8. // patch for 'unsafeWindow is not defined'
  9. const global = typeof unsafeWindow === 'undefined' ? window : unsafeWindow;
  10.  
  11. // RegExp
  12. const videoRE = /https:\/\/m\.bilibili\.com\/video\/.*/;
  13. const dynamicRE = /https:\/\/m.bilibili.com\/dynamic\/\d+/;
  14. const opusRE = /https:\/\/m.bilibili.com\/opus\/\d+/;
  15.  
  16. // essential data or elements
  17. let oid, createrID, commentType, replyList;
  18.  
  19. // define sort types
  20. const sortTypeConstant = { LATEST: 0, HOT: 2 };
  21. let currentSortType;
  22.  
  23. // offset data for time sort
  24. let timeSortOffsets;
  25.  
  26. // use to prevent loading duplicated main reply
  27. let replyPool;
  28.  
  29. // ---------- variables above ----------
  30.  
  31. // help to get oid and comment type in dynamic page
  32. if (dynamicRE.test(global.location.href)) setupXHRInterceptor();
  33.  
  34. // add style patch
  35. addStyle();
  36.  
  37. return { init };
  38.  
  39. // ---------- functions below ----------
  40.  
  41. async function init(commentModuleWrapper) {
  42. // initialize
  43. oid = createrID = commentType = undefined;
  44. replyPool = {};
  45. currentSortType = sortTypeConstant.HOT;
  46.  
  47. // setup standard comment container & get reply list
  48. setupStandardCommentContainer(commentModuleWrapper);
  49. replyList = commentModuleWrapper.querySelector('.reply-list');
  50.  
  51. // collect oid & commentType
  52. await new Promise(resolve => {
  53. const timer = setInterval(async () => {
  54. if (videoRE.test(global.location.href)) {
  55. const videoID = global.location.pathname.replace('/video/', '').replace('/', '');
  56. if (videoID.startsWith('av')) oid = videoID.slice(2);
  57. if (videoID.startsWith('BV')) oid = b2a(videoID);
  58. commentType = 1;
  59. } else if (dynamicRE.test(global.location.href)) {
  60. oid = global.dynamicDetail?.oid;
  61. commentType = global.dynamicDetail?.commentType;
  62. } else if (opusRE.test(global.location.href)) {
  63. oid = global?.__INITIAL_STATE__?.opus?.detail?.basic?.comment_id_str;
  64. commentType = global?.__INITIAL_STATE__?.opus?.detail?.basic?.comment_type; // should be '11'
  65. }
  66.  
  67. // final check
  68. if (oid && commentType) {
  69. clearInterval(timer);
  70. resolve();
  71. }
  72. }, 200);
  73. });
  74.  
  75. // enable switching sort type
  76. await enableSwitchingSortType();
  77.  
  78. // load first pagination
  79. await loadFirstPagination();
  80. }
  81.  
  82. function setupStandardCommentContainer(commentModuleWrapper) {
  83. commentModuleWrapper.innerHTML = `
  84. <div class="comment-container">
  85. <div class="reply-header">
  86. <div class="reply-navigation">
  87. <ul class="nav-bar">
  88. <li class="nav-title">
  89. <span class="nav-title-text">评论</span>
  90. <span class="total-reply">-</span>
  91. </li>
  92. <li class="nav-sort hot">
  93. <div class="hot-sort">最热</div>
  94. <div class="part-symbol"></div>
  95. <div class="time-sort">最新</div>
  96. </li>
  97. </ul>
  98. </div>
  99. </div>
  100. <div class="reply-warp">
  101. <div class="reply-list"></div>
  102. </div>
  103. </div>
  104. `;
  105. }
  106.  
  107. async function enableSwitchingSortType() {
  108. // collect elements
  109. const navSortElement = document.querySelector('.comment-container .reply-header .nav-sort');
  110. const hotSortElement = navSortElement.querySelector('.hot-sort');
  111. const timeSortElement = navSortElement.querySelector('.time-sort');
  112.  
  113. // reset classes
  114. navSortElement.classList.add('hot');
  115. navSortElement.classList.remove('time');
  116.  
  117. // setup click event listener
  118. hotSortElement.addEventListener('click', () => {
  119. if (currentSortType === sortTypeConstant.HOT) return;
  120. currentSortType = sortTypeConstant.HOT;
  121. navSortElement.classList.add('hot');
  122. navSortElement.classList.remove('time');
  123. loadFirstPagination();
  124. });
  125.  
  126. timeSortElement.addEventListener('click', () => {
  127. if (currentSortType === sortTypeConstant.LATEST) return;
  128. currentSortType = sortTypeConstant.LATEST;
  129. navSortElement.classList.add('time');
  130. navSortElement.classList.remove('hot');
  131. loadFirstPagination();
  132. });
  133. }
  134.  
  135. async function loadFirstPagination() {
  136. // reset offset data
  137. timeSortOffsets = { 1: `{"offset":""}` };
  138.  
  139. // get data of first pagination
  140. const { data: firstPaginationData, code: resultCode } = await getPaginationData(1);
  141.  
  142. // get creater ID
  143. createrID = firstPaginationData.upper.mid;
  144.  
  145. // clear replyList
  146. replyList.innerHTML = '';
  147.  
  148. // clear replyPool
  149. replyPool = {};
  150.  
  151. // clear bottom modules
  152. document.querySelector('.comment-container .reply-warp .no-more-replies-info')?.remove();
  153. document.querySelector('.comment-container .reply-warp .anchor-for-loading')?.remove();
  154.  
  155. // script ends here if not able to fetch pagination data
  156. if (resultCode !== 0) {
  157. // ref: BV12r4y147Bj
  158. const info = resultCode === 12061 ? 'UP主已关闭评论区' : '无法从API获取评论数据';
  159. replyList.innerHTML = `<p style="padding: 100px 0; text-align: center; color: #999;">${info}</p>`;
  160. return;
  161. }
  162.  
  163. // load reply count
  164. const totalReplyElement = document.querySelector('.comment-container .reply-header .total-reply');
  165. const totalReplyCount = parseInt(firstPaginationData?.cursor?.all_count) || 0;
  166. totalReplyElement.textContent = totalReplyCount;
  167.  
  168. // check whether replies are selected
  169. // ref: BV1Dy2mY3EGy
  170. if (firstPaginationData?.cursor?.name?.includes('精选')) {
  171. const navSortElement = document.querySelector('.comment-container .reply-header .nav-sort');
  172. navSortElement.innerHTML = `<div class="selected-sort">精选评论</div>`;
  173. }
  174.  
  175. // load the top reply if it exists
  176. if (firstPaginationData.top_replies && firstPaginationData.top_replies.length !== 0) {
  177. const topReplyData = firstPaginationData.top_replies[0];
  178. appendReplyItem(topReplyData, true);
  179. }
  180.  
  181. // script ends here if there are no more replies
  182. if (firstPaginationData.replies.length === 0) {
  183. const infoElement = document.createElement('p');
  184. infoElement.classList.add('no-more-replies-info');
  185. infoElement.style = 'padding-bottom: 100px; text-align: center; color: #999;';
  186. infoElement.textContent = '没有更多评论';
  187. document.querySelector('.comment-container .reply-warp').appendChild(infoElement);
  188. return;
  189. }
  190.  
  191. // load normal replies
  192. for (const replyData of firstPaginationData.replies) {
  193. appendReplyItem(replyData);
  194. }
  195.  
  196. // add page loader
  197. addAnchor();
  198. }
  199.  
  200. async function getPaginationData(paginationNumber) {
  201. const params = {
  202. oid,
  203. type: commentType,
  204. wts: parseInt(Date.now() / 1000)
  205. };
  206.  
  207. if (currentSortType === sortTypeConstant.HOT) {
  208. params.mode = 3;
  209. params.pagination_str = paginationNumber === 1 ? `{"offset":""}` : `{"offset":"{\\"type\\":1,\\"data\\":{\\"pn\\":${paginationNumber}}}"}`;
  210. return await fetch(`https://api.bilibili.com/x/v2/reply/wbi/main?${await getWbiQueryString(params)}`).then(res => res.json());
  211. }
  212.  
  213. if (currentSortType === sortTypeConstant.LATEST) {
  214. params.mode = 2;
  215. params.pagination_str = timeSortOffsets[paginationNumber];
  216. const fetchResult = await fetch(`https://api.bilibili.com/x/v2/reply/wbi/main?${await getWbiQueryString(params)}`).then(res => res.json());
  217.  
  218. // prepare offset data of next pagination
  219. if (fetchResult.code === 0) {
  220. const nextOffset = fetchResult.data.cursor.pagination_reply.next_offset || "";
  221. timeSortOffsets[paginationNumber + 1] = `{"offset":"${nextOffset}"}`;
  222. } else {
  223. fetchResult.data = fetchResult.data || {};
  224. }
  225.  
  226. return fetchResult;
  227. }
  228. }
  229.  
  230. function appendReplyItem(replyData, isTopReply) {
  231. if (replyPool[replyData.rpid_str]) return;
  232.  
  233. const replyItemElement = document.createElement('div');
  234. replyItemElement.classList.add('reply-item');
  235. replyItemElement.innerHTML = `
  236. <div class="root-reply-container">
  237. <a class="root-reply-avatar" href="https://space.bilibili.com/${replyData.mid}" target="_blank" data-user-id="${replyData.mid}" data-root-reply-id="${replyData.rpid}">
  238. <div class="avatar">
  239. <div class="bili-avatar">
  240. <img class="bili-avatar-img bili-avatar-face bili-avatar-img-radius" data-src="${replyData.member.avatar}" alt="" src="${replyData.member.avatar}">
  241. <span class="bili-avatar-icon bili-avatar-right-icon bili-avatar-size-40"></span>
  242. </div>
  243. </div>
  244. </a>
  245. <div class="content-warp">
  246. <div class="user-info">
  247. <a class="user-name" href="https://space.bilibili.com/${replyData.mid}" target="_blank" data-user-id="${replyData.mid}" data-root-reply-id="${replyData.rpid}" style="color: ${replyData.member.vip.nickname_color ? replyData.member.vip.nickname_color : '#61666d'}">${replyData.member.uname}</a>
  248. <span style="height: 14px; padding: 0 2px; margin-right: 4px; display: flex; align-items: center; font-size: 10px; color: white; border-radius: 2px; background-color: ${getMemberLevelColor(replyData.member.level_info.current_level)};">LV${replyData.member.level_info.current_level}</span>
  249. ${
  250. createrID === replyData.mid
  251. ? '<i class="svg-icon up-web up-icon" style="width: 20px; height: 24px; transform: scale(1.03);"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="0" y="4" width="24" height="16" rx="2" fill="#FF6699"></rect><path d="M5.7 8.36V12.79C5.7 13.72 5.96 14.43 6.49 14.93C6.99 15.4 7.72 15.64 8.67 15.64C9.61 15.64 10.34 15.4 10.86 14.92C11.38 14.43 11.64 13.72 11.64 12.79V8.36H10.47V12.81C10.47 13.43 10.32 13.88 10.04 14.18C9.75 14.47 9.29 14.62 8.67 14.62C8.04 14.62 7.58 14.47 7.3 14.18C7.01 13.88 6.87 13.43 6.87 12.81V8.36H5.7ZM13.0438 8.36V15.5H14.2138V12.76H15.9838C17.7238 12.76 18.5938 12.02 18.5938 10.55C18.5938 9.09 17.7238 8.36 16.0038 8.36H13.0438ZM14.2138 9.36H15.9138C16.4238 9.36 16.8038 9.45 17.0438 9.64C17.2838 9.82 17.4138 10.12 17.4138 10.55C17.4138 10.98 17.2938 11.29 17.0538 11.48C16.8138 11.66 16.4338 11.76 15.9138 11.76H14.2138V9.36Z" fill="white"></path></svg></i>'
  252. : ''
  253. }
  254. </div>
  255. <div class="root-reply">
  256. <span class="reply-content-container root-reply" style="padding-bottom: 8px;">
  257. <span class="reply-content">${isTopReply? '<span class="top-icon" style="top: -1px;">置顶</span>': ''}${replyData.content.pictures ? `<div class="note-prefix" style="transform: translateY(-1px);"><svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="#BBBBBB"><path d="M0 3.75C0 2.784.784 2 1.75 2h12.5c.966 0 1.75.784 1.75 1.75v8.5A1.75 1.75 0 0 1 14.25 14H1.75A1.75 1.75 0 0 1 0 12.25Zm1.75-.25a.25.25 0 0 0-.25.25v8.5c0 .138.112.25.25.25h12.5a.25.25 0 0 0 .25-.25v-8.5a.25.25 0 0 0-.25-.25ZM3.5 6.25a.75.75 0 0 1 .75-.75h7a.75.75 0 0 1 0 1.5h-7a.75.75 0 0 1-.75-.75Zm.75 2.25h4a.75.75 0 0 1 0 1.5h-4a.75.75 0 0 1 0-1.5Z"></path></svg><div style="margin-left: 3px;">笔记</div></div>` : ''}${getConvertedMessage(replyData.content)}</span>
  258. </span>
  259. ${
  260. replyData.content.pictures
  261. ? `
  262. <div class="image-exhibition" style="margin-top: 0; margin-bottom: 8px;">
  263. <div class="preview-image-container" style="display: flex; width: 300px;">
  264. ${getImageItems(replyData.content.pictures)}
  265. </div>
  266. </div>
  267. `
  268. : ''
  269. }
  270. <div class="reply-info">
  271. <span class="reply-time" style="margin-right: 20px;">${getFormattedTime(replyData.ctime)}</span>
  272. <span class="reply-like">
  273. <i class="svg-icon like use-color like-icon" style="width: 16px; height: 16px;"><svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3323" width="200" height="200"><path d="M594.176 151.168a34.048 34.048 0 0 0-29.184 10.816c-11.264 13.184-15.872 24.064-21.504 40.064l-1.92 5.632c-5.632 16.128-12.8 36.864-27.648 63.232-25.408 44.928-50.304 74.432-86.208 97.024-23.04 14.528-43.648 26.368-65.024 32.576v419.648a4569.408 4569.408 0 0 0 339.072-4.672c38.72-2.048 72-21.12 88.96-52.032 21.504-39.36 47.168-95.744 63.552-163.008a782.72 782.72 0 0 0 22.528-163.008c0.448-16.832-13.44-32.256-35.328-32.256h-197.312a32 32 0 0 1-28.608-46.336l0.192-0.32 0.64-1.344 2.56-5.504c2.112-4.8 5.12-11.776 8.32-20.16 6.592-17.088 13.568-39.04 16.768-60.416 4.992-33.344 3.776-60.16-9.344-84.992-14.08-26.688-30.016-33.728-40.512-34.944zM691.84 341.12h149.568c52.736 0 100.864 40.192 99.328 98.048a845.888 845.888 0 0 1-24.32 176.384 742.336 742.336 0 0 1-69.632 178.56c-29.184 53.44-84.48 82.304-141.76 85.248-55.68 2.88-138.304 5.952-235.712 5.952-96 0-183.552-3.008-244.672-5.76-66.432-3.136-123.392-51.392-131.008-119.872a1380.672 1380.672 0 0 1-0.768-296.704c7.68-72.768 70.4-121.792 140.032-121.792h97.728c13.76 0 28.16-5.504 62.976-27.456 24.064-15.104 42.432-35.2 64.512-74.24 11.904-21.184 17.408-36.928 22.912-52.8l2.048-5.888c6.656-18.88 14.4-38.4 33.28-60.416a97.984 97.984 0 0 1 85.12-32.768c35.264 4.096 67.776 26.88 89.792 68.608 22.208 42.176 21.888 84.864 16 124.352a342.464 342.464 0 0 1-15.424 60.544z m-393.216 477.248V405.184H232.96c-40.448 0-72.448 27.712-76.352 64.512a1318.912 1318.912 0 0 0 0.64 282.88c3.904 34.752 32.96 61.248 70.4 62.976 20.8 0.96 44.8 1.92 71.04 2.816z" p-id="3324" fill="#9499a0"></path></svg></i>
  274. <span>${replyData.like}</span>
  275. </span>
  276. </div>
  277. <div class="reply-tag-list">
  278. ${
  279. replyData.card_label
  280. ? replyData.card_label.reduce((acc, cur) => acc + `<span class="reply-tag-item ${cur.text_content === '热评' ? 'reply-tag-hot' : ''} ${cur.text_content === 'UP主觉得很赞' ? 'reply-tag-liked' : ''}" style="font-size: 12px; background-color: ${cur.label_color_day}; color: ${cur.text_color_day};">${cur.text_content}</span>`, '')
  281. : ''
  282. }
  283. </div>
  284. </div>
  285. </div>
  286. </div>
  287. <div class="sub-reply-container">
  288. <div class="sub-reply-list">
  289. ${getSubReplyItems(replyData.replies)}
  290. ${
  291. replyData.rcount > replyData.replies.length
  292. ? `
  293. <div class="view-more" style="padding-left: 8px; font-size: 13px; color: #9499A0;">
  294. <div class="view-more-default">
  295. <span>共${replyData.rcount}条回复, </span>
  296. <span class="view-more-btn" style="cursor: pointer;">点击查看</span>
  297. </div>
  298. </div>
  299. `
  300. : ''
  301. }
  302. </div>
  303. </div>
  304. `;
  305. replyList.appendChild(replyItemElement);
  306. replyPool[replyData.rpid_str] = true;
  307.  
  308. // setup image viewer
  309. const previewImageContainer = replyItemElement.querySelector('.preview-image-container');
  310. if (previewImageContainer) new Viewer(previewImageContainer, { title: false, toolbar: false, tooltip: false, keyboard: false });
  311.  
  312. // setup view more button
  313. const subReplyList = replyItemElement.querySelector('.sub-reply-list');
  314. const viewMoreBtn = replyItemElement.querySelector('.view-more-btn');
  315. viewMoreBtn && viewMoreBtn.addEventListener('click', () => loadPaginatedSubReplies(replyData.rpid, subReplyList, replyData.rcount, 1));
  316. }
  317.  
  318. function getFormattedTime(ms) {
  319. const time = new Date(ms * 1000);
  320. const year = time.getFullYear();
  321. const month = (time.getMonth() + 1).toString().padStart(2, '0');
  322. const day = time.getDate().toString().padStart(2, '0');
  323. const hour = time.getHours().toString().padStart(2, '0');
  324. const minute = time.getMinutes().toString().padStart(2, '0');
  325. return `${year}-${month}-${day} ${hour}:${minute}`;
  326. }
  327.  
  328. function getMemberLevelColor(level) {
  329. return ({
  330. 0: '#C0C0C0',
  331. 1: '#BBBBBB',
  332. 2: '#8BD29B',
  333. 3: '#7BCDEF',
  334. 4: '#FEBB8B',
  335. 5: '#EE672A',
  336. 6: '#F04C49'
  337. })[level];
  338. }
  339.  
  340. function getConvertedMessage(content) {
  341. let result = content.message;
  342.  
  343. // built blacklist of keyword, to avoid being converted to link incorrectly
  344. const keywordBlacklist = ['https://www.bilibili.com/video/av', 'https://b23.tv/mall-'];
  345.  
  346. // convert vote to link
  347. if (content.vote && content.vote.deleted === false) {
  348. const linkElementHTML = `<a class="jump-link normal" href="${content.vote.url}" target="_blank" noopener noreferrer>${content.vote.title}</a>`;
  349. keywordBlacklist.push(linkElementHTML);
  350. result = result.replace(`{vote:${content.vote.id}}`, linkElementHTML);
  351. }
  352.  
  353. // convert emote tag to image
  354. if (content.emote) {
  355. for (const [key, value] of Object.entries(content.emote)) {
  356. const imageElementHTML = `<img class="emoji-${['', 'small', 'large'][value.meta.size]}" src="${value.url}" alt="${key}">`;
  357. keywordBlacklist.push(imageElementHTML);
  358. result = result.replaceAll(key, imageElementHTML);
  359. }
  360. }
  361.  
  362. // convert timestamp to link
  363. result = result.replaceAll(/(\d{1,2}[::]){1,2}\d{1,2}/g, (timestamp) => {
  364. timestamp = timestamp.replaceAll(':', ':');
  365.  
  366. // return plain text if no video in page
  367. if(!(videoRE.test(global.location.href))) return timestamp;
  368.  
  369. const parts = timestamp.split(':');
  370. // return plain text if any part of timestamp equal to or bigger than 60
  371. if (parts.some(part => parseInt(part) >= 60)) return timestamp;
  372. let totalSecond;
  373. if (parts.length === 2) totalSecond = parseInt(parts[0]) * 60 + parseInt(parts[1]);
  374. else if (parts.length === 3) totalSecond = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]);
  375. // return plain text if failed to get vaild number of second
  376. if (Number.isNaN(totalSecond)) return timestamp;
  377.  
  378. const linkElementHTML = `<a class="jump-link video-time" onclick="(async () => {
  379. // jump to exact time
  380. const videoElement = document.querySelector('video.gsl-video');
  381. videoElement.currentTime = ${totalSecond};
  382.  
  383. // close comment module
  384. document.querySelector('.close-comment-module-btn').click();
  385.  
  386. // scroll to top
  387. window.scrollTo(0, 0);
  388.  
  389. // play video if it is paused
  390. if (videoElement.paused) videoElement.play();
  391. })()">${timestamp}</a>`
  392. keywordBlacklist.push(linkElementHTML);
  393.  
  394. return linkElementHTML;
  395. });
  396.  
  397. // convert @ user
  398. if (content.at_name_to_mid) {
  399. for (const [key, value] of Object.entries(content.at_name_to_mid)) {
  400. const linkElementHTML = `<a class="jump-link user" data-user-id="${value}" href="https://space.bilibili.com/${value}" target="_blank" noopener noreferrer>@${key}</a>`;
  401. keywordBlacklist.push(linkElementHTML);
  402. result = result.replaceAll(`@${key}`, linkElementHTML);
  403. }
  404. }
  405.  
  406. // convert url to link
  407. if (Object.keys(content.jump_url).length) {
  408. // make sure links are converted first
  409. const entries = [].concat(
  410. Object.entries(content.jump_url).filter(entry => entry[0].startsWith('https://')),
  411. Object.entries(content.jump_url).filter(entry => !entry[0].startsWith('https://'))
  412. );
  413.  
  414. for (const [key, value] of entries) {
  415. const href = (key.startsWith('BV') || /^av\d+$/.test(key)) ? `https://www.bilibili.com/video/${key}` : (value.pc_url || key);
  416. if (href.includes('search.bilibili.com') && keywordBlacklist.join('').includes(key)) continue;
  417. const linkElementHTML = `<img class="icon normal" src="${value.prefix_icon}" style="${value.extra && value.extra.is_word_search && 'width: 12px;'}"><a class="jump-link normal" href="${href}" target="_blank" noopener noreferrer>${value.title}</a>`;
  418. keywordBlacklist.push(linkElementHTML);
  419. result = result.replaceAll(key, linkElementHTML);
  420. }
  421. }
  422.  
  423. return result;
  424. }
  425.  
  426. function getImageItems(images) {
  427. let imageSizeConfig = 'width: 84px; height: 84px;';
  428. if (images.length === 1) imageSizeConfig = 'max-width: 260px; max-height: 180px;';
  429. if (images.length === 2) imageSizeConfig = 'width: 128px; height: 128px;';
  430.  
  431. let result = '';
  432. for (const image of images) {
  433. result += `<div class="image-item-wrap" style="margin-top: 4px; margin-right: 4px; cursor: zoom-in;"><img src="${image.img_src}" style="border-radius: 4px; ${imageSizeConfig}"></div>`;
  434. }
  435. return result;
  436. }
  437.  
  438. function getSubReplyItems(subReplies) {
  439. if (!(subReplies instanceof Array)) return '';
  440.  
  441. let result = '';
  442. for (const replyData of subReplies) {
  443. result += `
  444. <div class="sub-reply-item">
  445. <div class="sub-user-info">
  446. <a class="sub-reply-avatar" href="https://space.bilibili.com/${replyData.mid}" target="_blank" data-user-id="${replyData.mid}" data-root-reply-id="${replyData.rpid}">
  447. <div class="avatar">
  448. <div class="bili-avatar">
  449. <img class="bili-avatar-img bili-avatar-face bili-avatar-img-radius" data-src="${replyData.member.avatar}" alt="" src="${replyData.member.avatar}">
  450. <span class="bili-avatar-icon bili-avatar-right-icon bili-avatar-size-24"></span>
  451. </div>
  452. </div>
  453. </a>
  454. <a class="sub-user-name" href="https://space.bilibili.com/${replyData.mid}" target="_blank" data-user-id="${replyData.mid}" data-root-reply-id="${replyData.rpid}" style="color: ${replyData.member.vip.nickname_color ? replyData.member.vip.nickname_color : '#61666d'}">${replyData.member.uname}</a>
  455. <span style="height: 14px; padding: 0 2px; margin-right: 4px; display: flex; align-items: center; font-size: 10px; color: white; border-radius: 2px; background-color: ${getMemberLevelColor(replyData.member.level_info.current_level)};">LV${replyData.member.level_info.current_level}</span>
  456. ${
  457. createrID === replyData.mid
  458. ? `<i class="svg-icon up-web up-icon" style="width: 20px; height: 24px; transform: scale(1.03);"><svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg"><rect x="0" y="4" width="24" height="16" rx="2" fill="#FF6699"></rect><path d="M5.7 8.36V12.79C5.7 13.72 5.96 14.43 6.49 14.93C6.99 15.4 7.72 15.64 8.67 15.64C9.61 15.64 10.34 15.4 10.86 14.92C11.38 14.43 11.64 13.72 11.64 12.79V8.36H10.47V12.81C10.47 13.43 10.32 13.88 10.04 14.18C9.75 14.47 9.29 14.62 8.67 14.62C8.04 14.62 7.58 14.47 7.3 14.18C7.01 13.88 6.87 13.43 6.87 12.81V8.36H5.7ZM13.0438 8.36V15.5H14.2138V12.76H15.9838C17.7238 12.76 18.5938 12.02 18.5938 10.55C18.5938 9.09 17.7238 8.36 16.0038 8.36H13.0438ZM14.2138 9.36H15.9138C16.4238 9.36 16.8038 9.45 17.0438 9.64C17.2838 9.82 17.4138 10.12 17.4138 10.55C17.4138 10.98 17.2938 11.29 17.0538 11.48C16.8138 11.66 16.4338 11.76 15.9138 11.76H14.2138V9.36Z" fill="white"></path></svg></i>`
  459. : ''
  460. }
  461. </div>
  462. <span class="reply-content-container sub-reply-content">
  463. <span class="reply-content">${getConvertedMessage(replyData.content)}</span>
  464. </span>
  465. <div class="sub-reply-info" style="margin: 4px 0;">
  466. <span class="sub-reply-time" style="margin-right: 20px;">${getFormattedTime(replyData.ctime)}</span>
  467. <span class="sub-reply-like">
  468. <i class="svg-icon like use-color sub-like-icon" style="width: 16px; height: 16px;"><svg class="icon" viewBox="0 0 1024 1024" version="1.1" xmlns="http://www.w3.org/2000/svg" p-id="3323" width="200" height="200"><path d="M594.176 151.168a34.048 34.048 0 0 0-29.184 10.816c-11.264 13.184-15.872 24.064-21.504 40.064l-1.92 5.632c-5.632 16.128-12.8 36.864-27.648 63.232-25.408 44.928-50.304 74.432-86.208 97.024-23.04 14.528-43.648 26.368-65.024 32.576v419.648a4569.408 4569.408 0 0 0 339.072-4.672c38.72-2.048 72-21.12 88.96-52.032 21.504-39.36 47.168-95.744 63.552-163.008a782.72 782.72 0 0 0 22.528-163.008c0.448-16.832-13.44-32.256-35.328-32.256h-197.312a32 32 0 0 1-28.608-46.336l0.192-0.32 0.64-1.344 2.56-5.504c2.112-4.8 5.12-11.776 8.32-20.16 6.592-17.088 13.568-39.04 16.768-60.416 4.992-33.344 3.776-60.16-9.344-84.992-14.08-26.688-30.016-33.728-40.512-34.944zM691.84 341.12h149.568c52.736 0 100.864 40.192 99.328 98.048a845.888 845.888 0 0 1-24.32 176.384 742.336 742.336 0 0 1-69.632 178.56c-29.184 53.44-84.48 82.304-141.76 85.248-55.68 2.88-138.304 5.952-235.712 5.952-96 0-183.552-3.008-244.672-5.76-66.432-3.136-123.392-51.392-131.008-119.872a1380.672 1380.672 0 0 1-0.768-296.704c7.68-72.768 70.4-121.792 140.032-121.792h97.728c13.76 0 28.16-5.504 62.976-27.456 24.064-15.104 42.432-35.2 64.512-74.24 11.904-21.184 17.408-36.928 22.912-52.8l2.048-5.888c6.656-18.88 14.4-38.4 33.28-60.416a97.984 97.984 0 0 1 85.12-32.768c35.264 4.096 67.776 26.88 89.792 68.608 22.208 42.176 21.888 84.864 16 124.352a342.464 342.464 0 0 1-15.424 60.544z m-393.216 477.248V405.184H232.96c-40.448 0-72.448 27.712-76.352 64.512a1318.912 1318.912 0 0 0 0.64 282.88c3.904 34.752 32.96 61.248 70.4 62.976 20.8 0.96 44.8 1.92 71.04 2.816z" p-id="3324" fill="#9499a0"></path></svg></i>
  469. <span>${replyData.like}</span>
  470. </span>
  471. </div>
  472. </div>
  473. `;
  474. }
  475.  
  476. return result;
  477. }
  478.  
  479. async function loadPaginatedSubReplies(rootReplyID, subReplyList, subReplyAmount, paginationNumber) {
  480. // replace reply list with new replies
  481. const subReplyData = await fetch(`https://api.bilibili.com/x/v2/reply/reply?oid=${oid}&pn=${paginationNumber}&ps=10&root=${rootReplyID}&type=${commentType}`).then(res => res.json()).then(json => json.data);
  482. subReplyList.innerHTML = getSubReplyItems(subReplyData.replies);
  483.  
  484. // add page switcher
  485. addSubReplyPageSwitcher(rootReplyID, subReplyList, subReplyAmount, paginationNumber);
  486.  
  487. // scroll to the top of replyItem
  488. const replyItem = subReplyList.parentElement.parentElement;
  489. replyItem.scrollIntoView({ behavior: 'instant' });
  490.  
  491. // scroll up a bit more because of the fixed header
  492. global.scrollTo(0, document.documentElement.scrollTop - 60);
  493. }
  494.  
  495. function addSubReplyPageSwitcher(rootReplyID, subReplyList, subReplyAmount, currentPageNumber) {
  496. if (subReplyAmount <= 10) return;
  497.  
  498. const pageAmount = Math.ceil(subReplyAmount / 10);
  499. const pageSwitcher = document.createElement('div');
  500. pageSwitcher.classList.add('view-more');
  501. pageSwitcher.innerHTML = `
  502. <div class="view-more-pagination">
  503. <span class="pagination-page-count">共${pageAmount}页</span>
  504. ${ currentPageNumber !== 1 ? '<span class="pagination-btn pagination-to-prev-btn">上一页</span>' : '' }
  505. ${
  506. (() => {
  507. // 4 on the left, 4 on the right, then merge
  508. const left = [currentPageNumber - 4, currentPageNumber - 3, currentPageNumber - 2, currentPageNumber - 1].filter(num => num >= 1);
  509. const right = [currentPageNumber + 1, currentPageNumber + 2, currentPageNumber + 3, currentPageNumber + 4].filter(num => num <= pageAmount);
  510. const merge = [].concat(left, currentPageNumber, right);
  511.  
  512. // chosen 5(if able)
  513. let chosen;
  514. if (currentPageNumber <= 3) chosen = merge.slice(0, 5);
  515. else if (currentPageNumber >= pageAmount - 3) chosen = merge.reverse().slice(0, 5).reverse();
  516. else chosen = merge.slice(merge.indexOf(currentPageNumber) - 2, merge.indexOf(currentPageNumber) + 3);
  517.  
  518. // add first and dots
  519. let final = JSON.parse(JSON.stringify(chosen));
  520. if (!final.includes(1)) {
  521. let front = [1];
  522. if (final.at(0) !== 2) front = [1, '...'];
  523. final = [].concat(front, final);
  524. }
  525.  
  526. // add last and dots
  527. if (!final.includes(pageAmount)) {
  528. let back = [pageAmount];
  529. if (final.at(-1) !== pageAmount - 1) back = ['...', pageAmount];
  530. final = [].concat(final, back);
  531. }
  532.  
  533. // assemble to html
  534. return final.reduce((acc, cur) => {
  535. if (cur === '...') return acc + '<span class="pagination-page-dot">...</span>';
  536. if (cur === currentPageNumber) return acc + `<span class="pagination-page-number current-page">${cur}</span>`;
  537. return acc + `<span class="pagination-page-number">${cur}</span>`;
  538. }, '');
  539. })()
  540. }
  541. ${ currentPageNumber !== pageAmount ? '<span class="pagination-btn pagination-to-next-btn">下一页</span>': '' }
  542. </div>
  543. `;
  544. // add click event listener
  545. pageSwitcher.querySelector('.pagination-to-prev-btn')?.addEventListener('click', () => loadPaginatedSubReplies(rootReplyID, subReplyList, subReplyAmount, currentPageNumber - 1));
  546. pageSwitcher.querySelector('.pagination-to-next-btn')?.addEventListener('click', () => loadPaginatedSubReplies(rootReplyID, subReplyList, subReplyAmount, currentPageNumber + 1));
  547. pageSwitcher.querySelectorAll('.pagination-page-number:not(.current-page)')?.forEach(pageNumberElement => {
  548. const number = parseInt(pageNumberElement.textContent);
  549. pageNumberElement.addEventListener('click', () => loadPaginatedSubReplies(rootReplyID, subReplyList, subReplyAmount, number));
  550. });
  551.  
  552. // append page switcher
  553. subReplyList.appendChild(pageSwitcher);
  554. }
  555.  
  556. function addAnchor() {
  557. const anchorElement = document.createElement('div');
  558. anchorElement.classList.add('anchor-for-loading');
  559. anchorElement.textContent = '正在加载...';
  560. anchorElement.style = `text-align: center; color: #61666d; transform: translateY(-50px);`;
  561. document.querySelector('.comment-container .reply-warp').appendChild(anchorElement);
  562.  
  563. let paginationCounter = 1;
  564. const ob = new IntersectionObserver(async (entries) => {
  565. if (!entries[0].isIntersecting) return;
  566.  
  567. const { data: newPaginationData } = await getPaginationData(++paginationCounter);
  568. if (!newPaginationData.replies || newPaginationData.replies.length === 0) {
  569. anchorElement.textContent = '所有评论已加载完毕';
  570. ob.disconnect();
  571. return;
  572. }
  573.  
  574. for (const replyData of newPaginationData.replies) {
  575. appendReplyItem(replyData);
  576. }
  577. });
  578.  
  579. ob.observe(anchorElement);
  580. }
  581.  
  582. // bvid to aid, ref: https://greatest.deepsurf.us/scripts/394296
  583. function b2a(bvid) {
  584. const XOR_CODE = 23442827791579n;
  585. const MASK_CODE = 2251799813685247n;
  586. const BASE = 58n;
  587. const BYTES = ["B", "V", 1, "", "", "", "", "", "", "", "", ""];
  588. const BV_LEN = BYTES.length;
  589. const ALPHABET = 'FcwAPNKTMug3GV5Lj7EJnHpWsx4tb8haYeviqBz6rkCy12mUSDQX9RdoZf'.split('');
  590. const DIGIT_MAP = [0, 1, 2, 9, 7, 5, 6, 4, 8, 3, 10, 11];
  591.  
  592. let r = 0n;
  593. for (let i = 3; i < BV_LEN; i++) {
  594. r = r * BASE + BigInt(ALPHABET.indexOf(bvid[DIGIT_MAP[i]]));
  595. }
  596. return `${r & MASK_CODE ^ XOR_CODE}`;
  597. }
  598.  
  599. // ref: https://socialsisteryi.github.io/bilibili-API-collect/docs/misc/sign/wbi.html
  600. async function getWbiQueryString(params) {
  601. // get origin key
  602. const { img_url, sub_url } = await fetch('https://api.bilibili.com/x/web-interface/nav').then(res => res.json()).then(json => json.data.wbi_img);
  603. const imgKey = img_url.slice(img_url.lastIndexOf('/') + 1, img_url.lastIndexOf('.'));
  604. const subKey = sub_url.slice(sub_url.lastIndexOf('/') + 1, sub_url.lastIndexOf('.'));
  605. const originKey = imgKey + subKey;
  606.  
  607. // get mixin key
  608. const mixinKeyEncryptTable = [
  609. 46, 47, 18, 2, 53, 8, 23, 32, 15, 50, 10, 31, 58, 3, 45, 35, 27, 43, 5, 49,
  610. 33, 9, 42, 19, 29, 28, 14, 39, 12, 38, 41, 13, 37, 48, 7, 16, 24, 55, 40,
  611. 61, 26, 17, 0, 1, 60, 51, 30, 4, 22, 25, 54, 21, 56, 59, 6, 63, 57, 62, 11,
  612. 36, 20, 34, 44, 52
  613. ];
  614. const mixinKey = mixinKeyEncryptTable.map(n => originKey[n]).join('').slice(0, 32);
  615.  
  616. // generate basic query string
  617. const query = Object
  618. .keys(params)
  619. .sort() // sort properties by key
  620. .map(key => {
  621. const value = params[key].toString().replace(/[!'()*]/g, ''); // remove characters !'()* in value
  622. return `${encodeURIComponent(key)}=${encodeURIComponent(value)}`
  623. })
  624. .join('&');
  625.  
  626. // calculate wbi sign
  627. const wbiSign = SparkMD5.hash(query + mixinKey);
  628.  
  629. return query + '&w_rid=' + wbiSign;
  630. }
  631.  
  632. function setupXHRInterceptor() {
  633. const originXHROpen = XMLHttpRequest.prototype.open;
  634. XMLHttpRequest.prototype.open = function () {
  635. const url = arguments[1];
  636. if (typeof url === 'string' && url.includes('reply/wbi/main')) {
  637. const { searchParams } = new URL(`${url.startsWith('//') ? 'https:' : ''}${url}`);
  638. global.dynamicDetail = {
  639. oid: searchParams.get('oid'),
  640. commentType: searchParams.get('type')
  641. }
  642. }
  643. return originXHROpen.apply(this, arguments);
  644. }
  645. }
  646.  
  647. async function addStyle() {
  648. // wait until document is ready
  649. await new Promise(resolve => {
  650. const timer = setInterval(() => {
  651. if (document && document.createElement && document.head && document.head.appendChild) { clearInterval(timer); resolve(); }
  652. }, 100);
  653. });
  654.  
  655. // reply header CSS
  656. const replyHeaderCSS = document.createElement('style');
  657. replyHeaderCSS.textContent = `
  658. .reply-header {
  659. padding: 12px;
  660. border-bottom: 1px solid #f1f2f3;
  661. }
  662.  
  663. .reply-navigation {
  664. margin-bottom: 0 !important;
  665. }
  666.  
  667. .reply-navigation .nav-bar .nav-title {
  668. font-size: 1rem !important;
  669. }
  670. `;
  671. document.head.appendChild(replyHeaderCSS);
  672.  
  673. // reply list CSS
  674. const replyListCSS = document.createElement('style');
  675. replyListCSS.textContent = `
  676. .reply-list {
  677. margin-top: 0 !important;
  678. margin-bottom: 0 !important;
  679. }
  680.  
  681. .reply-item {
  682. padding: 12px !important;
  683. font-size: 1rem !important;
  684. border-bottom: 1px solid #f4f5f7;
  685. }
  686.  
  687. .reply-item .root-reply-container {
  688. padding: 0 !important;
  689. display: flex;
  690. }
  691.  
  692. .reply-item .root-reply-container .root-reply-avatar {
  693. position: relative !important;
  694. width: initial !important;
  695. }
  696.  
  697. .reply-item .root-reply-container .content-warp {
  698. margin-left: 12px;
  699. }
  700.  
  701. .reply-item .root-reply-container .content-warp .user-info,
  702. .reply-item .root-reply-container .content-warp .root-reply .reply-content {
  703. font-size: 14px !important;
  704. }
  705.  
  706. .reply-item .root-reply-container .content-warp .root-reply .reply-content-container {
  707. width: calc(100vw - 88px) !important;
  708. }
  709.  
  710. .reply-item .root-reply-container .content-warp .root-reply .reply-content .note-prefix {
  711. margin-right: 4px !important;
  712. }
  713.  
  714. .reply-item .sub-reply-container {
  715. padding-left: 44px !important;
  716. }
  717.  
  718. .reply-item .sub-reply-container .sub-reply-list .sub-reply-item {
  719. width: calc(100% - 24px);
  720. }
  721.  
  722. .reply-item .sub-reply-container .sub-reply-list .sub-reply-item .sub-user-info {
  723. margin-right: 0 !important;
  724. }
  725.  
  726. .reply-item .sub-reply-container .sub-reply-list .sub-reply-item .sub-user-info .sub-user-name,
  727. .reply-item .sub-reply-container .sub-reply-list .sub-reply-item .reply-content {
  728. font-size: 14px !important;
  729. }
  730.  
  731. .reply-info .reply-time,
  732. .reply-info .reply-like,
  733. .sub-reply-info .sub-reply-time,
  734. .sub-reply-info .sub-reply-like {
  735. margin-right: 12px !important;
  736. }
  737. `;
  738. document.head.appendChild(replyListCSS);
  739.  
  740. // avatar CSS
  741. const avatarCSS = document.createElement('style');
  742. avatarCSS.textContent = `
  743. .reply-item .root-reply-avatar .avatar .bili-avatar {
  744. width: 40px;
  745. height: 40px;
  746. }
  747.  
  748. .sub-reply-item .sub-reply-avatar .avatar .bili-avatar {
  749. width: 24px;
  750. height: 24px;
  751. }
  752. `;
  753. document.head.appendChild(avatarCSS);
  754.  
  755. // view-more CSS
  756. const viewMoreCSS = document.createElement('style');
  757. viewMoreCSS.textContent = `
  758. .sub-reply-container .view-more-btn:hover {
  759. color: #00AEEC;
  760. }
  761.  
  762. .view-more {
  763. padding-left: 8px;
  764. color: #222;
  765. font-size: 13px;
  766. user-select: none;
  767. }
  768.  
  769. .pagination-page-count {
  770. margin-right: 4px !important;
  771. }
  772.  
  773. .pagination-page-dot,
  774. .pagination-page-number {
  775. margin: 0 4px;
  776. }
  777.  
  778. .pagination-btn,
  779. .pagination-page-number {
  780. cursor: pointer;
  781. }
  782.  
  783. .current-page,
  784. .pagination-btn:hover,
  785. .pagination-page-number:hover {
  786. color: #00AEEC;
  787. }
  788. `;
  789. document.head.appendChild(viewMoreCSS);
  790.  
  791. // add other CSS
  792. const otherCSS = document.createElement('style');
  793. otherCSS.textContent = `
  794. :root {
  795. --text1: #18191C;
  796. --text3: #9499A0;
  797. --brand_blue: #00AEEC;
  798. --brand_pink: #FF6699;
  799. --bg2: #F6F7F8;
  800. }
  801.  
  802. .jump-link {
  803. color: #008DDA;
  804. }
  805. `;
  806. document.head.appendChild(otherCSS);
  807. }
  808.  
  809. })();