import { BilibiliApiConfig } from "@/api/BilibiliApiConfig"; import { addStyle, DOMUtils, httpx, log, utils } from "@/env"; import { XhrHook } from "@/hook/BilibiliNetworkHook"; import { BilibiliGlobalData } from "@/main/BilibiliGlobalData"; import { BilibiliRouter } from "@/router/BilibiliRouter"; import Qmsg from "qmsg"; import Viewer from "viewerjs"; import { unsafeWindow } from "ViteGM"; import { b2a } from "./b2a"; import { wbi } from "./wbi"; // patch for 'unsafeWindow is not defined' const global = typeof unsafeWindow === "undefined" ? window : unsafeWindow; // define sort types const sortTypeConstant = { LATEST: 0, HOT: 2 }; /** * * + https://greasyfork.org/zh-CN/scripts/524844-bilibili-mobile-comment-module */ export const MobileCommentModule = { $data: { oid: null as null | string, createrID: void 0 as undefined | string, commentType: null as null | string | number, /** * 当前评论排序类型 * * + 0 最新 * + 2 最热 */ currentSortType: sortTypeConstant.HOT, replyPool: {}, nextOffset: "", dynamicDetail: { oid: null as string | null, commentType: null as string | null, }, }, $el: { /** * 回复列表元素 */ replyList: null as null | HTMLElement, /** * 排序导航元素 */ navSort: null as null | HTMLElement, /** * 最热 */ hotSort: null as null | HTMLElement, /** * 最新 */ timeSort: null as null | HTMLElement, /** * 评论数量 */ totalReply: null as null | HTMLElement, /** * 评论容器 */ replyWrapper: null as null | HTMLElement, }, $flag: { isInitCSS: false, isHookNetwork: false, }, async init($el: HTMLElement) { this.initData(); this.networkHook(); this.addStyle(); this.setupStandardCommentContainer($el); // collect oid & commentType await new Promise((resolve) => { DOMUtils.wait(() => { if (BilibiliRouter.isVideo()) { const videoID = global.location.pathname.replace("/video/", "").replace("/", ""); if (videoID.startsWith("av")) this.$data.oid = videoID.slice(2); if (videoID.startsWith("BV")) this.$data.oid = b2a(videoID); this.$data.commentType = 1; } else if (BilibiliRouter.isDynamic()) { this.$data.oid = this.$data.dynamicDetail?.oid; this.$data.commentType = this.$data.dynamicDetail?.commentType; } else if (BilibiliRouter.isOpus()) { // @ts-expect-error this.$data.oid = global?.__INITIAL_STATE__?.opus?.detail?.basic?.comment_id_str; // @ts-expect-error this.$data.commentType = global?.__INITIAL_STATE__?.opus?.detail?.basic?.comment_type; // should be '11' } // final check if (this.$data.oid && this.$data.commentType) { // utils.workerClearInterval(timer); resolve(null); return { success: true, data: {}, }; } return { success: false, data: null, }; }, 0); }); await this.enableSwitchingSortType($el); await this.loadFirstPagination($el); }, /** * 初始化数据 */ initData() { this.$data.oid = null; this.$data.createrID = void 0; this.$data.commentType = null; // @ts-expect-error this.$data.replyPool = null; this.$data.replyPool = {}; this.$data.nextOffset = ""; this.$data.currentSortType = sortTypeConstant.HOT; this.$data.dynamicDetail = { oid: null, commentType: null, }; Object.keys(this.$el).forEach((key) => { const value = Reflect.get(this.$el, key); if (!document.contains(value)) return; Reflect.set(this.$el, key, null); }); }, /** * 网络劫持 */ networkHook() { if (this.$flag.isHookNetwork) return; if (!BilibiliRouter.isDynamic()) return; XhrHook.ajaxHooker.hook((request) => { const url = request.url; if (typeof url === "string" && url.includes("reply/wbi/main")) { const { searchParams } = new URL(`${url.startsWith("//") ? "https:" : ""}${url}`); this.$data.dynamicDetail = { oid: searchParams.get("oid"), commentType: searchParams.get("type"), }; } }); }, /** * 添加样式 */ async addStyle() { if (this.$flag.isInitCSS) return; this.$flag.isInitCSS = true; await DOMUtils.onReady(); // reply header CSS addStyle(/*css*/ ` .reply-header { padding: 12px; border-bottom: 1px solid #f1f2f3; } .reply-navigation { margin-bottom: 0 !important; } .reply-navigation .nav-bar .nav-title { font-size: 1rem !important; } `); // reply list CSS addStyle(/*css*/ ` .reply-list { margin-top: 0 !important; margin-bottom: 0 !important; } .reply-item { padding: 12px !important; font-size: 1rem !important; border-bottom: 1px solid #f4f5f7; } .reply-item .root-reply-container { padding: 0 !important; display: flex; } .reply-item .root-reply-container .root-reply-avatar { position: relative !important; width: initial !important; } .reply-item .root-reply-container .content-warp { margin-left: 12px; } .reply-item .root-reply-container .content-warp .user-info, .reply-item .root-reply-container .content-warp .root-reply .reply-content { font-size: 14px !important; } .reply-item .root-reply-container .content-warp .root-reply .reply-content-container { width: calc(100vw - 88px) !important; } .reply-item .root-reply-container .content-warp .root-reply .reply-content .note-prefix { margin-right: 4px !important; } .reply-item .sub-reply-container { padding-left: 44px !important; } .reply-item .sub-reply-container .sub-reply-list .sub-reply-item { width: calc(100% - 24px); } .reply-item .sub-reply-container .sub-reply-list .sub-reply-item .sub-user-info { margin-right: 0 !important; } .reply-item .sub-reply-container .sub-reply-list .sub-reply-item .sub-user-info .sub-user-name, .reply-item .sub-reply-container .sub-reply-list .sub-reply-item .reply-content { font-size: 14px !important; } .reply-info .reply-time, .reply-info .reply-like, .sub-reply-info .sub-reply-time, .sub-reply-info .sub-reply-like { margin-right: 12px !important; } `); // avatar CSS const avatarCSS = document.createElement("style"); avatarCSS.textContent = ` `; addStyle(/*css*/ ` .reply-item .root-reply-avatar .avatar .bili-avatar { width: 40px; height: 40px; } .sub-reply-item .sub-reply-avatar .avatar .bili-avatar { width: 24px; height: 24px; } `); // view-more CSS addStyle(/*css*/ ` .sub-reply-container .view-more-btn:hover { color: #00AEEC; } .view-more { padding-left: 8px; color: #222; font-size: 13px; user-select: none; } .pagination-page-count { margin-right: 4px !important; } .pagination-page-dot, .pagination-page-number { margin: 0 4px; } .pagination-btn, .pagination-page-number { cursor: pointer; } .current-page, .pagination-btn:hover, .pagination-page-number:hover { color: #00AEEC; } `); // add other CSS const otherCSS = document.createElement("style"); otherCSS.textContent = ` `; addStyle(/*css*/ ` :root { --text1: #18191C; --text3: #9499A0; --brand_blue: #00AEEC; --brand_pink: #FF6699; --bg2: #F6F7F8; } .jump-link { color: #008DDA; } `); }, /** * setup standard comment container & get reply list */ setupStandardCommentContainer($root: HTMLElement) { DOMUtils.html( $root, /*html*/ `
` ); this.$el.replyList = $root.querySelector(".reply-list"); this.$el.navSort = $root.querySelector(".comment-container .reply-header .nav-sort"); this.$el.hotSort = this.$el.navSort!.querySelector(".hot-sort"); this.$el.timeSort = this.$el.navSort!.querySelector(".time-sort"); this.$el.totalReply = $root.querySelector(".comment-container .reply-header .total-reply"); this.$el.replyWrapper = $root.querySelector(".comment-container .reply-warp"); }, /** * enable switching sort type */ enableSwitchingSortType($root: HTMLElement) { // reset classes DOMUtils.addClass(this.$el.navSort!, "hot"); DOMUtils.removeClass(this.$el.navSort!, "time"); // setup click event listener DOMUtils.on(this.$el.hotSort, "click", (evt) => { DOMUtils.preventEvent(evt); if (this.$data.currentSortType === sortTypeConstant.HOT) return; this.$data.currentSortType = sortTypeConstant.HOT; DOMUtils.addClass(this.$el.navSort!, "hot"); DOMUtils.removeClass(this.$el.navSort!, "time"); $root.scrollTo(0, 0); this.loadFirstPagination($root); }); DOMUtils.on(this.$el.timeSort, "click", (evt) => { DOMUtils.preventEvent(evt); if (this.$data.currentSortType === sortTypeConstant.LATEST) return; this.$data.currentSortType = sortTypeConstant.LATEST; DOMUtils.addClass(this.$el.navSort!, "time"); DOMUtils.removeClass(this.$el.navSort!, "hot"); $root.scrollTo(0, 0); this.loadFirstPagination($root); }); }, /** * load first pagination */ async loadFirstPagination($root: HTMLElement) { // reset offset data this.$data.nextOffset = ""; // get data of first pagination const { data: firstPaginationData, code: resultCode } = await this.getPaginationData(1); // get creater ID this.$data.createrID = firstPaginationData.upper.mid; // clear replyList DOMUtils.empty(this.$el.replyList!); // clear replyPool this.$data.replyPool = {}; // clear bottom modules DOMUtils.remove(".comment-container .reply-warp .no-more-replies-info"); DOMUtils.remove(".comment-container .reply-warp .anchor-for-loading"); // script ends here if not able to fetch pagination data if (resultCode !== 0) { // ref: BV12r4y147Bj const info = resultCode === 12061 ? "UP主已关闭评论区" : "无法从API获取评论数据"; DOMUtils.html( this.$el.replyList!, /*html*/ `

${info}

` ); return; } // load reply count const totalReplyCount = parseInt(firstPaginationData?.cursor?.all_count) || 0; DOMUtils.text(this.$el.totalReply!, totalReplyCount); // check whether replies are selected // ref: BV1Dy2mY3EGy if (firstPaginationData?.cursor?.name?.includes("精选")) { DOMUtils.html( this.$el.navSort!, /*html*/ `
精选评论
` ); } // load the top reply if it exists if (firstPaginationData.top_replies && firstPaginationData.top_replies.length !== 0) { const topReplyData = firstPaginationData.top_replies[0]; this.appendReplyItem(topReplyData, true); } // load normal replies for (const replyData of firstPaginationData.replies) { this.appendReplyItem(replyData); } // script ends here if there are no more replies if (firstPaginationData.replies.length === 0 || firstPaginationData.cursor.is_end) { const infoElement = DOMUtils.createElement( "p", { className: "no-more-replies-info", textContent: "没有更多评论", }, { style: "padding-bottom: 100px; text-align: center; color: #999;", } ); DOMUtils.append(this.$el.replyWrapper!, infoElement); return; } // add page loader this.addAnchor(); }, async getPaginationData(plat: number) { const params = { pagination_str: JSON.stringify({ offset: this.$data.nextOffset || "", }), oid: this.$data.oid, type: this.$data.commentType, wts: parseInt((Date.now() / 1000).toString()), // plat: 1, // web_location: 1315875, }; if (this.$data.currentSortType === sortTypeConstant.HOT) { Reflect.set(params, "mode", 3); if (!this.$data.nextOffset) { // params.seek_rpid = ""; } } else if (this.$data.currentSortType === sortTypeConstant.LATEST) { Reflect.set(params, "mode", 2); } const isLogin = await BilibiliGlobalData.$data.isLogin; // 已登录就用跨域请求且不带Cookie // 未登录就用fetch请求 const fetchResult = await httpx.get( `https://${BilibiliApiConfig.web_host}/x/v2/reply/wbi/main?${await wbi(params)}`, { fetch: !isLogin, fetchInit: { credentials: "same-origin", }, anonymous: !isLogin, } ); const fetchResultJSON = utils.toJSON(fetchResult.data.responseText); this.$data.nextOffset = fetchResultJSON.data.cursor?.pagination_reply?.next_offset || ""; return fetchResultJSON; }, /** * 添加评论项 */ appendReplyItem(replyData: any, isTopReply?: boolean) { if ( typeof this.$data.replyPool === "object" && this.$data.replyPool != null && // @ts-expect-error this.$data.replyPool[replyData.rpid] ) { // 评论重复了 return; } const $replyItem = DOMUtils.createElement("div", { className: "reply-item", innerHTML: /*html*/ `
${ isTopReply ? '置顶' : "" }${ replyData.content.pictures ? `
笔记
` : "" }${this.getConvertedMessage(replyData.content)}
${ replyData.content.pictures ? `
${this.getImageItems(replyData.content.pictures)}
` : "" }
${this.getFormattedTime(replyData.ctime)} ${replyData.like}
${ replyData.card_label ? replyData.card_label.reduce( (acc: any, cur: any) => acc + `${cur.text_content}`, "" ) : "" }
${this.getSubReplyItems(replyData.replies)} ${ replyData.rcount > (replyData.replies || []).length ? `
共${replyData.rcount}条回复, 点击查看
` : "" }
`, }); DOMUtils.append(this.$el.replyList!, $replyItem); // @ts-expect-error this.$data.replyPool[replyData.rpid_str] = true; // setup image viewer const $previewImageContainer = $replyItem.querySelector(".preview-image-container"); if ($previewImageContainer) new Viewer($previewImageContainer, { title: false, toolbar: false, tooltip: false, keyboard: false, }); // setup view more button const subReplyList = $replyItem.querySelector(".sub-reply-list")!; const viewMoreBtn = $replyItem.querySelector(".view-more-btn")!; if (viewMoreBtn) { DOMUtils.on(viewMoreBtn, "click", () => { this.loadPaginatedSubReplies(replyData.rpid, subReplyList, replyData.rcount, 1); }); } }, getFormattedTime(ms: number) { const time = new Date(ms * 1000); const year = time.getFullYear(); const month = (time.getMonth() + 1).toString().padStart(2, "0"); const day = time.getDate().toString().padStart(2, "0"); const hour = time.getHours().toString().padStart(2, "0"); const minute = time.getMinutes().toString().padStart(2, "0"); return `${year}-${month}-${day} ${hour}:${minute}`; }, /** * 获取等级颜色 */ getMemberLevelColor(level: number | string) { return { 0: "#C0C0C0", 1: "#BBBBBB", 2: "#8BD29B", 3: "#7BCDEF", 4: "#FEBB8B", 5: "#EE672A", 6: "#F04C49", }[level]; }, getConvertedMessage(content: any) { let result: string = content.message; // built blacklist of keyword, to avoid being converted to link incorrectly const keywordBlacklist = ["https://www.bilibili.com/video/av", "https://b23.tv/mall-"]; // convert vote to link if (content.vote && content.vote.deleted === false) { const linkElementHTML = /*html*/ `${content.vote.title}`; keywordBlacklist.push(linkElementHTML); result = result.replace(`{vote:${content.vote.id}}`, linkElementHTML); } // convert emote tag to image if (content.emote) { for (const [key, value] of Object.entries(content.emote)) { const imageElementHTML = `${key}`; keywordBlacklist.push(imageElementHTML); result = result.replaceAll(key, imageElementHTML); } } // convert timestamp to link result = result.replaceAll(/(\d{1,2}[::]){1,2}\d{1,2}/g, (timestamp) => { timestamp = timestamp.replaceAll(":", ":"); // return plain text if no video in page if (!BilibiliRouter.isVideo()) return timestamp; const parts = timestamp.split(":"); // return plain text if any part of timestamp equal to or bigger than 60 if (parts.some((part) => parseInt(part) >= 60)) return timestamp; let totalSecond; if (parts.length === 2) totalSecond = parseInt(parts[0]) * 60 + parseInt(parts[1]); else if (parts.length === 3) totalSecond = parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseInt(parts[2]); // return plain text if failed to get vaild number of second if (Number.isNaN(totalSecond)) return timestamp; const linkElementHTML = /*html*/ `${timestamp}`; keywordBlacklist.push(linkElementHTML); return linkElementHTML; }); // convert @ user if (content.at_name_to_mid) { for (const [key, value] of Object.entries(content.at_name_to_mid)) { const linkElementHTML = `@${key}`; keywordBlacklist.push(linkElementHTML); result = result.replaceAll(`@${key}`, linkElementHTML); } } // convert url to link if (Object.keys(content.jump_url).length) { // make sure links are converted first const entries: any[] = [].concat( // @ts-expect-error Object.entries(content.jump_url).filter((entry) => entry[0].startsWith("https://")), Object.entries(content.jump_url).filter((entry) => !entry[0].startsWith("https://")) ); for (const [key, value] of entries) { const href = key.startsWith("BV") || /^av\d+$/.test(key) ? `https://www.bilibili.com/video/${key}` : value.pc_url || key; if (href.includes("search.bilibili.com") && keywordBlacklist.join("").includes(key)) continue; const linkElementHTML = /*html*/ `${value.title}`; keywordBlacklist.push(linkElementHTML); result = result.replaceAll(key, linkElementHTML); } } return result; }, getImageItems(images: any[]) { let imageSizeConfig = "width: 84px; height: 84px;"; if (images.length === 1) imageSizeConfig = "max-width: 260px; max-height: 180px;"; if (images.length === 2) imageSizeConfig = "width: 128px; height: 128px;"; let result = ""; for (const image of images) { result += `
`; } return result; }, getSubReplyItems(subReplies: any[]) { if (!(subReplies instanceof Array)) return ""; let result = ""; for (const replyData of subReplies) { result += /*html*/ `
${this.getConvertedMessage(replyData.content)}
${this.getFormattedTime(replyData.ctime)} ${replyData.like}
`; } return result; }, async loadPaginatedSubReplies(rootReplyID: any, subReplyList: any, subReplyAmount: any, paginationNumber: number) { // replace reply list with new replies const params = { oid: this.$data.oid, type: this.$data.commentType, root: rootReplyID, ps: 10, pn: paginationNumber, web_location: 333.788, }; const isLogin = await BilibiliGlobalData.$data.isLogin; // 已登录就用跨域请求且不带Cookie // 未登录就用fetch请求 const subReplyResponse = await httpx.get( `https://${BilibiliApiConfig.web_host}/x/v2/reply/reply?${utils.toSearchParamsStr(params)}`, { allowInterceptConfig: false, fetch: !isLogin, fetchInit: { credentials: "same-origin", }, anonymous: !isLogin, } ); if (!subReplyResponse.status) { log.error(subReplyResponse); Qmsg.error("请求异常,获取评论的回复失败"); return; } const subReplyJSON = utils.toJSON(subReplyResponse.data.responseText); if (subReplyJSON === -352) { Qmsg.error("请登录后再进行操作"); console.error("you should login first", subReplyResponse); return; } const subReplyData = subReplyJSON.data; subReplyList.innerHTML = this.getSubReplyItems(subReplyData.replies); // add page switcher this.addSubReplyPageSwitcher(rootReplyID, subReplyList, subReplyAmount, paginationNumber); // scroll to the top of replyItem const replyItem = subReplyList.parentElement.parentElement; replyItem.scrollIntoView({ behavior: "instant" }); // scroll up a bit more because of the fixed header global.scrollTo(0, document.documentElement.scrollTop - 60); }, addSubReplyPageSwitcher(rootReplyID: any, subReplyList: any, subReplyAmount: any, currentPageNumber: number) { if (subReplyAmount <= 10) return; const pageAmount = Math.ceil(subReplyAmount / 10); const pageSwitcher = DOMUtils.createElement("div", { className: "sub-reply-pagination", innerHTML: /*html*/ `
共${pageAmount}页 ${currentPageNumber !== 1 ? '上一页' : ""} ${(() => { // 4 on the left, 4 on the right, then merge const left = [ currentPageNumber - 4, currentPageNumber - 3, currentPageNumber - 2, currentPageNumber - 1, ].filter((num) => num >= 1); const right = [ currentPageNumber + 1, currentPageNumber + 2, currentPageNumber + 3, currentPageNumber + 4, ].filter((num) => num <= pageAmount); const merge = ([]).concat(left, currentPageNumber, right); // chosen 5(if able) let chosen; if (currentPageNumber <= 3) chosen = merge.slice(0, 5); else if (currentPageNumber >= pageAmount - 3) chosen = merge.reverse().slice(0, 5).reverse(); else chosen = merge.slice(merge.indexOf(currentPageNumber) - 2, merge.indexOf(currentPageNumber) + 3); // add first and dots let final = JSON.parse(JSON.stringify(chosen)); if (!final.includes(1)) { let front = [1]; // @ts-expect-error if (final.at(0) !== 2) front = [1, "..."]; // @ts-expect-error final = [].concat(front, final); } // add last and dots if (!final.includes(pageAmount)) { let back = [pageAmount]; // @ts-expect-error if (final.at(-1) !== pageAmount - 1) back = ["...", pageAmount]; // @ts-expect-error final = [].concat(final, back); } // assemble to html return final.reduce((acc: string, cur: number | string) => { if (cur === "...") return acc + '...'; if (cur === currentPageNumber) return acc + /*html*/ `${cur}`; return acc + /*html*/ `${cur}`; }, ""); })()} ${currentPageNumber !== pageAmount ? /*html*/ `下一页` : ""}
`, }); // add click event listener const pageSwitcherPrevBtn = pageSwitcher.querySelector(".pagination-to-prev-btn"); DOMUtils.on(pageSwitcherPrevBtn, "click", () => { this.loadPaginatedSubReplies(rootReplyID, subReplyList, subReplyAmount, currentPageNumber - 1); }); const pageSwitcherNextBtn = pageSwitcher.querySelector(".pagination-to-next-btn"); DOMUtils.on(pageSwitcherNextBtn, "click", () => { this.loadPaginatedSubReplies(rootReplyID, subReplyList, subReplyAmount, currentPageNumber + 1); }); pageSwitcher.querySelectorAll(".pagination-page-number:not(.current-page)")?.forEach(($pageNumber) => { const number = parseInt($pageNumber.textContent); DOMUtils.on($pageNumber, "click", () => this.loadPaginatedSubReplies(rootReplyID, subReplyList, subReplyAmount, number) ); }); // append page switcher subReplyList.appendChild(pageSwitcher); }, addAnchor() { const anchorElement = DOMUtils.createElement( "div", { className: "anchor-for-loading", textContent: "正在加载...", }, { style: `text-align: center; color: #61666d; transform: translateY(-50px)`, } ); DOMUtils.append(this.$el.replyWrapper!, anchorElement); let paginationCounter = 1; const ob = new IntersectionObserver(async (entries) => { if (!entries[0].isIntersecting) return; const { data: newPaginationData } = await this.getPaginationData(++paginationCounter); if (!newPaginationData.replies || newPaginationData.replies.length === 0) { anchorElement.textContent = "所有评论已加载完毕"; ob.disconnect(); return; } for (const replyData of newPaginationData.replies) { this.appendReplyItem(replyData); } }); ob.observe(anchorElement); }, };