704 lines
23 KiB
JavaScript
704 lines
23 KiB
JavaScript
/**
|
||
* 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
|
||
}
|
||
})
|