Files
Thing/src-tauri/resources/thing-extension/background.js
T

704 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* Thing Extension - 后台 Service Worker
*
* 职责:
* 1. 接管浏览器下载,转发到 Thing 下载引擎
* 2. 资源嗅探:识别视频/音频/图片/压缩包/安装包等,存储到内存供 popup 查看
* 3. 右键菜单"使用 Thing 下载"
*
* 注:Service Worker 是短生命周期的,配置需持久化到 chrome.storage
*/
// ===== 默认配置 =====
const DEFAULT_CONFIG = {
serverUrl: 'http://127.0.0.1:16800',
secret: '',
interceptDownload: true,
minSize: 0,
excludeDomains: [],
// 嗅探开关
sniffEnabled: true,
// 嗅探的资源类型:只保留视频/音频/图片/压缩包/种子/安装包
// 其他类型(文档、字幕、json、js、css 等)一律不嗅探
sniffTypes: ['video', 'audio', 'image', 'archive', 'torrent', 'installer'],
// 嗅探记录最大数量(避免无限增长)
sniffMaxItems: 200,
// 嗅探最小文件大小(字节,0=全部)。仅对有 Content-Length 的请求生效
sniffMinSize: 1024 * 100, // 100KB
// 嗅探排除的域名(CDN、统计等噪声)
sniffExcludeDomains: ['google-analytics.com', 'doubleclick.net', 'facebook.net', 'googletagmanager.com']
}
// ===== 非资源扩展名黑名单 =====
// 这些扩展名即使出现在 URL 中也不是可下载资源(接口、前端资源、数据文件等)
const NON_RESOURCE_EXTS = new Set([
// 数据/配置
'json', 'xml', 'yaml', 'yml', 'toml', 'ini', 'conf', 'env',
// 前端资源
'js', 'mjs', 'cjs', 'css', 'scss', 'sass', 'less', 'html', 'htm', 'vue', 'jsx', 'tsx',
// 字体
'woff', 'woff2', 'ttf', 'otf', 'eot',
// 网页元数据
'ico', 'webmanifest', 'sitemap', 'rss', 'atom',
// 接口常见后缀(部分 API 返回这些格式)
'jsonp', 'graphql',
// 地图/数据
'map', 'geojson', 'protobuf', 'pb',
// 模板/源码
'tpl', 'pug', 'md', 'rst',
])
// ===== API 路径关键词黑名单 =====
// URL 路径包含这些关键词的视为接口请求,不嗅探
const API_PATH_KEYWORDS = [
'/api/', '/api/', '/v1/', '/v2/', '/v3/',
'/init', '/send', '/track', '/log', '/beacon', '/analytics',
'/collect', '/report', '/event', '/events', '/metrics',
'/stat', '/stats', '/count', '/click', '/redirect',
'/auth', '/login', '/logout', '/token', '/session',
'/webhook', '/callback', '/notify',
'/sentry', '/rum', '/monitor', '/perf',
'/rpc', '/jsonrpc', '/graphql'
]
/**
* 判断 URL 是否是 API/接口请求(应排除)
*/
function isApiRequest(url) {
let path = ''
try {
path = new URL(url).pathname.toLowerCase()
} catch {
return false
}
// 路径无扩展名且包含 api 关键词
if (API_PATH_KEYWORDS.some(kw => path.includes(kw))) return true
// 路径以 /api 开头
if (path.startsWith('/api') || path.startsWith('/rpc')) return true
return false
}
// ===== 资源类型识别规则 =====
// 按扩展名 + MIME 类型识别
// 只保留用户需要的 6 类:视频/音频/图片/压缩包/种子/安装包
const RESOURCE_RULES = [
{
type: 'video',
label: '视频',
color: '#ef4444',
exts: ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'mpg', 'mpeg', 'ts', 'm3u8', 'rmvb', 'rm', 'm4s', 'f4v', '3gp', 'ogv'],
mimes: ['video/']
},
{
type: 'audio',
label: '音频',
color: '#f59e0b',
exts: ['mp3', 'flac', 'ape', 'wav', 'aac', 'ogg', 'm4a', 'wma', 'opus', 'm3u'],
mimes: ['audio/']
},
{
type: 'image',
label: '图片',
color: '#10b981',
exts: ['jpg', 'jpeg', 'png', 'gif', 'webp', 'bmp', 'svg', 'tiff', 'heic', 'avif'],
mimes: ['image/']
},
{
type: 'archive',
label: '压缩包',
color: '#8b5cf6',
exts: ['zip', 'rar', '7z', 'tar', 'gz', 'bz2', 'xz', 'iso', 'cab', 'tgz', 'tbz2'],
mimes: ['application/zip', 'application/x-rar', 'application/x-7z', 'application/x-tar', 'application/gzip', 'application/x-iso9660-image']
},
{
type: 'installer',
label: '安装包',
color: '#3b82f6',
exts: ['exe', 'msi', 'dmg', 'pkg', 'deb', 'rpm', 'appimage', 'apk', 'ipa'],
mimes: ['application/x-msdownload', 'application/x-msi', 'application/vnd.android.package-archive']
},
{
type: 'torrent',
label: '种子',
color: '#14b8a6',
exts: ['torrent'],
mimes: ['application/x-bittorrent']
}
]
// ===== 流媒体 URL 路径关键词 =====
// B站等流媒体通过 xhr 加载 .m4s 分片,URL 路径包含这些关键词且 MIME 为二进制流时识别为视频
const STREAM_PATH_KEYWORDS = [
'/upgcxcode/', // B站视频分片
'/video/', // 通用视频路径
'/media/', // 通用媒体路径
'/stream/', // 流媒体路径
'/play/', // 播放路径
'/dash/', // DASH 流
'/hls/', // HLS 流
'/video/', // 视频流
]
/**
* 识别资源类型
* @param {string} url
* @param {string} [mimeType]
* @returns {{type:string,label:string,color:string}|null} 匹配失败返回 null(不嗅探)
*/
function identifyResource(url, mimeType) {
let path = url
let pathname = ''
try {
const u = new URL(url)
path = u.pathname
pathname = u.pathname.toLowerCase()
} catch { /* ignore */ }
// 去掉查询参数后取扩展名
const ext = path.split('?')[0].split('/').pop()?.split('.').pop()?.toLowerCase() || ''
// 非资源扩展名黑名单(json/js/css 等)直接排除
if (ext && NON_RESOURCE_EXTS.has(ext)) return null
// API 接口请求排除
if (isApiRequest(url)) return null
// 先按扩展名 + MIME 匹配规则
for (const rule of RESOURCE_RULES) {
if (ext && rule.exts.includes(ext)) return rule
if (mimeType && rule.mimes.some(m => mimeType.toLowerCase().startsWith(m))) return rule
}
// 流媒体特殊识别:MIME 为二进制流且 URL 路径含视频关键词,识别为视频
// 这能抓到 B站等没有扩展名但通过路径关键词标识的流媒体分片
if (mimeType && (mimeType === 'application/octet-stream' || mimeType === 'application/vnd.apple.mpegurl' || mimeType === 'video/mp2t')) {
if (STREAM_PATH_KEYWORDS.some(kw => pathname.includes(kw))) {
return RESOURCE_RULES[0] // video
}
}
// 未命中任何规则,不视为资源(避免 json/js 等被误判为"其他"
return null
}
/**
* 从 URL 提取文件名
*/
function extractFilename(url, contentDisposition) {
// 优先从 Content-Disposition 提取
if (contentDisposition) {
const m = contentDisposition.match(/filename\*?=(?:UTF-8'')?["']?([^"';]+)/i)
if (m && m[1]) {
try { return decodeURIComponent(m[1]) } catch { return m[1] }
}
}
// 从 URL 提取
try {
const u = new URL(url)
const name = u.pathname.split('/').pop()
if (name) return decodeURIComponent(name)
} catch { /* ignore */ }
return ''
}
/**
* 格式化文件大小
*/
function formatSize(bytes) {
if (!bytes || bytes <= 0) return '—'
if (bytes < 1024) return `${bytes} B`
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`
if (bytes < 1024 * 1024 * 1024) return `${(bytes / 1024 / 1024).toFixed(1)} MB`
return `${(bytes / 1024 / 1024 / 1024).toFixed(2)} GB`
}
// ===== 资源嗅探存储 =====
// 使用 chrome.storage.local 持久化(Service Worker 可能随时重启)
const SNIFF_STORAGE_KEY = 'sniffedResources'
// 按 tabId 分组:{ [tabId]: { url, title, resources: [] } }
// 同时保留一个全局合并视图
async function getSniffedAll() {
const data = await chrome.storage.local.get(SNIFF_STORAGE_KEY)
return data[SNIFF_STORAGE_KEY] || {}
}
async function saveSniffedAll(all) {
await chrome.storage.local.set({ [SNIFF_STORAGE_KEY]: all })
}
/**
* 添加一条嗅探记录
*/
async function addSniffedItem(tabId, tabUrl, tabTitle, item) {
const config = await getConfig()
if (!config.sniffEnabled) return
if (!config.sniffTypes.includes(item.type)) return
// 排除域名
try {
const host = new URL(item.url).hostname
if (config.sniffExcludeDomains.some(d => host.includes(d))) return
} catch { /* ignore */ }
const all = await getSniffedAll()
if (!all[tabId]) {
all[tabId] = { url: tabUrl, title: tabTitle, resources: [], updatedAt: Date.now() }
}
const tabData = all[tabId]
tabData.url = tabUrl
tabData.title = tabTitle
tabData.updatedAt = Date.now()
// 去重(同 URL 不重复添加,但更新 size/filename
const existing = tabData.resources.find(r => r.url === item.url)
if (existing) {
if (item.size && !existing.size) existing.size = item.size
if (item.filename && !existing.filename) existing.filename = item.filename
existing.lastSeen = Date.now()
} else {
tabData.resources.push(item)
// 限制单 tab 资源数
if (tabData.resources.length > 100) {
tabData.resources = tabData.resources.slice(-100)
}
}
// 限制总 tab 数
const tabIds = Object.keys(all)
if (tabIds.length > 50) {
// 删除最久未更新的
tabIds.sort((a, b) => (all[a].updatedAt || 0) - (all[b].updatedAt || 0))
for (let i = 0; i < tabIds.length - 50; i++) delete all[tabIds[i]]
}
await saveSniffedAll(all)
}
/**
* 清空所有嗅探记录
*/
async function clearSniffedAll() {
await saveSniffedAll({})
}
/**
* 清空指定 tab 的嗅探记录
*/
async function clearSniffedByTab(tabId) {
const all = await getSniffedAll()
delete all[tabId]
await saveSniffedAll(all)
}
// ===== 配置读取 =====
async function getConfig() {
const stored = await chrome.storage.local.get('config')
return { ...DEFAULT_CONFIG, ...(stored.config || {}) }
}
async function saveConfig(config) {
await chrome.storage.local.set({ config })
}
// ===== Thing 下载引擎 REST API 调用 =====
async function apiRequest(path, options = {}) {
const config = await getConfig()
const url = config.serverUrl.replace(/\/$/, '') + path
const headers = { 'Content-Type': 'application/json' }
if (config.secret) {
headers['Authorization'] = `Bearer ${config.secret}`
}
const resp = await fetch(url, {
...options,
headers: { ...headers, ...options.headers }
})
if (!resp.ok) {
const text = await resp.text().catch(() => '')
throw new Error(`API HTTP ${resp.status}: ${text || resp.statusText}`)
}
return resp.json()
}
/**
* 测试连接(GET /health
*/
async function testConnection() {
const config = await getConfig()
const url = config.serverUrl.replace(/\/$/, '') + '/health'
const resp = await fetch(url)
if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
return true
}
/**
* 添加下载任务(POST /api/downloads
*/
async function addDownload(url, filename, referer, cookies, headers) {
const body = { url }
if (filename) body.filename = filename
// 构造请求头
const headerMap = {}
headerMap['User-Agent'] = navigator.userAgent
if (referer) headerMap['Referer'] = referer
if (cookies) headerMap['Cookie'] = cookies
if (headers && headers.length) {
for (const h of headers) {
const idx = h.indexOf(':')
if (idx > 0) {
headerMap[h.slice(0, idx).trim()] = h.slice(idx + 1).trim()
}
}
}
body.headers = headerMap
const result = await apiRequest('/api/downloads', {
method: 'POST',
body: JSON.stringify(body)
})
return result.id
}
// ===== 下载拦截 =====
// 处理中的 URL(防止同一 URL 并发/重入,也避免与引擎去重检查竞态)
const processingUrls = new Set()
// 我们自己用 chrome.downloads.download 回退创建的下载 URL(短时间内跳过,防止再次被拦截形成死循环)
const fallbackUrls = new Map() // url -> 过期时间戳
async function shouldIntercept(downloadItem) {
const config = await getConfig()
if (!config.interceptDownload) return false
if (config.minSize > 0 && downloadItem.fileSize > 0 && downloadItem.fileSize < config.minSize) {
return false
}
try {
const url = new URL(downloadItem.finalUrl || downloadItem.url)
if (config.excludeDomains.some(d => url.hostname.includes(d))) {
return false
}
} catch { /* ignore */ }
return true
}
/**
* 判断是否为"历史下载"(安装插件之前就已存在、随后被浏览器恢复的旧下载)
* 这类下载一律不接管,交给浏览器原生处理,实现"只接管以后的下载,历史都不管"
*/
async function isHistoricalDownload(item) {
// 1) canResume=true 表示已存在有效的部分文件,说明浏览器在恢复旧下载
if (item.canResume) return true
// 2) 开始时间早于插件首次安装时间(浏览器重启后恢复的旧下载会保留原来的开始时间)
try {
const start = item.startTime ? new Date(item.startTime).getTime() : 0
if (start > 0) {
const stored = await chrome.storage.local.get('installTime')
const installTime = stored.installTime || 0
if (installTime > 0 && start < installTime) return true
}
} catch { /* ignore */ }
return false
}
/**
* 查询引擎是否已有同 URL 的非终态任务(活跃/排队/暂停)
* 用于防止同一下载被重复转发、重复下载
*/
async function hasExistingTask(url) {
try {
const tasks = await apiRequest('/api/downloads')
return tasks.some(t => {
const s = t.status
if (s === 'complete' || s === 'error') return false
return t.url === url
})
} catch {
return false
}
}
async function handleDownloadCreated(downloadItem) {
const url = downloadItem.finalUrl || downloadItem.url
// 回退下载:我们自己用 chrome.downloads.download 创建的,直接跳过,避免无限循环
const fbExp = fallbackUrls.get(url)
if (fbExp && Date.now() < fbExp) {
fallbackUrls.delete(url)
return
}
// 历史下载(安装前的旧下载被浏览器恢复)一律不接管
if (await isHistoricalDownload(downloadItem)) return
if (!await shouldIntercept(downloadItem)) return
// 同一 URL 已在处理中,跳过(防并发/防重复转发)
if (processingUrls.has(url)) return
// 引擎不可达时不接管(保留浏览器原生下载),避免取消后下载无处可去
let connected = false
try { connected = await testConnection() } catch { connected = false }
if (!connected) return
// 引擎已有同 URL 的非终态任务,不重复转发
if (await hasExistingTask(url)) return
processingUrls.add(url)
try {
try {
await chrome.downloads.cancel(downloadItem.id)
await chrome.downloads.erase({ id: downloadItem.id })
} catch { /* ignore */ }
const filename = downloadItem.filename || ''
try {
await addDownload(url, filename, downloadItem.referrer, '')
} catch (e) {
// 添加失败:回退为浏览器自带下载,并标记该 URL 短时间内跳过,防止再次被拦截形成死循环
fallbackUrls.set(url, Date.now() + 3000)
try { await chrome.downloads.download({ url }) } catch { /* ignore */ }
}
} finally {
processingUrls.delete(url)
}
}
// ===== 右键菜单 & 安装标记 =====
chrome.runtime.onInstalled.addListener(async (details) => {
// 记录首次安装时间:用于区分"安装前的历史下载"(被浏览器恢复的旧下载)与"安装后的新下载"
if (details.reason === 'install') {
await chrome.storage.local.set({ installTime: Date.now() })
}
chrome.contextMenus.create({
id: 'thing-download-link',
title: '使用 Thing 下载此链接',
contexts: ['link']
})
chrome.contextMenus.create({
id: 'thing-download-sniffed',
title: '查看嗅探到的资源',
contexts: ['action']
})
})
chrome.contextMenus.onClicked.addListener(async (info, tab) => {
if (info.menuItemId === 'thing-download-link') {
const url = info.linkUrl
const filename = url.split('/').pop()?.split('?')[0] || ''
try {
await addDownload(url, filename, info.pageUrl, '')
} catch (e) { /* 忽略:添加失败时不打扰用户 */ }
}
})
// ===== 下载事件监听 =====
chrome.downloads.onCreated.addListener(handleDownloadCreated)
// ===== 资源嗅探:webRequest 监听 =====
// 监听所有请求的响应头,由 identifyResource 统一识别
// 不再按请求类型过滤(B站等流媒体通过 xhr 加载 .m4s 分片)
chrome.webRequest.onHeadersReceived.addListener(
async (details) => {
// 跳过扩展自身的请求
if (details.initiator && details.initiator.startsWith('chrome-extension://')) return
const config = await getConfig()
if (!config.sniffEnabled) return
// 提取 MIME 和大小
let mimeType = ''
let contentLength = 0
let contentDisposition = ''
for (const h of (details.responseHeaders || [])) {
const name = h.name.toLowerCase()
if (name === 'content-type') mimeType = (h.value || '').split(';')[0].trim()
else if (name === 'content-length') contentLength = parseInt(h.value, 10) || 0
else if (name === 'content-disposition') contentDisposition = h.value || ''
}
// 识别资源类型(内部已排除 API 请求和 json/js 等非资源扩展名)
const matched = identifyResource(details.url, mimeType)
if (!matched) return
// 按 sniffTypes 配置过滤
if (!config.sniffTypes.includes(matched.type)) return
// 大小阈值过滤(仅对有 Content-Length 的生效)
// 注意:流媒体分片的 Content-Length 是单个分片大小,不是完整视频大小
// 但仍记录,至少能让用户看到单个请求大小
if (config.sniffMinSize > 0 && contentLength > 0 && contentLength < config.sniffMinSize) return
// 获取 tab 信息
let tabUrl = '', tabTitle = ''
if (details.tabId && details.tabId > 0) {
try {
const tab = await chrome.tabs.get(details.tabId)
tabUrl = tab.url || ''
tabTitle = tab.title || ''
} catch { /* ignore */ }
}
const filename = extractFilename(details.url, contentDisposition)
const item = {
url: details.url,
filename,
type: matched.type,
typeLabel: matched.label,
color: matched.color,
mimeType,
size: contentLength,
sizeText: formatSize(contentLength),
tabId: details.tabId,
tabUrl,
tabTitle,
firstSeen: Date.now(),
lastSeen: Date.now()
}
await addSniffedItem(String(details.tabId || '0'), tabUrl, tabTitle, item)
},
{ urls: ['<all_urls>'] },
['responseHeaders']
)
/**
* 额外嗅探:通过 chrome.scripting 注入到页面,扫描 <video>/<audio>/<img><a href> 等
* 这能抓到 webRequest 抓不到的静态资源(如 src 直接写在 HTML 里的)
* 还能抓到 blob: URL(MSE 流媒体,B站等常用)
*/
async function sniffTabResources(tabId) {
const config = await getConfig()
if (!config.sniffEnabled) return
try {
const results = await chrome.scripting.executeScript({
target: { tabId },
func: () => {
const found = []
// video/audio/source - 包括 currentSrc(可能指向 blob:
document.querySelectorAll('video, audio, source').forEach(el => {
const src = el.src || el.getAttribute('src') || el.currentSrc
if (src) {
found.push({ url: src, tag: el.tagName.toLowerCase(), isBlob: src.startsWith('blob:') })
}
})
// img
document.querySelectorAll('img[src]').forEach(el => {
const src = el.src || el.getAttribute('src')
if (src) found.push({ url: src, tag: 'img', isBlob: false })
})
// a[href] 指向资源文件的
document.querySelectorAll('a[href]').forEach(el => {
const href = el.href
if (!href) return
const ext = href.split('?')[0].split('.').pop()?.toLowerCase() || ''
const resourceExts = ['mp4','mkv','avi','mov','mp3','flac','wav','zip','rar','7z','exe','msi','dmg','pkg','torrent','iso','apk','ipa','webm','m4v','m4a','ogg','opus','webp','jpg','jpeg','png','gif','bmp','svg','m4s','ts','flv']
if (resourceExts.includes(ext)) {
found.push({ url: href, tag: 'a', isBlob: false })
}
})
return found
}
})
if (!results || !results[0]) return
const items = results[0].result || []
let tab
try { tab = await chrome.tabs.get(tabId) } catch { tab = null }
for (const it of items) {
// blob: URL 无法直接下载(MSE 流),但记录下来让用户知道存在
if (it.isBlob) {
const tagType = it.tag === 'video' ? 'video' : it.tag === 'audio' ? 'audio' : 'other'
if (!config.sniffTypes.includes(tagType)) continue
await addSniffedItem(String(tabId), tab?.url || '', tab?.title || '', {
url: it.url,
filename: `(流媒体 blob) ${tab?.title || ''}`,
type: tagType,
typeLabel: tagType === 'video' ? '视频' : '音频',
color: tagType === 'video' ? '#ef4444' : '#f59e0b',
mimeType: '',
size: 0,
sizeText: 'blob',
tabId,
tabUrl: tab?.url || '',
tabTitle: tab?.title || '',
firstSeen: Date.now(),
lastSeen: Date.now()
})
continue
}
const matched = identifyResource(it.url, '')
if (!matched) continue
if (!config.sniffTypes.includes(matched.type)) continue
const filename = extractFilename(it.url, '')
await addSniffedItem(String(tabId), tab?.url || '', tab?.title || '', {
url: it.url,
filename,
type: matched.type,
typeLabel: matched.label,
color: matched.color,
mimeType: '',
size: 0,
sizeText: '—',
tabId,
tabUrl: tab?.url || '',
tabTitle: tab?.title || '',
firstSeen: Date.now(),
lastSeen: Date.now()
})
}
} catch { /* ignore: 可能是无权限页面(chrome:// 等)*/ }
}
// 页面完成加载时触发一次脚本嗅探
chrome.tabs.onUpdated.addListener((tabId, changeInfo, tab) => {
if (changeInfo.status === 'complete' && tab.url && /^https?:/.test(tab.url)) {
sniffTabResources(tabId)
}
})
// 标签关闭时清理对应的嗅探记录(可选,避免存储膨胀)
chrome.tabs.onRemoved.addListener(async (tabId) => {
// 不自动清理,让用户可在 popup 查看历史。仅当数量超限才在 addSniffedItem 中淘汰
// 如需自动清理,取消下方注释:
// await clearSniffedByTab(String(tabId))
})
// ===== 来自 popup 的消息 =====
chrome.runtime.onMessage.addListener((msg, _sender, sendResponse) => {
if (msg.type === 'getConfig') {
getConfig().then(sendResponse)
return true
}
if (msg.type === 'saveConfig') {
saveConfig(msg.config).then(() => sendResponse({ ok: true })).catch(e => sendResponse({ ok: false, error: e.message }))
return true
}
if (msg.type === 'testConnection') {
testConnection()
.then(() => sendResponse({ ok: true }))
.catch(e => sendResponse({ ok: false, error: e.message }))
return true
}
if (msg.type === 'getSniffed') {
getSniffedAll().then(sendResponse)
return true
}
if (msg.type === 'clearSniffed') {
clearSniffedAll().then(() => sendResponse({ ok: true }))
return true
}
if (msg.type === 'sniffCurrentTab') {
chrome.tabs.query({ active: true, currentWindow: true }).then(async (tabs) => {
if (tabs[0]) {
await sniffTabResources(tabs[0].id)
sendResponse({ ok: true })
} else {
sendResponse({ ok: false, error: '无活动标签' })
}
})
return true
}
if (msg.type === 'downloadSniffed') {
const { url, filename, referer } = msg
addDownload(url, filename, referer || '', '')
.then(id => sendResponse({ ok: true, id }))
.catch(e => sendResponse({ ok: false, error: e.message }))
return true
}
})