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

资讯详情

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

B站分P视频总剩余时间计算器:油猴脚本开发实战与优化

B站分P视频总剩余时间计算器:油猴脚本开发实战与优化 1. 项目缘起一个被忽视的“时间焦虑”痛点作为一个重度B站用户我经常在追一些长篇课程、纪录片或者游戏实况合集。B站的分P功能对内容创作者很友好可以把一个长系列拆成多个短章节方便观众按需观看。但对我这种想一口气刷完的“肝帝”来说体验上有个不大不小的麻烦我永远不知道看完这个系列到底还要花多久。网页播放器右下角显示的时间永远是当前这个P的剩余时长。当你聚精会神以为这P结束就大功告成时页面自动跳转到下一P那个剩余时间又从头开始计时。这种不确定性尤其是在深夜追更时会带来一种微妙的“时间焦虑”——“我是该看完还是该去睡觉” 手动把后面所有P的时长加起来太麻烦了而且P数动辄几十个根本算不过来。市面上有很多强大的浏览器扩展但功能往往大而全。我需要的只是一个极其轻量、聚焦于解决这一个问题的工具。于是一个想法诞生了为什么不自己写一个油猴Tampermonkey脚本呢它足够轻量无需安装独立软件注入到B站页面后就能实时计算并展示整个分P视频列表的总剩余时间。这听起来是个简单的需求但实际动手会发现从B站页面结构解析、数据获取到动态更新里面有不少值得琢磨的细节。今天我就把这个脚本从构思到实现的完整过程以及踩过的坑和优化思路分享给大家。2. 核心思路拆解如何“无中生有”地算出总时间在动手写代码之前我们需要把问题拆解成几个可执行的步骤。我们的目标是在B站分P视频播放页脚本能自动计算并显示一个“总剩余时间”。2.1 目标分析我们需要哪些信息获取全部分P列表这是基础。需要知道这个视频总共有多少个P以及每个P的标题和时长。识别当前播放的P脚本需要知道用户正在看哪一个这样才能计算“剩余”时间。获取当前P的已播放时长网页播放器提供了当前播放进度我们需要拿到这个数据。执行计算逻辑当前P的剩余时长 当前P的总时长 - 当前P的已播放时长。后续所有P的总时长 从下一个P开始到列表结束所有P的时长之和。总剩余时长 当前P的剩余时长 后续所有P的总时长。动态展示计算结果需要找一个合适的位置把计算出来的总剩余时间清晰地展示出来并且这个显示要能随着视频播放、用户切换分P而实时更新。2.2 技术选型为什么是油猴脚本轻量级与无侵入油猴脚本运行在用户的浏览器扩展环境中不需要B站官方提供任何接口或支持。它通过注入JavaScript代码来操作页面DOM文档对象模型实现功能。对用户来说只需安装一个油猴扩展管理器再安装脚本即可无需担心软件冲突。精准定位我们可以编写脚本让它只在匹配B站视频播放页的网址如www.bilibili.com/video/*时执行避免影响其他网站。开发与部署简单脚本本质是一段JS代码用任何文本编辑器都能写。调试方便更新也快用户更新脚本文件即可。注意油猴脚本的能力受限于浏览器扩展API和页面本身的JavaScript环境。它不能绕过网站的安全策略如CORS也无法获取未暴露在页面上的数据比如需要登录且接口加密的详细观看记录。我们的所有数据都必须从当前加载的页面中提取。2.3 潜在挑战预判页面结构变化B站前端页面可能会改版导致我们用来定位分P列表、播放器元素的CSS选择器失效。这是用户脚本最常见的“失效率”。数据加载时机分P列表可能是异步加载的脚本启动时列表可能还未渲染到页面上导致获取不到数据。性能与更新频率如果设置一个非常高频的定时器来更新显示时间比如每秒10次可能会对低性能设备的页面流畅度造成影响。UI展示的兼容性需要在不同B站主题如夜间模式、不同播放器布局如宽屏、网页全屏、剧场模式下都能正常且美观地显示信息。3. 实战开发从零构建时间计算器接下来我们进入具体的代码实现环节。我会分模块讲解并解释每一步的意图。3.1 脚本元信息与基础框架首先创建一个新的用户脚本文件例如bilibili_total_remaining_time.user.js。油猴脚本的开头需要一段特殊的注释块来定义元信息。// UserScript // name B站分P视频总剩余时间计算器 // namespace http://tampermonkey.net/ // version 1.0.0 // description 在B站分P视频页面计算并显示看完整个系列所需的总剩余时间。 // author YourName // match *://www.bilibili.com/video/* // match *://m.bilibili.com/video/* // icon https://www.bilibili.com/favicon.ico // grant none // run-at document-idle // /UserScript (function() { use strict; // 你的代码将写在这里 })();match: 这是最重要的指令之一指定脚本在哪些网址下运行。我们同时匹配了PC端和移动端的视频播放页。run-at document-idle: 指定脚本在页面主体加载完成、接近空闲状态时再执行。这比document-end更晚能确保页面元素特别是异步加载的分P列表已经基本就位提高了我们获取数据的成功率。grant none: 表示脚本不需要特殊的油猴API权限使用标准网页JavaScript环境即可。IIFE (立即调用函数表达式):(function(){...})()将我们的代码包裹在一个独立的作用域中避免污染页面的全局命名空间也避免与页面原有代码或其它脚本冲突。3.2 核心数据获取抓取分P列表与播放状态这是脚本最核心也最脆弱的部分。我们需要从页面DOM中“挖”出需要的数据。第一步获取分P列表在B站PC端页面分P列表通常在一个类名为list-box或含有episode字样的容器内。每个分P项目是一个li或div标签。function getEpisodeList() { // 尝试多种可能的选择器提高兼容性 const selectors [ .list-box .list-item, // 旧版/常见选择器 .video-section-list .video-episode__list-item, // 可能的改版后选择器 [class*episode] [class*list] * // 更模糊的匹配 ]; let listItems []; for (const selector of selectors) { listItems document.querySelectorAll(selector); if (listItems.length 0) { console.log(使用选择器 ${selector} 找到 ${listItems.length} 个分P项目); break; } } const episodes []; listItems.forEach((item, index) { // 提取标题 const titleElem item.querySelector(.title, [class*title], .tit); const title titleElem ? titleElem.textContent.trim() : 分P ${index 1}; // 提取时长 - 这是关键 // 时长通常在一个单独的span里类名可能包含duration, time, length const durationElem item.querySelector(.duration, .time, .length, [class*duration]); let durationText durationElem ? durationElem.textContent.trim() : 0:00; // 时长文本可能是 1:23:45 或 23:45 或 1:23 const durationInSeconds parseDuration(durationText); // 判断是否为当前播放的P // B站通常会给当前播放的项添加一个active类如active, on const isActive item.classList.contains(active) || item.classList.contains(on) || item.getAttribute(data-selected) true; episodes.push({ index: index, title: title, durationSec: durationInSeconds, isActive: isActive, element: item // 保留引用便于后续高亮或交互 }); }); return episodes; }第二步解析时长字符串从页面抓取的时长是像15:30或1:05:22这样的字符串我们需要把它转换成以秒为单位的数字方便计算。function parseDuration(durationStr) { if (!durationStr) return 0; const parts durationStr.split(:).map(part parseInt(part, 10) || 0); let seconds 0; // 处理 mm:ss 或 hh:mm:ss if (parts.length 2) { seconds parts[0] * 60 parts[1]; } else if (parts.length 3) { seconds parts[0] * 3600 parts[1] * 60 parts[2]; } return seconds; }第三步获取当前播放器的进度B站的HTML5播放器通常是一个video标签或者被包裹在复杂的播放器控件中。更可靠的方法是监听播放器API提供的事件或者直接尝试获取视频元素。function getCurrentPlayerInfo() { const videoElement document.querySelector(video); if (!videoElement) { return { currentTime: 0, duration: 0 }; } return { currentTime: videoElement.currentTime, duration: videoElement.duration }; }注意videoElement.duration在视频元数据完全加载前可能是NaN或0。在实际代码中需要对此进行判断或者等待loadedmetadata事件。3.3 计算逻辑实现有了数据计算就相对简单了。function calculateTotalRemainingTime(episodes, currentPlayerTime, currentPlayerDuration) { if (episodes.length 0) { return 0; // 不是分P视频或列表未加载 } let totalRemainingSeconds 0; let foundCurrent false; for (const ep of episodes) { if (ep.isActive) { // 找到当前播放的P foundCurrent true; // 使用播放器获取的当前P总时长更准确如果获取不到则用列表中的时长 const currentEpDuration (currentPlayerDuration 0) ? currentPlayerDuration : ep.durationSec; const remainingInCurrentEp Math.max(0, currentEpDuration - currentPlayerTime); totalRemainingSeconds remainingInCurrentEp; } else if (foundCurrent) { // 当前P之后的所有P totalRemainingSeconds ep.durationSec; } // 当前P之前的P已播放完毕不计入剩余时间 } // 如果没有找到标记为active的P可能页面状态不同则默认第一个为当前P if (!foundCurrent episodes.length 0) { const firstEp episodes[0]; const firstEpDuration (currentPlayerDuration 0) ? currentPlayerDuration : firstEp.durationSec; const remainingInFirstEp Math.max(0, firstEpDuration - currentPlayerTime); totalRemainingSeconds remainingInFirstEp episodes.slice(1).reduce((sum, ep) sum ep.durationSec, 0); } return totalRemainingSeconds; }3.4 动态UI创建与更新我们需要在页面上创建一个固定位置显示信息的区域。为了更好的视觉体验我们把它放在播放器控制栏附近。class RemainingTimeDisplay { constructor() { this.container null; this.timeElement null; this.init(); } init() { // 创建容器 this.container document.createElement(div); this.container.id bili-total-remaining-time; Object.assign(this.container.style, { position: absolute, bottom: 60px, // 放在控制栏上方 right: 20px, backgroundColor: rgba(0, 0, 0, 0.7), color: #fff, padding: 6px 12px, borderRadius: 4px, fontSize: 14px, fontWeight: bold, zIndex: 9999, // 确保在最上层 fontFamily: inherit, pointerEvents: none, // 防止遮挡播放器控件 transition: opacity 0.3s, opacity: 0.9 }); this.timeElement document.createElement(span); this.container.appendChild(this.timeElement); this.updateText(加载中...); // 尝试插入到播放器区域 const playerArea document.querySelector(.bpx-player-container, .bilibili-player, #bilibiliPlayer); if (playerArea) { playerArea.appendChild(this.container); } else { // 备用方案插入到body document.body.appendChild(this.container); } } updateText(text) { if (this.timeElement) { this.timeElement.textContent 总剩余: ${text}; } } updateTime(seconds) { if (seconds 0) { this.updateText(已看完); return; } const hours Math.floor(seconds / 3600); const minutes Math.floor((seconds % 3600) / 60); const secs Math.floor(seconds % 60); let timeStr ; if (hours 0) { timeStr ${hours}:; } timeStr ${minutes.toString().padStart(2, 0)}:${secs.toString().padStart(2, 0)}; this.updateText(timeStr); } hide() { if (this.container) { this.container.style.opacity 0; } } show() { if (this.container) { this.container.style.opacity 0.9; } } }3.5 主循环与事件监听脚本需要持续工作响应播放、切换分P等事件。(function main() { use strict; const display new RemainingTimeDisplay(); let lastUpdateTime 0; const UPDATE_INTERVAL 1000; // 每秒更新一次平衡实时性和性能 function updateRemainingTime() { const now Date.now(); // 节流控制避免过于频繁的计算和DOM操作 if (now - lastUpdateTime UPDATE_INTERVAL) { return; } lastUpdateTime now; const episodes getEpisodeList(); const playerInfo getCurrentPlayerInfo(); // 如果只有一个P或没有分P列表则隐藏或显示当前视频剩余时间 if (episodes.length 1) { const remaining Math.max(0, playerInfo.duration - playerInfo.currentTime); display.updateTime(remaining); // 也可以选择 display.hide(); return; } const totalRemaining calculateTotalRemainingTime(episodes, playerInfo.currentTime, playerInfo.duration); display.updateTime(totalRemaining); } // 初始更新 setTimeout(updateRemainingTime, 1500); // 延迟执行等待页面更稳定 // 设置定时器定期更新 const timer setInterval(updateRemainingTime, UPDATE_INTERVAL); // 监听可能改变状态的事件 // 1. 视频时间更新播放、拖动进度条 const videoEl document.querySelector(video); if (videoEl) { videoEl.addEventListener(timeupdate, updateRemainingTime); } // 2. 分P切换B站可能通过AJAX或SPA路由切换 // 监听URL hash变化或特定按钮点击是一个方法但更通用的是监听DOM子树变化 const observer new MutationObserver((mutations) { // 检查分P列表区域或播放器区域是否有变化 for (const mutation of mutations) { if (mutation.type childList || mutation.type subtree) { // 简单防抖避免频繁调用 clearTimeout(window._biliTimer); window._biliTimer setTimeout(updateRemainingTime, 500); break; } } }); // 观察页面主体注意不要观察太大范围以免性能问题 const pageContainer document.querySelector(#app, .main-container); if (pageContainer) { observer.observe(pageContainer, { childList: true, subtree: true }); } // 页面卸载时清理 window.addEventListener(unload, () { clearInterval(timer); if (videoEl) { videoEl.removeEventListener(timeupdate, updateRemainingTime); } observer.disconnect(); }); })();4. 避坑指南与进阶优化脚本写完了能跑起来但距离“好用”和“稳定”还有一段距离。下面是我在实际使用和测试中遇到的主要问题及解决方案。4.1 页面结构变化导致的选择器失效这是用户脚本的头号杀手。B站前端的类名可能随时因版本更新而改变。应对策略多重选择器备用就像我在getEpisodeList函数里做的那样准备多个可能的选择器按顺序尝试。更宽松的模糊匹配使用属性选择器[class*episode]类名包含“episode”比精确的.list-box容错率更高但可能匹配到不相关元素。数据源降级如果无法从DOM获取分P列表可以尝试从页面全局变量 (window.__INITIAL_STATE__) 或网络请求中寻找数据。通过浏览器的开发者工具“网络”面板查看页面加载时获取数据的API接口。有时这些接口返回的JSON数据里包含了结构化的视频信息比解析DOM更稳定。实操技巧在B站视频页打开开发者工具切换到“网络”标签过滤“XHR”或“Fetch”请求刷新页面寻找包含video或episode关键词的请求查看其“预览”内容。如果找到可以在脚本中尝试用fetch拦截或重放该请求来获取数据。但这涉及更复杂的异步处理和可能的风控需谨慎使用。4.2 异步加载与SPA路由问题B站是单页应用SPA切换分P或进行其他导航时页面URL变化但不会完全刷新只是局部更新DOM。我们的脚本在初始加载后可能无法感知到分P切换。解决方案MutationObserver API如上文代码所示这是监听DOM变化的现代标准方法。我们观察页面中可能发生内容变化的区域如播放器容器、分P列表容器当检测到变化时重新执行数据获取和计算逻辑。监听History APISPA通常使用history.pushState或hashchange来管理路由。可以监听这些事件来感知页面“跳转”。window.addEventListener(popstate, updateRemainingTime); const originalPushState history.pushState; history.pushState function(...args) { originalPushState.apply(this, args); setTimeout(updateRemainingTime, 300); // 延迟一下等新内容渲染 };提高轮询频率作为保底即使事件监听失败我们还有setInterval定时器作为保底机制虽然效率低一些但能保证最终数据会更新。4.3 性能优化与用户体验计算节流updateRemainingTime函数会被timeupdate事件每秒触发4-60次、定时器、DOM观察者等多处触发。必须加入节流逻辑如记录上次执行时间戳防止在1秒内重复计算和更新DOM数十次造成不必要的性能消耗。UI显示优化自动隐藏可以设置当鼠标离开播放器区域一段时间后将显示面板的透明度降低避免遮挡内容。位置自适应检测播放器是否进入网页全屏或剧场模式动态调整显示面板的位置使其始终贴合播放器控件。样式隔离为我们的UI元素添加独特的前缀ID或类名并尽量使用!important来定义关键样式防止被B站页面的CSS覆盖。错误处理与降级在getEpisodeList和getCurrentPlayerInfo函数中每一步操作都应进行空值判断。如果完全无法获取数据应向用户显示友好的提示如“无法获取分P信息”而不是一个错误的“0:00”或让脚本静默失败。4.4 一个实际的踩坑案例剧场模式下的定位最初我把显示面板的定位写死为bottom: 60px; right: 20px;。在普通页面下工作良好。但当用户点击“网页全屏”或“剧场模式”按钮后播放器的容器尺寸和位置发生了巨大变化我的面板要么消失不见要么定位错乱。排查与解决过程现象点击剧场模式后时间显示不见了。排查打开开发者工具检查元素发现#bili-total-remaining-time这个div依然在DOM中但它的style里的bottom和right值在新的播放器容器内变得无效因为它被插入到了body下而剧场模式是一个新的全屏层。根因我的init()函数只在脚本初始化时执行一次将面板插入到了初始的播放器容器。剧场模式会动态创建一个新的全屏容器并将播放器移入其中我的面板却留在了旧的容器里被隐藏了。解决方案我需要监听播放器模式的变化并动态将面板移动到新的活动播放器容器中。方案A事件监听尝试监听B站播放器模式切换的自定义事件。通过观察控制台发现切换时会在window上触发一些事件如bilibili_player_modechange。可以监听这些事件并重新执行面板的插入逻辑。方案B更稳健的轮询检查在定时器函数中不仅更新时间也检查面板的父容器是否还是有效的播放器区域。如果不在则重新寻找当前有效的播放器容器并移动过去。function ensureDisplayInCorrectContainer(displayInstance) { const currentParent displayInstance.container.parentElement; const activePlayerArea document.querySelector(.bpx-player-container[data-screenfull], .bilibili-player-area, video)?.closest(div[class*player]); // 如果当前父容器不是活跃的播放器区域且找到了新的区域则移动 if (activePlayerArea currentParent ! activePlayerArea) { // 避免重复插入 if (activePlayerArea.contains(displayInstance.container)) { return; } activePlayerArea.appendChild(displayInstance.container); console.log(检测到播放器容器变化已移动时间显示面板。); } } // 然后在 updateRemainingTime 函数中调用它 ensureDisplayInCorrectContainer(display);这个案例告诉我们对于动态性很强的现代网页不能假设DOM结构一成不变。脚本必须具备一定的“自适应”能力。5. 脚本的发布、维护与扩展思考完成开发后可以将脚本发布到 GreasyFork 或 OpenUserJS 等用户脚本平台方便他人一键安装。在发布时清晰的描述、截图和版本更新日志非常重要。关于维护由于对B站页面结构的强依赖此脚本在B站前端更新后大概率会失效。作为开发者可以在脚本描述中留下反馈渠道如GitHub Issues页面。使用updateURL元指令让油猴可以自动检查更新。自己定期使用一下确保功能正常。可能的扩展方向总时长显示不仅显示剩余时间也显示整个系列的总时长。观看进度百分比计算并显示“已观看时长 / 总时长”的百分比。快捷键支持例如按某个键快速显示/隐藏时间面板。自定义样式提供脚本设置界面让用户自定义时间显示的位置、颜色、字体大小等。多平台支持适配其他类似结构的视频网站如AcFun。开发这样一个工具性质的小脚本最大的成就感来自于它切实地解决了一个具体的、细微的体验问题。整个过程涉及了前端开发中的DOM操作、事件处理、异步编程、性能优化和跨浏览器兼容性思考是一个非常好的练手项目。它提醒我们好的用户体验往往就藏在这些“不痛不痒”的细节里而技术则是实现这些改进最直接的工具。
返回列表