WXT + Vue 实现 B 站视频 AI 浏览器插件

179 阅读4分钟

微信图片_20251027115146_2622_1.png

一、浏览器扩展基础(Manifest、content script、background、sidepanel、options、popup、DevTools)

  • 浏览器插件核心概念与角色
    • Manifest: 扩展的“配置与权限清单”。在本项目由 WXT 自动产出,入口在 wxt.config.ts 的 manifest 字段配置。常见字段:permissions、action、side_panel。
    • Background: 扩展的“常驻后台服务”,负责事件监听、跨页面/跨域操作、集中调度。对应 entrypoints/background.ts。
    • Content Script: 注入到网页的脚本,能访问页面 DOM,负责嵌入 UI、采集上下文。对应 entrypoints/content/index.ts 与 entrypoints/content/App.vue。
    • Sidepanel: 浏览器侧边栏中的独立页面,适合承载复杂交互和长会话 UI。对应 entrypoints/sidepanel/main.ts + entrypoints/sidepanel/App.vue。
    • Options: 扩展设置页,用于管理 API Key、参数、偏好。对应 entrypoints/options/main.ts 与 entrypoints/options/index.html。
    • Popup: 扩展图标点击后弹出的轻量级操作窗口,适合快速操作。对应 entrypoints/popup/main.ts 与 entrypoints/popup/App.vue
    • DevTools: 用于扩展浏览器自带的 DevTools(开发者工具)。对应 entrypoints/devtools/main.ts 与 entrypoints/devtools/App.vue

为什么选择 WXT 做构建工具?

  • 支持多种前端框架,如:Vanilla、Vue 、React 、Svelte 、Solid 都支持。
  • 生态丰富,开源的例子比较多。
  • 另一个 Plasmo 构建工具也不错,支持 Vue 、React 、Svelte

二、通信

项目中用 @webext-core/messaging 作为各模块通信基础,因为其支持 TS(参考 messags/messaging.ts)。 @webext-core/messaging 基础实现是依赖 tabs.sendMessage(其他组件向 content script 通信)和 runtime.sendMessage(除了 content script 的其他组件之间通信)。

通信规则:

  • content script ↔ background 相互直接通信
  • content script 无法直接与 sidepanel 通信,必须通过 background 服务工作器作为中介
    • content script → background → sidepanel
    • content script 发送配置更新消息到 background
    • background 接收消息后,将其转发给当前打开的 sidepanel
    • sidepanel 监听消息并更新界面
  • sidepanel ↔ background 相互直接通信
  • sidepanel → content script 单向通信
  • sidepanel ↔ options 单向通信

总结:只要是 content script 与其他组件通信,都需要通过 background 转发,其他组件之间都可以相互通信,只有与 content script 是单向通信。

// 通信例子
import { defineExtensionMessaging } from "@webext-core/messaging";
export const { sendMessage, onMessage } = defineExtensionMessaging();
export const sendAIMessage = (data) => {
  sendMessage("seting", data);
};
export const onAIMessage = (callback) => {
  onMessage("aiMessage", (message) => {
    callback(message.data);
  });
};

// 发送数据到content script模块
export const sendSeekToTime = async (data: number) => {
  const activeTabId = await getActiveTabId(); // 获取激活的 tab ID
  sendMessage("seekToTime", data, activeTabId);
};

三、B 站视频页字幕抓取

抓取视频字幕三步实现

// 获取当前视频页信息
const view = await fetch.get(
  "https://api.bilibili.com/x/web-interface/wbi/view/detail",
  { bvid } // bvid视频页唯一标识
);
// 获取当前视频字幕链接
const subtitleUrl = await fetch.get(
  "https://api.bilibili.com/x/player/wbi/v2",
  { aid: view.aid, cid: view.cid }
);
// 根据视频字幕链接获取字幕
await fetch.get(subtitleUrl);

四、封装 AI 请求与错误治理

// 智谱文档: https://docs.bigmodel.cn/api-reference/%E6%A8%A1%E5%9E%8B-api/%E5%AF%B9%E8%AF%9D%E8%A1%A5%E5%85%A8%E5%BC%82%E6%AD%A5

async function createChatCompletion(messages) {
  const url = "https://open.bigmodel.cn/api/paas/v4/chat/completions";
  const body = {
    model: "glm-4.6",
    messages: [
      {
        role: "system",
        content: `你是一名思维导图设计专家,分析内容。根据内容输出思维导图要求符合快速了解学习当前内容信息。请以markdown格式输出`,
      },
      ...messages,
      // {
      //   role: "user",
      //   content: `视频字幕xxxxxxxx`,
      // },
    ],
    /**
     * 采样温度,控制输出的随机性和创造性,取值范围为 [0.0, 1.0],限两位小数。值越低,输出越确定、越保守;值越高,输出越随机、越有创造性
     * 较高的值(如0.8)会使输出更随机、更具创造性,适合创意写作和头脑风暴;较低的值(如0.2)会使输出更稳定、更确定,适合事实性问答和代码生成
     */
    temperature: 1,
    // 模型输出的最大令牌token数量限制。GLM-4.6最大支持128K输出长度,GLM-4.5最大支持96K输出长度,建议设置不小于1024。
    max_tokens: 65536,
    stream: true, // 流式输出
  };
  const response = await fetch(url, options);
  return streamToJSON(response.data);
}

// 处理流式数据
async function* streamToJSON(stream, doneMarked) {
  const reader = stream.getReader();
  const decoder = new TextDecoder();
  let buffer = "";

  try {
    while (true) {
      const { done, value } = await reader.read();
      if (done) break;

      buffer += decoder.decode(value, { stream: true });
      const lines = buffer.split("\n");

      for (let i = 0; i < lines.length - 1; i++) {
        const line = lines[i].trim();
        if (line.startsWith("data: ") && line !== doneMarked) {
          yield JSON.parse(line.slice(6));
        }
      }

      buffer = lines[lines.length - 1];
    }

    if (buffer.trim() && buffer.trim() !== doneMarked) {
      yield JSON.parse(buffer.trim().slice(6));
    }
  } finally {
    reader.releaseLock();
  }
}

const process = async (messages, callback) => {
  const completion = await createChatCompletion(messages);
  let fullResponse = "";
  for await (const chunk of completion) {
    const { reasoning_content = "", content = "" } =
      chunk?.choices?.[0]?.delta || {};
    if (reasoning_content || content) {
      fullResponse += reasoning_content || content;
      callback({
        id: chunk.id,
        messages: fullResponse,
        isEnd: false,
      });
    }
  }
  callback({ messages: fullResponse, isEnd: true });
};

export default process;

五、可视化与知识导图

<svg fill="white" ref="svgRef" class="markmap-svg"></svg>
import { Transformer } from "markmap-lib";
import { Markmap } from "markmap-view";
import * as htmlToImage from "html-to-image";
import { saveAs } from "file-saver";
const svgRef = ref();
const transformer = new Transformer();
// 初始化markmap思维导图
const markmap = Markmap.create(svgRef.value);

const update = (newestMessages) => {
  // 更新思维导图渲染
  const values = newestMessages.match(/\`\`\`markdown([\s\S]*?)\`\`\`/);
  const text = values && values[1] ? values[1] : newestMessages;
  const { root } = transformer.transform(text);
  markmap.setData(root);
};

// 保持图片
const onSave = async () => {
  const canvas = await htmlToImage.toCanvas(svgRef.value, {
    pixelRatio: window.devicePixelRatio * 4,
    backgroundColor: "#fff",
  });
  const dataUrl = await canvas.toDataURL();
  if (dataUrl) {
    saveAs(dataUrl, "pastking.png");
  }
};

Github

Chrome Store