From 141547acb6efd9de13f25376ce6e0f9d61739d8a Mon Sep 17 00:00:00 2001 From: zhongluofeng Date: Tue, 21 Jul 2026 17:40:55 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B5=8F=E8=A7=88=E5=99=A8=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E6=8F=92=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../resources/thing-extension/background.js | 499 +++++++++++++++++- .../resources/thing-extension/manifest.json | 8 +- src-tauri/resources/thing-extension/popup.css | 358 ++++++++++++- .../resources/thing-extension/popup.html | 134 +++-- src-tauri/resources/thing-extension/popup.js | 287 ++++++++-- .../thing-extension/thing-extension.zip | Bin 0 -> 410686 bytes src-tauri/src/aria2_manager.rs | 5 +- src-tauri/src/lib.rs | 29 +- src-tauri/src/process_manager.rs | 131 ++++- 9 files changed, 1322 insertions(+), 129 deletions(-) create mode 100644 src-tauri/resources/thing-extension/thing-extension.zip diff --git a/src-tauri/resources/thing-extension/background.js b/src-tauri/resources/thing-extension/background.js index ad81310..1241701 100644 --- a/src-tauri/resources/thing-extension/background.js +++ b/src-tauri/resources/thing-extension/background.js @@ -3,9 +3,8 @@ * * 职责: * 1. 接管浏览器下载,转发到 aria2 - * 2. 与本应用(可选)通过 JSON-RPC 通信 - * 3. 提供右键菜单"使用 aria2 下载链接" - * 4. 预留资源嗅探能力(webRequest 监听) + * 2. 资源嗅探:识别视频/音频/图片/压缩包/安装包等,存储到内存供 popup 查看 + * 3. 右键菜单"使用 aria2 下载" * * 注:Service Worker 是短生命周期的,配置需持久化到 chrome.storage */ @@ -14,14 +13,282 @@ const DEFAULT_CONFIG = { rpcUrl: 'http://127.0.0.1:6800/jsonrpc', rpcSecret: '', - // 是否拦截浏览器原生下载 interceptDownload: true, - // 文件大小阈值(字节),超过才转 aria2。0 = 全部转 minSize: 0, - // 排除的域名(这些域名的下载走浏览器原生) excludeDomains: [], - // 是否显示桌面通知 - showNotifications: true + showNotifications: true, + // 嗅探开关 + 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) } // ===== 配置读取 ===== @@ -65,14 +332,15 @@ async function aria2Call(method, params = []) { /** * 添加 URI 下载任务到 aria2 - * @returns {string} gid */ -async function aria2AddUri(url, filename, referer, cookies) { +async function aria2AddUri(url, filename, referer, cookies, headers) { const options = {} if (filename) options.out = filename if (referer) options.referer = referer - if (cookies) options.header = [`Cookie: ${cookies}`] - // User-Agent 用浏览器默认值更兼容 + const headerList = [] + if (cookies) headerList.push(`Cookie: ${cookies}`) + if (headers && headers.length) headerList.push(...headers) + if (headerList.length) options.header = headerList options['user-agent'] = navigator.userAgent return aria2Call('aria2.addUri', [[url], options]) } @@ -81,11 +349,9 @@ async function aria2AddUri(url, filename, referer, cookies) { 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))) { @@ -98,7 +364,6 @@ async function shouldIntercept(downloadItem) { async function handleDownloadCreated(downloadItem) { if (!await shouldIntercept(downloadItem)) return - // 立即取消浏览器原生下载 try { await chrome.downloads.cancel(downloadItem.id) await chrome.downloads.erase({ id: downloadItem.id }) @@ -112,7 +377,6 @@ async function handleDownloadCreated(downloadItem) { await notify('已添加到 aria2', `${filename || url}\nGID: ${gid}`) } catch (e) { await notify('aria2 添加失败', `${filename || url}\n${e.message}`) - // 失败时把 URL 重新交给浏览器下载 try { await chrome.downloads.download({ url }) } catch { /* ignore */ } } } @@ -139,9 +403,9 @@ chrome.runtime.onInstalled.addListener(() => { contexts: ['link'] }) chrome.contextMenus.create({ - id: 'thing-download-page', - title: '使用 aria2 下载当前页面资源', - contexts: ['page'] + id: 'thing-download-sniffed', + title: '查看嗅探到的资源', + contexts: ['action'] }) }) @@ -161,18 +425,173 @@ chrome.contextMenus.onClicked.addListener(async (info, tab) => { // ===== 下载事件监听 ===== chrome.downloads.onCreated.addListener(handleDownloadCreated) -// ===== 资源嗅探(预留,仅日志,不拦截)===== -// 后续可启用:监听页面媒体资源,提供"嗅探到的资源"列表 -chrome.webRequest.onBeforeRequest.addListener( - (details) => { - // 预留:识别视频/音频流等可下载资源 - // 当前不处理,仅保留权限和入口 - return undefined +// ===== 资源嗅探: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 注入到页面,扫描