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*/ `
${replyData.member.uname}
LV${replyData.member.level_info.current_level}
${
this.$data.createrID === replyData.mid
? '
'
: ""
}
${
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 = ` `;
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*/ `
${replyData.member.uname}
LV${replyData.member.level_info.current_level}
${
this.$data.createrID === replyData.mid
? `
`
: ""
}
${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*/ `
`,
});
// 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);
},
};