feat(extension): P2 视频页悬浮按钮 + 右键菜单 + cookie 直通;P3 侧边栏首版

- contentScripts: 仅在支持的视频平台(B 站 / YouTube / 抖音 / 快手)注入悬浮 BiliNote 按钮,点击通过 webext-bridge 发 'bilinote-start' 给 background
- background: 处理 bilinote-start 与右键菜单点击;调 /api/generate_note;写 chrome.storage;自动打开侧边栏。logic/storage 是 Vue 反应式版本,service worker 不能 import,因此把常量抽到 logic/constants.ts
- contextMenus: onInstalled 时注册"用 BiliNote 总结此视频",限定 4 个支持平台域名
- 浏览器 Cookie 同步:options 页加按钮,按平台读 chrome.cookies.getAll,序列化为 'name=value; ...' 后 POST 给后端 /api/update_downloader_cookie。chrome.cookies + contextMenus 权限补到 manifest
- 侧边栏(P3 首版):从 storage 读最近任务并轮询,复用 TaskProgress + MarkdownView。markmap 思维导图与 RAG 问答推到后续
- 修 P1 endpoint 拼错的 bug:/api/get_models_by_provider 实际是 /api/model_enable,404 来自这里

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
huangjianwu
2026-05-07 11:46:09 +08:00
parent b8f359e7e7
commit 880587f2db
10 changed files with 435 additions and 50 deletions

View File

@@ -1,4 +1,7 @@
import { onMessage } from 'webext-bridge/background'
import type { Settings, TaskRecord } from '~/logic/types'
import { DEFAULT_SETTINGS, MAX_TASKS, SETTINGS_KEY, TASKS_KEY } from '~/logic/constants'
import { detectPlatform } from '~/logic/platform'
// only on dev mode
if (import.meta.hot) {
@@ -8,16 +11,162 @@ if (import.meta.hot) {
import('./contentScriptHMR')
}
// 工具栏图标点击 → 打开 popup默认行为无需配置
// side panel 留给 P3 阶段:在那时把 popup 替换成"action 行为"或加单独命令
// 此处不开启 openPanelOnActionClick否则会绕过 default_popup
// ---------- 直接操作 chrome.storageservice worker 里别用 Vue 反应式)----------
browser.runtime.onInstalled.addListener((): void => {
// eslint-disable-next-line no-console
console.log('BiliNote extension installed')
async function readSettings(): Promise<Settings> {
const obj = await browser.storage.local.get(SETTINGS_KEY)
const raw = obj[SETTINGS_KEY] as string | undefined
if (!raw)
return { ...DEFAULT_SETTINGS }
try {
return { ...DEFAULT_SETTINGS, ...(JSON.parse(raw) as Partial<Settings>) }
}
catch {
return { ...DEFAULT_SETTINGS }
}
}
async function readTasks(): Promise<TaskRecord[]> {
const obj = await browser.storage.local.get(TASKS_KEY)
const raw = obj[TASKS_KEY] as string | undefined
if (!raw)
return []
try {
return JSON.parse(raw) as TaskRecord[]
}
catch {
return []
}
}
async function writeTasks(tasks: TaskRecord[]) {
await browser.storage.local.set({ [TASKS_KEY]: JSON.stringify(tasks.slice(0, MAX_TASKS)) })
}
async function upsertTask(record: TaskRecord) {
const tasks = await readTasks()
const idx = tasks.findIndex(t => t.taskId === record.taskId)
if (idx >= 0)
tasks.splice(idx, 1, { ...tasks[idx], ...record })
else
tasks.unshift(record)
await writeTasks(tasks)
}
// ---------- 启动任务 ----------
async function startTask(url: string): Promise<{ ok: boolean, taskId?: string, error?: string }> {
const platform = detectPlatform(url)
if (!platform)
return { ok: false, error: '当前链接不是支持的视频平台' }
const settings = await readSettings()
if (!settings.providerId || !settings.modelName)
return { ok: false, error: '请先在设置页选择供应商与模型' }
const backend = settings.backendUrl.replace(/\/$/, '')
try {
const res = await fetch(`${backend}/api/generate_note`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
video_url: url,
platform,
quality: settings.quality,
provider_id: settings.providerId,
model_name: settings.modelName,
screenshot: settings.screenshot,
link: settings.link,
style: settings.style || undefined,
format: [
...(settings.screenshot ? ['screenshot'] : []),
...(settings.link ? ['link'] : []),
],
}),
})
if (!res.ok)
return { ok: false, error: `HTTP ${res.status}` }
const body = await res.json() as { code: number, msg: string, data: { task_id: string } }
if (body.code !== 0)
return { ok: false, error: body.msg }
await upsertTask({
taskId: body.data.task_id,
videoUrl: url,
platform,
status: 'PENDING',
message: '已提交',
createdAt: Date.now(),
updatedAt: Date.now(),
})
return { ok: true, taskId: body.data.task_id }
}
catch (e) {
return { ok: false, error: (e as Error).message }
}
}
async function openSidePanelInTab(tabId?: number) {
try {
// @ts-expect-error chrome.sidePanel 类型在 webextension-polyfill 中尚未补全
if (typeof chrome !== 'undefined' && chrome.sidePanel?.open && tabId !== undefined)
// @ts-expect-error see above
await chrome.sidePanel.open({ tabId })
}
catch (err) {
console.warn('打开侧边栏失败:', err)
}
}
// ---------- 消息桥 ----------
onMessage<{ url: string }, 'bilinote-start'>('bilinote-start', async ({ data, sender }) => {
const result = await startTask(data.url)
// 成功就把侧边栏拉起来给用户看进度
if (result.ok)
await openSidePanelInTab(sender?.tabId)
return result
})
// 占位:未来 content script 通过 webext-bridge 触发任务时,由这里转发到后端
// ---------- 安装时事件 ----------
browser.runtime.onInstalled.addListener(() => {
console.log('BiliNote extension installed')
// 右键菜单:在视频页或视频链接上"用 BiliNote 总结"
try {
browser.contextMenus.create({
id: 'bilinote-summarize-page',
title: '用 BiliNote 总结此视频',
contexts: ['page', 'link', 'video'],
documentUrlPatterns: [
'*://*.bilibili.com/*',
'*://*.youtube.com/*',
'*://youtu.be/*',
'*://*.douyin.com/*',
'*://*.kuaishou.com/*',
],
})
}
catch (e) {
console.warn('注册右键菜单失败:', e)
}
})
browser.contextMenus?.onClicked.addListener(async (info, tab) => {
if (info.menuItemId !== 'bilinote-summarize-page')
return
const url = info.linkUrl || tab?.url
if (!url)
return
const result = await startTask(url)
if (result.ok)
await openSidePanelInTab(tab?.id)
else
console.warn('右键启动失败:', result.error)
})
// content script 占位握手 —— 未来可扩展为查询当前任务等
onMessage('get-current-tab', async () => {
try {
const [tab] = await browser.tabs.query({ active: true, currentWindow: true })
@@ -27,4 +176,3 @@ onMessage('get-current-tab', async () => {
return { title: undefined, url: undefined }
}
})