// ==UserScript== // @name Bilibili评论区图片批量下载 // @namespace BilibiliCommentImageDownloader // @version 1.0.1 // @author Kaesinol // @description 批量下载B站评论区中的图片(暂仅支持动态和视频评论区) // @license MIT // @icon https://www.gstatic.com/android/keyboard/emojikitchen/20240206/u1f4be/u1f4be_u1f4ac.png // @match https://t.bilibili.com/* // @match https://*.bilibili.com/opus/* // @match https://www.bilibili.com/video/* // @match https://www.bilibili.com/list/* // @match https://space.bilibili.com/* // @grant GM_cookie // @grant GM_deleteValue // @grant GM_download // @grant GM_getValue // @grant GM_listValues // @grant GM_registerMenuCommand // @grant GM_setValue // @grant GM_xmlhttpRequest // @grant unsafeWindow // @run-at document-start // ==/UserScript== (function() { "use strict"; var API_TYPES = { TRADITIONAL: "/x/v2/reply", WBI: "/x/v2/reply/wbi/main" }; var API_TYPE_STORAGE_KEY = "biliApiType"; var REPLY_STORAGE_KEY = "biliReplyDict"; function getIdentifier() { const commentEl = document.querySelector("bili-comments[data-params]"); if (!commentEl) return null; const oidMatch = commentEl.getAttribute("data-params")?.match(/\d{4,}/); return oidMatch ? oidMatch[0] : null; } function getCurrentApiType() { return GM_getValue(API_TYPE_STORAGE_KEY, "WBI"); } function setApiType(type) { GM_setValue(API_TYPE_STORAGE_KEY, type); console.log(`[API配置] 已切换到 ${type === "WBI" ? "WBI签名" : "传统"} 接口`); } function exposeApiConfig() { unsafeWindow.getCurrentApiType = getCurrentApiType; unsafeWindow.setApiType = setApiType; } function setupFetchInterceptor() { unsafeWindow.lastBiliReply = null; const originalFetch = unsafeWindow.fetch; unsafeWindow.fetch = function patchedFetch(...args) { if (getCurrentApiType() === "TRADITIONAL") return originalFetch.apply(this, args); const requestUrl = getFetchRequestUrl(args[0]); const targetApiPath = API_TYPES[getCurrentApiType()]; if (requestUrl && requestUrl.includes(targetApiPath)) { console.log(`[B站API监控] 捕获评论API请求 (${getCurrentApiType()}):`, requestUrl); return originalFetch.apply(this, args).then((response) => { return response.clone().json().then((data) => { cacheWbiReplyData(requestUrl, data); return response; }).catch((error) => { console.warn("[B站API监控] 解析评论响应失败:", error); return response; }); }).catch((error) => { console.error("[B站API监控] 请求出错:", error); throw error; }); } return originalFetch.apply(this, args); }; if (getCurrentApiType() === "WBI") console.log("[B站API监控] 脚本已注入,开始监控评论API"); } function getStoredData(identifier) { if (!identifier) return []; try { return readReplyStore()[identifier] || []; } catch (error) { console.warn("获取存储数据失败:", error); return []; } } function fetchCommentData(oid, page = 1) { return new Promise((resolve, reject) => { const initialType = window.location.href.startsWith("https://www.bilibili.com/video/") ? 1 : 11; const fetchWithType = (type) => { const apiUrl = `https://api.bilibili.com/x/v2/reply?type=${type}&oid=${oid}&pn=${page}`; GM_xmlhttpRequest({ method: "GET", url: apiUrl, onload(response) { try { const data = JSON.parse(response.responseText); if (data && data.code === 0) resolve(data); else if (type === 11) { console.warn("Type 11 failed, retrying with Type 17..."); fetchWithType(17); } else reject(`获取数据失败: ${data.message || "未知错误"}`); } catch (error) { reject(`解析数据失败: ${error instanceof Error ? error.message : String(error)}`); } }, onerror(error) { reject(`网络请求失败: ${String(error)}`); } }); }; fetchWithType(initialType); }); } function clearStoredValues() { const keys = GM_listValues(); for (const key of keys) GM_deleteValue(key); return keys.length; } function cacheWbiReplyData(requestUrl, data) { const identifier = getOidFromRequestUrl(requestUrl); if (!identifier) return; const allStoredData = readReplyStore(); if (!allStoredData[identifier]) allStoredData[identifier] = []; const currentReplies = Array.isArray(data?.data?.replies) ? data.data.replies : []; const currentTopReplies = Array.isArray(data?.data?.top_replies) ? data.data.top_replies : []; const existingReplies = allStoredData[identifier]; const deduplicatedReplies = deduplicateReplies([ ...currentReplies, ...currentTopReplies, ...existingReplies ]); const hasNewData = deduplicatedReplies.length > existingReplies.length; allStoredData[identifier] = deduplicatedReplies; GM_setValue(REPLY_STORAGE_KEY, JSON.stringify(allStoredData)); if (data.data) { const topRepliesCount = currentTopReplies.length; data.data.top_replies = deduplicatedReplies.slice(0, topRepliesCount); data.data.replies = deduplicatedReplies.slice(topRepliesCount); } console.log(`[评论去重] 动态${identifier}: 总计${deduplicatedReplies.length}条评论${hasNewData ? " (有新数据)" : ""}`); if (hasNewData) setTimeout(() => { console.log("[实时更新] 检测到新评论,通知下载界面更新"); window.dispatchEvent(new CustomEvent("biliCommentUpdate")); }, 100); } function getFetchRequestUrl(input) { if (typeof input === "string") return input; if (input instanceof URL) return input.toString(); return input.url; } function getOidFromRequestUrl(requestUrl) { return new URL(requestUrl, window.location.origin).searchParams.get("oid"); } function readReplyStore() { try { return JSON.parse(GM_getValue(REPLY_STORAGE_KEY, "{}")); } catch (error) { console.warn("解析存储的评论数据失败:", error); return {}; } } function deduplicateReplies(replies) { const seen = new Set(); return replies.filter((reply) => { if (seen.has(reply.rpid)) return false; seen.add(reply.rpid); return true; }); } function fragmentFromHtml(html) { const template = document.createElement("template"); template.innerHTML = html.trim(); return template.content; } function elementFromHtml(html) { const element = fragmentFromHtml(html).firstElementChild; if (!(element instanceof HTMLElement)) throw new Error("HTML 模板没有根元素"); return element; } function queryRequired(root, selector) { const element = root.querySelector(selector); if (!element) throw new Error(`找不到元素: ${selector}`); return element; } function byId(id) { const element = document.getElementById(id); if (!element) throw new Error(`找不到元素: ${id}`); return element; } function injectStyleOnce(id, css) { if (document.getElementById(id)) return; const style = document.createElement("style"); style.id = id; style.textContent = css; document.head.appendChild(style); } var menu_default$1 = ":where(#bili-img-download-menu,.bili-dialog){--bili-bg:#fff;--bili-text:#111;--bili-muted:#666;--bili-subtle:#999;--bili-border:#eee;--bili-border-strong:#ccc;--bili-hover-bg:#f5f5f5;--bili-primary:#00a1d6;--bili-primary-dark:#0089b8;--bili-shadow:0 0 10px #0003;--bili-focus:#00a0dc40}:where(body:has(.dark_mode),html.night-mode) :where(#bili-img-download-menu,.bili-dialog){--bili-bg:#0f1011;--bili-text:#e9eef6;--bili-muted:#cfd8e6;--bili-subtle:#888;--bili-border:#333;--bili-border-strong:#3a3f43;--bili-hover-bg:#1e2226;--bili-shadow:0 6px 26px #0009}#bili-img-download-menu{z-index:9999;box-sizing:border-box;border:1px solid var(--bili-border-strong);background:var(--bili-bg);width:min(400px,100vw - 40px);max-height:min(600px,100vh - 90px);color:var(--bili-text);box-shadow:var(--bili-shadow);border-radius:5px;padding:10px;position:fixed;top:70px;right:20px;overflow-y:auto}#bili-img-download-menu[hidden],#bili-img-pagination[hidden]{display:none}.bili-img-download-header{border-bottom:1px solid var(--bili-border);cursor:move;-webkit-user-select:none;user-select:none;touch-action:none;justify-content:space-between;margin-bottom:10px;padding-bottom:5px;display:flex}.bili-img-download-header h3{color:var(--bili-text);margin:0;font-size:16px}.bili-img-download-header-actions{cursor:default;align-items:center;gap:10px;display:flex}.bili-img-icon-button{appearance:none;color:inherit;cursor:pointer;background:0 0;border:none;padding:0;font-size:16px;font-weight:700;line-height:1}.bili-img-nav-trigger{cursor:pointer}#bili-menu-close-button{font-size:18px}#bili-img-pagination{border-top:1px solid var(--bili-border);justify-content:space-between;margin-top:10px;padding-top:10px;display:flex}#bili-img-pagination button,.bili-dialog .btn{cursor:pointer}#bili-img-pagination button{background-color:var(--bili-primary);color:#fff;border:none;border-radius:3px;padding:5px 10px}#bili-img-pagination button:disabled{cursor:not-allowed;opacity:.6}#bili-page-info{line-height:30px}.bili-dialog{box-sizing:border-box;border:1px solid var(--bili-border);background-color:var(--bili-bg);width:min(400px,90vw);max-width:none;color:var(--bili-text);box-shadow:var(--bili-shadow);border-radius:8px;padding:20px;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,Roboto,Helvetica Neue,Arial}.bili-dialog::backdrop{-webkit-tap-highlight-color:transparent;background-color:#00000080}:where(body:has(.dark_mode),html.night-mode) .bili-dialog::backdrop{background-color:#000000a6}.bili-dialog-title{margin:0 0 15px;font-size:18px;line-height:1;display:block}.bili-dialog-desc{color:var(--bili-muted);margin-bottom:15px;font-size:13px;line-height:1.5}.bili-dialog-desc small{color:var(--bili-muted);margin-top:6px;font-size:12px;display:block}.bili-dialog label{cursor:pointer;-webkit-user-select:none;user-select:none;margin-bottom:10px;font-size:14px;display:block}.bili-dialog input[type=radio]{vertical-align:middle;margin-right:8px}.bili-dialog-buttons{justify-content:flex-end;gap:10px;margin-top:8px;display:flex}.bili-dialog .btn{min-width:72px;box-shadow:none;border-radius:6px;padding:8px 14px;font-size:14px;line-height:1}.bili-dialog .btn.cancel{border:1px solid var(--bili-border-strong);color:inherit;background-color:#0000}.bili-dialog .btn.confirm{background-color:var(--bili-primary);color:#fff;border:none}:where(body:has(.dark_mode),html.night-mode) .bili-dialog .btn.confirm{background-color:var(--bili-primary-dark)}.bili-dialog .btn:focus,.bili-dialog input[type=radio]:focus{outline:2px solid var(--bili-focus);outline-offset:2px}.bili-download-stats{border-bottom:1px solid var(--bili-border);color:var(--bili-muted);margin-bottom:10px;padding:5px 0;font-size:12px}.bili-download-option{border:1px solid var(--bili-border);width:100%;color:inherit;font:inherit;text-align:left;cursor:pointer;background:0 0;border-radius:3px;margin:5px 0;padding:8px;transition:background-color .2s;display:block}.bili-download-option:hover{background-color:var(--bili-hover-bg)}.bili-download-option-content{justify-content:space-between;align-items:center;display:flex}.bili-download-info{flex:1;align-items:center;display:flex;overflow:hidden}.bili-download-username{white-space:nowrap;margin-right:5px;font-weight:700}.bili-download-message{white-space:nowrap;text-overflow:ellipsis;flex:1;margin:0 5px;overflow:hidden}.bili-download-time{color:var(--bili-subtle);white-space:nowrap;margin-left:5px;font-size:11px}.bili-download-count{color:var(--bili-primary);white-space:nowrap;margin-left:10px}@media (width<=360px){.bili-dialog-buttons{flex-direction:column-reverse;align-items:stretch}.bili-dialog .btn{width:100%}}"; var api_dialog_default = "\n

API接口配置

\n
\n 选择要使用的评论API接口:\n \n • WBI签名接口:新版接口,支持实时拦截,禁用翻页
\n • 传统接口:旧版接口,支持翻页功能\n
\n
\n \n \n
\n \n \n
\n
\n"; var download_option_default = "\n"; var menu_default = "\n"; var NIGHT_MODE_CLASS = "night-mode"; var currentPage = 1; var currentIdentifier = null; function createDownloadMenu() { injectStyleOnce("bili-img-download-style", menu_default$1); applyThemeFromBilibiliCookie(); const existingMenu = document.getElementById("bili-img-download-menu"); if (existingMenu instanceof HTMLDivElement) return existingMenu; const menuContainer = elementFromHtml(menu_default); document.body.appendChild(menuContainer); setupDraggableMenu(menuContainer); byId("bili-menu-close-button").addEventListener("click", () => { hideDownloadMenu(menuContainer); }); byId("bili-api-config-button").addEventListener("click", () => { showApiConfigDialog(); }); return menuContainer; } function showApiConfigDialog() { injectStyleOnce("bili-img-download-style", menu_default$1); applyThemeFromBilibiliCookie(); const dialog = elementFromHtml(api_dialog_default); const cancelButton = queryRequired(dialog, ".btn.cancel"); const confirmButton = queryRequired(dialog, ".btn.confirm"); const currentApiType = getCurrentApiType(); const radio = dialog.querySelector(`input[name="apiType"][value="${currentApiType}"]`); if (radio) radio.checked = true; function closeDialog() { if (dialog.open) dialog.close(); } cancelButton.addEventListener("click", closeDialog); confirmButton.addEventListener("click", () => { const selectedRadio = dialog.querySelector("input[name=\"apiType\"]:checked"); if (selectedRadio) { const newApiType = selectedRadio.value; if (newApiType !== currentApiType) { try { setApiType(newApiType); } catch (error) { console.error(error); } try { alert(`API接口已切换到 ${newApiType === "WBI" ? "WBI签名" : "传统"} 接口\n请刷新页面使配置生效`); } catch {} } } closeDialog(); }); dialog.addEventListener("click", (event) => { if (event.target !== dialog) return; const rect = dialog.getBoundingClientRect(); if (event.clientX < rect.left || event.clientX > rect.right || event.clientY < rect.top || event.clientY > rect.bottom) closeDialog(); }); dialog.addEventListener("close", () => { dialog.remove(); }); document.body.appendChild(dialog); dialog.showModal(); setTimeout(() => { (dialog.querySelector("input[name=\"apiType\"]") || cancelButton).focus(); }, 0); } async function loadAndDisplayData(page = 1) { const menuContainer = document.getElementById("bili-img-download-menu") || createDownloadMenu(); const menuContent = byId("bili-img-download-content"); const pageInfo = byId("bili-page-info"); const prevButton = byId("bili-prev-page"); showDownloadMenu(menuContainer); menuContent.innerHTML = "

正在加载数据...

"; try { const identifier = currentIdentifier || getIdentifier(); const apiType = getCurrentApiType(); console.log(`当前oid: ${identifier}, API类型: ${apiType}`); const allReplies = await loadReplies(identifier, apiType, page, menuContent, false); if (!allReplies) return; const processedData = processData(allReplies); createDownloadOptions(processedData, menuContent); updatePaginationVisibility(); updateTraditionalPagination(apiType, page, processedData, pageInfo, prevButton); } catch (error) { menuContent.innerHTML = `

错误: ${String(error)}

`; console.error("Error:", error); } } function setupPaginationEvents() { const tryAttach = () => { const paginationDiv = document.getElementById("bili-img-pagination"); if (!paginationDiv) return false; attachPaginationHandlers(paginationDiv); return true; }; if (tryAttach()) return; const observer = new MutationObserver((_, obs) => { if (tryAttach()) obs.disconnect(); }); observer.observe(document.body, { childList: true, subtree: true }); setTimeout(() => observer.disconnect(), 1e4); } function setupCommentUpdateListener() { window.addEventListener("biliCommentUpdate", () => { refreshDownloadInterface(); }); } function addNavButton() { if (/bilibili\.com\/(video|list)/.test(location.href)) { const root1 = document.querySelector("bili-comments")?.shadowRoot; const root2 = root1?.querySelector("bili-comments-header-renderer")?.shadowRoot; if (!root1 || !root2) { setTimeout(addNavButton, 2e3); return; } const buttons = root2.querySelectorAll("bili-text-button"); const lastButton = buttons[buttons.length - 1]; if (lastButton) { const newButton = document.createElement("bili-text-button"); newButton.textContent = "解析评论区图片"; lastButton.after(newButton); newButton.addEventListener("click", () => { applyThemeFromBilibiliCookie(); loadAndDisplayData(); }); } return; } const navContainer = document.querySelector(".bili-tabs__nav__items"); if (!navContainer) { setTimeout(addNavButton, 1e3); return; } const navItem = document.createElement("div"); navItem.className = "bili-tabs__nav__item bili-img-nav-trigger"; navItem.textContent = "解析评论区图片"; navItem.addEventListener("click", () => { applyThemeFromBilibiliCookie(); loadAndDisplayData(); }); navContainer.appendChild(navItem); } function setupSpaceDynamicButtonPolling() { if (location.hostname !== "space.bilibili.com") return; setInterval(() => { if (/space\.bilibili\.com\/\d+\/dynamic/.test(location.href)) addSpaceNavButton(); }, 2e3); } function addSpaceNavButton() { document.querySelectorAll(".bili-dyn-item:has(.bili-dyn-action.comment.active)")?.forEach((opus) => { const root1 = opus.querySelector("bili-comments")?.shadowRoot; if (!root1) return; const root2 = root1.querySelector("bili-comments-header-renderer:not([data-processed])")?.shadowRoot; if (!root2) return; const buttons = root2.querySelectorAll("bili-text-button"); const lastButton = buttons[buttons.length - 1]; if (!lastButton) return; const newButton = document.createElement("bili-text-button"); newButton.textContent = "解析评论区图片"; lastButton.after(newButton); newButton.addEventListener("click", () => { const identifier = root1.host.getAttribute("data-params")?.match(/\d{4,}/)?.[0] || null; applyThemeFromBilibiliCookie(); currentIdentifier = identifier; loadAndDisplayData(); }); root2.host.setAttribute("data-processed", "true"); }); } async function loadAndDisplayDataSilent(page = 1) { const menuContent = document.getElementById("bili-img-download-content"); const pageInfo = document.getElementById("bili-page-info"); const prevButton = document.getElementById("bili-prev-page"); if (!(menuContent instanceof HTMLDivElement) || !(pageInfo instanceof HTMLSpanElement) || !(prevButton instanceof HTMLButtonElement)) return; try { const identifier = currentIdentifier || getIdentifier(); const apiType = getCurrentApiType(); const allReplies = await loadReplies(identifier, apiType, page, menuContent, true); if (!allReplies) return; const processedData = processData(allReplies); createDownloadOptions(processedData, menuContent); updatePaginationVisibility(); updateTraditionalPagination(apiType, page, processedData, pageInfo, prevButton); } catch (error) { console.error("Silent refresh error:", error); } } async function loadReplies(identifier, apiType, page, menuContent, silent) { if (apiType === "WBI") { const storedReplies = getStoredData(identifier); if (storedReplies.length > 0) { if (!silent) console.log(`从存储中加载到 ${storedReplies.length} 条评论`); return storedReplies; } if (!silent) menuContent.innerHTML = "

WBI接口模式:请先滚动评论区加载数据,或等待页面自动加载评论

"; return null; } if (!identifier) { menuContent.innerHTML = "

错误: 无法获取OID,请确保在正确的页面

"; return null; } const targetData = (await fetchCommentData(identifier, page)).data; return page === 1 ? [...targetData?.top_replies || [], ...targetData?.replies || []] : targetData?.replies || []; } function processData(replies) { const processedData = []; for (const reply of replies) { if (!reply.member || !reply.content) continue; const pictures = reply.content.pictures || []; if (pictures.length === 0) continue; const message = reply.content.message || ""; const truncatedMessage = message.length > 10 ? `${message.substring(0, 10)}...` : message; const hardTruncatedMessage = message.length > 20 ? `${message.substring(0, 20)}...` : message; const displayText = `${reply.member.uname} - ${truncatedMessage} - ${formatTimestamp(reply.ctime)}`; const imageData = pictures.map((pic, index) => { const originalUrl = pic.img_src; const fileExtension = originalUrl.split(".").pop()?.split("?")[0] || "jpg"; const bizScene = (reply.reply_control?.biz_scene || "unknown").replace("opus_", ""); return { url: originalUrl, fileName: `${reply.member?.uname || ""} - ${reply.member?.mid || ""} - ${bizScene} - ${index}.${fileExtension}` }; }); processedData.push({ displayText, fullMessage: message, truncatedMessage: hardTruncatedMessage, username: reply.member.uname, timestamp: formatTimestamp(reply.ctime), images: imageData }); } return processedData; } function createDownloadOptions(processedData, menuContent) { menuContent.innerHTML = ""; if (processedData.length === 0) { menuContent.innerHTML = "

没有找到包含图片的评论

"; return; } const statsDiv = document.createElement("div"); statsDiv.className = "bili-download-stats"; const apiTypeText = getCurrentApiType() === "WBI" ? "WBI签名接口(实时更新)" : "传统接口"; statsDiv.textContent = `共找到 ${processedData.length} 条包含图片的评论 [${apiTypeText}]`; menuContent.appendChild(statsDiv); const fragment = document.createDocumentFragment(); for (const item of processedData) { const downloadOption = elementFromHtml(download_option_default); const usernameSpan = queryRequired(downloadOption, ".bili-download-username"); const messageSpan = queryRequired(downloadOption, ".bili-download-message"); const timeSpan = queryRequired(downloadOption, ".bili-download-time"); const countSpan = queryRequired(downloadOption, ".bili-download-count"); usernameSpan.textContent = item.username; messageSpan.textContent = item.truncatedMessage; messageSpan.title = item.fullMessage; timeSpan.textContent = item.timestamp; countSpan.textContent = `[${item.images.length}张]`; downloadOption.addEventListener("click", () => { downloadImages(item.images); }); fragment.appendChild(downloadOption); } menuContent.appendChild(fragment); } function downloadImages(images) { let downloaded = 0; console.log("开始下载", `准备下载 ${images.length} 张图片...`); for (const image of images) GM_download({ url: image.url, name: image.fileName, onload() { downloaded++; if (downloaded === images.length) console.log(`成功下载 ${downloaded} 张图片...`); }, onerror(error) { console.log(`图片 ${image.fileName} 下载失败`, error); } }); } function refreshDownloadInterface() { if (!isDownloadMenuOpen(document.getElementById("bili-img-download-menu"))) return; loadAndDisplayDataSilent(currentPage); } function updatePaginationVisibility() { const paginationDiv = document.getElementById("bili-img-pagination"); if (paginationDiv) paginationDiv.hidden = getCurrentApiType() === "WBI"; } function updateTraditionalPagination(apiType, page, processedData, pageInfo, prevButton) { if (apiType !== "TRADITIONAL") return; currentPage = page; pageInfo.textContent = `第${page}页`; prevButton.disabled = page <= 1; const nextButton = document.getElementById("bili-next-page"); if (nextButton instanceof HTMLButtonElement) nextButton.disabled = processedData.length === 0; } function attachPaginationHandlers(paginationDiv) { if (paginationDiv.__bili_pagination_bound) return; paginationDiv.__bili_pagination_bound = true; const prevButton = paginationDiv.querySelector("#bili-prev-page"); const nextButton = paginationDiv.querySelector("#bili-next-page"); prevButton?.addEventListener("click", () => { if (getCurrentApiType() !== "TRADITIONAL") return; if (prevButton.disabled) return; if (currentPage > 1) loadAndDisplayData(currentPage - 1); }); nextButton?.addEventListener("click", () => { if (getCurrentApiType() !== "TRADITIONAL") return; if (nextButton.disabled) return; loadAndDisplayData(currentPage + 1); }); } function setupDraggableMenu(menuContainer) { const header = menuContainer.querySelector(".bili-img-download-header"); if (!header) return; let offsetX = 0; let offsetY = 0; header.addEventListener("pointerdown", (event) => { if (event.button !== 0) return; if (event.target?.closest(".bili-img-download-header-actions")) return; const rect = menuContainer.getBoundingClientRect(); offsetX = event.clientX - rect.left; offsetY = event.clientY - rect.top; menuContainer.style.left = `${rect.left}px`; menuContainer.style.top = `${rect.top}px`; menuContainer.style.right = "auto"; menuContainer.style.margin = "0"; header.setPointerCapture(event.pointerId); event.preventDefault(); }); header.addEventListener("pointermove", (event) => { if (!header.hasPointerCapture(event.pointerId)) return; const rect = menuContainer.getBoundingClientRect(); const maxLeft = Math.max(0, window.innerWidth - rect.width); const maxTop = Math.max(0, window.innerHeight - rect.height); const nextLeft = clamp(event.clientX - offsetX, 0, maxLeft); const nextTop = clamp(event.clientY - offsetY, 0, maxTop); menuContainer.style.left = `${nextLeft}px`; menuContainer.style.top = `${nextTop}px`; }); header.addEventListener("pointerup", (event) => { if (header.hasPointerCapture(event.pointerId)) header.releasePointerCapture(event.pointerId); }); header.addEventListener("pointercancel", (event) => { if (header.hasPointerCapture(event.pointerId)) header.releasePointerCapture(event.pointerId); }); } function clamp(value, min, max) { return Math.min(Math.max(value, min), max); } function showDownloadMenu(menuContainer) { if (menuContainer instanceof HTMLElement) menuContainer.hidden = false; } function hideDownloadMenu(menuContainer) { menuContainer.hidden = true; } function isDownloadMenuOpen(menuContainer) { return menuContainer instanceof HTMLElement && !menuContainer.hidden; } async function applyThemeFromBilibiliCookie() { const themeStyle = await getBilibiliThemeStyle(); document.documentElement.classList.toggle(NIGHT_MODE_CLASS, themeStyle === "dark"); } function getBilibiliThemeStyle() { return new Promise((resolve) => { GM_cookie.list({ url: window.location.href, name: "theme_style" }, (cookies, error) => { if (error) { console.warn("读取 Bilibili 主题 Cookie 失败:", error); resolve(null); return; } resolve(cookies.find((cookie) => cookie.name === "theme_style")?.value || null); }); }); } function formatTimestamp(timestamp) { const date = new Date(timestamp * 1e3); return `${date.getFullYear()}-${padZero(date.getMonth() + 1)}-${padZero(date.getDate())} ${padZero(date.getHours())}:${padZero(date.getMinutes())}`; } function padZero(num) { return num < 10 ? `0${num}` : String(num); } exposeApiConfig(); setupFetchInterceptor(); function main() { createDownloadMenu(); addNavButton(); setupSpaceDynamicButtonPolling(); setupPaginationEvents(); setupCommentUpdateListener(); GM_registerMenuCommand("显示下载界面", () => { loadAndDisplayData(1); }); } GM_registerMenuCommand("清除数据", () => { const count = clearStoredValues(); alert(`清除 ${count} 条数据完成`); }); GM_registerMenuCommand("API接口配置", () => { showApiConfigDialog(); }); window.addEventListener("load", main); })();