这件事的技术含量不高,但是它很有趣。这是我制作的一款插件,插件的下载地址、源码以及使用方式在这里。如果你是在微信公众号上阅读到的这篇文章,请点击左下角的阅读原文获取。
同时它也开源的,其解决问题的核心思路也是本文想要分享的。希望它能给你带来更多实用AI解决问题的灵感。
Showcase
以下是图文讲解
简单来说插件的广告识别分为两个步骤,首先如果它发现该视频具备广告识别的条件,在浏览器周围会出现浅蓝色光晕,代表它正在思考

一旦识别完成之后,光晕消失。在进度条上出现广告时间区间

在播放极端即将进入广告区间时,播放器周围会出现五彩光晕进行提示,表示即将跳过该广告区间

核心原理
简单来说通过字幕。
网站在加载每个视频时都需要请求一个路径为api.bilibili.com/x/player/wbi/v2的接口,我不知道这个接口是做什么的,但是在这个接口中会返回字幕的“元信息”。之所以称之为元信息,是因为它不包含字幕本身,而是包含围绕字幕的有关其他信息,例如该字幕属于哪国语言,该字幕是否由AI生成,以及最重要的请求字幕用的URL
注意,接口只会在登录的情况下才会返回字幕信息。同时注意字幕的URL中是带有token的,所以你无法一直持有并访问它,因为token需要被更新。

我通常会选取中文字幕,并且找到其中的URL,然后发送请求,得到的字幕结果内容如下:

理论上我认为把这段JSON发给AI,它就可以识别出其中的广告。但是为了确保准确性,以及节省token,这里我们需要将其传化为更为简单的结构,转化的函数如下:
export function convertSubtitleObjToStr(subtitles: subtitle[]): string {
return subtitles.map((sub: subtitle) => {
const { from, to, content } = sub
const subtitleStr: SubtitleString = `[${from}-${to}]:${content}`;
return subtitleStr;
}).join(';')
}
转化后的结果如下:
[0.82-2.06]:哇来了哇;[2.06-5.48]哦让我们去看看这个今天就吃一桶螺蛳粉了;[5.48-9.41]哇这哇哦终于吃螺蛳粉了;
在将字幕转化为指定格式之后,需要将其发送至AI,送使用的提示词(prompt)如下:
接下我会分享给你一段视频字幕,该段字幕由多个字幕语句组成。 每一句字幕包含三部分内容,分别是起始时间,结束时间,以及字幕内容,格式如下:[{起始时间}-{结束时间}]:{字幕内容}。语句之间由分号(;)隔开。 帮助我分析其中哪些是与视频无关的广告内容,给出其中连续广告内容起始时间和终止时间。我可能还会分享给你视频的标题以及视频的描述,用于辅助你判断广告内容 如果存在广告内容,请将广告的起止时间返回给我,返回格式为:{startTime: number, endTime: number} 如果不存在广告内容,返回null 字幕内容如下:
因为我需要在网页播放器的时间轴上精确的渲染出来广告时间段,所以在上述提示词中我明确告诉AI将广告的起止时间以JSON的格式返回。
继续优化
以上能够覆盖90%的情况了,但是在代码的实现过程中还是有必要解决一下边缘情况。
首先在提示词中指定返回的JSON格式并不靠谱,于是我选择在调用SDK的代码中以代码的方式指定返回的JSON schema
const responseSchema = {
type: 'OBJECT',
properties: {
startTime: {type: 'number', nullable: false},
endTime: {type: 'number', nullable: false},
},
required: ['startTime', 'endTime'],
};
const response = await geminiClient.models.generateContent({
model: aiModel,
config: {
responseJsonSchema: responseSchema,
responseMimeType: "application/json",
httpOptions: {
timeout: 1000 * 60,
}
},
contents: ''
});
其次我们还可以通过向AI提供更丰富的信息来协助他,例如视频的标题和描述,所以的我最终喂给AI的最终提示词中实际上是包含视频的标题以及描述的:
接下我会分享给你一段视频字幕,该段字幕由多个字幕语句组成
……
字幕内容如下: xxxx
视频标题如下: xxxx
视频描述如下: xxx
最后我发现对于短视频AI通过字幕判断广告的并不理想,所以默认我不对五分钟以内的视频进行判断。
技术细节
也许你对实现过程中的一些技术细节感兴趣,这里我把我能够想到的都分享出来。
如何找到字幕接口的
搜索,这是最简单的方式。
例如在上面的视频中我发现视频字幕中出现了“螺蛳粉”这三个字,于是我就在Chrome的开发者工具中全局搜索“螺蛳粉”(你需要再视频一开始加载时就打开开发者工具),结果如下

很明搜索列表的第一个结果就是字幕接口的返回,右边就可以找到该返回所对应的请求URL是什么。有了这个aisubtitle.hdslb.com关键词之后我就顺藤摸瓜找到返回这个域名的接口

如何拿到视频的标题、描述以及时长信息
事实上我不用等待页面完全渲染完毕之后从DOM中截取,视频的各种基本信息已经被提前被存储到了全局变量window.__INITIAL_STATE__中

通过页面源代码不难看出,该变量早就由后端渲染在了页面上

事实上还有一种较为复杂的场景是播放列表。在这个场景中上一个视频播放完毕之后会自动播放下一个视频。不用担心,此时的window.__INITIAL_STATE__变量内的数据也会得到更新
如何拦截请求并得到其中的字幕加载URL
前端想要发送API请求,无非只有两个手段1)通过XMLHttpRequest;2)通过Fetch API。经过测试不难发现Bilibili网站试用的是前者,于是我选择通过monkey patch方式将它“黑”掉,替换成我的实现方法以便我监控每一个发出的请求,以及获取到它们的返回:
const originalOpen = XMLHttpRequest.prototype.open;
const originalSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function(method: string, url: string | URL, ...args: any[]) {
// @ts-ignore
this._url = url.toString();
// @ts-ignore
return originalOpen.call(this, method, url, ...args);
};
XMLHttpRequest.prototype.send = function(...args: any[]) {
// @ts-ignore
const url = this._url;
if (window.location.pathname.startsWith('/video/') && url && url.includes('api.bilibili.com/x/player/wbi/v2')) {
console.log('📺 ✔️ Detected player API request');
为什么只考虑了Gemini?
因为兼容每一种大模型都需要时间成本,而我的精力实在有限(欢迎给源代码提pull request)。我唯二非常想集成的大模型是OpenAI,苦于他们不接受国内信用卡,亲测Google Pay也无效。
集成不同模型最高效的办法是用某个兼容超强的SDK,例如Vercel提供的AI SDK,又或者某个Agent框架例如Mastra,可问题在于虽然多数框架支持的编程语言是TypeScript,但框架并支持在浏览器端运行。
事实上我还考虑过使用Chrome内置的本地模型——没错,目前Chrome支持用户下载一个Gemini Nano模型安装在本地(大约2G左右),但是该模型判断广告的效果非常差,于是隐藏了该功能。你还可以在我的源码中找到该部分代码
未来
如果每个人都安装了这个插件的话,可想而知同一个热门视频的字幕会被发送给AI很多次——这其实是一种浪费。理想情况下,只要我们中的任何一个人拿到了AI返回的结果那么它接可以与其他人共同分享。
这才是我最想做的事情:把每个人得到的不同视频的分析结果上传到云端共享,这样一来不仅用户体验可以大大提升,个体花费的时间和金钱也可以节省不少。

