尧图建网站 尧图建网站 YAOTU WEB BUILD 免费咨询
ARTICLE DETAIL

资讯详情

深耕网站建设与建站编程的一线实战洞察。

B站关注列表增强:油猴脚本实现UP主最后更新时间显示

B站关注列表增强:油猴脚本实现UP主最后更新时间显示 1. 项目缘起一个被忽视的“信息差”痛点作为一个重度B站用户我的关注列表常年保持在三位数。每天打开B站面对长长的关注列表我常常陷入一种选择困难我该看谁的新视频我关注的这位UP主是不是已经“停更”了或者他最近是不是在某个我没注意到的时段更新了这个问题看似微小却实实在在地影响着我的浏览效率和使用体验。B站官方界面提供了丰富的视频信息比如播放量、弹幕数、发布时间但在“我的关注”这个核心列表页里却唯独缺少了一个对我而言至关重要的信息UP主最后发布视频的时间。我只能看到UP主的头像、昵称和最新的一个视频标题如果这个视频是几天前甚至几周前的我无法快速判断这位UP主是暂时没更新还是已经“年更”甚至“退网”了。这种信息的不对称迫使我需要频繁点击进入UP主个人主页去查看动态操作繁琐打断了沉浸式的浏览流程。于是一个想法自然产生能否通过技术手段在B站原有的关注列表页面上直接为每个UP主条目追加显示其最后一次发布视频的时间这不仅能让我一眼识别活跃UP主方便优先观看其内容也能帮我“清理”那些早已不更新的账号优化我的关注列表质量。实现这个功能最直接、最轻量化的方式就是编写一个用户脚本UserScript而Tampermonkey油猴正是运行这类脚本的绝佳平台。它无需修改B站官方代码仅通过注入前端脚本就能实现个性化的界面增强这正是“油猴脚本”的魅力所在。2. 技术选型与实现原理为什么是油猴脚本在决定动手之前我们需要明确技术路径。实现网页内容修改通常有几种方式浏览器的开发者工具直接修改临时、编写浏览器扩展如Chrome Extension、或者使用用户脚本管理器。这里我们选择油猴脚本基于以下几个核心考量2.1 油猴脚本的核心优势跨浏览器兼容Tampermonkey作为脚本管理器支持Chrome、Firefox、Safari、Edge等几乎所有主流浏览器。脚本一次编写多处运行避免了为不同浏览器单独开发扩展的麻烦。开发部署极简一个脚本本质上就是一个.js文件配合标准的元数据头UserScript。开发时只需在Tampermonkey的管理面板中新建脚本粘贴代码即可分享时用户也只需安装这一个脚本文件。相比需要处理manifest.json、背景页、内容脚本、弹出页等复杂结构的浏览器扩展门槛低得多。沙盒环境与安全脚本运行在相对隔离的环境中通过GM_* API与浏览器交互权限可控。用户安装时可以清晰看到脚本申请的权限如访问特定网站、存储数据等比一些不明来源的扩展更透明。精准的页面注入通过match、include等元数据指令可以精确控制脚本在哪些页面生效。对于本项目我们只需要脚本在B站的“我的关注”列表页面https://space.bilibili.com/*/fans/follow*运行不会影响其他页面做到了功能的最小化干扰。2.2 实现思路拆解我们的目标是在B站关注列表的每个UP主卡片上追加显示“最后更新”时间。 要实现它我们需要解决几个关键问题数据从哪里来B站关注列表页面初始加载时并不会直接提供每个UP主的最新视频发布时间。这个数据需要我们去获取。如何获取数据最直接的思路是模拟网络请求。打开浏览器开发者工具F12切换到“网络Network”标签然后刷新关注列表页。观察发现列表数据是通过一个异步接口加载的其URL模式类似于https://api.bilibili.com/x/relation/followings?...。这个接口返回的JSON数据中包含了每个UP主的mid用户ID、uname昵称、face头像等信息但没有最新视频时间。如何获取每个UP主的最新视频时间既然列表接口不给我们就需要为每个UP主发起额外的请求。通常我们可以访问UP主的个人空间动态页或视频投稿列表页来获取。例如https://api.bilibili.com/x/space/arc/search?midUP主MIDps1这个接口可以获取该UP主最新投稿的视频列表其中第一条数据的pubdate字段就是发布时间戳。我们需要遍历关注列表中的所有UP主逐个请求这个接口。如何避免请求风暴如果关注了500个UP主瞬间发起500个网络请求会对B站服务器造成不必要的压力也容易被限制或导致自身IP被封。因此必须实现异步、队列化、有延迟的请求控制。如何将获取到的时间渲染到页面上我们需要分析关注列表页面的DOM结构找到每个UP主卡片对应的HTML元素然后在合适的位置比如昵称下方动态插入一个显示时间的span元素。2.3 技术栈确定基于以上分析我们的技术栈非常清晰语言纯JavaScript (ES6)油猴脚本原生支持。核心APIGM_xmlhttpRequest油猴提供的跨域请求API用于获取B站接口数据比原生fetch或XMLHttpRequest权限更高可以绕过某些CORS限制。MutationObserver用于监听DOM变化。因为B站页面可能是动态加载的如滚动加载更多我们需要在这个API检测到新的UP主卡片被添加到页面时触发我们的处理函数。setTimeout/async/await用于控制请求队列的延迟。数据解析处理JSON响应从pubdate时间戳通常是Unix秒级时间戳转换为人类可读的格式如“X分钟前”、“昨天”、“YYYY-MM-DD”。3. 实战开发从零编写油猴脚本接下来我们进入具体的代码实现环节。我会分步骤详细解释并提供完整的代码示例和关键注释。3.1 搭建脚本骨架与元数据首先在Tampermonkey中点击“创建新脚本”会生成一个模板。我们修改其元数据部分// UserScript // name B站关注列表显示UP主最后更新时间 // namespace http://tampermonkey.net/ // version 1.0.0 // description 在B站个人空间-关注列表页面为每个UP主显示其最后一次发布视频的时间。 // author YourName // match https://space.bilibili.com/*/fans/follow* // match https://space.bilibili.com/*/fans/follow?* // grant GM_xmlhttpRequest // grant GM_getValue // grant GM_setValue // connect api.bilibili.com // connect api.b23.tv // license MIT // /UserScript (function() { use strict; // 主函数将在DOM加载完成后执行 if (document.readyState loading) { document.addEventListener(DOMContentLoaded, init); } else { init(); } function init() { console.log([B站关注列表增强] 脚本启动); // 主逻辑入口 startObserver(); } })();match指定脚本生效的页面URL模式。这里匹配的是B站个人空间的关注列表页面。grant声明脚本需要使用的油猴特殊API。GM_xmlhttpRequest用于跨域请求GM_getValue和GM_setValue可用于缓存数据提升性能后续优化会用到。connect告知油猴脚本需要连接的外部域名这是使用GM_xmlhttpRequest所必需的。init函数在页面DOM加载完成后执行作为脚本的启动入口。3.2 监听动态加载的UP主列表B站的关注列表是滚动加载的。我们不能只在页面初始加载时运行一次必须持续监听新内容的加入。MutationObserver是完成这个任务的利器。let observer null; function startObserver() { // 找到关注列表的容器元素。通过观察B站页面结构发现列表通常在一个类名为.be-list或具有特定data属性的ul中。 // 这里需要根据实际情况调整选择器。一个更稳健的方法是寻找包含“关注”标题的附近容器。 const targetNode document.querySelector(.follow-list); // 示例选择器可能需要调整 if (!targetNode) { // 如果没找到可能页面结构变了或者还没加载出来延迟重试 console.warn([B站关注列表增强] 未找到关注列表容器3秒后重试); setTimeout(startObserver, 3000); return; } // 停止之前的观察者如果有 if (observer) { observer.disconnect(); } // 创建观察者实例 observer new MutationObserver((mutationsList) { for (let mutation of mutationsList) { // 检查是否有新的子节点被添加 if (mutation.type childList mutation.addedNodes.length 0) { // 给一点时间让新节点的内容渲染完整 setTimeout(() { processUPList(); }, 500); } } }); // 开始观察目标节点配置观察子节点的变化 const config { childList: true, subtree: true }; observer.observe(targetNode, config); // 立即处理一次已存在的UP主列表 processUPList(); }关键点processUPList()是我们处理UP主列表的核心函数。MutationObserver确保每当用户滚动加载出新的一批UP主时这个函数会被自动调用。3.3 处理UP主列表与发起请求这是脚本最核心的部分。我们需要找到页面上所有的UP主卡片。为每个卡片提取UP主的MID用户ID。检查该MID的数据是否已缓存或正在处理。将需要请求的MID加入一个队列并控制速率逐一请求。const requestQueue []; let isRequesting false; const REQUEST_DELAY 1000; // 每个请求间隔1秒避免过快 const CACHE_DURATION 3600 * 1000; // 缓存有效期1小时 function processUPList() { // 更精确地选择UP主卡片。B站的卡片通常有特定的类如.follow-item或.list-item。 // 需要手动检查页面元素来确定。这里假设卡片有 data-mid 属性存放MID。 const upCards document.querySelectorAll(.follow-item[data-mid]); if (upCards.length 0) { console.log([B站关注列表增强] 未找到UP主卡片); return; } upCards.forEach(card { const mid card.getAttribute(data-mid); const upName card.querySelector(.up-name)?.textContent || 未知UP; // 检查是否已经显示过时间了 if (card.querySelector(.last-update-time)) { return; // 已处理跳过 } // 检查本地缓存 const cacheKey last_update_${mid}; const cachedData GM_getValue(cacheKey); if (cachedData (Date.now() - cachedData.timestamp CACHE_DURATION)) { // 缓存有效直接渲染 renderUpdateTime(card, cachedData.timeStr, cachedData.timestamp); return; } // 加入请求队列 if (!requestQueue.includes(mid)) { requestQueue.push(mid); console.log([B站关注列表增强] 将UP主 ${upName} (MID: ${mid}) 加入请求队列); } }); // 启动队列处理器如果没在运行的话 processQueue(); } async function processQueue() { if (isRequesting || requestQueue.length 0) { return; } isRequesting true; while (requestQueue.length 0) { const mid requestQueue.shift(); // 从队列头部取出一个MID await fetchUPLastUpdateTime(mid); // 延迟一段时间再处理下一个请求 await new Promise(resolve setTimeout(resolve, REQUEST_DELAY)); } isRequesting false; }3.4 获取UP主最后更新时间这里我们使用GM_xmlhttpRequest调用B站的API。function fetchUPLastUpdateTime(mid) { return new Promise((resolve) { // 构造请求URL获取UP主最新视频取1条 const apiUrl https://api.bilibili.com/x/space/arc/search?mid${mid}ps1orderpubdate; GM_xmlhttpRequest({ method: GET, url: apiUrl, headers: { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36, Referer: https://space.bilibili.com/${mid}, }, onload: function(response) { if (response.status 200) { try { const data JSON.parse(response.responseText); if (data.code 0 data.data.list.vlist data.data.list.vlist.length 0) { const video data.data.list.vlist[0]; const pubDateTimestamp video.created * 1000; // 接口返回的是秒转成毫秒 const timeStr formatTime(pubDateTimestamp); // 缓存结果 const cacheKey last_update_${mid}; GM_setValue(cacheKey, { timestamp: Date.now(), timeStr: timeStr, pubDate: pubDateTimestamp }); // 找到对应的卡片并渲染 const card document.querySelector(.follow-item[data-mid${mid}]); if (card) { renderUpdateTime(card, timeStr, pubDateTimestamp); } console.log([B站关注列表增强] 成功获取 MID:${mid} 最后更新时间: ${timeStr}); } else { // 该UP主可能没有视频或接口返回空 console.warn([B站关注列表增强] MID:${mid} 无视频数据或接口异常, data); renderUpdateTime(document.querySelector(.follow-item[data-mid${mid}]), 暂无视频, null); } } catch (e) { console.error([B站关注列表增强] 解析响应数据失败 MID:${mid}, e); } } else { console.error([B站关注列表增强] 请求失败 MID:${mid}, 状态码: ${response.status}); } resolve(); }, onerror: function(error) { console.error([B站关注列表增强] 网络请求错误 MID:${mid}, error); resolve(); // 即使出错也要resolve让队列继续 } }); }); }3.5 渲染时间到页面 时间格式化function renderUpdateTime(cardElement, timeStr, timestamp) { if (!cardElement) return; // 防止重复渲染 if (cardElement.querySelector(.last-update-time)) { return; } // 寻找合适的位置插入。通常可以在UP主昵称所在的元素后面。 // 假设昵称在一个类名为 .name 的标签内。 const nameElement cardElement.querySelector(.name, .up-name, .username); // 多种可能的选择器 if (!nameElement) { console.warn(未找到UP主名称元素无法插入时间); return; } const timeSpan document.createElement(span); timeSpan.className last-update-time; timeSpan.style.cssText margin-left: 8px; font-size: 12px; color: #9499a0;; timeSpan.textContent 最后更新: ${timeStr}; // 如果时间戳存在可以设置title属性显示完整时间 if (timestamp) { timeSpan.title new Date(timestamp).toLocaleString(); } // 插入到名称元素后面 nameElement.parentNode.insertBefore(timeSpan, nameElement.nextSibling); } function formatTime(timestamp) { const now Date.now(); const diff now - timestamp; const diffInSeconds Math.floor(diff / 1000); const diffInMinutes Math.floor(diffInSeconds / 60); const diffInHours Math.floor(diffInMinutes / 60); const diffInDays Math.floor(diffInHours / 24); if (diffInDays 30) { return new Date(timestamp).toLocaleDateString().replace(/\//g, -); // 格式化为 YYYY-MM-DD } else if (diffInDays 0) { return ${diffInDays}天前; } else if (diffInHours 0) { return ${diffInHours}小时前; } else if (diffInMinutes 0) { return ${diffInMinutes}分钟前; } else { return 刚刚; } }4. 核心优化与避坑指南一个基础的脚本写完了但要让它在实际使用中稳定、高效、用户友好还需要进行一系列优化并避开许多潜在的“坑”。4.1 精准的元素选择器应对B站页面改版上面代码中的.follow-item、.name等选择器是示例。B站前端结构可能随时调整。最稳健的做法不是依赖固定的类名而是依赖相对稳定的数据属性或DOM结构关系。实战技巧在开发者工具中找到UP主卡片元素观察其属性。B站经常会给列表项添加>function findUpCards() { const selectors [ .follow-list .list-item[data-mid], .be-list li[data-mid], .relation-list li[data-id] // 备用选择器 ]; for (const selector of selectors) { const cards document.querySelectorAll(selector); if (cards.length 0) { return cards; } } return []; }4.2 请求队列与速率限制做有道德的爬虫无节制的并发请求是脚本被封禁的主要原因。我们采用了队列和延迟但还可以更完善指数退避重试当某个请求失败如网络错误、429状态码不应立即放弃。可以实现一个简单的重试机制并随着重试次数增加延迟时间。const maxRetries 3; async function fetchWithRetry(mid, retryCount 0) { try { await fetchUPLastUpdateTime(mid); // 假设这个函数返回Promise } catch (error) { if (retryCount maxRetries) { const delay Math.pow(2, retryCount) * 1000; // 1s, 2s, 4s... console.warn([重试] MID:${mid} 第${retryCount1}次重试等待${delay}ms); await new Promise(r setTimeout(r, delay)); return fetchWithRetry(mid, retryCount 1); } else { console.error([放弃] MID:${mid} 重试${maxRetries}次后失败); } } }动态延迟调整可以根据服务器响应状态码动态调整请求间隔。如果收到429请求过多可以大幅增加延迟。4.3 数据缓存策略提升性能与用户体验每次刷新页面都重新请求几百个UP主的数据是低效的。利用GM_setValue/GM_getValue进行本地缓存至关重要。缓存数据结构我们不仅缓存格式化后的时间字符串还应缓存原始时间戳和缓存时间。缓存失效我们设置了1小时的有效期。对于更新频繁的UP主可以缩短对于“年更”UP主可以延长。一个更聪明的策略是在渲染时如果发现缓存时间超过一天但在页面上显示为“1天前”可以标记为“需刷新”在后台低优先级地更新它。缓存清理脚本可以提供一个简单的设置界面通过油猴的菜单让用户手动清理缓存。4.4 错误处理与用户体验网络状态提示可以在页面角落添加一个小的状态指示器显示“正在获取更新时间...”、“已获取 X/Y 个”等信息。请求失败友好提示对于获取失败的UP主可以显示“获取失败”或“网络异常”而不是留白或显示错误代码。兼容性检查脚本开始运行时可以检查当前页面URL是否确实为关注列表页避免在其他页面误运行。5. 功能扩展与进阶玩法基础功能实现后我们可以思考如何让它变得更强大、更好用。5.1 排序与筛选功能既然我们已经拿到了每个UP主的最后更新时间那么实现按“最后更新”时间对关注列表进行排序就是一个非常自然且有价值的功能扩展。实现思路获取所有UP主卡片元素及其对应的时间戳从缓存或实时获取。将卡片元素从DOM中暂时移除存储在一个数组中。根据时间戳对数组进行排序从新到旧或从旧到新。将排序后的数组元素按顺序重新追加回列表容器。交互设计可以在列表顶部添加一个按钮点击后在“默认排序B站顺序”和“按最后更新排序”之间切换。5.2 视觉增强与自定义样式时间颜色编码可以根据时间远近使用不同颜色。例如1小时内绿色#00a36824小时内蓝色#00aeec1周内灰色#9499a01月内浅棕色#b57a4c超过1个月红色#ff4d4f(暗示可能已停更)添加“停更预警”图标对于超过特定阈值如3个月未更新的UP主可以在其昵称旁添加一个小的警示图标鼠标悬停显示“该UP主已超过3个月未更新”。5.3 与B站API的深度交互获取更精确的“更新”我们目前使用的是“最新投稿视频”的时间。但有些UP主可能不投稿而是更新专栏、动态或直播。可以考虑监听更多类型的动态。B站有/x/space/dynamic接口可以获取用户的所有动态包括投稿、转发、专栏等。分析这个接口的返回数据找到最新的timestamp可能更能代表UP主的“活跃时间”。批量请求优化虽然B站没有提供批量查询UP主最后更新时间的官方接口但我们可以探索是否有其他聚合接口或者使用Promise.all配合更长的延迟来小幅提升效率需谨慎。5.4 配置化与用户设置一个成熟的脚本应该允许用户自定义行为。我们可以利用油猴的GM_getValue/GM_setValue来保存用户偏好。可配置项示例是否启用脚本总开关。时间显示格式相对时间X天前或绝对时间YYYY-MM-DD。缓存有效期用户可自行调整1小时、6小时、1天。请求延迟时间网络环境好的用户可以调低反之调高。高亮阈值定义多少天未更新算“久未更新”并高亮显示。实现方式创建一个简单的HTML浮窗作为设置面板通过油猴菜单触发显示。用户修改设置后立即保存并刷新页面生效。6. 脚本的发布、维护与伦理考量6.1 测试与调试单元测试对于formatTime这类纯函数可以单独写测试用例验证其逻辑。真实环境测试在Tampermonkey的开发模式下频繁修改和保存脚本观察控制台输出和页面变化。使用console.log进行关键步骤的日志记录。多场景测试测试关注数少10、多100、列表为空等边界情况。测试页面滚动加载。6.2 发布与更新代码托管将脚本代码托管在GitHub、GitLab等平台便于版本管理和用户反馈。发布到脚本平台可以发布到Greasy Fork、OpenUserJS等用户脚本社区。这些平台提供了自动更新机制通过updateURL元数据。更新日志在脚本头部或发布页面清晰说明每个版本的改动特别是修复了哪些B站页面改版导致的问题。6.3 伦理与合规性尊重服务器严格遵守我们设定的请求延迟这是对B站服务器的基本尊重也是脚本能长期存活的前提。绝对不要为了追求速度而移除延迟限制。明确功能边界本脚本仅用于增强客户端显示不涉及任何破解、下载、绕过限制等违规操作。在脚本描述中应清晰说明这一点。用户隐私脚本仅在用户本地浏览器运行获取的数据UP主MID、更新时间仅用于本地显示不应收集或上传到任何第三方服务器。免责声明在脚本说明中注明本脚本为第三方开源工具与B站官方无关使用风险自负。编写这样一个油猴脚本从发现问题、分析原理、编码实现到优化完善是一个完整的“小而美”的前端工程项目。它不仅解决了实际使用中的痛点也深入实践了DOM操作、异步编程、API调用、性能优化等前端核心技能。更重要的是它培养了一种“用技术优化生活和工作流程”的思维。当你再次遇到某个网站用起来不顺手时或许第一个想到的就是“我能不能写个脚本让它更好用”。
返回列表