/** * 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 } // ===== 下载拦截 ===== 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 handleDownloadCreated(downloadItem) { if (!await shouldIntercept(downloadItem)) return try { await chrome.downloads.cancel(downloadItem.id) await chrome.downloads.erase({ id: downloadItem.id }) } catch { /* ignore */ } const url = downloadItem.finalUrl || downloadItem.url const filename = downloadItem.filename || '' try { await addDownload(url, filename, downloadItem.referrer, '') } catch (e) { // 添加失败:回退到浏览器自带下载,不弹通知 try { await chrome.downloads.download({ url }) } catch { /* ignore */ } } } // ===== 右键菜单 ===== chrome.runtime.onInstalled.addListener(() => { 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: [''] }, ['responseHeaders'] ) /** * 额外嗅探:通过 chrome.scripting 注入到页面,扫描