1. 从零拆解:动漫网站弹幕播放脚本到底在解决什么问题
1.1 一个真实的使用场景
我平时追番的习惯比较杂,有些番在A站看,有些在B站看,还有些老番只有一些小站才有资源。问题就来了:小站往往没有弹幕系统,或者弹幕池空空荡荡,看番的时候总觉得少了点什么。弹幕这个东西,看的时候嫌它挡画面,一旦没有了又觉得像吃面没放醋——不是不能吃,就是差点意思。
于是我就琢磨,能不能自己写一个油猴脚本,把弹幕功能“嫁接”到那些没有弹幕的动漫网站上。这个想法其实不新鲜,市面上已经有一些类似的脚本,但要么年久失修,要么只支持特定站点,要么弹幕源单一。我决定自己动手做一个,核心目标很明确:在任意动漫视频播放页面上,注入一个可用的弹幕层,弹幕数据从公开的弹幕接口拉取,并且支持发送弹幕。
这个脚本适合谁用?一是像我这样经常在小站追番、又离不开弹幕氛围的人;二是想学习油猴脚本开发、想了解弹幕系统实现原理的前端新手。因为整个项目涉及DOM操作、网络请求、Canvas渲染、事件监听等前端核心技能,拿来做练手项目非常合适。
1.2 为什么选择油猴脚本这个形态
油猴脚本的本质是一段注入到目标页面的JavaScript代码,它通过浏览器扩展(如Tampermonkey、Violentmonkey)提供的API,在页面加载的不同阶段执行。选择这个形态有几个实实在在的好处:
- 无需修改目标网站:脚本运行在浏览器端,对服务器零侵入,不会因为改别人的代码而惹麻烦。
- 跨站通用:写一套逻辑,通过配置匹配规则,可以适配多个动漫站点。
- 开发调试快:改完代码刷新页面就能看到效果,不需要部署、不需要编译。
- 用户安装门槛低:装个扩展,点一下安装按钮就完事了。
当然,油猴脚本也有它的局限。比如它无法解决跨域请求的问题(需要依赖目标站点的CORS策略或者使用GM_xmlhttpRequest),也无法在页面加载前修改服务器的响应内容。但对于弹幕播放这个需求来说,这些限制并不致命。
1.3 弹幕播放的核心技术点有哪些
要把弹幕跑起来,至少需要解决以下几个问题:
- 视频元素的定位:找到页面中的
<video>标签,获取它的位置和尺寸,弹幕层要精确覆盖在视频上方。 - 弹幕数据的获取:从某个弹幕接口拉取弹幕数据,通常返回的是JSON或XML格式,需要解析成弹幕对象。
- 弹幕的渲染:用Canvas或DOM来绘制弹幕,Canvas性能更好,DOM更容易做交互。
- 弹幕的调度:根据视频当前播放时间,决定哪些弹幕应该出现、出现在哪条轨道上、移动速度是多少。
- 弹幕的发送:提供一个输入框,让用户输入弹幕内容,然后提交到弹幕接口。
- 与视频播放状态的同步:视频暂停时弹幕也要暂停,视频跳转时弹幕要重新调度。
这六个点里,最核心的是第三和第四点。渲染方式决定了性能上限,调度算法决定了弹幕的观感是否自然。
1.4 整体架构设计思路
我的设计思路是这样的:脚本启动后,先等待视频元素出现,然后在视频上方创建一个绝对定位的Canvas元素,尺寸与视频保持一致。接着从配置好的弹幕接口拉取当前视频的弹幕数据,存入一个数组。然后启动一个定时器(或者用requestAnimationFrame),每一帧检查视频的currentTime,把时间轴上即将进入视野的弹幕推入渲染队列,Canvas每帧清空重绘。
弹幕的轨道分配采用“贪心算法”:每条弹幕根据其出现时间和移动速度,计算出它占据轨道的时间区间,然后找到第一条在当前时间点空闲的轨道。如果所有轨道都满了,就丢弃这条弹幕或者让它重叠显示。
发送弹幕的部分,我在视频控制栏旁边注入一个输入框和发送按钮,用户输入后通过GM_xmlhttpRequest提交到弹幕接口,成功后把弹幕立即加入本地渲染队列,实现“秒见”效果。
2. 动手之前:环境准备与关键工具选型
2.1 浏览器扩展的选择
油猴脚本需要运行在用户脚本管理器上。目前主流的有Tampermonkey、Violentmonkey和Greasemonkey。我三个都用过,最后选了Violentmonkey,原因很简单:开源、轻量、API兼容性好,而且它的GM_xmlhttpRequest实现比较稳定。
Tampermonkey功能最全,但闭源,而且有时候会抽风。Greasemonkey在Firefox上表现不错,但Chrome上的支持不如前两者。Violentmonkey在Chrome和Firefox上都能跑,安装脚本的方式也很直观。
安装好扩展之后,在扩展的管理面板里新建一个脚本,就可以开始写了。脚本的元数据块(Metadata Block)是必须的,它告诉扩展这个脚本叫什么、在哪些网站上运行、需要哪些权限。
// ==UserScript== // @name 动漫网站弹幕播放 // @namespace http://your-namespace // @version 1.0.0 // @description 为任意动漫网站添加弹幕播放功能 // @author YourName // @match *://*.example-anime-site.com/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @connect danmaku-api.example.com // ==/UserScript==这里有几个关键点需要注意:
@match决定了脚本在哪些页面上运行。你可以用通配符匹配多个站点,但不要写得太宽泛,否则会在无关页面上执行,浪费性能。@grant声明需要的API权限。GM_xmlhttpRequest用于跨域请求,GM_addStyle用于注入CSS样式。@connect声明允许跨域请求的域名。如果不写,GM_xmlhttpRequest可能会被拦截。
注意:不同扩展对
@connect的支持程度不同。Violentmonkey在未声明@connect时也会放行请求,但Tampermonkey会严格检查。为了兼容性,建议把所有需要请求的域名都列上。
2.2 弹幕数据源的选型
弹幕数据从哪来?这是整个项目最关键的外部依赖。我考虑过几种方案:
| 方案 | 优点 | 缺点 | 是否采用 |
|---|---|---|---|
| 自建弹幕服务器 | 完全可控 | 需要服务器和运维成本 | 否 |
| 使用公开弹幕API | 零成本、数据丰富 | 接口可能不稳定、有频率限制 | 是 |
| 本地存储弹幕 | 无网络依赖 | 数据量少、无法共享 | 否 |
| 爬取其他站点弹幕 | 数据量大 | 法律风险高、技术复杂 | 否 |
最终我选择了使用公开的弹幕API。具体来说,我调研了几个提供弹幕服务的平台,发现它们的接口格式大同小异,通常是通过视频ID或者关键词来查询弹幕。接口返回的JSON结构一般包含弹幕内容、出现时间、颜色、字号等信息。
这里要特别说明:使用公开API时一定要遵守对方的服务条款,控制请求频率,不要做恶意爬取。我在脚本里加了缓存机制,同一个视频的弹幕只请求一次,避免重复请求给服务器造成压力。
2.3 渲染方式:Canvas还是DOM
这是弹幕系统开发中一个经典的选型问题。我两种都实现过,说一下实际感受。
DOM方式的优点是实现简单,每条弹幕就是一个<div>,用CSS动画或者transform来移动。调试也方便,打开开发者工具就能看到每条弹幕的元素。但缺点也很明显:当弹幕数量多的时候(比如同时有几百条),DOM节点数量爆炸,浏览器渲染压力大,容易出现卡顿。
Canvas方式的优点是性能好,所有弹幕绘制在一个画布上,无论多少条弹幕,都只有一个DOM节点。缺点是调试麻烦,而且要做弹幕的点击交互(比如点击弹幕暂停、查看弹幕详情)需要自己实现命中检测。
考虑到动漫网站的弹幕密度通常比较高,我最终选择了Canvas方案。实测下来,在同时渲染500条弹幕的情况下,Canvas方案的帧率能稳定在60fps,而DOM方案会掉到30fps以下。
2.4 开发调试环境的搭建
油猴脚本的调试和普通前端开发有些不同。你不能用webpack打包,也不能用ES模块的import语法(除非你用构建工具预处理)。脚本就是一个单独的.js文件,所有代码都写在一起。
我的调试流程是这样的:
- 在Violentmonkey中新建脚本,粘贴代码。
- 打开目标动漫网站的视频页面。
- 按F12打开开发者工具,在Console中查看日志输出。
- 修改代码后,在Violentmonkey面板中点击“保存并重新加载”,然后刷新页面。
为了提高效率,我在脚本里加了一个调试开关,打开后会在Console中输出详细的日志,包括弹幕数量、轨道分配情况、渲染帧率等。这个开关在正式使用时关掉,避免性能损耗。
const DEBUG = false; function log(...args) { if (DEBUG) { console.log('[弹幕脚本]', ...args); } }3. 核心实现:从视频定位到弹幕渲染的完整流程
3.1 视频元素的定位与监听
动漫网站的视频播放器千奇百怪,有的用原生<video>标签,有的用第三方播放器库(如DPlayer、Video.js),还有的用iframe嵌套。我的策略是优先查找<video>标签,如果找不到,再尝试常见的播放器容器选择器。
function findVideoElement() { // 直接查找video标签 let video = document.querySelector('video'); if (video) return video; // 查找常见播放器容器内的video const selectors = [ '.dplayer video', '.video-js video', '#player video', '.player-container video' ]; for (const sel of selectors) { video = document.querySelector(sel); if (video) return video; } return null; }找到视频元素后,需要监听它的加载状态。因为视频可能是异步加载的,脚本执行的时候视频还没出现。我用MutationObserver来监听DOM变化,一旦视频元素出现就初始化弹幕层。
function waitForVideo(callback) { const video = findVideoElement(); if (video) { callback(video); return; } const observer = new MutationObserver(() => { const v = findVideoElement(); if (v) { observer.disconnect(); callback(v); } }); observer.observe(document.body, { childList: true, subtree: true }); }这里有个坑要注意:有些网站的视频元素会被动态替换(比如切换清晰度时),原来的video标签被移除,新的video标签被插入。如果只监听一次,弹幕层就会失效。我的做法是同时监听video元素的loadedmetadata事件,如果视频源变了,重新初始化弹幕层。
3.2 弹幕层的创建与样式注入
弹幕层是一个绝对定位的Canvas元素,覆盖在视频上方。关键是要让它的位置和尺寸与视频完全同步。我用getBoundingClientRect()获取视频的位置和尺寸,然后设置Canvas的style。
function createDanmakuLayer(video) { const canvas = document.createElement('canvas'); canvas.className = 'danmaku-canvas'; canvas.style.position = 'absolute'; canvas.style.pointerEvents = 'none'; canvas.style.zIndex = '9999'; // 将canvas插入到视频的父容器中 const parent = video.parentElement; parent.style.position = 'relative'; parent.appendChild(canvas); // 同步尺寸 function syncSize() { const rect = video.getBoundingClientRect(); const parentRect = parent.getBoundingClientRect(); canvas.style.left = (rect.left - parentRect.left) + 'px'; canvas.style.top = (rect.top - parentRect.top) + 'px'; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; canvas.width = rect.width * window.devicePixelRatio; canvas.height = rect.height * window.devicePixelRatio; canvas.getContext('2d').scale(window.devicePixelRatio, window.devicePixelRatio); } syncSize(); window.addEventListener('resize', syncSize); return canvas; }pointerEvents: none这个设置很重要,它让Canvas不拦截鼠标事件,否则用户就没法点击视频控制栏了。但这也意味着弹幕本身无法被点击。如果你需要弹幕交互,可以再加一个透明的交互层,或者用坐标计算来做命中检测。
样式的注入我用GM_addStyle,把弹幕相关的CSS统一管理。包括弹幕输入框、发送按钮、弹幕设置面板等UI元素的样式。
3.3 弹幕数据的获取与解析
弹幕数据的获取是整个脚本中最容易出问题的环节。不同的弹幕API返回格式不同,有的返回XML,有的返回JSON,字段名也五花八门。我写了一个适配层,把不同格式的弹幕数据统一转换成内部对象。
// 内部弹幕对象格式 // { // text: '弹幕内容', // time: 12.5, // 出现时间(秒) // color: '#ffffff', // 颜色 // fontSize: 25, // 字号 // mode: 1 // 1滚动 4底部 5顶部 // } function parseDanmakuResponse(response) { const danmakuList = []; // 假设返回的是JSON格式 if (typeof response === 'string') { try { response = JSON.parse(response); } catch (e) { log('弹幕数据解析失败', e); return danmakuList; } } // 适配不同的字段名 const items = response.data || response.danmaku || response.comments || []; for (const item of items) { danmakuList.push({ text: item.text || item.content || item.m, time: parseFloat(item.time || item.t || item.p), color: item.color || '#ffffff', fontSize: item.fontSize || item.size || 25, mode: item.mode || item.type || 1 }); } // 按时间排序 danmakuList.sort((a, b) => a.time - b.time); return danmakuList; }请求弹幕数据用GM_xmlhttpRequest,它不受同源策略限制。但要注意设置合理的超时时间和错误处理。
function fetchDanmaku(videoId) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: `https://danmaku-api.example.com/api?vid=${videoId}`, timeout: 10000, onload: (res) => { if (res.status === 200) { resolve(parseDanmakuResponse(res.responseText)); } else { reject(new Error('请求失败: ' + res.status)); } }, onerror: reject, ontimeout: () => reject(new Error('请求超时')) }); }); }实操心得:弹幕接口的稳定性直接决定了脚本的可用性。我在脚本里加了本地缓存,用
GM_setValue和GM_getValue把弹幕数据存起来,同一个视频第二次打开时直接用缓存,既加快了加载速度,也减少了对接口的压力。缓存有效期设为24小时。
3.4 弹幕轨道分配算法
轨道分配是弹幕调度的核心。假设弹幕从右向左滚动,每条弹幕的速度是固定的(比如每秒移动200像素),那么一条弹幕从进入屏幕到完全离开屏幕,需要的时间是(屏幕宽度 + 弹幕宽度) / 速度。在这段时间内,它占据的轨道不能被其他弹幕使用。
我用一个数组来记录每条轨道的“空闲时间”:
class TrackManager { constructor(trackCount, screenWidth, speed) { this.trackCount = trackCount; this.screenWidth = screenWidth; this.speed = speed; this.trackEndTime = new Array(trackCount).fill(0); } // 为一条弹幕分配轨道,返回轨道索引,如果没有可用轨道返回-1 allocate(danmaku, currentTime) { const textWidth = this.measureText(danmaku); const duration = (this.screenWidth + textWidth) / this.speed; for (let i = 0; i < this.trackCount; i++) { if (this.trackEndTime[i] <= currentTime) { this.trackEndTime[i] = currentTime + duration; return i; } } return -1; // 所有轨道都满了 } measureText(danmaku) { // 用离屏Canvas测量文字宽度 const ctx = this.measureCtx || (this.measureCtx = document.createElement('canvas').getContext('2d')); ctx.font = `${danmaku.fontSize}px sans-serif`; return ctx.measureText(danmaku.text).width; } }这个算法的优点是简单高效,缺点是当弹幕密度很高时,后面的弹幕会被丢弃。我加了一个“重叠模式”作为备选:如果所有轨道都满了,允许弹幕重叠显示,但降低透明度,避免完全遮挡。
轨道的数量根据视频高度和弹幕字号来计算。一般来说,每条轨道的高度是字号加上一些间距,轨道数 = 视频高度 / 轨道高度。我通常设置为视频高度的80%用于弹幕显示,剩下的20%留空,避免弹幕遮挡字幕。
3.5 Canvas渲染循环的实现
渲染循环用requestAnimationFrame驱动,每一帧做三件事:清空画布、计算当前时间点应该显示的弹幕、绘制弹幕。
class DanmakuRenderer { constructor(canvas, video, danmakuList) { this.canvas = canvas; this.ctx = canvas.getContext('2d'); this.video = video; this.danmakuList = danmakuList; this.activeDanmaku = []; // 当前正在显示的弹幕 this.trackManager = new TrackManager(20, canvas.width, 200); this.lastTime = 0; this.paused = false; } start() { this.video.addEventListener('pause', () => { this.paused = true; }); this.video.addEventListener('play', () => { this.paused = false; }); this.loop(); } loop() { requestAnimationFrame(() => this.loop()); if (this.paused) return; const currentTime = this.video.currentTime; const deltaTime = currentTime - this.lastTime; this.lastTime = currentTime; // 如果视频跳转,清空当前弹幕 if (Math.abs(deltaTime) > 1) { this.activeDanmaku = []; this.trackManager.reset(); } // 从弹幕列表中取出当前时间点应该出现的弹幕 while (this.danmakuList.length > 0 && this.danmakuList[0].time <= currentTime) { const danmaku = this.danmakuList.shift(); const track = this.trackManager.allocate(danmaku, currentTime); if (track >= 0) { danmaku.track = track; danmaku.startTime = currentTime; danmaku.x = this.canvas.width; this.activeDanmaku.push(danmaku); } } // 更新和绘制 this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height); for (let i = this.activeDanmaku.length - 1; i >= 0; i--) { const d = this.activeDanmaku[i]; const elapsed = currentTime - d.startTime; d.x = this.canvas.width - elapsed * 200; if (d.x + this.measureText(d) < 0) { this.activeDanmaku.splice(i, 1); continue; } this.drawDanmaku(d); } } drawDanmaku(d) { const y = d.track * 30 + 20; this.ctx.font = `${d.fontSize}px sans-serif`; this.ctx.fillStyle = d.color; this.ctx.strokeStyle = 'rgba(0,0,0,0.8)'; this.ctx.lineWidth = 2; this.ctx.strokeText(d.text, d.x, y); this.ctx.fillText(d.text, d.x, y); } }这里有几个细节值得展开说。第一,deltaTime的计算用于检测视频跳转。如果两次帧之间的时间差超过1秒,说明用户拖动了进度条,这时候需要清空当前弹幕并重置轨道管理器。第二,弹幕的绘制顺序是先描边再填充,这样弹幕在浅色背景上也能看清。第三,activeDanmaku数组是倒序遍历的,因为删除元素时不会影响前面的索引。
3.6 弹幕发送功能的实现
发送弹幕的UI我放在视频控制栏的下方,用一个简单的表单实现。用户输入内容后按回车或者点击发送按钮,脚本把弹幕提交到接口,同时立即在本地显示。
function createSendBox(video, renderer) { const box = document.createElement('div'); box.className = 'danmaku-send-box'; box.innerHTML = ` <input type="text" placeholder="发个弹幕吧..." maxlength="50"> <button>发送</button> `; const input = box.querySelector('input'); const button = box.querySelector('button'); async function send() { const text = input.value.trim(); if (!text) return; const danmaku = { text: text, time: video.currentTime, color: '#ffffff', fontSize: 25, mode: 1 }; // 立即在本地显示 renderer.addLocalDanmaku(danmaku); input.value = ''; // 提交到服务器 try { await postDanmaku(danmaku); } catch (e) { log('弹幕发送失败', e); } } button.addEventListener('click', send); input.addEventListener('keydown', (e) => { if (e.key === 'Enter') send(); }); return box; }发送弹幕的接口调用和获取弹幕类似,只是用POST方法。这里要注意的是,很多弹幕接口需要用户登录或者有频率限制。我在脚本里加了简单的频率控制,同一用户10秒内只能发送一条弹幕。
4. 踩坑实录:常见问题与排查技巧
4.1 弹幕层位置偏移怎么办
这是最常见的问题。表现是弹幕层没有完全覆盖视频,或者随着页面滚动,弹幕层和视频错位了。
原因通常有两个:一是视频的父容器没有设置position: relative,导致绝对定位的Canvas相对于更外层的元素定位;二是页面滚动时没有重新计算位置。
解决方法是在创建弹幕层时,确保视频的父容器有position: relative,并且监听scroll事件重新同步位置。不过频繁的scroll事件会影响性能,我用requestAnimationFrame做了节流。
let ticking = false; window.addEventListener('scroll', () => { if (!ticking) { requestAnimationFrame(() => { syncSize(); ticking = false; }); ticking = true; } });4.2 弹幕不显示或者显示不全
如果弹幕完全不显示,先检查Console有没有报错。常见的原因包括:弹幕接口请求失败、Canvas尺寸为0、弹幕数据解析出错。
如果弹幕显示不全,比如只显示了一部分就消失了,通常是轨道分配的问题。检查trackManager的轨道数量是否足够,以及trackEndTime的计算是否正确。
还有一个容易被忽略的点:Canvas的尺寸设置。如果只设置了CSS的width和height,没有设置canvas元素的width和height属性,Canvas的实际分辨率会是默认的300x150,导致绘制的内容被拉伸或裁剪。正确的做法是同时设置CSS尺寸和canvas属性尺寸,并考虑devicePixelRatio。
4.3 视频切换后弹幕失效
很多动漫网站支持多集切换,切换后视频元素会被替换,但弹幕层还挂在旧的视频上。解决方法是监听视频元素的loadedmetadata事件,每次视频源变化时重新初始化弹幕层。
function onVideoChange(video) { // 移除旧的弹幕层 const oldCanvas = document.querySelector('.danmaku-canvas'); if (oldCanvas) oldCanvas.remove(); // 重新初始化 initDanmaku(video); } video.addEventListener('loadedmetadata', () => onVideoChange(video));4.4 弹幕接口请求被拦截
用GM_xmlhttpRequest请求弹幕接口时,如果返回403或者被重定向,通常是因为缺少必要的请求头。有些接口需要Referer或者User-Agent,可以在请求参数里手动加上。
GM_xmlhttpRequest({ method: 'GET', url: apiUrl, headers: { 'Referer': location.href, 'User-Agent': navigator.userAgent }, // ... });另外,如果接口返回的是HTTPS但你的页面是HTTP,或者反过来,也可能被浏览器拦截。确保请求的协议和页面一致。
4.5 性能问题排查
弹幕脚本的性能瓶颈通常出现在两个地方:一是弹幕数量太多导致渲染卡顿,二是频繁的DOM操作导致页面响应变慢。
对于渲染卡顿,可以限制同时显示的弹幕数量,比如最多200条,超出的丢弃。另外,用requestAnimationFrame代替setInterval,让渲染和浏览器的刷新率同步。
对于DOM操作,尽量把弹幕相关的UI元素一次性创建好,避免在循环中反复创建和插入元素。样式的修改也尽量批量进行,减少重排和重绘。
下面是我整理的一份常见问题速查表:
| 问题现象 | 可能原因 | 排查方法 | 解决方案 |
|---|---|---|---|
| 弹幕层不显示 | Canvas未插入DOM | 检查DOM中是否有.danmaku-canvas | 确认插入逻辑,检查父容器 |
| 弹幕位置偏移 | 父容器未设relative | 检查父容器computed style | 设置position: relative |
| 弹幕不滚动 | 渲染循环未启动 | 检查requestAnimationFrame是否执行 | 确认video已加载 |
| 弹幕重叠严重 | 轨道数不足 | 检查trackCount和视频高度 | 增加轨道数或降低字号 |
| 发送弹幕失败 | 接口需要登录 | 查看Network面板的响应 | 添加请求头或提示用户 |
| 视频切换后失效 | 未监听loadedmetadata | 检查事件监听 | 重新初始化弹幕层 |
避坑技巧:在开发阶段,我建议把弹幕速度调慢一点(比如从200px/s降到100px/s),这样更容易观察弹幕的调度和渲染是否正确。等调试完成后再调回正常速度。另外,可以在Canvas上绘制轨道分隔线(调试模式下),直观地看到每条弹幕占用的轨道。
4.6 兼容性问题的处理
不同动漫网站的视频播放器实现差异很大,有的用Shadow DOM,有的用iframe,有的把视频放在多层嵌套的容器里。我的经验是,不要试图用一个选择器适配所有网站,而是为每个网站写一个适配器。
const siteAdapters = { 'site-a.com': { findVideo: () => document.querySelector('#player video'), getVideoId: () => new URLSearchParams(location.search).get('vid') }, 'site-b.com': { findVideo: () => document.querySelector('.dplayer video'), getVideoId: () => location.pathname.split('/').pop() } }; function getAdapter() { const host = location.hostname; for (const [domain, adapter] of Object.entries(siteAdapters)) { if (host.includes(domain)) return adapter; } return null; }这种适配器模式的好处是,新增一个网站只需要加一条配置,不需要改动核心逻辑。而且每个网站的适配逻辑独立,互不影响。
5. 进阶优化:让弹幕体验更接近原生
5.1 弹幕防遮挡与透明度调节
弹幕遮挡视频内容是一个老生常谈的问题。我的做法是提供几个选项让用户自己调节:弹幕透明度(0.3到1.0)、弹幕显示区域(全屏、上半屏、下半屏)、弹幕字号缩放。
这些设置用GM_setValue持久化,下次打开自动生效。UI上用一个简单的设置面板,点击齿轮图标展开。
function applySettings(renderer) { const opacity = GM_getValue('danmakuOpacity', 0.9); const area = GM_getValue('danmakuArea', 'full'); const scale = GM_getValue('danmakuScale', 1.0); renderer.ctx.globalAlpha = opacity; renderer.area = area; renderer.scale = scale; }5.2 弹幕屏蔽与关键词过滤
有些弹幕内容重复或者引战,影响观看体验。我加了一个简单的关键词过滤功能,用户可以在设置里添加屏蔽词,包含这些词的弹幕会被自动过滤掉。
function shouldFilter(danmaku, blockedWords) { for (const word of blockedWords) { if (danmaku.text.includes(word)) return true; } return false; }这个功能实现简单,但效果很好。我自己的屏蔽词列表里有“前排”、“火钳刘明”、“第一”这些常见的无意义弹幕。
5.3 弹幕数据缓存与离线观看
前面提到过用GM_setValue缓存弹幕数据。这里再展开说一下缓存的策略:以视频ID为key,存储弹幕数组和缓存时间。每次请求前先检查缓存,如果缓存未过期(24小时内),直接使用缓存数据。
function getCachedDanmaku(videoId) { const cacheKey = 'danmaku_' + videoId; const cached = GM_getValue(cacheKey); if (!cached) return null; const { data, timestamp } = cached; const now = Date.now(); if (now - timestamp > 24 * 60 * 60 * 1000) { GM_deleteValue(cacheKey); return null; } return data; }这个缓存机制不仅加快了加载速度,还让脚本在弹幕接口暂时不可用的时候也能正常工作(用旧缓存)。
5.4 移动端适配的注意事项
虽然油猴脚本主要在桌面浏览器上使用,但有些用户也会在移动端浏览器(如Kiwi Browser、Yandex Browser)上安装扩展。移动端的适配要注意几点:
- 触摸事件代替鼠标事件,输入框的弹出可能会遮挡视频。
- 屏幕尺寸小,弹幕字号要适当缩小,轨道数也要减少。
- 性能相对较弱,需要降低弹幕密度和渲染帧率。
我在脚本里加了一个简单的设备检测,如果是移动端,自动调整参数。
const isMobile = /Android|iPhone|iPad|iPod/i.test(navigator.userAgent); if (isMobile) { defaultFontSize = 18; maxTracks = 10; maxDanmaku = 100; }5.5 脚本更新与版本管理
油猴脚本的更新机制是通过@version和@updateURL实现的。当你在脚本托管平台上发布了新版本,扩展会定期检查更新。我建议在脚本里加一个版本检查的逻辑,如果发现新版本,在Console里提示用户。
// ==UserScript== // @version 1.0.0 // @updateURL https://example.com/danmaku-script.meta.js // @downloadURL https://example.com/danmaku-script.user.js // ==/UserScript==版本号遵循语义化版本规范:主版本号.次版本号.修订号。修复bug时递增修订号,新增功能时递增次版本号,不兼容的改动递增主版本号。
5.6 安全性与合规性考量
最后说一下安全性和合规性。油猴脚本运行在用户的浏览器上,理论上可以读取和修改页面上的任何内容。所以脚本的代码必须透明,不能包含恶意逻辑。我建议把脚本开源,让用户可以看到完整的代码。
另外,弹幕内容的发送和获取要遵守目标平台的服务条款。不要用脚本做批量发送、刷屏等行为。我在脚本里加了发送频率限制和内容长度限制,既是为了合规,也是为了避免被接口封禁。
提示:如果你打算把脚本发布到公开的脚本托管平台,一定要仔细阅读平台的审核规则。有些平台不允许脚本包含跨域请求,有些平台对弹幕类脚本有额外的要求。发布前先确认清楚,避免脚本被下架。
这个脚本我从最初的一个简单原型,到现在稳定使用了大半年,中间迭代了十几个版本。最大的体会是:弹幕系统的核心不在于技术有多复杂,而在于细节的打磨。轨道分配的时机、弹幕消失的边界条件、视频跳转时的状态重置,这些看似不起眼的地方,恰恰决定了最终体验的好坏。如果你也在做类似的项目,我的建议是先跑通最小可用版本,然后在实际使用中不断发现问题、优化细节。