// ==UserScript== // @name B站评论显示状态 // @namespace https://github.com/ZBpine/bili-danmaku-adapt/ // @description 评论显示状态,以便知道是否被阿瓦隆。 // @version 1.2.1 // @author ZBpine // @icon https://www.bilibili.com/favicon.ico // @match https://www.bilibili.com/* // @match https://t.bilibili.com/* // @match https://space.bilibili.com/* // @grant GM_registerMenuCommand // @grant GM_unregisterMenuCommand // @grant GM_getValue // @grant GM_setValue // @grant unsafeWindow // @run-at document-start // @license MIT // ==/UserScript== /******/ (() => { // webpackBootstrap /******/ "use strict"; ;// ./src/reply-settings.css const reply_settings_namespaceObject = ":host {\n font-family: \"PingFang SC\", \"Microsoft YaHei\", sans-serif;\n color-scheme: light dark;\n}\ndialog {\n box-sizing: border-box;\n width: min(360px, calc(100vw - 32px));\n max-height: calc(100vh - 32px);\n overflow: auto;\n margin: auto;\n padding: 0;\n border: 1px solid var(--line_regular, #e3e5e7);\n border-radius: 12px;\n background: var(--bg1_float, #fff);\n color: var(--text1, #18191c);\n box-shadow: 0 12px 48px #0003;\n font-size: 14px;\n}\ndialog::backdrop { background: #0005; }\nheader {\n display: flex;\n align-items: center;\n justify-content: space-between;\n padding: 18px 20px 12px;\n}\nh2 { margin: 0; font-size: 17px; font-weight: 600; }\n.close {\n width: 30px;\n height: 30px;\n border: 0;\n border-radius: 6px;\n background: transparent;\n color: var(--text3, #9499a0);\n font-size: 24px;\n cursor: pointer;\n}\n.close:hover { background: var(--graph_bg_regular_float, #f1f2f3); }\n.options { padding: 0 20px 8px; }\n.option {\n display: flex;\n align-items: center;\n justify-content: space-between;\n gap: 16px;\n padding: 14px 0;\n cursor: pointer;\n}\n.option + .option { border-top: 1px solid var(--line_light, #f1f2f3); }\ninput {\n appearance: none;\n flex: 0 0 36px;\n width: 36px;\n height: 20px;\n margin: 0;\n border: 0;\n border-radius: 10px;\n background: var(--graph_bg_thick, #c9ccd0);\n cursor: pointer;\n transition: background 0.15s;\n}\ninput::before {\n content: \"\";\n display: block;\n width: 16px;\n height: 16px;\n margin: 2px;\n border-radius: 50%;\n background: #fff;\n transition: transform 0.15s;\n}\ninput:checked { background: var(--brand_blue, #00aeec); }\ninput:checked::before { transform: translateX(16px); }\ninput:focus-visible, button:focus-visible { outline: 2px solid var(--brand_blue, #00aeec); outline-offset: 3px; }\n@media (prefers-color-scheme: dark) {\n dialog { background: var(--bg1_float, #252628); color: var(--text1, #f1f2f3); border-color: var(--line_regular, #444); }\n .option + .option { border-color: var(--line_light, #38393b); }\n .close:hover { background: var(--graph_bg_regular_float, #38393b); }\n}\n@media (prefers-reduced-motion: reduce) {\n input, input::before { transition: none; }\n}\n"; ;// ./src/reply-settings.js const COMMENT_OPTIONS = [ ["showIP", "显示 IP 属地"], ["showState", "显示状态"], ["showAttr", "显示属性位"], ["enhanceRepliesToggle", "增强回复展开 / 收起"], ["showZeroReplyRefresh", "0 回复时显示刷新"], ]; function createReplySettings(settings, onChange) { let dialog; let inputs; function openSettings() { if (!dialog) { const host = document.createElement("div"); host.id = "bili-reply-adapt-settings"; const shadow = host.attachShadow({ mode: "open" }); shadow.innerHTML = `

评论增强设置

`; (document.body || document.documentElement).appendChild(host); dialog = shadow.querySelector("dialog"); inputs = new Map(); const options = shadow.querySelector(".options"); for (const [key, label] of COMMENT_OPTIONS) { const row = document.createElement("label"); row.className = "option"; row.innerHTML = `${label} `; const input = row.querySelector("input"); input.addEventListener("change", () => { settings[key] = input.checked; GM_setValue(key, input.checked); onChange(key); }); inputs.set(key, input); options.appendChild(row); } shadow.querySelector(".close").addEventListener("click", () => dialog.close()); dialog.addEventListener("click", (event) => { // dialog 的 backdrop 点击仍以 dialog 为 target;不把面板内部空白当成关闭。 const rect = dialog.getBoundingClientRect(); if (event.target === dialog && (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom)) dialog.close(); }); } for (const [key, input] of inputs) input.checked = settings[key]; if (!dialog.open) dialog.showModal(); } GM_registerMenuCommand("评论增强设置…", openSettings); if (location.pathname.startsWith("/opus/")) { let articleMenuId; const registerArticleMenu = () => { articleMenuId = GM_registerMenuCommand( `${settings.showArticleStats ? "✅" : "❌"} 专栏阅读 / 投币数(刷新后生效)`, () => { settings.showArticleStats = !settings.showArticleStats; GM_setValue("showArticleStats", settings.showArticleStats); GM_unregisterMenuCommand(articleMenuId); registerArticleMenu(); }, ); }; registerArticleMenu(); } function injectMenuSettings(menu) { const options = menu.shadowRoot?.querySelector("#options"); if (!options || options.querySelector(".reply-adapt-settings-entry")) return; const entry = document.createElement("li"); entry.className = "reply-adapt-settings-entry"; entry.textContent = "评论增强设置"; entry.setAttribute("role", "menuitem"); entry.tabIndex = 0; entry.style.borderTop = "1px solid var(--line_regular, #e3e5e7)"; const open = (event) => { event.preventDefault(); event.stopPropagation(); // 原生父组件用此状态控制菜单;不派发 select,避免触发举报等原生 action。 const owner = menu.getRootNode().host; if (owner && "showMoreMenu" in owner) owner.showMoreMenu = false; openSettings(); }; entry.addEventListener("click", open); entry.addEventListener("keydown", (event) => { if (event.key === "Enter" || event.key === " ") open(event); }); options.appendChild(entry); } return { injectMenuSettings }; } ;// ./src/reply.js const settings = { showIP: GM_getValue("showIP", true), showState: GM_getValue("showState", true), showAttr: GM_getValue("showAttr", true), enhanceRepliesToggle: GM_getValue("enhanceRepliesToggle", true), showZeroReplyRefresh: GM_getValue("showZeroReplyRefresh", GM_getValue("enhanceRepliesToggle", true)), showArticleStats: GM_getValue("showArticleStats", true), }; const { injectMenuSettings } = createReplySettings(settings, () => { visitCommentComponents((ctx) => { if (ctx.localName === "bili-comment-replies-renderer") injectRefreshToReplies(ctx); else if (ctx.localName !== "bili-comment-menu") performInjection(ctx); }); }); const STATE_MAP = { 11: "阿瓦隆 - 审核状态", 17: "阿瓦隆 - 仅自己可见", 18: "阿瓦隆 - 恶意评论", }; const ATTR_MAP = { 1: "置顶", 4: "妙评", 7: "广告链接", 8: "UP主点赞", 9: "UP主回复", 15: "热评", 17: "仅自己可见", 21: "仅自己可见", 22: "仅自己可见", 25: "妙评", 27: "带图", 36: "恶意评论", }; const deepQuery = (root, selector) => { let el = root.querySelector(selector); if (el) return el; const subs = root.querySelectorAll("*"); for (let s of subs) { if (s.shadowRoot) { el = deepQuery(s.shadowRoot, selector); if (el) return el; } } return null; }; function parseAttr(attr) { if (!attr) return null; let str = Number(attr).toString(2); const len = str.length; let bits = []; let descs = []; for (let i = 0; i < len; i++) { if (str[len - 1 - i] === "1") { bits.push(i); const desc = ATTR_MAP[i] || "未知"; descs.push(`${i}: ${desc}`); } } return { bits: bits.join("|"), descs: descs.join("\n"), }; } /** * 核心注入逻辑 * @param {HTMLElement} ctx - 组件实例 (this) */ function performInjection(ctx) { const data = ctx.__data; if (!data) return; // 2. 在 shadowRoot 中寻找 UI 节点 // 使用深度选择器,因为 #pubdate 可能在更深层的 action-buttons 组件里 const shadow = ctx.shadowRoot; if (!shadow) return; // 这是一个通用的深度查找函数 const pubdate = deepQuery(shadow, "#pubdate"); if (!pubdate) return; // 3. 准备信息 const ip = data.reply_control?.location || ""; const state = data.state; const attr = data.attr; let extra = pubdate.querySelector(".custom-hook-info"); if (!extra) { extra = document.createElement("span"); extra.className = "custom-hook-info"; extra.style.marginLeft = "15px"; } extra.innerHTML = ""; if (settings.showState && state > 0) { const sSpan = document.createElement("span"); sSpan.textContent = `状态:${state} `; sSpan.title = STATE_MAP[state] || "未知"; // sSpan.style.cursor = "help"; extra.appendChild(sSpan); } if (settings.showAttr && attr > 0) { const attrData = parseAttr(attr); if (attrData) { const aSpan = document.createElement("span"); // aSpan.style.cursor = "help"; aSpan.textContent = `属性:${attrData.bits}`; aSpan.title = attrData.descs; extra.appendChild(aSpan); } } // 兼容其他插件的 .ip-location;只更新或移除本脚本插入的节点。 let ipSpan = pubdate.querySelector(".custom-hook-ip"); const otherIP = pubdate.querySelector(".ip-location:not(.custom-hook-ip)"); if (settings.showIP && ip && !otherIP) { if (!ipSpan) { ipSpan = document.createElement("span"); ipSpan.className = "ip-location custom-hook-ip"; ipSpan.style.marginLeft = "15px"; pubdate.appendChild(ipSpan); } ipSpan.textContent = ip; } else ipSpan?.remove(); // 最后追加状态/属性,确保它们排在 IP 属地之后(也包括其他插件的属地)。 if (extra.childElementCount) pubdate.appendChild(extra); else extra.remove(); } /** * 调试辅助:查看组件实例上有哪些自定义属性和方法 * 选中 bili-comment-replies-renderer * (function (el) { // 1. 获取当前元素的直接原型(即 B站定义的类) const customProto = Object.getPrototypeOf(el); // 2. 获取标准 HTML 元素的属性列表作为参照 const nativeProps = Object.getOwnPropertyNames(HTMLElement.prototype); // 3. 筛选出不在原生列表中的属性和方法 const customStuff = Object.getOwnPropertyNames(customProto).filter( (prop) => { return !nativeProps.includes(prop); }, ); console.table( customStuff.map((prop) => ({ 名称: prop, 类型: typeof el[prop], 当前值: el[prop], })), ); })($0); */ /** * 新增功能:回复区注入刷新按钮 * 针对组件:bili-comment-replies-renderer */ function injectRefreshToReplies(ctx) { const shadow = ctx.shadowRoot; if (!shadow) return; const footer = shadow.querySelector("#expander-footer"); if (!footer) return; // 清理旧的自定义包裹层,防止重复堆叠 footer.querySelector(".custom-wrapper")?.remove(); // 如果 footer 里面已经有 B 站原生的按钮就跳过 if (footer.children.length > 0) return; const rcount = ctx.data?.rcount ?? 0; const rlist = ctx.list?.length ?? 0; const zeroReplies = rlist === 0 && rcount <= 0; if (zeroReplies ? !settings.showZeroReplyRefresh : !settings.enhanceRepliesToggle) return; // 辅助函数:创建 Bilibili 原生风格按钮 const createBiliBtn = (text, onClick) => { const btn = document.createElement("bili-text-button"); btn.innerText = text; btn.onclick = async (e) => { e.stopPropagation(); await onClick(btn); // footer.querySelector(".custom-wrapper").remove(); // 点击后移除按钮 }; return btn; }; const createWrapper = (id) => { const wrapper = document.createElement("div"); wrapper.className = "custom-wrapper"; wrapper.id = id; return wrapper; }; if (rlist > 0) { const wrapper = createWrapper("pagination"); const wrapperHead = createWrapper("pagination-head"); const wrapperFoot = createWrapper("pagination-foot"); wrapperHead.innerText = "共1页"; const btn = createBiliBtn("收起", async (btn) => { try { ctx.handleRevert(); } catch (e) { console.error("收起失败", e); } }); wrapperFoot.appendChild(btn); wrapper.appendChild(wrapperHead); wrapper.appendChild(wrapperFoot); footer.appendChild(wrapper); } else { const wrapper = createWrapper("view-more"); const refreshSpan = document.createElement("span"); refreshSpan.innerText = `共${rcount}条回复,`; const refreshBtn = createBiliBtn( `点击${rcount > 0 ? "查看" : "刷新"}`, async (btn) => { btn.innerText = "正在请求..."; try { await ctx.getList(); // 调用原生加载函数 } catch (e) { console.error("请求回复失败", e); } }, ); wrapper.appendChild(refreshSpan); wrapper.appendChild(refreshBtn); footer.appendChild(wrapper); } } /** * Hook customElements.define * 这是最底层的拦截,当 B站注册评论组件时,我们直接修改组件类 */ const targets = [ "bili-comment-renderer", // 主楼容器 "bili-comment-reply-renderer", // 回复容器 "bili-comment-replies-renderer", // 回复区容器 "bili-comment-menu", // 原生菜单内的设置入口 ]; const hookedPrototypes = new WeakSet(); function injectComponent(name, ctx) { if (name === "bili-comment-menu") injectMenuSettings(ctx); else if (name === "bili-comment-replies-renderer") injectRefreshToReplies(ctx); else performInjection(ctx); } // 设置切换时才遍历现有 Shadow DOM;平时继续使用组件生命周期更新。 function visitCommentComponents(callback, root = document) { for (const el of root.querySelectorAll("*")) { if (targets.includes(el.localName)) callback(el); if (el.shadowRoot) visitCommentComponents(callback, el.shadowRoot); } } function hookComponent(name, constructor) { const proto = constructor.prototype; if (hookedPrototypes.has(proto)) return; hookedPrototypes.add(proto); const originalUpdated = proto.updated; proto.updated = function (changedProperties) { originalUpdated?.call(this, changedProperties); Promise.resolve().then(() => injectComponent(name, this)); }; } const originalDefine = customElements.define; customElements.define = function (name, constructor, options) { if (targets.includes(name)) hookComponent(name, constructor); return originalDefine.call(this, name, constructor, options); }; for (const name of targets) { const constructor = customElements.get(name); if (constructor) hookComponent(name, constructor); } const injectExisting = () => visitCommentComponents((ctx) => injectComponent(ctx.localName, ctx)); if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", injectExisting, { once: true }); else injectExisting(); // ===================== 专栏(opus)观看/投币数注入 ===================== // 在 opus 动态页(转发了专栏时),把专栏的观看数/投币数写进右侧 side-toolbar。 // 两条线:① 钩住 __INITIAL_STATE__ 拿到 cvid;② 等 .side-toolbar__box 出现再写回。 const ARTICLE_VIEW_SVG = ``; function setupArticleStats() { // 仅开启开关且在 opus 动态页生效 if (!settings.showArticleStats) return; if (!location.href.includes("/opus/")) return; let cvid = null; // 专栏 id(来自 __INITIAL_STATE__) let stats = null; // { view, coin, cvid } let currentToolbar = null; // 当前 .side-toolbar__box let fetchFailed = false; // 请求失败时显示“获取失败” let isFetching = false; // 请求锁,避免并发重复请求(如连点刷新) // —— 线1:通过 unsafeWindow 捕获 __INITIAL_STATE__ 写入,拿到 cvid —— const onState = (state) => { const basic = state?.detail?.basic; if (basic?.comment_type === 12) { const id = String(basic.comment_id_str); if (id && id !== cvid) { cvid = id; stats = null; fetchFailed = false; if (currentToolbar) applyStats(currentToolbar); // 先渲染占位 fetchArticleStats(); } } else { // 不是专栏(或状态被清空):重置,避免把旧数据写进新页面 cvid = null; stats = null; } }; // 隔离上下文里 window.__INITIAL_STATE__ 访问不到,改用 unsafeWindow(指向页面真实 window) const uw = typeof unsafeWindow !== "undefined" ? unsafeWindow : window; if (Object.prototype.hasOwnProperty.call(uw, "__INITIAL_STATE__")) { onState(uw.__INITIAL_STATE__); } let _val = uw.__INITIAL_STATE__; try { Object.defineProperty(uw, "__INITIAL_STATE__", { configurable: true, get() { return _val; }, set(v) { _val = v; onState(v); }, }); } catch (e) { console.warn("[article-stats] 无法挂钩 __INITIAL_STATE__", e); } function fetchArticleStats() { if (!cvid || isFetching) return; // 上一次请求未返回则忽略,避免并发 isFetching = true; fetchFailed = false; fetch(`https://api.bilibili.com/x/article/view?id=${cvid}`, { credentials: "include" }) .then((r) => r.json()) .then((res) => { if (res && res.code === 0 && res.data && res.data.stats) { stats = { view: res.data.stats.view, coin: res.data.stats.coin, cvid: cvid, }; if (currentToolbar) applyStats(currentToolbar); } }) .catch((e) => { fetchFailed = true; if (currentToolbar) applyStats(currentToolbar); console.error("[article-stats] 获取专栏数据失败", e); }) .finally(() => { isFetching = false; }); } // —— 线2:等待 .side-toolbar__box 出现并写回(含加载占位) —— function applyStats(box) { if (!box || !cvid) return; // 不是专栏就不碰 const ready = stats && stats.cvid === cvid; // 数据已就绪且属于当前专栏 const disp = (v) => ready ? formatCount(v) : (fetchFailed ? "获取失败" : "..."); // 投币数:占位 / 真实值 const coinText = box.querySelector(".side-toolbar__action.coin .side-toolbar__action__text"); if (coinText) coinText.textContent = disp(stats && stats.coin); // 观看数:新增一块,占位 / 真实值 let viewEl = box.querySelector(".side-toolbar__action.opus-view"); if (!viewEl) { viewEl = document.createElement("div"); viewEl.className = "side-toolbar__action opus-view"; viewEl.innerHTML = `${ARTICLE_VIEW_SVG}
`; viewEl.title = "点击刷新观看/投币数"; viewEl.style.cursor = "pointer"; viewEl.addEventListener("click", () => { stats = null; // 进入加载态 fetchFailed = false; applyStats(box); // 先把观看/投币显示成 ... fetchArticleStats(); // 重新拉取 }); box.appendChild(viewEl); } viewEl.querySelector(".side-toolbar__action__text").textContent = disp(stats && stats.view); } const observer = new MutationObserver(() => { const box = document.querySelector(".side-toolbar__box"); if (box && box !== currentToolbar) { currentToolbar = box; applyStats(box); } }); observer.observe(document.documentElement, { childList: true, subtree: true }); const initBox = document.querySelector(".side-toolbar__box"); if (initBox) { currentToolbar = initBox; applyStats(initBox); } } function formatCount(n) { n = Number(n) || 0; if (n >= 1e8) return round1(n / 1e8) + "亿"; if (n >= 1e4) return round1(n / 1e4) + "万"; return String(n); } // 四舍五入到 1 位小数,规避 toFixed 的浮点误差(如 1.05.toFixed(1) === "1.0") function round1(x) { const r = Math.round(x * 10) / 10; return Number.isInteger(r) ? String(r) : r.toFixed(1); } setupArticleStats(); /******/ })() ;