B站关注列表显示UP主最后更新时间的油猴脚本开发指南

📅 2026/8/26 4:28:24
B站关注列表显示UP主最后更新时间的油猴脚本开发指南
1. 项目概述为什么我们需要这个脚本作为一个深度B站用户我的关注列表里躺着几百位UP主。时间久了一个非常现实的问题就浮现出来我关注的这位UP主他/她还在更新吗上一次更新是什么时候是半年前还是上周B站官方的关注列表页面只显示了UP主的粉丝数、投稿视频总数以及一个笼统的“近期更新”标签比如“3天前更新”但这个“近期”的范围很模糊而且一旦超过某个时限比如几个月这个标签就干脆不显示了只留下一个孤零零的粉丝数和投稿数。这就导致我的关注列表成了一个“信息黑洞”我无法快速判断哪些UP主已经长期停更哪些依然活跃从而难以高效地管理我的关注关系。这个痛点催生了“在B站关注列表显示UP主最后更新时间”的油猴脚本项目。它的核心目标非常直接将UP主最后一次发布视频或专栏等动态的具体日期清晰地标注在关注列表的每一个UP主卡片上。这不仅仅是一个简单的信息展示更是一种对关注关系的“数据化”管理。通过这个脚本你可以一眼扫过列表快速识别出那些已经“年更”甚至“失踪”的UP主从而决定是继续保留关注以期待“诈尸”还是果断取关、清理列表为新的、活跃的创作者腾出空间。对于内容消费者来说这极大地提升了信息获取效率和关注列表的“健康度”。2. 核心思路与技术选型解析要实现这个功能我们需要拆解B站关注列表页面的数据流和展示逻辑并选择最合适的技术路径进行干预。2.1 功能需求拆解脚本需要完成以下几个核心动作定位目标页面仅在B站www.bilibili.com的“我的关注”列表页面生效。获取UP主列表从当前页面中提取出所有已加载的UP主的唯一标识如mid即用户ID。查询最后更新数据针对每个UP主向B站的数据接口发起请求获取其最新的动态信息并从中解析出发布时间。数据渲染与展示将获取到的“最后更新时间”格式化后插入到对应UP主卡片的合适位置。处理动态加载B站的关注列表是滚动加载的脚本需要监听页面滚动或元素变化对新加载的UP主卡片重复步骤2-4。2.2 技术方案对比与选型实现前端页面功能增强通常有几种方式浏览器插件、用户样式User CSS和用户脚本User Script。这里我们选择用户脚本油猴脚本理由如下开发效率与便捷性油猴脚本使用纯JavaScript配合少量CSS开发门槛相对较低无需像浏览器插件那样处理复杂的manifest配置、背景页、内容脚本通信等。一个.user.js文件即可包含所有逻辑。部署与分享极其简单用户只需安装Tampermonkey或Violentmonkey等脚本管理器点击脚本安装链接即可一键部署。脚本更新也会自动提示非常适合这种小型、具体的功能增强需求。精准的页面控制通过match或include元数据可以精确控制脚本在哪些URL下注入执行避免污染其他网站。强大的社区与生态针对B站的各种增强脚本生态丰富有很多可参考的代码和实践例如如何调用B站内部API、处理加密参数等。因此使用Tampermonkey油猴编写用户脚本是本项目的最优解。我们将利用JavaScript监听页面变化、发起网络请求、解析数据并操作DOM文档对象模型来实现功能。2.3 B站接口分析与逆向思路这是整个脚本的技术核心。我们不能直接从前端页面已渲染的HTML里获取“最后更新时间”因为这个信息本身就不存在。我们必须找到B站内部用于提供这些数据的API接口。常见思路与步骤观察网络请求打开浏览器开发者工具F12切换到“网络”Network标签页然后访问你的B站关注列表页。在列表滚动加载时观察出现的XHRFetch请求。识别关键接口通常获取用户动态信息的接口会包含“dynamic”、“feed”、“space”等关键词。例如一个可能的接口模式是https://api.bilibili.com/x/space/arc/search?mid123456ps1tid0pn1orderpubdate。mid: UP主的用户ID。ps: 每页数量设为1表示我们只获取最新的一条投稿。orderpubdate: 按发布时间排序。这个接口的返回数据里data.list.vlist[0].created或pubdate字段通常就是时间戳格式的发布时间。处理潜在风控一些重要的API可能会校验Cookie、Referer或添加签名参数如w_rid、wts。对于关注列表这种相对公开的信息简单的接口可能不需要复杂签名但为了脚本的稳定性和通用性直接复用当前页面请求的Cookie和头信息是最稳妥的做法。在油猴脚本中我们可以使用GM_xmlhttpRequest需声明connect权限来发起跨域请求并自动携带当前站点的Cookie。降级方案考虑如果主接口不稳定或需要复杂签名可以考虑备用方案例如请求UP主的个人空间主页space.bilibili.com/{mid}然后解析HTML中关于最新视频的描述文本。这种方式更稳定但解析DOM结构容易因B站前端改版而失效且效率较低。本项目优先选择寻找并调用稳定的JSON API接口。注意逆向和使用非公开API存在一定风险。B站可能会更改接口地址、参数或返回格式。因此脚本需要具备一定的容错能力例如try...catch包裹数据解析逻辑并在接口失效时给出友好提示。同时必须尊重网站规则请求频率不宜过高避免对B站服务器造成不必要的压力。3. 脚本详细设计与实现步骤接下来我们将从零开始一步步构建这个油猴脚本。我会假设你已安装Tampermonkey扩展。3.1 脚本元信息与基础框架首先创建一个新的用户脚本。在Tampermonkey管理面板点击“添加新脚本”会生成一个模板。我们将其修改如下// UserScript // name B站关注列表显示UP主最后更新时间 // namespace http://tampermonkey.net/ // version 1.0.0 // description 在B站关注列表的每个UP主卡片上显示该UP主最后一次发布视频/动态的具体时间便于管理关注列表。 // author YourName // match https://www.bilibili.com/* // grant GM_xmlhttpRequest // grant GM_notification // connect api.bilibili.com // connect space.bilibili.com // license MIT // /UserScript (function() { use strict; // 主函数将在DOM加载完成后执行 if (document.readyState loading) { document.addEventListener(DOMContentLoaded, main); } else { main(); } function main() { // 仅在我的关注页面执行 if (!window.location.pathname.includes(/account/)) { // 注意实际关注列表路径需确认 return; } console.log([UP主最后更新时间脚本] 开始执行); init(); } function init() { // 初始化逻辑 } })();关键元数据解释match: 控制脚本注入的页面。这里使用https://www.bilibili.com/*稍后我们会在main函数内通过路径名进行更精确的判断避免在其他页面误执行。grant: 声明脚本需要的特殊权限。GM_xmlhttpRequest用于跨域请求B站APIGM_notification可选用于在出错或完成时通知用户。connect: 声明脚本允许连接的域名这是使用GM_xmlhttpRequest所必需的。3.2 核心功能模块实现3.2.1 页面判断与UP主卡片识别B站关注列表的页面结构可能会变化我们需要找到稳定且唯一的特征来识别UP主卡片容器和每个卡片。// 配置项 const CONFIG { // 关注列表容器的CSS选择器需根据B站当前HTML结构调整 listContainerSelector: .relation-list, // 示例实际需审查元素确定 // 单个UP主卡片的CSS选择器 cardSelector: .relation-list .list-item, // 示例 // UP主IDmid从卡片中提取的方式可能是data属性或某个子元素的链接 midExtractRegex: /space\.bilibili\.com\/(\d)/, // 时间显示插入的位置相对于卡片的选择器 timeInsertSelector: .info .desc, // 示例插入到描述区域 // 时间显示的文字模板 timeTextTemplate: 最后更新{time}, // 请求并发限制避免同时发起太多请求 requestConcurrency: 3 }; let isProcessing false; let requestQueue []; let activeRequests 0; function init() { // 1. 首次执行处理已加载的卡片 processExistingCards(); // 2. 监听列表动态加载使用MutationObserver const observer new MutationObserver((mutations) { // 简单的防抖处理避免频繁触发 if (!isProcessing) { isProcessing true; setTimeout(() { processExistingCards(); isProcessing false; }, 500); } }); const listContainer document.querySelector(CONFIG.listContainerSelector); if (listContainer) { observer.observe(listContainer, { childList: true, subtree: true }); console.log(已启动对关注列表的动态监听); } else { console.warn(未找到关注列表容器脚本可能无法正常工作。请检查选择器, CONFIG.listContainerSelector); // 可以尝试延迟查找或通过GM_notification提示用户 } }实操心得选择器的稳定性B站前端可能会进行A/B测试或改版导致CSS类名变化。因此选择器是脚本最脆弱的环节。一个好的策略是选择具有>/** * 获取UP主的最后更新时间 * param {string|number} mid - UP主用户ID * returns {Promisestring} 格式化后的时间字符串如“2023-10-27” */ async function fetchLastUpdateTime(mid) { // 方案一尝试通过API获取最新视频投稿时间优先 const apiUrl https://api.bilibili.com/x/space/arc/search?mid${mid}ps1tid0pn1orderpubdate; try { const data await makeRequest(apiUrl); // 解析API返回的JSON路径需要根据实际接口响应调整 if (data data.code 0 data.data data.data.list data.data.list.vlist data.data.list.vlist.length 0) { const latestVideo data.data.list.vlist[0]; const timestamp latestVideo.created || latestVideo.pubdate; // 单位可能是秒 return formatTime(timestamp * 1000); // 转换为毫秒 } else { // 如果该UP主无投稿尝试其他方案或返回特定文本 return await fallbackFetchTime(mid); } } catch (apiError) { console.error(通过API获取 mid${mid} 的最后更新时间失败:, apiError); // API失败降级到备用方案 return await fallbackFetchTime(mid); } } /** * 备用方案通过解析个人空间页面获取时间 */ async function fallbackFetchTime(mid) { const spaceUrl https://space.bilibili.com/${mid}; try { const htmlText await makeRequest(spaceUrl, GET, null, text); // 使用DOMParser解析HTML寻找最新视频的时间信息 const parser new DOMParser(); const doc parser.parseFromString(htmlText, text/html); // 这里的选择器同样需要根据B站空间页的实际结构来写示例 const timeElement doc.querySelector(.small-item:first-child .time); if (timeElement) { return timeElement.textContent.trim(); } return 暂无公开投稿; } catch (fallbackError) { console.error(备用方案获取 mid${mid} 时间失败:, fallbackError); return 时间获取失败; } } /** * 封装的网络请求函数使用GM_xmlhttpRequest */ function makeRequest(url, method GET, data null, responseType json) { return new Promise((resolve, reject) { GM_xmlhttpRequest({ method: method, url: url, data: data, responseType: responseType, onload: function(response) { if (response.status 200 response.status 300) { resolve(response.response); } else { reject(new Error(Request failed with status ${response.status}: ${url})); } }, onerror: function(error) { reject(error); }, // 携带当前页面的Cookie和Referer提高成功率 headers: { Referer: https://www.bilibili.com/, // Cookie会自动携带无需手动设置 } }); }); } /** * 格式化时间戳 * param {number} timestampMs - 毫秒时间戳 * returns {string} 格式化的日期字符串 */ function formatTime(timestampMs) { const date new Date(timestampMs); const now new Date(); const diffMs now - date; const diffDays Math.floor(diffMs / (1000 * 60 * 60 * 24)); // 根据时间差显示不同格式 if (diffDays 0) { return 今天; } else if (diffDays 1) { return 昨天; } else if (diffDays 7) { return ${diffDays}天前; } else if (diffDays 30) { return ${Math.floor(diffDays / 7)}周前; } else if (diffDays 365) { return ${Math.floor(diffDays / 30)}个月前; } else { return ${date.getFullYear()}-${String(date.getMonth() 1).padStart(2, 0)}-${String(date.getDate()).padStart(2, 0)}; } }注意事项API路径与解析逻辑https://api.bilibili.com/x/space/arc/search这个接口及其返回的数据结构是我基于常见模式给出的示例。在实际开发中你必须通过浏览器开发者工具在B站关注列表或个人空间页面亲自找到并确认正确的接口URL和返回数据的JSON路径。这是脚本能否工作的关键。错误处理与降级网络请求可能失败API可能变更UP主可能没有投稿。因此fetchLastUpdateTime函数包含了完整的try...catch和降级方案fallbackFetchTime。即使主方案失败脚本也不会崩溃而是优雅地显示备用信息或错误提示。请求频率控制在processExistingCards函数中我们需要实现一个简单的队列和并发控制避免瞬间对几十上百个UP主发起API请求这既是对B站服务器的尊重也能防止自己的IP被临时限制。可以使用Promise.all配合slice进行分批处理。3.2.3 页面渲染与更新获取到时间数据后我们需要将其插入到对应的UP主卡片上。/** * 处理页面上已存在的所有UP主卡片 */ async function processExistingCards() { const cards document.querySelectorAll(CONFIG.cardSelector); console.log(发现 ${cards.length} 张UP主卡片); const cardsArray Array.from(cards); // 过滤掉已经处理过的卡片例如已有我们添加的时间标签 const cardsToProcess cardsArray.filter(card !card.hasAttribute(data-last-update-processed)); // 分批处理控制并发 for (let i 0; i cardsToProcess.length; i CONFIG.requestConcurrency) { const batch cardsToProcess.slice(i, i CONFIG.requestConcurrency); const promises batch.map(card processSingleCard(card)); await Promise.allSettled(promises); // 使用allSettled确保一个失败不影响其他 // 每批之间可以加一个短暂延迟进一步降低请求密度 await sleep(200); } } async function processSingleCard(card) { // 1. 从卡片中提取UP主mid const mid extractMidFromCard(card); if (!mid) { console.warn(无法从卡片中提取mid:, card); return; } // 2. 检查是否已有缓存避免重复请求 const cacheKey last_update_${mid}; const cachedTime sessionStorage.getItem(cacheKey); let displayTime; if (cachedTime) { displayTime cachedTime; } else { // 3. 发起网络请求获取时间 displayTime await fetchLastUpdateTime(mid); // 缓存结果本次会话有效 sessionStorage.setItem(cacheKey, displayTime); } // 4. 将时间信息插入卡片 insertTimeIntoCard(card, displayTime); // 5. 标记为已处理 card.setAttribute(data-last-update-processed, true); } function extractMidFromCard(card) { // 方法1从卡片内的链接中提取 const link card.querySelector(a[href*space.bilibili.com]); if (link) { const match link.href.match(CONFIG.midExtractRegex); if (match match[1]) { return match[1]; } } // 方法2从卡片的数据属性中提取如果B站有设置 const dataMid card.getAttribute(data-mid); if (dataMid) { return dataMid; } // 方法3从其他特征元素中提取需要具体分析HTML结构 // ... return null; } function insertTimeIntoCard(card, timeStr) { const insertPoint card.querySelector(CONFIG.timeInsertSelector); if (!insertPoint) { console.warn(未找到时间插入点:, CONFIG.timeInsertSelector, card); // 尝试其他备选插入位置 return; } // 创建或更新时间显示元素 let timeEl card.querySelector(.custom-last-update); if (!timeEl) { timeEl document.createElement(span); timeEl.className custom-last-update; // 添加一些样式 timeEl.style.cssText color: #999; font-size: 12px; margin-left: 8px; display: block;; insertPoint.appendChild(timeEl); } const finalText CONFIG.timeTextTemplate.replace({time}, timeStr); timeEl.textContent finalText; } function sleep(ms) { return new Promise(resolve setTimeout(resolve, ms)); }实操心得性能与体验使用sessionStorage缓存结果可以避免在同一个页面会话中因来回切换或滚动加载而重复请求相同UP主的数据显著提升体验。标记已处理给卡片添加>// UserScript // name B站关注列表显示UP主最后更新时间 // namespace http://tampermonkey.net/ // version 1.1.0 // description 在B站关注列表的每个UP主卡片上显示该UP主最后一次发布视频/动态的具体时间便于管理关注列表。支持动态加载、请求缓存。 // author YourName // match https://www.bilibili.com/* // grant GM_xmlhttpRequest // grant GM_notification // grant GM_setValue // grant GM_getValue // connect api.bilibili.com // connect space.bilibili.com // license MIT // /UserScript (function() { use strict; const CONFIG { listContainerSelector: .relation-list, cardSelector: .relation-list .list-item, midExtractRegex: /space\.bilibili\.com\/(\d)/, timeInsertSelector: .info .desc, timeTextTemplate: 最后更新{time}, requestConcurrency: 3, cacheDuration: 30 * 60 * 1000, // 缓存时长30分钟 enableSort: false // 是否启用按时间排序功能高级功能 }; let observer null; let isProcessing false; // 主入口 if (document.readyState loading) { document.addEventListener(DOMContentLoaded, main); } else { main(); } function main() { // 更精确的页面判断关注列表页的URL通常包含“/account/”和“following” const path window.location.pathname; if (!path.includes(/account/) || !path.includes(following)) { return; } console.log([UP主最后更新时间脚本] 在关注列表页启动); // 注入自定义样式 injectStyles(); // 初始化 init(); } function injectStyles() { const style document.createElement(style); style.textContent .custom-last-update { color: #999 !important; font-size: 12px !important; margin-top: 2px !important; display: block !important; } .custom-last-update.recent { color: #00a1d6 !important; /* B站主题色用于近期更新 */ } .custom-last-update.old { color: #f25d8e !important; /* 醒目颜色用于长期未更新 */ } ; document.head.appendChild(style); } async function init() { // 先处理一次现有卡片 await processExistingCards(); // 设置监听器监听列表容器的变化 const listContainer document.querySelector(CONFIG.listContainerSelector); if (listContainer) { observer new MutationObserver(debounce(async () { if (!isProcessing) { isProcessing true; await processExistingCards(); isProcessing false; } }, 500)); observer.observe(listContainer, { childList: true, subtree: true }); } else { // 如果没找到可能页面结构变了尝试延迟查找 setTimeout(() { const retryContainer document.querySelector(CONFIG.listContainerSelector); if (retryContainer) { init(); // 重新初始化 } else { console.error(脚本初始化失败未找到关注列表容器。请检查脚本选择器或B站页面是否已更新。); GM_notification({ text: B站关注列表脚本无法找到列表容器可能页面已改版请检查脚本更新。, title: 脚本运行异常, timeout: 5000 }); } }, 2000); } } // ... 这里插入之前定义的 processExistingCards, processSingleCard, extractMidFromCard, insertTimeIntoCard, fetchLastUpdateTime, makeRequest, formatTime, sleep 等函数 ... // 注意在完整脚本中这些函数需要按顺序定义在主函数外部或内部。 // 增强的 insertTimeIntoCard 函数根据时间添加样式类 function insertTimeIntoCard(card, timeStr) { const insertPoint card.querySelector(CONFIG.timeInsertSelector); if (!insertPoint) return; let timeEl card.querySelector(.custom-last-update); if (!timeEl) { timeEl document.createElement(span); timeEl.className custom-last-update; insertPoint.appendChild(timeEl); } const finalText CONFIG.timeTextTemplate.replace({time}, timeStr); timeEl.textContent finalText; // 根据时间字符串添加样式简单示例 if (timeStr.includes(今天) || timeStr.includes(昨天) || timeStr.includes(天前) parseInt(timeStr) 7) { timeEl.classList.add(recent); timeEl.classList.remove(old); } else if (timeStr.includes(个月前) || timeStr.includes(年前) || timeStr 暂无公开投稿) { timeEl.classList.add(old); timeEl.classList.remove(recent); } else { timeEl.classList.remove(recent, old); } } // 防抖函数 function debounce(func, wait) { let timeout; return function executedFunction(...args) { const later () { clearTimeout(timeout); func(...args); }; clearTimeout(timeout); timeout setTimeout(later, wait); }; } })();5. 常见问题排查与进阶优化即使脚本编写完成在实际使用中也可能遇到各种问题。这里记录一些典型的排查思路和进阶优化方向。5.1 脚本不生效一步步排查检查脚本是否启用打开Tampermonkey仪表盘确保脚本右侧的开关是绿色的已启用。检查匹配的URL确认你所在的B站页面URL关注列表页是否匹配脚本头部的match规则。可以临时将match改为*://*/*测试但完成后要改回来。打开控制台查看错误按F12打开开发者工具切换到“控制台”Console标签页。刷新页面查看是否有红色的JavaScript报错信息。常见的错误包括Uncaught ReferenceError: GM_xmlhttpRequest is not defined检查脚本头部是否声明了grant GM_xmlhttpRequest。TypeError: Cannot read property querySelector of null说明CONFIG.listContainerSelector选择器没找到元素需要更新选择器。网络请求错误CORS或404检查connect域名是否正确以及API接口URL是否有效。检查元素选择器在关注列表页右键点击UP主卡片区域选择“检查”Inspect。查看该元素及其父级的CSS类名或ID更新CONFIG中的listContainerSelector和cardSelector。检查API接口在“网络”Network标签页筛选XHR请求寻找获取视频列表的请求确认其URL和响应格式据此修改fetchLastUpdateTime函数中的解析逻辑。5.2 功能进阶与优化建议增加排序功能这是很多用户期待的进阶功能。思路是获取所有卡片的时间数据后将时间转换为可比较的值如时间戳然后重新排列卡片DOM节点的顺序。需要注意的是这可能会干扰B站原有的排序如默认按关注时间排序且操作DOM需谨慎。// 伪代码思路 async function sortCardsByUpdateTime() { const cards Array.from(document.querySelectorAll(CONFIG.cardSelector)); const cardData await Promise.all(cards.map(async card { const mid extractMidFromCard(card); const timeText await fetchLastUpdateTime(mid); // 或从缓存取 const timestamp parseTimeTextToTimestamp(timeText); // 需要实现这个解析函数 return { card, timestamp }; })); // 按timestamp降序排序最新在前 cardData.sort((a, b) b.timestamp - a.timestamp); const container document.querySelector(CONFIG.listContainerSelector); // 清空容器并按新顺序追加卡片注意这会丢失滚动位置和可能的分页状态 container.innerHTML ; cardData.forEach(item container.appendChild(item.card)); }添加配置面板使用GM_getValue和GM_setValue存储用户偏好例如是否开启排序、时间显示格式相对时间/绝对日期、缓存时间等。可以创建一个浮窗按钮点击后弹出配置界面。支持更多动态类型目前主要针对视频投稿。可以扩展为也检查专栏文章、动态发布等。这需要寻找对应的API接口如获取用户动态的接口。性能优化虚拟列表如果关注列表非常长上千同时为所有卡片请求数据可能导致性能问题。可以实现“视口内渲染”只对用户当前能看到的卡片进行查询和渲染。请求合并如果B站有批量查询UP主状态的接口通常没有可以合并请求。没有的话并发控制就是关键。更智能的缓存除了sessionStorage可以使用GM_setValue进行跨会话的持久化缓存并设置合理的过期时间。5.3 维护与更新B站的前端是不断迭代的。作为脚本作者你需要关注用户反馈在脚本发布页面如GreasyFork留意评论用户是最快的“测试员”。定期自查每隔一段时间自己用一下脚本看是否还正常工作。代码可维护性将容易变化的配置如CSS选择器、API URL放在文件开头的CONFIG对象中方便快速修改。版本管理在脚本头部更新version并在更新日志中说明修改内容。编写油猴脚本是一个与网站前端“斗智斗勇”的过程充满了挑战和乐趣。这个“B站关注列表显示UP主最后更新时间”的脚本从一个小痛点出发涉及了前端逆向、DOM操作、异步编程、性能优化等多个方面是一个非常好的练手项目。希望这份详细的拆解和实现指南能帮助你不仅完成这个脚本更能理解其背后的技术逻辑从而能够应对更多类似的需求。