☰
油猴脚本实现动漫网站通用弹幕播放:三层架构与跨域适配
2026/9/25 6:28:15 网站建设 项目流程

1. 从零拆解“动漫网站弹幕播放”脚本的核心逻辑

1.1 这个脚本到底解决了什么问题

如果你经常在各类动漫网站追番,大概率遇到过这种尴尬:网站本身有弹幕功能,但体验参差不齐——有的弹幕库空空如也,有的加载慢到出戏,有的干脆只支持自家平台的弹幕,换个站点就什么都没有了。更别提有些小众动漫站点压根没有弹幕系统,看番的时候总觉得少了点“大家一起看”的氛围。

“动漫网站弹幕播放”这个油猴脚本要干的事情,说白了就一句话:把弹幕能力从特定平台里抽出来,做成一个可以跨站挂载的通用层。你打开任意一个动漫播放页面,脚本自动识别视频元素,然后从弹幕接口拉取对应剧集的弹幕数据,再以覆盖层的形式渲染到视频上方。整个过程对原网站零侵入,不需要改一行网站代码,也不需要网站方配合。

这个脚本适合谁用?三类人最需要:一是追番量大、经常在多个站点之间切换的深度用户;二是自己搭建了动漫站、想快速给站点加上弹幕能力但不想从头开发弹幕系统的站长;三是对前端感兴趣、想通过一个真实项目学习油猴脚本开发、DOM操作和异步数据处理的开发者。哪怕你只会一点点JavaScript,跟着思路走也能理解整个链路。

1.2 为什么选择油猴脚本而不是浏览器扩展

这里有一个关键的技术选型问题值得说清楚。实现弹幕播放,理论上至少有三种路径:写浏览器扩展、写油猴脚本、或者直接改网站源码。浏览器扩展的能力最强,可以调用更多API,但它有几个硬伤——安装门槛高(需要上架商店或开发者模式加载)、更新链路长、权限申请让用户警惕。而油猴脚本的优势在于:安装即用、更新方便、权限相对轻量、社区生态成熟。

油猴脚本运行在页面的上下文里,通过@match或@include规则匹配目标URL,在页面加载的合适时机注入代码。它可以直接操作DOM、监听视频事件、发起跨域请求(受CORS限制但可以通过GM_xmlhttpRequest绕过)。对于弹幕播放这种“增强现有页面”的需求来说,油猴脚本的粒度刚刚好——它不需要接管整个页面,只需要在视频元素旁边“贴”上一层弹幕画布。

另一个考量是维护成本。动漫网站的页面结构经常变,今天这个class名,明天那个id,如果用扩展的方式写死适配逻辑,每次网站改版都要发新版本。油猴脚本可以通过@require引入公共库、通过@grant申请必要权限,更新只需要改脚本文件,用户端自动同步。这种轻量迭代的模式,非常适合弹幕这种需要持续适配多个站点的场景。

1.3 整体架构:三层分离的设计思路

我在实际写这个脚本的时候,采用的是数据层、渲染层、适配层三层分离的结构。这个设计不是拍脑袋想的,而是踩过坑之后总结出来的。

最早我把所有逻辑写在一个文件里,结果发现每加一个网站适配就要改核心代码,改着改着就乱了。后来拆成三层之后,逻辑清晰了很多:数据层负责弹幕的获取、解析、缓存和发送;渲染层负责把弹幕画到屏幕上,处理滚动、暂停、透明度、速度这些视觉参数;适配层则专门处理“这个网站的视频元素在哪”“这个网站的剧集ID怎么取”“这个网站用的是什么弹幕格式”这类站点特有的问题。

三层之间通过定义好的接口通信。数据层输出统一的弹幕对象数组,每个对象包含time(出现时间,秒)、text(弹幕内容)、color(颜色)、mode(滚动/顶部/底部)、size(字号)。渲染层只认这个格式,不关心数据从哪来。适配层则负责把各个网站的差异“翻译”成统一格式。这样一来,新增一个网站支持,只需要写一个适配器,核心代码完全不用动。

提示:三层分离的另一个好处是调试方便。弹幕不显示,你可以先看数据层有没有拿到数据,再看渲染层有没有正确绘制,最后看适配层有没有取对视频元素。逐层排查,比在一大坨代码里找问题快得多。

2. 核心细节解析与实操要点

2.1 弹幕数据的获取与跨域处理

弹幕数据从哪来?这是整个脚本最核心的问题。常见的来源有三种:一是目标网站自带的弹幕接口,直接调用就能拿到该站弹幕;二是第三方公共弹幕库,比如一些开源的弹幕聚合服务;三是本地存储或用户手动导入的弹幕文件。

对于第一种,你需要分析目标网站的弹幕请求。打开开发者工具的Network面板,播放一集动漫,观察有没有类似danmu、barrage、comment这样的请求。找到之后,看它的请求参数——通常包含剧集ID、时间戳、分页信息。把这些参数提取出来,在脚本里用GM_xmlhttpRequest重新发起请求。这里必须用GM_xmlhttpRequest而不是普通的fetch,因为油猴脚本运行在页面上下文,直接发请求会受同源策略限制,而GM_xmlhttpRequest是油猴提供的特权API,可以跨域。

// 用GM_xmlhttpRequest获取弹幕数据的典型写法 function fetchDanmaku(url, callback) { GM_xmlhttpRequest({ method: "GET", url: url, headers: { "Referer": location.href, "User-Agent": navigator.userAgent }, onload: function(response) { try { var data = JSON.parse(response.responseText); callback(null, data); } catch (e) { callback(e, null); } }, onerror: function(err) { callback(err, null); } }); }

注意Referer头的设置。很多弹幕接口会校验来源,如果Referer不对会返回403。把当前页面URL作为Referer传进去,大多数情况下能通过校验。如果还是不行,可能需要进一步分析接口的签名参数——有些网站会对请求参数做MD5或时间戳签名,这种情况就需要把签名算法也逆向出来。

对于第二种公共弹幕库,好处是数据量大、覆盖广,坏处是格式不统一、质量参差不齐。我一般会做一个格式转换层,把不同来源的弹幕统一成前面说的标准格式。转换的时候要特别注意时间基准——有些弹幕库用的是毫秒,有些用秒,有些还带偏移量,不统一的话弹幕会对不上画面。

2.2 渲染层的实现:Canvas还是DOM

弹幕渲染有两种主流方案:DOM元素和Canvas绘制。我两种都试过,最后选择了Canvas为主、DOM为辅的混合方案。

DOM方案的优点是实现简单,每个弹幕就是一个绝对定位的div,用CSS动画让它从右向左移动。但缺点也很明显:弹幕一多,页面里几百个div同时做动画,浏览器渲染压力巨大,低配电脑直接卡成幻灯片。而且DOM弹幕的碰撞检测很麻烦,容易出现弹幕重叠。

Canvas方案的优点正好相反:所有弹幕画在一张画布上,性能极好,几千条弹幕同时滚动也不卡。碰撞检测也容易做——你可以在画布上维护每条弹幕的轨道占用情况,新弹幕来了先找空轨道。缺点是文字渲染不如DOM灵活,比如要做复杂的文字阴影、渐变、描边,Canvas的API写起来比较繁琐。

我的选择是:滚动弹幕用Canvas,顶部和底部固定弹幕用DOM。因为固定弹幕数量少、位置固定,用DOM实现更简单,而且可以利用CSS做更丰富的样式。滚动弹幕数量大、需要频繁重绘,用Canvas性能最优。

// Canvas渲染滚动弹幕的核心逻辑 function renderDanmaku(ctx, danmakuList, currentTime) { ctx.clearRect(0, 0, canvas.width, canvas.height); var tracks = []; // 轨道占用情况 danmakuList.forEach(function(dm) { if (dm.time > currentTime || dm.time < currentTime - 8) return; var elapsed = currentTime - dm.time; var x = canvas.width - elapsed * speed; // 碰撞检测:找一条不重叠的轨道 var trackIndex = findFreeTrack(tracks, x, dm.text); if (trackIndex === -1) return; // 没有空轨道就跳过 var y = trackIndex * lineHeight + lineHeight; ctx.font = dm.size + "px sans-serif"; ctx.fillStyle = dm.color; ctx.fillText(dm.text, x, y); }); }

轨道高度一般设为字号加行距,比如24px字号配30px轨道高度。速度控制也很关键,太快看不清,太慢弹幕堆积。我的经验值是:滚动弹幕从右边缘到左边缘的时间控制在8到12秒之间,具体根据视频宽度微调。宽度大的屏幕可以适当加快,否则弹幕在屏幕上停留太久。

2.3 视频事件的监听与同步

弹幕必须和视频画面严格同步,否则就会出现“人还没说话,弹幕先到了”的尴尬。同步的关键是监听视频元素的timeupdate事件,用video.currentTime作为弹幕渲染的时间基准。

但timeupdate事件的触发频率只有每秒4次左右,对于弹幕这种需要精确到0.1秒的场景来说不够用。所以我会额外用requestAnimationFrame做渲染循环,每一帧都读取video.currentTime,然后计算当前应该显示哪些弹幕。

// 渲染循环 function renderLoop() { if (!video.paused && !video.ended) { var currentTime = video.currentTime; renderDanmaku(ctx, danmakuList, currentTime); } requestAnimationFrame(renderLoop); }

这里有个细节:视频暂停时应该停止渲染循环,否则弹幕会继续滚动,和画面脱节。视频拖动进度条时,需要清空画布并重新计算弹幕位置,避免出现弹幕“穿越”的错乱。视频倍速播放时,弹幕速度也要相应调整,否则1.5倍速下弹幕会显得太慢。

还有一个容易被忽略的点:视频切换剧集时的处理。很多动漫网站是单页应用,切换剧集不会刷新页面,只是换了视频源。这时候需要监听URL变化或视频元素的loadedmetadata事件,重新拉取对应剧集的弹幕数据,并重置渲染状态。

注意:不同网站的视频元素可能被包裹在iframe里,或者用了Shadow DOM。这种情况下直接document.querySelector('video')可能拿不到。需要先找到iframe,再进入其contentDocument查找,或者用递归遍历Shadow Root的方式定位。

3. 实操过程与核心环节实现

3.1 脚本元数据的配置与权限申请

油猴脚本的开头是一段元数据块,用// ==UserScript==包裹。这段配置决定了脚本在哪些页面运行、需要哪些权限、依赖哪些库。写得好不好,直接影响脚本的兼容性和安全性。

// ==UserScript== // @name 动漫网站弹幕播放 // @namespace http://your-namespace // @version 1.0.0 // @description 为动漫网站提供通用弹幕播放能力 // @author YourName // @match *://*.example-anime.com/* // @match *://*.another-anime.net/* // @grant GM_xmlhttpRequest // @grant GM_setValue // @grant GM_getValue // @grant GM_addStyle // @connect api.example-anime.com // @connect danmaku-public-lib.com // @run-at document-end // ==/UserScript==

@match规则要尽量精确,不要用*://*/*这种通配所有网站的写法,那样脚本会在所有页面注入,既浪费性能又可能和其他脚本冲突。@connect列出所有需要跨域请求的域名,这是安全限制,不写的话GM_xmlhttpRequest会拒绝请求。@run-at设为document-end,确保DOM已经解析完成,视频元素已经存在。

@grant里的GM_setValue和GM_getValue用来做本地存储,可以缓存弹幕数据、记住用户的弹幕设置(比如透明度、速度、是否屏蔽某些关键词)。GM_addStyle用来注入弹幕层的CSS样式,比手动创建style标签方便。

3.2 视频元素的定位与弹幕层的挂载

脚本启动后的第一件事是找到视频元素。但不同网站的视频元素位置千差万别,有的直接在body下,有的在多层div嵌套里,有的在iframe里。我的做法是写一个通用的查找函数,按优先级依次尝试。

function findVideoElement() { // 优先找正在播放的视频 var videos = document.querySelectorAll('video'); for (var i = 0; i < videos.length; i++) { if (!videos[i].paused && videos[i].currentTime > 0) { return videos[i]; } } // 其次找最大的视频元素 var largest = null; var maxArea = 0; videos.forEach(function(v) { var rect = v.getBoundingClientRect(); var area = rect.width * rect.height; if (area > maxArea) { maxArea = area; largest = v; } }); return largest; }

找到视频后,需要创建一个弹幕容器。这个容器要绝对定位在视频上方,尺寸和视频完全一致,并且要跟随视频的缩放和移动实时调整。我用ResizeObserver监听视频元素的尺寸变化,用MutationObserver监听视频元素的样式变化,确保弹幕层始终对齐。

function createDanmakuLayer(video) { var layer = document.createElement('div'); layer.id = 'danmaku-layer'; layer.style.position = 'absolute'; layer.style.pointerEvents = 'none'; // 不阻挡视频点击 layer.style.zIndex = '9999'; var canvas = document.createElement('canvas'); canvas.width = video.clientWidth; canvas.height = video.clientHeight; layer.appendChild(canvas); // 把弹幕层插入到视频的父容器 video.parentNode.style.position = 'relative'; video.parentNode.appendChild(layer); // 监听尺寸变化 var resizeObserver = new ResizeObserver(function() { canvas.width = video.clientWidth; canvas.height = video.clientHeight; }); resizeObserver.observe(video); return { layer: layer, canvas: canvas }; }

pointerEvents: none这个设置很关键。弹幕层覆盖在视频上方,如果不设这个属性,用户点击视频暂停、全屏、调进度都会被弹幕层挡住。设成none之后,鼠标事件穿透弹幕层,直接作用在视频上,用户体验和没装脚本一样。

3.3 弹幕发送功能的实现

光看弹幕不够,能发弹幕才算完整。发送弹幕的流程是:用户在输入框输入文字、选择颜色和模式,点击发送后,脚本把弹幕内容POST到目标网站的弹幕接口,同时立即在本地渲染出来(乐观更新),不用等服务器返回。

function sendDanmaku(text, color, mode) { var video = findVideoElement(); var time = video.currentTime; // 先本地渲染 var localDanmaku = { time: time, text: text, color: color, mode: mode, size: 24 }; danmakuList.push(localDanmaku); // 再发送到服务器 var payload = { cid: getCurrentEpisodeId(), time: time, text: text, color: color, mode: mode }; GM_xmlhttpRequest({ method: "POST", url: getDanmakuPostUrl(), headers: { "Content-Type": "application/json", "Referer": location.href }, data: JSON.stringify(payload), onload: function(response) { if (response.status !== 200) { console.warn("弹幕发送失败", response.status); } } }); }

发送接口的鉴权是个难点。有些网站需要登录态,需要带上Cookie;有些需要CSRF Token,要从页面里提取;有些还有频率限制,发太快会被封。我的处理方式是:优先复用页面已有的登录态(GM_xmlhttpRequest会自动带上同域Cookie),如果接口需要额外Token,就从页面的meta标签或全局变量里找。频率限制方面,加一个简单的节流,比如每3秒最多发一条。

提示:发送弹幕前最好做一下内容校验,过滤掉空内容、超长内容、纯空格。有些网站对弹幕长度有限制(比如50字),超了会被服务器拒绝,不如在本地就截断或提示。

3.4 弹幕设置面板的交互设计

一个完整的弹幕播放器需要给用户提供设置选项:弹幕开关、透明度、速度、字号、屏蔽词、屏蔽类型(滚动/顶部/底部)。这些设置我用一个悬浮面板来承载,面板默认隐藏,鼠标移到弹幕层右上角时显示。

面板的HTML结构用GM_addStyle注入样式,用原生DOM创建元素。设置项的值存在GM_setValue里,下次打开页面自动恢复。屏蔽词功能用正则匹配,用户输入的关键词用|分隔,渲染时过滤掉匹配的弹幕。

// 屏蔽词过滤 function shouldFilter(danmaku) { var blockedWords = GM_getValue('blockedWords', ''); if (!blockedWords) return false; var words = blockedWords.split('|').filter(Boolean); for (var i = 0; i < words.length; i++) { if (danmaku.text.indexOf(words[i]) !== -1) { return true; } } return false; }

透明度设置直接改Canvas的globalAlpha或弹幕层的opacity。速度设置影响滚动弹幕的移动速度系数。字号设置影响Canvas绘制时的font属性。这些设置都是实时生效的,用户拖动滑块就能看到变化,不需要刷新页面。

4. 常见问题与排查技巧实录

4.1 弹幕不显示或显示错位的排查思路

弹幕不显示是最常见的问题,原因可能出在数据层、渲染层或适配层的任何一层。我一般按这个顺序排查:

排查步骤检查内容常见原因解决方法
第一步控制台有没有报错脚本语法错误、API调用失败看报错信息定位具体行
第二步弹幕数据有没有拉到接口地址变了、跨域被拒在Network面板看请求状态
第三步弹幕层有没有创建视频元素没找到、父容器定位问题检查#danmaku-layer是否存在
第四步Canvas有没有绘制时间基准不对、轨道计算错误在渲染循环里打日志
第五步弹幕位置对不对视频尺寸变化没同步检查ResizeObserver是否生效

显示错位通常是弹幕层和视频元素没有对齐。原因可能是视频元素的offsetParent不是弹幕层的父容器,或者视频有transform缩放。解决方法是把弹幕层挂到和视频同一个父容器下,并且用getBoundingClientRect而不是offsetLeft/offsetTop来计算位置。

另一个常见问题是全屏时弹幕消失。这是因为全屏模式下,浏览器会把全屏元素提升到顶层,弹幕层如果不在全屏元素内部,就会被盖住。解决方法是在全屏事件触发时,把弹幕层移动到全屏元素内部,退出全屏时再移回来。

4.2 弹幕卡顿与性能优化

弹幕一多就卡,这是Canvas渲染的典型问题。优化方向有几个:

第一,减少不必要的重绘。视频暂停时停止渲染循环,页面不可见时(document.hidden)停止渲染,弹幕开关关闭时停止渲染。这些简单的判断能省下大量CPU。

第二,控制同屏弹幕数量。一般同屏弹幕超过200条,视觉上已经非常密集了,再多也看不清。可以在渲染时限制同屏数量,超出的弹幕直接跳过不画。

第三,用离屏Canvas做预渲染。如果弹幕文字样式固定,可以把每条弹幕先画到一个离屏Canvas上,渲染时直接drawImage,比每次fillText快很多。不过这个优化实现起来复杂,弹幕数量不是特别大的话没必要。

第四,降低渲染帧率。弹幕不需要60帧渲染,30帧足够了。可以用setTimeout代替requestAnimationFrame,或者用时间戳判断,每两帧才渲染一次。

// 限制渲染帧率为30fps var lastRenderTime = 0; function renderLoop(timestamp) { if (timestamp - lastRenderTime > 33) { // 约30fps lastRenderTime = timestamp; // 执行渲染 } requestAnimationFrame(renderLoop); }

4.3 网站改版导致脚本失效的应对

动漫网站改版是家常便饭,class名一改,脚本的适配层就挂了。应对这个问题,我的经验是:尽量用稳定的选择器,不要依赖易变的class名。

什么是稳定的选择器?video标签本身是稳定的,id通常比class稳定,>var siteAdapters = { 'example-anime.com': { videoSelector: 'video', episodeIdFromUrl: function(url) { var match = url.match(/\/play\/(\d+)/); return match ? match[1] : null; }, danmakuApi: 'https://api.example-anime.com/danmaku', danmakuFormat: 'json' }, 'another-anime.net': { videoSelector: '#player video', episodeIdFromUrl: function(url) { var match = url.match(/ep=(\d+)/); return match ? match[1] : null; }, danmakuApi: 'https://another-anime.net/api/comments', danmakuFormat: 'xml' } };

这样新增网站支持只需要加一个配置项,改版也只需要改配置。如果某个网站的适配逻辑特别复杂,可以单独写一个适配器函数,在配置里引用。

4.4 弹幕数据缓存与离线观看

弹幕数据每次打开页面都重新拉取,既慢又浪费流量。我的做法是用GM_setValue做本地缓存,按“网站+剧集ID”作为key,缓存弹幕数据。下次打开同一集,先从缓存读,缓存过期(比如24小时)再重新拉。

function getDanmakuWithCache(site, episodeId, callback) { var cacheKey = 'danmaku_' + site + '_' + episodeId; var cached = GM_getValue(cacheKey, null); if (cached && Date.now() - cached.timestamp < 24 * 60 * 60 * 1000) { callback(cached.data); return; } fetchDanmakuFromApi(site, episodeId, function(err, data) { if (!err) { GM_setValue(cacheKey, { timestamp: Date.now(), data: data }); } callback(data); }); }

缓存还有一个好处是支持离线观看。如果你提前把某一集的弹幕缓存下来,即使断网了,只要视频文件在本地,弹幕依然能正常显示。这个功能对于经常在通勤路上看番的人来说很实用。

注意:GM_setValue的存储空间有限,一般只有几MB。弹幕数据量大的话,要定期清理旧缓存,或者只缓存最近几集。可以在脚本启动时检查缓存总量,超过阈值就删掉最旧的。

4.5 和其他弹幕脚本的冲突处理

很多人可能同时装了好几个弹幕脚本,这时候容易出现弹幕重叠、设置面板打架的问题。我的处理方式是:在脚本启动时检测页面上有没有其他弹幕层,如果有,就延迟启动或者提示用户。

function checkConflict() { var existingLayers = document.querySelectorAll( '#danmaku-layer, .danmaku-layer, [class*="danmaku"]' ); if (existingLayers.length > 0) { console.warn('检测到已有弹幕层,可能存在冲突'); return true; } return false; }

如果确实冲突了,最简单的办法是让用户手动关掉其中一个。也可以在脚本设置里加一个“兼容模式”,把自己的弹幕层透明度调低,或者偏移到视频下方,避免和另一个脚本的弹幕完全重叠。

5. 从脚本到产品:一些个人经验

写这个脚本的过程中,我最大的体会是:弹幕播放的技术难点不在渲染,而在适配。渲染逻辑写一次就够了,但每加一个网站就要重新分析接口、重新写适配器。所以如果你打算长期维护这个脚本,一定要把适配层设计得足够灵活,最好能做到“配置即适配”。

另一个体会是关于用户体验的。弹幕脚本是“增强型”工具,用户装了之后如果感觉不到它的存在,那是最好的——视频照常播放,弹幕自然出现,设置想调就调,不想调就用默认值。不要搞太多花哨的功能,不要弹窗打扰用户,不要改网站原有的布局。克制,是这类脚本的美德。

最后说一个实际使用中的小技巧:如果你发现某个网站的弹幕质量太差(全是刷屏、剧透、无意义内容),可以在设置里把弹幕来源切换到公共弹幕库,或者干脆只显示特定颜色的弹幕。我自己的习惯是屏蔽掉白色以外的所有颜色,这样弹幕清爽很多,重要内容也不容易漏掉。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询