这件事的技术含量不高,但是它很有趣。这是我制作的一款插件,插件的下载地址、源码以及使用方式在这里。如果你是在微信公众号上阅读到的这篇文章,请点击左下角的阅读原文获取。

同时它也开源的,其解决问题的核心思路也是本文想要分享的。希望它能给你带来更多实用AI解决问题的灵感。

Showcase

以下是图文讲解

简单来说插件的广告识别分为两个步骤,首先如果它发现该视频具备广告识别的条件,在浏览器周围会出现浅蓝色光晕,代表它正在思考

image.png

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

image.png

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

image.png

核心原理

简单来说通过字幕

网站在加载每个视频时都需要请求一个路径为api.bilibili.com/x/player/wbi/v2的接口,我不知道这个接口是做什么的,但是在这个接口中会返回字幕的“元信息”。之所以称之为元信息,是因为它不包含字幕本身,而是包含围绕字幕的有关其他信息,例如该字幕属于哪国语言,该字幕是否由AI生成,以及最重要的请求字幕用的URL

注意,接口只会在登录的情况下才会返回字幕信息。同时注意字幕的URL中是带有token的,所以你无法一直持有并访问它,因为token需要被更新。

image.png

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

image.png

理论上我认为把这段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的开发者工具中全局搜索“螺蛳粉”(你需要再视频一开始加载时就打开开发者工具),结果如下

image.png

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

image.png

如何拿到视频的标题、描述以及时长信息

事实上我不用等待页面完全渲染完毕之后从DOM中截取,视频的各种基本信息已经被提前被存储到了全局变量window.__INITIAL_STATE__

image.png

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

image.png

事实上还有一种较为复杂的场景是播放列表。在这个场景中上一个视频播放完毕之后会自动播放下一个视频。不用担心,此时的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返回的结果那么它接可以与其他人共同分享。

这才是我最想做的事情:把每个人得到的不同视频的分析结果上传到云端共享,这样一来不仅用户体验可以大大提升,个体花费的时间和金钱也可以节省不少。