diff --git a/components/Main.vue b/components/Main.vue index a4f1bce..befb990 100644 --- a/components/Main.vue +++ b/components/Main.vue @@ -167,6 +167,23 @@ + + + + + + 视频字幕翻译 + + + + + + + + + diff --git a/entrypoints/content.ts b/entrypoints/content.ts index 21fb805..05edbea 100644 --- a/entrypoints/content.ts +++ b/entrypoints/content.ts @@ -9,7 +9,8 @@ import { mountSelectionTranslator, unmountSelectionTranslator } from "@/entrypoi import { cancelAllTranslations, translateText } from "@/entrypoints/utils/translateApi"; import { createApp } from 'vue'; import TranslationStatus from '@/components/TranslationStatus.vue'; -import { mountNewApiComponent } from "@/entrypoints/utils/newApi"; +import { mountNewApiComponent } from "@/entrypoints/utils/newApi" +import { initVideoSubtitle } from "@/entrypoints/video/manager"; export default defineContentScript({ matches: [''], // 匹配所有页面 @@ -55,6 +56,9 @@ export default defineContentScript({ mountNewApiComponent(); + // 初始化视频字幕翻译(在支持的平台上注入拦截脚本) + initVideoSubtitle(); + cache.cleaner(); // 检测是否清理缓存 // background.ts diff --git a/entrypoints/utils/model.ts b/entrypoints/utils/model.ts index 915e58c..b1e2b2e 100644 --- a/entrypoints/utils/model.ts +++ b/entrypoints/utils/model.ts @@ -53,6 +53,7 @@ export class Config { translationStatus: boolean; // 是否启用全文翻译进度面板 inputBoxTranslationTrigger: string; // 输入框翻译触发方式 inputBoxTranslationTarget: string; // 输入框翻译目标语言 + enableVideoSubtitle: boolean; // 是否启用视频字幕翻译 constructor() { this.on = true; @@ -98,6 +99,7 @@ export class Config { this.translationStatus = true; // 默认启用翻译进度面板 this.inputBoxTranslationTrigger = 'disabled'; // 默认关闭输入框翻译 this.inputBoxTranslationTarget = 'en'; // 默认翻译成英文 + this.enableVideoSubtitle = true; // 默认启用视频字幕翻译 } } diff --git a/entrypoints/video/manager.ts b/entrypoints/video/manager.ts new file mode 100644 index 0000000..31c2a96 --- /dev/null +++ b/entrypoints/video/manager.ts @@ -0,0 +1,344 @@ +import { detectPlatform, getAllSubtitlePatterns } from './platforms' +import { detectSubtitleFormat, parseYouTubeXML, parseYouTubeJSON3, parseVTT, type SubtitleCue } from './parser' +import { SubtitleOverlay } from './overlay' +import { translateText } from '@/entrypoints/utils/translateApi' +import { config } from '@/entrypoints/utils/config' + +// ── 常量 ────────────────────────────────────────────────────────────────────── +const EVENT_TYPE = 'fr-subtitle-inject' +const BATCH_SIZE = 5 // 每批翻译的句子组数 +const MERGE_GAP_MS = 600 // 相邻 cue 间隔 < 此值(毫秒)则合并为同一句 +const MAX_WORDS = 20 // 单组超过此词数强制断开 +const QUICK_BTN_ID = 'fr-subtitle-quick-btn' + +// ── 类型 ────────────────────────────────────────────────────────────────────── +interface SentenceGroup { + cues: SubtitleCue[] + text: string +} + +// ── 模块状态 ────────────────────────────────────────────────────────────────── +const overlay = new SubtitleOverlay() +let listenerAttached = false +let processingUrl = '' // 去重:同一字幕 URL 只翻译一次 +let subtitleEnabled = true + +// ── 公开入口 ────────────────────────────────────────────────────────────────── + +/** 由 content.ts 调用,初始化视频字幕翻译 */ +export function initVideoSubtitle() { + console.log('[FR] initVideoSubtitle called, enableVideoSubtitle=', config.enableVideoSubtitle, 'hostname=', window.location.hostname) + if (!config.enableVideoSubtitle) return + // 拦截脚本已由 WXT 以 MAIN world content script 形式在 document_start 注入, + // 此处只需推送动态配置并开始监听消息。 + sendConfig() + attachMessageListener() + watchNavigation() + // 在 YouTube 上立即挂载快捷按钮,无需等待字幕捕获 + if (window.location.hostname.includes('youtube.com')) { + console.log('[FR] on YouTube, calling mountQuickButton') + mountQuickButton() + } +} + +// ── 私有实现 ────────────────────────────────────────────────────────────────── + +/** 向注入脚本发送字幕 URL 正则列表(动态更新,覆盖注入脚本内置的默认规则) */ +function sendConfig() { + window.postMessage({ + eventType: EVENT_TYPE, + type: 'config', + patterns: getAllSubtitlePatterns(), + }, '*') +} + +/** 监听来自注入脚本的 postMessage */ +function attachMessageListener() { + if (listenerAttached) return + listenerAttached = true + + window.addEventListener('message', async (event) => { + if (event.source !== window) return + const msg = event.data + if (!msg || msg.eventType !== EVENT_TYPE) return + + if (msg.type === 'subtitle-captured') { + const { url, data } = msg + if (!url || !data) return + if (!subtitleEnabled) return + // 同一 URL 不重复处理 + if (url === processingUrl) return + processingUrl = url + try { + await handleSubtitleData(url, data) + } finally { + processingUrl = '' + } + } + }) +} + +/** 解析字幕 → 初始化 overlay → 批量翻译 */ +async function handleSubtitleData(url: string, rawData: string) { + const format = detectSubtitleFormat(url, rawData) + if (!format) return + + const cues: SubtitleCue[] = + format === 'youtube-xml' ? parseYouTubeXML(rawData) : + format === 'youtube-json3' ? parseYouTubeJSON3(rawData) : + parseVTT(rawData) + + if (!cues.length) return + + const video = findVideo() + if (!video) return + + const mountTarget = findMountTarget(video) + overlay.mount(video, mountTarget) + overlay.setCues([...cues]) // 先用原文渲染,避免空白等待 + + hideNativeSubtitle() + mountQuickButton() + + // 分批翻译,边翻译边更新 overlay + await translateCuesBatched(cues, () => overlay.setCues([...cues])) +} + +/** + * 按时间间隔合并相邻 cue: + * - 相邻两条 cue 的间隔 < MERGE_GAP_MS → 合并为同一句(说话中的正常停顿) + * - 间隔 ≥ MERGE_GAP_MS 或词数超过 MAX_WORDS → 断开(句子之间的自然停顿) + * 这样"united states"等跨 cue 短语能被合并进同一组,避免词级切断的翻译错误。 + */ +function mergeByTimeGap(cues: SubtitleCue[]): SentenceGroup[] { + const groups: SentenceGroup[] = [] + let current: SubtitleCue[] = [] + + const flush = (arr: SubtitleCue[]) => groups.push({ + cues: [...arr], + text: arr.map(c => c.text).join(' ').replace(/\s+/g, ' ').trim(), + }) + + for (let i = 0; i < cues.length; i++) { + current.push(cues[i]) + const next = cues[i + 1] + const wordCount = current.reduce((n, c) => n + c.text.split(/\s+/).length, 0) + const gapMs = next ? (next.start - cues[i].end) * 1000 : Infinity + const bigGap = !next || gapMs >= MERGE_GAP_MS + const tooLong = wordCount >= MAX_WORDS + + if (bigGap || tooLong) { + if (tooLong && !bigGap && current.length > 1) { + // 词数超限但下一条紧跟(小间隔)→ 末尾 cue 进位到下一组, + // 避免碎片句(如 "but the seeds")因超限被孤立 + const carryOver = current.pop()! + flush(current) + current = [carryOver] + } else { + flush(current) + current = [] + } + } + } + if (current.length) flush(current) + return groups +} + +/** + * 将 cue 按时间间隔合并为句子组后批量翻译。 + * 组内所有 cue 共享同一译文,消除跨 cue 词级切断问题。 + */ +async function translateCuesBatched(cues: SubtitleCue[], onProgress: () => void) { + const groups = mergeByTimeGap(cues) + const instruction = + 'Video subtitle segments. Translate each [N] line. ' + + 'Return the same number of [N] lines, no extra explanation.\n\n' + + for (let i = 0; i < groups.length; i += BATCH_SIZE) { + const batch = groups.slice(i, i + BATCH_SIZE) + + const prevContext = i > 0 + ? `[context: ...${groups[i - 1].text.split(' ').slice(-8).join(' ')}]\n` + : '' + + const joined = instruction + prevContext + + batch.map((g, j) => `[${j + 1}] ${g.text}`).join('\n') + + try { + const translated = await translateText(joined, document.title) + const map = new Map() + for (const line of translated.split('\n')) { + const m = line.match(/^\[(\d+)\]\s*(.*)/) + if (m) map.set(parseInt(m[1]), m[2].trim()) + } + batch.forEach((group, j) => { + const translation = map.get(j + 1) || group.text + group.cues.forEach(cue => { cue.translatedText = translation }) + }) + } catch { + batch.forEach(group => { + group.cues.forEach(cue => { cue.translatedText = cue.text }) + }) + } + + onProgress() + } +} + +// ── YouTube 工具栏快捷按钮 ───────────────────────────────────────────────────── + +/** + * 在 YouTube 播放器右侧控制栏注入一个翻译开关按钮。 + * 点击可切换字幕翻译的显示/隐藏,不影响原生字幕。 + */ +function mountQuickButton() { + console.log('[FR] mountQuickButton called, existing=', !!document.getElementById(QUICK_BTN_ID)) + if (document.getElementById(QUICK_BTN_ID)) return + + // YouTube 右侧控制栏,等待其出现 + console.log('[FR] waiting for .ytp-right-controls, current=', document.querySelector('.ytp-right-controls')) + waitForElement('.ytp-right-controls', (controls) => { + console.log('[FR] .ytp-right-controls found, inserting button') + if (document.getElementById(QUICK_BTN_ID)) return + + const btn = document.createElement('button') + btn.id = QUICK_BTN_ID + btn.title = '流畅阅读:字幕翻译' + btn.setAttribute('aria-label', '字幕翻译') + btn.style.cssText = [ + 'background:transparent', + 'border:none', + 'cursor:pointer', + 'padding:0 6px', + 'height:100%', + 'display:inline-flex', + 'align-items:center', + 'opacity:0.9', + 'vertical-align:top', + ].join(';') + + btn.appendChild(buildBtnSvg(subtitleEnabled)) + + btn.addEventListener('click', () => { + subtitleEnabled = !subtitleEnabled + btn.replaceChildren(buildBtnSvg(subtitleEnabled)) + btn.title = subtitleEnabled ? '流畅阅读:字幕翻译(开)' : '流畅阅读:字幕翻译(关)' + if (subtitleEnabled) { + hideNativeSubtitle() + overlay.show() + } else { + overlay.hide() + restoreNativeSubtitle() + } + }) + + // 插入到右侧控制栏最左边 + controls.prepend(btn) + }) +} + +function buildBtnSvg(active: boolean): SVGElement { + const ns = 'http://www.w3.org/2000/svg' + const svg = document.createElementNS(ns, 'svg') + svg.setAttribute('viewBox', '0 0 24 24') + svg.setAttribute('width', '22') + svg.setAttribute('height', '22') + svg.setAttribute('fill', active ? '#fff' : 'rgba(255,255,255,0.4)') + const path = document.createElementNS(ns, 'path') + path.setAttribute('d', 'M20 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2zm0 14H4V6h16v12zM6 10h2v2H6zm0 4h8v2H6zm10 0h2v2h-2zm-6-4h8v2h-8z') + svg.appendChild(path) + return svg +} + +/** 轮询等待目标元素出现 */ +function waitForElement(selector: string, callback: (el: Element) => void, maxMs = 10000) { + const el = document.querySelector(selector) + if (el) { callback(el); return } + + const start = Date.now() + const timer = setInterval(() => { + const found = document.querySelector(selector) + if (found) { + clearInterval(timer) + callback(found) + } else if (Date.now() - start > maxMs) { + clearInterval(timer) + } + }, 300) +} + +// ── DOM 工具 ────────────────────────────────────────────────────────────────── + +function findVideo(): HTMLVideoElement | null { + const platform = detectPlatform(window.location.hostname) + if (platform.videoSelector) { + const v = document.querySelector(platform.videoSelector) + if (v) return v + } + return document.querySelector('video') +} + +function findMountTarget(video: HTMLVideoElement): HTMLElement { + const platform = detectPlatform(window.location.hostname) + if (platform.containerSelector) { + const el = document.querySelector(platform.containerSelector) + if (el) return el + } + return (video.parentElement as HTMLElement) || document.body +} + +function hideNativeSubtitle() { + const platform = detectPlatform(window.location.hostname) + if (!platform.hideNativeSelector) return + // 用 display:none 彻底隐藏,visibility:hidden 仍占位且有时被 YouTube 重置 + // 隐藏前将原始内联 display 值存入 data- 属性,供还原时使用 + document.querySelectorAll(platform.hideNativeSelector) + .forEach(el => { + el.dataset.frOrigDisplay = el.style.display + el.style.setProperty('display', 'none', 'important') + }) +} + +function restoreNativeSubtitle() { + const platform = detectPlatform(window.location.hostname) + if (!platform.hideNativeSelector) return + document.querySelectorAll(platform.hideNativeSelector) + .forEach(el => { + const orig = el.dataset.frOrigDisplay + if (orig !== undefined) { + el.style.display = orig + delete el.dataset.frOrigDisplay + } else { + el.style.removeProperty('display') + } + }) +} + +// ── SPA 导航监听 ────────────────────────────────────────────────────────────── + +function watchNavigation() { + let lastUrl = location.href + + const onUrlChange = () => { + const cur = location.href + if (cur !== lastUrl) { + lastUrl = cur + overlay.cleanup() + document.getElementById(QUICK_BTN_ID)?.remove() + processingUrl = '' + subtitleEnabled = true + restoreNativeSubtitle() + // SPA 导航到视频页时重新挂载按钮 + if (window.location.hostname.includes('youtube.com')) { + mountQuickButton() + } + } + } + + window.addEventListener('yt-navigate-finish', onUrlChange) + + const titleEl = document.querySelector('title') + if (titleEl) { + new MutationObserver(onUrlChange).observe(titleEl, { childList: true }) + } +} diff --git a/entrypoints/video/overlay.ts b/entrypoints/video/overlay.ts new file mode 100644 index 0000000..871388a --- /dev/null +++ b/entrypoints/video/overlay.ts @@ -0,0 +1,151 @@ +import type { SubtitleCue } from './parser' +import { config } from '@/entrypoints/utils/config' + +const OVERLAY_ID = 'fr-subtitle-overlay' + +export class SubtitleOverlay { + private container: HTMLElement | null = null + private video: HTMLVideoElement | null = null + private cues: SubtitleCue[] = [] + private rafId: number | null = null + private lastCueKey: string | null = null // 避免每帧重复 DOM 操作 + private mountTarget: HTMLElement | undefined + private originalMountPosition: string | undefined // undefined = 未修改过 + + /** 在视频容器内创建字幕层,开始时间轴循环 */ + mount(video: HTMLVideoElement, mountTarget: HTMLElement) { + this.video = video + this.cleanup() + + const overlay = document.createElement('div') + overlay.id = OVERLAY_ID + overlay.style.cssText = [ + 'position:absolute', + 'bottom:8%', + 'left:50%', + 'transform:translateX(-50%)', + 'z-index:2147483640', + 'text-align:center', + 'pointer-events:none', + 'width:max-content', + 'max-width:94%', + ].join(';') + + // 确保挂载目标有定位上下文,记录原始内联 position 以便 cleanup 还原 + const computedPos = window.getComputedStyle(mountTarget).position + if (computedPos === 'static') { + this.originalMountPosition = mountTarget.style.position + mountTarget.style.position = 'relative' + } + + mountTarget.appendChild(overlay) + this.mountTarget = mountTarget + this.container = overlay + this.startLoop() + } + + /** 更新全部字幕数据(解析完成 / 翻译进度更新时调用) */ + setCues(cues: SubtitleCue[]) { + this.cues = cues + this.lastCueKey = null // 强制刷新一次显示 + } + + show() { + if (this.container) this.container.style.display = '' + } + + hide() { + if (this.container) this.container.style.display = 'none' + } + + /** 停止渲染并移除 DOM,还原挂载目标的原始 position */ + cleanup() { + if (this.rafId !== null) { + cancelAnimationFrame(this.rafId) + this.rafId = null + } + this.container?.remove() + if (this.mountTarget !== undefined && this.originalMountPosition !== undefined) { + this.mountTarget.style.position = this.originalMountPosition + } + this.container = null + this.cues = [] + this.lastCueKey = null + this.mountTarget = undefined + this.originalMountPosition = undefined + } + + // ── 内部方法 ────────────────────────────────────────────────────────────── + + private startLoop() { + const tick = () => { + if (!this.video || !this.container) return + const t = this.video.currentTime + const cue = this.findCue(t) + const key = cue ? `${cue.start}~${cue.end}` : '' + + if (key !== this.lastCueKey) { + this.lastCueKey = key + this.render(cue) + } + this.rafId = requestAnimationFrame(tick) + } + this.rafId = requestAnimationFrame(tick) + } + + private findCue(time: number): SubtitleCue | null { + // 二分找到第一个 start <= time 且 end > time 的 cue, + // 继续向后扫描以处理 YouTube 滚动字幕的 overlap cue(取最后一条) + let lo = 0, hi = this.cues.length - 1, result: SubtitleCue | null = null + while (lo <= hi) { + const mid = (lo + hi) >>> 1 + const cue = this.cues[mid] + if (time < cue.start) { + hi = mid - 1 + } else if (time >= cue.end) { + lo = mid + 1 + } else { + result = cue + lo = mid + 1 // 继续往后找是否有更晚开始的 overlap cue + } + } + return result + } + + private render(cue: SubtitleCue | null) { + if (!this.container) return + this.container.replaceChildren() + if (!cue) return + + const isBilingual = config.display === 1 // 1 = 双语对照 + const hasTranslation = !!cue.translatedText + + const lineStyle = [ + 'display:block', + 'background:rgba(8,8,8,0.80)', + 'color:#fff', + 'padding:4px 14px', + 'border-radius:4px', + 'font-size:22px', + 'line-height:1.65', + 'white-space:pre-wrap', + 'word-break:break-word', + 'text-align:center', + 'margin-bottom:4px', + ].join(';') + + const originalStyle = lineStyle + ';opacity:0.75;font-size:18px' + + if (isBilingual && cue.text) { + const div = document.createElement('div') + div.style.cssText = originalStyle + div.textContent = cue.text + this.container.appendChild(div) + } + + const mainDiv = document.createElement('div') + mainDiv.style.cssText = lineStyle + mainDiv.textContent = hasTranslation ? cue.translatedText! : cue.text + this.container.appendChild(mainDiv) + } +} diff --git a/entrypoints/video/parser.ts b/entrypoints/video/parser.ts new file mode 100644 index 0000000..7f667ac --- /dev/null +++ b/entrypoints/video/parser.ts @@ -0,0 +1,132 @@ +export interface SubtitleCue { + start: number // 开始时间(秒) + end: number // 结束时间(秒) + text: string // 原文 + translatedText?: string // 译文(翻译后填入) +} + +// ── YouTube timedtext XML ───────────────────────────────────────────────────── +// 格式:Hello +export function parseYouTubeXML(xmlText: string): SubtitleCue[] { + try { + const doc = new DOMParser().parseFromString(xmlText, 'text/xml') + if (doc.querySelector('parsererror')) return [] + const raw: SubtitleCue[] = [] + doc.querySelectorAll('text').forEach(node => { + const start = parseFloat(node.getAttribute('start') || '0') + const dur = parseFloat(node.getAttribute('dur') || '0') + const text = decodeEntities(node.textContent || '').trim() + if (text) raw.push({ start, end: start + dur, text }) + }) + // YouTube timedtext 会产生时间重叠的 cue(滚动字幕效果), + // 合并重叠 cue 使碎片句回归完整,减少翻译时的上下文割裂 + return mergeOverlappingCues(raw) + } catch { + return [] + } +} + +/** 合并时间上有重叠的相邻 cue,文本以空格拼接,时间取并集 */ +function mergeOverlappingCues(cues: SubtitleCue[]): SubtitleCue[] { + if (!cues.length) return cues + const merged: SubtitleCue[] = [{ ...cues[0] }] + for (let i = 1; i < cues.length; i++) { + const prev = merged[merged.length - 1] + const cur = cues[i] + if (cur.start < prev.end) { + // 重叠:合并文本,延伸结束时间 + prev.text = prev.text + ' ' + cur.text + prev.end = Math.max(prev.end, cur.end) + } else { + merged.push({ ...cur }) + } + } + return merged +} + +// ── WebVTT ──────────────────────────────────────────────────────────────────── +export function parseVTT(vttText: string): SubtitleCue[] { + const cues: SubtitleCue[] = [] + // 统一换行符,按空行分割 cue 块 + const blocks = vttText.replace(/\r\n/g, '\n').replace(/\r/g, '\n').split(/\n\n+/) + + for (const block of blocks) { + const lines = block.trim().split('\n') + // 找到包含 --> 的时间行 + const timeLine = lines.find(l => l.includes('-->')) + if (!timeLine) continue + + const [startStr, endStr] = timeLine.split('-->').map(s => s.trim().split(/\s/)[0]) + const start = vttTimeToSeconds(startStr) + const end = vttTimeToSeconds(endStr) + + // 时间行之后的行为字幕文本(去掉 VTT 内联标签) + const textLines = lines + .slice(lines.indexOf(timeLine) + 1) + .map(l => stripVttTags(l)) + .filter(l => l.trim()) + + const text = textLines.join(' ').trim() + if (text) cues.push({ start, end, text }) + } + return cues +} + +// ── 辅助函数 ────────────────────────────────────────────────────────────────── +function vttTimeToSeconds(t: string): number { + if (!t) return 0 + const parts = t.split(':') + if (parts.length === 3) { + return parseInt(parts[0]) * 3600 + parseInt(parts[1]) * 60 + parseFloat(parts[2]) + } else if (parts.length === 2) { + return parseInt(parts[0]) * 60 + parseFloat(parts[1]) + } + return parseFloat(t) || 0 +} + +function stripVttTags(text: string): string { + // 去掉 , <00:00:00.000>, , 等 VTT 标签 + return text.replace(/<[^>]+>/g, '').replace(/&/g, '&') +} + +function decodeEntities(text: string): string { + try { + const doc = new DOMParser().parseFromString(text, 'text/html') + return doc.documentElement.textContent || text + } catch { + return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/'/g, "'").replace(/"/g, '"') + } +} + +// ── YouTube timedtext JSON3 ─────────────────────────────────────────────────── +// 格式:{"events":[{"tStartMs":0,"dDurationMs":5000,"segs":[{"utf8":"Hello"}]},...]} +export function parseYouTubeJSON3(jsonText: string): SubtitleCue[] { + try { + const data = JSON.parse(jsonText) + if (!data.events) return [] + const cues: SubtitleCue[] = [] + for (const event of data.events) { + if (!event.segs) continue + const text = event.segs.map((s: { utf8?: string }) => s.utf8 || '').join('').trim() + if (!text || text === '\n') continue + const start = (event.tStartMs || 0) / 1000 + const end = start + (event.dDurationMs || 0) / 1000 + cues.push({ start, end, text }) + } + return cues + } catch { + return [] + } +} + +/** + * 自动检测字幕格式 + */ +export function detectSubtitleFormat(url: string, data: string): 'youtube-xml' | 'youtube-json3' | 'vtt' | null { + if (data.trimStart().startsWith('WEBVTT')) return 'vtt' + if (url.match(/\.vtt(\?|#|$)/i)) return 'vtt' + if (data.trimStart().startsWith('{')) return 'youtube-json3' + if (data.includes(' 元素选择器 + containerSelector: string // 字幕层挂载目标选择器 + hideNativeSelector?: string // 需要隐藏的原生字幕容器 +} + +export const platforms: PlatformConfig[] = [ + { + id: 'youtube', + matches: ['youtube.com', 'youtubekids.com'], + subtitleUrlPatterns: ['/api/timedtext'], + format: 'youtube-xml', + videoSelector: '.html5-video-player video', + containerSelector: '.html5-video-player', + hideNativeSelector: '.ytp-caption-window-container', + }, + { + // 通用 WebVTT:覆盖 Udemy / Coursera / Khan Academy 等大多数学习平台 + id: 'generic-vtt', + matches: ['*'], + subtitleUrlPatterns: ['\\.vtt(\\?|#|$)', 'subtitles?.*\\.vtt', '/captions/'], + format: 'vtt', + videoSelector: 'video', + containerSelector: '', // 动态确定:video.parentElement + }, +] + +/** + * 根据当前 hostname 匹配平台配置。 + * 优先返回精确平台,没有则返回通用 VTT 兜底。 + */ +export function detectPlatform(hostname: string): PlatformConfig { + for (const platform of platforms) { + if (platform.id === 'generic-vtt') continue + if (platform.matches.some(m => hostname.includes(m))) { + return platform + } + } + return platforms.find(p => p.id === 'generic-vtt')! +} + +/** 返回所有平台的字幕 URL 正则列表(去重),发送给注入脚本 */ +export function getAllSubtitlePatterns(): string[] { + const set = new Set() + for (const platform of platforms) { + for (const pattern of platform.subtitleUrlPatterns) { + set.add(pattern) + } + } + return Array.from(set) +} diff --git a/public/video-subtitle-inject.js b/public/video-subtitle-inject.js new file mode 100644 index 0000000..a63b766 --- /dev/null +++ b/public/video-subtitle-inject.js @@ -0,0 +1,92 @@ +(function () { + const EVENT_TYPE = 'fr-subtitle-inject'; + // 默认 patterns,在 content script 发送配置前就能拦截常见字幕请求 + let subtitlePatterns = ['/api/timedtext', '\\.vtt(\\?|#|$)', 'subtitles?.*\\.vtt', '/captions/']; + + // 缓存最近一次捕获,用于内容脚本晚于字幕请求就绪时补发 + var lastCapture = null; + + // ── 工具函数 ────────────────────────────────────────────── + function isSubtitleUrl(url) { + if (!url || !subtitlePatterns.length) return false; + return subtitlePatterns.some(function (pattern) { + try { return new RegExp(pattern).test(url); } catch (_) { return false; } + }); + } + + function getUrl(input) { + if (!input) return ''; + if (typeof input === 'string') return input; + if (input instanceof URL) return input.href; + if (input instanceof Request) return input.url; + return String(input); + } + + function sendToContent(payload) { + window.postMessage(Object.assign({ eventType: EVENT_TYPE }, payload), '*'); + } + + // ── 接收 Content Script 发来的配置 ─────────────────────── + window.addEventListener('message', function (event) { + if (event.source !== window) return; + var data = event.data; + if (!data || data.eventType !== EVENT_TYPE) return; + if (data.type === 'config') { + subtitlePatterns = data.patterns || []; + // 内容脚本刚就绪,把已缓存的字幕补发过去 + if (lastCapture) { + sendToContent({ type: 'subtitle-captured', url: lastCapture.url, data: lastCapture.data }); + } + } + }); + + // ── Hook XMLHttpRequest ─────────────────────────────────── + var originalOpen = XMLHttpRequest.prototype.open; + var originalSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function () { + this._fr_url = typeof arguments[1] === 'string' + ? arguments[1] + : (arguments[1] && arguments[1].href) || ''; + return originalOpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function () { + var url = this._fr_url; + if (url && isSubtitleUrl(url)) { + var self = this; + self.addEventListener('load', function () { + if (self.status === 200 && self.responseText) { + lastCapture = { url: url, data: self.responseText }; + sendToContent({ type: 'subtitle-captured', url: url, data: self.responseText }); + } + }); + } + return originalSend.apply(this, arguments); + }; + + // ── Hook fetch ──────────────────────────────────────────── + var originalFetch = window.fetch; + if (originalFetch) { + window.fetch = function (input, init) { + var url = getUrl(input); + if (url && isSubtitleUrl(url)) { + return originalFetch.call(this, input, init).then(function (response) { + if (response.ok) { + response.clone().text().then(function (text) { + if (text) { + lastCapture = { url: url, data: text }; + sendToContent({ type: 'subtitle-captured', url: url, data: text }); + } + }).catch(function () {}); + } + return response; + }); + } + return originalFetch.apply(this, arguments); + }; + } + + // ── 通知 Content Script 注入脚本已就绪 ──────────────────── + sendToContent({ type: 'ready' }); +})(); diff --git a/wxt.config.ts b/wxt.config.ts index 4835c97..72055a4 100644 --- a/wxt.config.ts +++ b/wxt.config.ts @@ -23,6 +23,25 @@ export default defineConfig({ }), manifest: { permissions: ['storage', 'contextMenus', 'offscreen'], + // 直接在 manifest 中声明 MAIN world 脚本,绕开 WXT entrypoint 命名体系。 + // public/video-subtitle-inject.js 会被 WXT 原样复制到扩展根目录。 + // Chrome 加载 manifest content_scripts 时会绕过页面 CSP,不受 YouTube 等限制。 + content_scripts: [ + { + // 仅注入已支持的视频平台,避免在无关站点执行 XHR/fetch hook。 + // 如需新增平台,在此同步添加对应域名。 + matches: [ + '*://*.youtube.com/*', + '*://*.youtubekids.com/*', + '*://*.udemy.com/*', + '*://*.coursera.org/*', + '*://*.khanacademy.org/*', + ], + js: ['video-subtitle-inject.js'], + world: 'MAIN', + run_at: 'document_start', + } as any, + ], }, }); \ No newline at end of file