Greasy Fork is available in English.
适配最新Chrome,支持多线程并发数调节、深色模式、主播放列表选择、内存优化,进度/速度/剩余时间更准确
// ==UserScript== // @name M3U8 超级下载器 Pro v5.0.1 // @namespace https://github.com/yourusername/m3u8-downloader-pro // @version 5.0.1 // @description 适配最新Chrome,支持多线程并发数调节、深色模式、主播放列表选择、内存优化,进度/速度/剩余时间更准确 // @author Optimized // @match *://*/* // @exclude *://*.google.com/* // @exclude *://*.baidu.com/* // @grant GM_xmlhttpRequest // @grant GM_download // @grant GM_setValue // @grant GM_getValue // @grant unsafeWindow // @run-at document-end // @connect * // @require https://cdn.jsdelivr.net/npm/[email protected]/dist/m3u8-parser.min.js // ==/UserScript== (function() { 'use strict'; // ========== 环境与依赖检查 ========== if (typeof document === 'undefined' || typeof window === 'undefined') { console.error('【M3U8下载器】该脚本仅支持在浏览器的用户脚本管理器中运行!'); return; } const waitForParser = () => { return new Promise((resolve, reject) => { if (typeof m3u8Parser !== 'undefined') { resolve(m3u8Parser); } else { let attempts = 0; const checkInterval = setInterval(() => { attempts++; if (typeof m3u8Parser !== 'undefined') { clearInterval(checkInterval); resolve(m3u8Parser); } else if (attempts > 50) { clearInterval(checkInterval); reject(new Error('m3u8-parser 库加载失败,请检查网络连接或脚本源。')); } }, 100); } }); }; // ==================== 默认配置中心 ==================== const DEFAULT_CONFIG = { maxConcurrent: 6, maxConcurrentMin: 1, maxConcurrentMax: 16, timeout: 60000, retryCount: 5, fileNamePrefix: '', saveSubdirectory: 'M3U8下载', useGMdownload: true, speedWindow: 2000, uiUpdateInterval: 100, theme: 'auto', // 'light' | 'dark' | 'auto' }; const TITLE_RULES = { default: [/^(.+?)\s*[-_|]\s*.*$/], 'youtube.com': [/^(.+?)\s*-\s*YouTube$/], 'bilibili.com': [/^(.+?)\s*[-_|]\s*哔哩哔哩.*$/], }; // ==================== 工具函数库 ==================== const Utils = { getConfig() { try { const saved = GM_getValue('m3u8_downloader_settings', {}); saved.maxConcurrent = Math.max(DEFAULT_CONFIG.maxConcurrentMin, Math.min(DEFAULT_CONFIG.maxConcurrentMax, saved.maxConcurrent || DEFAULT_CONFIG.maxConcurrent)); return { ...DEFAULT_CONFIG, ...saved }; } catch (e) { return DEFAULT_CONFIG; } }, saveConfig(newConfig) { const current = this.getConfig(); const merged = { ...current, ...newConfig }; merged.maxConcurrent = Math.max(DEFAULT_CONFIG.maxConcurrentMin, Math.min(DEFAULT_CONFIG.maxConcurrentMax, merged.maxConcurrent)); GM_setValue('m3u8_downloader_settings', merged); return merged; }, extractTitle() { let title = document.title; const hostname = window.location.hostname; const config = this.getConfig(); let rules = TITLE_RULES.default; for (const [domain, domainRules] of Object.entries(TITLE_RULES)) { if (hostname.includes(domain)) { rules = domainRules; break; } } for (const rule of rules) { const match = title.match(rule); if (match && match[1]) { title = match[1].trim(); break; } } title = title.replace(/[\\/:*?"<>|]/g, '_').replace(/\s+/g, ' '); if (title.length < 2) title = `video_${new Date().getTime()}`; return (config.fileNamePrefix || '') + title; }, formatSize(bytes) { if (!bytes || bytes === 0) return '0 B'; const k = 1024, sizes = ['B', 'KB', 'MB', 'GB', 'TB']; const i = Math.floor(Math.log(bytes) / Math.log(k)); return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]; }, formatTime(seconds) { if (!seconds || seconds <= 0) return '未知时长'; seconds = Math.max(1, Math.ceil(seconds)); if (seconds < 60) return `${seconds}秒`; if (seconds < 3600) { const mins = Math.floor(seconds / 60); const secs = seconds % 60; return `${mins}分${secs}秒`; } const hours = Math.floor(seconds / 3600); const mins = Math.floor((seconds % 3600) / 60); const secs = seconds % 60; return `${hours}小时${mins}分${secs}秒`; }, generateId() { return Math.random().toString(36).substr(2, 9); }, getBaseUrl(url) { const parts = url.split('/'); parts.pop(); return parts.join('/') + '/'; }, resolveUrl(uri, baseUrl) { if (!uri) return ''; if (uri.startsWith('http://') || uri.startsWith('https://')) return uri; try { return new URL(uri, baseUrl).href; } catch (e) { try { const base = new URL(baseUrl); return uri.startsWith('/') ? base.origin + uri : base.origin + base.pathname.split('/').slice(0, -1).join('/') + '/' + uri; } catch (e2) { return uri; } } }, async aesDecrypt(data, key, iv, segmentIndex = 0) { try { let cryptoKey = key; if (key instanceof Uint8Array) { cryptoKey = await crypto.subtle.importKey( 'raw', key, { name: 'AES-CBC' }, false, ['decrypt'] ); } const actualIv = this.padIV(iv, segmentIndex); return await crypto.subtle.decrypt( { name: 'AES-CBC', iv: actualIv }, cryptoKey, data ); } catch (e) { console.error('解密失败详情:', e); throw new Error(`视频解密失败 (可能是不支持的加密方式): ${e.message}`); } }, padIV(iv, segmentIndex) { const buffer = new ArrayBuffer(16); const view = new DataView(buffer); if (iv && iv.length > 0) { const src = new Uint8Array(iv.buffer || iv); const dst = new Uint8Array(buffer); dst.set(src.slice(0, 16)); } else { view.setUint32(12, segmentIndex, false); } return new Uint8Array(buffer); }, hexToBytes(hex) { if (!hex) return null; hex = hex.replace(/^0x/, ''); if (hex.length % 2) hex = '0' + hex; const bytes = new Uint8Array(hex.length / 2); for (let i = 0; i < bytes.length; i++) { bytes[i] = parseInt(hex.substr(i * 2, 2), 16); } return bytes; }, debounce(func, wait, immediate = false) { let timeout; return function(...args) { const context = this; const later = () => { timeout = null; if (!immediate) func.apply(context, args); }; const callNow = immediate && !timeout; clearTimeout(timeout); timeout = setTimeout(later, wait); if (callNow) func.apply(context, args); }; } }; // ==================== 增强型网络请求层 ==================== class NetworkManager { constructor() { this.requestQueue = []; this.activeRequests = new Map(); this.keyCache = new Map(); this.refreshConfig(); } refreshConfig() { const config = Utils.getConfig(); this.maxConcurrent = config.maxConcurrent; this.timeout = config.timeout; this.retryCount = config.retryCount; } async request(options, context = {}) { this.refreshConfig(); return new Promise((resolve, reject) => { const requestId = Utils.generateId(); const request = { id: requestId, ...options, resolve, reject, retries: 0, context }; if (context.isCancelled) { reject(new Error('Request cancelled')); return; } this.requestQueue.push(request); this.processQueue(); }); } processQueue() { this.refreshConfig(); while (this.activeRequests.size < this.maxConcurrent && this.requestQueue.length > 0) { const request = this.requestQueue.shift(); if (request.context.isCancelled) { request.reject(new Error('Download cancelled')); continue; } this.executeRequest(request); } } executeRequest(request) { const xhrOptions = { method: request.method || 'GET', url: request.url, headers: { 'Referer': window.location.href, 'Origin': window.location.origin, 'User-Agent': navigator.userAgent, ...request.headers }, timeout: request.timeout || this.timeout, responseType: request.responseType || 'text', onload: (response) => { this.activeRequests.delete(request.id); if (request.context.isCancelled) { request.reject(new Error('Download cancelled')); this.processQueue(); return; } if (response.status >= 200 && response.status < 300) { request.resolve(response); } else { this.handleError(request, new Error(`HTTP ${response.status} (${response.statusText})`)); } this.processQueue(); }, onerror: (error) => { this.activeRequests.delete(request.id); if (request.context.isCancelled) request.reject(new Error('Download cancelled')); else this.handleError(request, error || new Error('Network Error')); this.processQueue(); }, ontimeout: () => { this.activeRequests.delete(request.id); if (request.context.isCancelled) request.reject(new Error('Download cancelled')); else this.handleError(request, new Error('Request timeout')); this.processQueue(); } }; try { GM_xmlhttpRequest(xhrOptions); this.activeRequests.set(request.id, { request }); } catch (e) { this.handleError(request, new Error(`XHR Init Failed: ${e.message}`)); } } handleError(request, error) { request.retries++; if (request.retries < this.retryCount && !request.context.isCancelled) { const delay = Math.min(1000 * Math.pow(2, request.retries - 1), 10000); console.warn(`[M3U8 Downloader] 请求失败 (${request.url}), ${request.retries} 次重试 (${delay}ms)...`); setTimeout(() => { if (!request.context.isCancelled) { this.requestQueue.unshift(request); this.processQueue(); } else { request.reject(new Error('Download cancelled')); } }, delay); } else { request.reject(error); } } async getDecryptKey(keyUrl, context) { if (this.keyCache.has(keyUrl)) return this.keyCache.get(keyUrl); try { const response = await this.request({ url: keyUrl, responseType: 'arraybuffer' }, context); const key = new Uint8Array(response.response); this.keyCache.set(keyUrl, key); return key; } catch (e) { console.error('获取解密 Key 失败:', e); throw new Error(`无法获取解密 Key: ${e.message}`); } } } // ==================== M3U8解析器 ==================== class M3U8Parser { constructor() { this.network = new NetworkManager(); } async parse(url, context, parserLib) { try { const response = await this.network.request({ url, responseType: 'text' }, context); if (!response || !response.responseText) { throw new Error('获取到的 M3U8 内容为空'); } const parser = new parserLib.Parser(); parser.push(response.responseText); parser.end(); const manifest = parser.manifest; if (!manifest) { throw new Error('m3u8-parser 无法解析该链接 (格式错误或非标准 M3U8)'); } const baseUrl = Utils.getBaseUrl(url); if (manifest.playlists && manifest.playlists.length > 0) { return { success: true, isMaster: true, playlists: manifest.playlists.map(pl => ({ uri: Utils.resolveUrl(pl.uri, baseUrl), attributes: pl.attributes || {} })), baseUrl }; } return { success: true, isMaster: false, manifest, baseUrl }; } catch (error) { console.error('M3U8 解析错误:', error); return { success: false, error: error.message }; } } async parseVariant(url, context, parserLib) { return this.parse(url, context, parserLib); } async getVideoInfo(url, context = {}, parserLib, variantUrl = null) { try { const parseResult = await this.parse(url, context, parserLib); if (!parseResult.success) { return { success: false, error: parseResult.error }; } let targetManifest = parseResult.manifest; if (parseResult.isMaster) { // 返回主列表信息,不自动选择 return { success: true, isMaster: true, playlists: parseResult.playlists, baseUrl: parseResult.baseUrl }; } const manifest = targetManifest; const isLive = !manifest.endList; const totalDuration = manifest.segments ? manifest.segments.reduce((sum, seg) => sum + (seg.duration || 0), 0) : 0; let estimatedSize = 0; let bandwidth = 0; if (!isLive && parseResult.masterAttributes?.BANDWIDTH) { bandwidth = parseResult.masterAttributes.BANDWIDTH; estimatedSize = (bandwidth * totalDuration) / 8; } return { success: true, isMaster: false, totalDuration, estimatedSize, bandwidth, segmentCount: manifest.segments ? manifest.segments.length : 0, isLive }; } catch (error) { return { success: false, error: error.message }; } } prepareSegments(manifest, baseUrl) { if (!manifest?.segments) return []; let currentKey = null; return manifest.segments.map((segment, index) => { if (segment.key) { currentKey = { uri: segment.key.uri ? Utils.resolveUrl(segment.key.uri, baseUrl) : null, iv: segment.key.iv ? (typeof segment.key.iv === 'string' ? Utils.hexToBytes(segment.key.iv) : new Uint8Array(segment.key.iv.buffer)) : null, method: segment.key.method || 'AES-128' }; } return { index, url: Utils.resolveUrl(segment.uri, baseUrl), duration: segment.duration || 0, key: currentKey ? { ...currentKey } : null, downloaded: false, data: null }; }); } } // ==================== 下载管理器 ==================== class DownloadManager { constructor() { this.network = new NetworkManager(); this.parser = new M3U8Parser(); this.downloads = new Map(); this.ui = null; this.parserLib = null; this.config = Utils.getConfig(); this.updateTimers = new Map(); } setParserLib(lib) { this.parserLib = lib; } setUI(ui) { this.ui = ui; } updateConcurrent(count) { Utils.saveConfig({ maxConcurrent: count }); this.config = Utils.getConfig(); this.ui?.updateConcurrentDisplay(count); this.network.refreshConfig(); this.network.processQueue(); this.ui?.showToast(`并发数已设置为 ${count},新任务立即生效,正在下载的任务请暂停后恢复`, 3000); } async addDownload(url, customTitle) { if (!this.parserLib) { this.ui?.showToast('解析库未就绪,请稍后重试', 3000); return; } const id = Utils.generateId(); const context = { isPaused: false, isCancelled: false, pausePromise: null, pauseResolve: null }; const speedStats = { queue: [], maxLength: Math.ceil(this.config.speedWindow / 100) }; const download = { id, url, title: customTitle || Utils.extractTitle(), status: 'preparing', progress: 0, speed: 0, size: 0, estimatedTotalSize: 0, remainingTime: 0, downloadedCount: 0, totalSegments: 0, startTime: performance.now(), segmentSizes: [], error: null, context: context, speedStats: speedStats, lastProgress: 0, variantUri: null }; this.downloads.set(id, download); this.ui.updateDownloads(); this.startDownloadTask(id, download, context); } async startDownloadTask(id, download, context) { try { this.ui?.showToast('正在解析视频地址...'); const parseResult = await this.parser.parse(download.url, context, this.parserLib); if (context.isCancelled) throw new Error('下载已取消'); if (!parseResult.success) { download.status = 'error'; download.error = parseResult.error; this.ui.updateDownloads(); return; } if (parseResult.isMaster) { // 主播放列表:弹出选择框 const selectedUri = await this.ui.showVariantSelector(parseResult.playlists); if (!selectedUri) { download.status = 'cancelled'; download.error = '未选择清晰度'; this.ui.updateDownloads(); return; } download.variantUri = selectedUri; // 重新解析选中的子列表 const subResult = await this.parser.parseVariant(selectedUri, context, this.parserLib); if (!subResult.success) { download.status = 'error'; download.error = subResult.error; this.ui.updateDownloads(); return; } download.url = selectedUri; // 更新实际地址 const videoSegments = this.parser.prepareSegments(subResult.manifest, subResult.baseUrl); if (videoSegments.length === 0) { download.status = 'error'; download.error = '未检测到有效视频分片'; this.ui.updateDownloads(); return; } download.videoSegments = videoSegments; download.totalSegments = videoSegments.length; // 估算大小 const dur = videoSegments.reduce((sum, s) => sum + (s.duration || 0), 0); download.estimatedTotalSize = dur * 1024 * 1024; // 粗略估计 } else { const videoSegments = this.parser.prepareSegments(parseResult.manifest, parseResult.baseUrl); if (videoSegments.length === 0) { download.status = 'error'; download.error = '未检测到有效视频分片'; this.ui.updateDownloads(); return; } download.videoSegments = videoSegments; download.totalSegments = videoSegments.length; if (parseResult.masterAttributes?.BANDWIDTH) { const totalDur = videoSegments.reduce((sum, s) => sum + (s.duration || 0), 0); download.estimatedTotalSize = (parseResult.masterAttributes.BANDWIDTH * totalDur) / 8; } else { download.estimatedTotalSize = videoSegments.length * 1024 * 1024; } } download.status = 'downloading'; this.ui.updateDownloads(); this.startUIAutoUpdate(id, download); await this.downloadAndDecryptSegments(id, download, context); if (context.isCancelled) throw new Error('下载已取消'); this.stopUIAutoUpdate(id); download.status = 'merging'; this.ui.updateDownloads(); await this.mergeSegments(download); download.status = 'completed'; download.progress = 100; download.remainingTime = 0; download.speed = 0; this.ui.updateDownloads(); this.ui.showToast(`【${download.title}】下载完成!`, 3000); } catch (error) { this.stopUIAutoUpdate(id); if (context.isCancelled) { download.status = 'cancelled'; download.error = '下载已取消'; } else { download.status = 'error'; download.error = error.message; } this.ui.updateDownloads(); } finally { if (download.videoSegments) { download.videoSegments.forEach(seg => seg.data = null); } } } startUIAutoUpdate(id, download) { if (this.updateTimers.has(id)) return; const timer = setInterval(() => { if (download.context.isCancelled || download.context.isPaused || download.status !== 'downloading') { this.stopUIAutoUpdate(id); return; } this.calculateProgressAndETA(download); this.ui.updateDownloads(); }, this.config.uiUpdateInterval); this.updateTimers.set(id, timer); } stopUIAutoUpdate(id) { const timer = this.updateTimers.get(id); if (timer) { clearInterval(timer); this.updateTimers.delete(id); } } pauseDownload(id) { const download = this.downloads.get(id); if (download && download.context && !download.context.isCancelled) { download.context.isPaused = true; download.status = 'paused'; this.stopUIAutoUpdate(id); this.ui.updateDownloads(); } } resumeDownload(id) { const download = this.downloads.get(id); if (download && download.context && download.context.isPaused) { download.context.isPaused = false; download.context.pausePromise = null; download.context.pauseResolve = null; download.status = 'downloading'; download.startTime = performance.now(); this.network.refreshConfig(); this.startUIAutoUpdate(id); this.ui.updateDownloads(); this.network.processQueue(); } } cancelDownload(id) { const download = this.downloads.get(id); if (download && download.context) { download.context.isCancelled = true; if (download.context.pauseResolve) { download.context.pauseResolve(); download.context.pauseResolve = null; } download.status = 'cancelled'; this.stopUIAutoUpdate(id); this.ui.updateDownloads(); } } deleteDownload(id) { const download = this.downloads.get(id); if (download) { this.stopUIAutoUpdate(id); if (download.context) { download.context.isCancelled = true; if (download.context.pauseResolve) download.context.pauseResolve(); } if (download.videoSegments) { download.videoSegments.forEach(seg => seg.data = null); } this.downloads.delete(id); this.ui.updateDownloads(); } } async downloadAndDecryptSegments(id, download, context) { const segments = download.videoSegments; const totalSegments = segments.length; let nextIndex = 0; let error = null; download.speedStats.queue = [{ time: performance.now(), bytes: 0 }]; const worker = async () => { while (!context.isCancelled && !error && nextIndex < totalSegments) { if (context.isPaused) { await new Promise(resolve => { context.pauseResolve = resolve; }); if (context.isCancelled) break; } const currentIndex = nextIndex++; if (currentIndex >= totalSegments) break; const segment = segments[currentIndex]; if (segment.downloaded) continue; try { const response = await this.network.request({ url: segment.url, responseType: 'arraybuffer' }, context); let segmentData = new Uint8Array(response.response); if (segment.key && segment.key.method !== 'NONE' && segment.key.uri) { if (segment.key.method !== 'AES-128') { console.warn(`分片 ${currentIndex} 使用了非 AES-128 加密 (${segment.key.method}),无法解密,将保存原始数据`); } else { const key = await this.network.getDecryptKey(segment.key.uri, context); segmentData = new Uint8Array( await Utils.aesDecrypt(segmentData, key, segment.key.iv, segment.index) ); } } segment.data = segmentData; segment.downloaded = true; const segSize = segmentData.length; download.segmentSizes.push(segSize); download.size += segSize; download.downloadedCount++; const now = performance.now(); download.speedStats.queue.push({ time: now, bytes: download.size }); const cutoffTime = now - this.config.speedWindow; download.speedStats.queue = download.speedStats.queue.filter(item => item.time >= cutoffTime); if (download.downloadedCount % 10 === 0 && download.segmentSizes.length > 0) { const avgSegSize = download.segmentSizes.reduce((a, b) => a + b, 0) / download.segmentSizes.length; download.estimatedTotalSize = avgSegSize * totalSegments; } } catch (e) { if (!context.isCancelled) { console.error(`分片 ${currentIndex} 下载失败:`, e); error = e; } } } }; const config = Utils.getConfig(); const workers = []; for (let i = 0; i < config.maxConcurrent; i++) { workers.push(worker()); } await Promise.all(workers); if (error) throw error; if (context.isCancelled) throw new Error('已取消'); } calculateProgressAndETA(download) { const now = performance.now(); const { speedStats, estimatedTotalSize, size, totalSegments, downloadedCount } = download; if (speedStats.queue.length >= 2) { const first = speedStats.queue[0]; const last = speedStats.queue[speedStats.queue.length - 1]; const timeDiff = (last.time - first.time) / 1000; const bytesDiff = last.bytes - first.bytes; download.speed = timeDiff > 0 ? bytesDiff / timeDiff : 0; } else { download.speed = 0; } let finalTotalSize = estimatedTotalSize; if (download.segmentSizes.length > 0) { const avgSegSize = download.segmentSizes.reduce((a, b) => a + b, 0) / download.segmentSizes.length; finalTotalSize = avgSegSize * totalSegments; } download.estimatedTotalSize = finalTotalSize; let progress = 0; if (finalTotalSize > 0 && size > 0) { progress = Math.min((size / finalTotalSize) * 100, 99.9); } else { progress = (downloadedCount / totalSegments) * 100; } download.progress = Math.max(progress, download.lastProgress); download.lastProgress = download.progress; if (download.speed > 0 && finalTotalSize > 0) { const remainingBytes = Math.max(0, finalTotalSize - size); download.remainingTime = remainingBytes / download.speed; } else { const elapsed = (now - download.startTime) / 1000; if (downloadedCount > 0 && elapsed > 0) { const avgTimePerSeg = elapsed / downloadedCount; download.remainingTime = avgTimePerSeg * (totalSegments - downloadedCount); } else { download.remainingTime = 0; } } } async mergeSegments(download) { const { videoSegments, title } = download; const sorted = videoSegments.sort((a, b) => a.index - b.index); // 使用 Blob 分段合并,避免一次性创建超大的 Uint8Array const chunkSize = 50; // 一次合并多少个分片 const blobs = []; for (let i = 0; i < sorted.length; i += chunkSize) { const chunk = sorted.slice(i, i + chunkSize); let totalLen = 0; for (const s of chunk) if (s.data) totalLen += s.data.length; if (totalLen === 0) continue; const merged = new Uint8Array(totalLen); let offset = 0; for (const s of chunk) { if (s.data) { merged.set(s.data, offset); offset += s.data.length; s.data = null; // 释放内存 } } blobs.push(new Blob([merged], { type: 'video/MP2T' })); } const finalBlob = new Blob(blobs, { type: 'video/MP2T' }); const fileName = `${title}.ts`; // 允许用户自定义文件名 const customName = await this.ui.promptFileName(fileName); this.triggerBrowserDownload(finalBlob, customName || fileName); } triggerBrowserDownload(blob, fileName) { const config = Utils.getConfig(); if (config.useGMdownload && typeof GM_download !== 'undefined') { try { const blobUrl = URL.createObjectURL(blob); GM_download({ url: blobUrl, name: fileName, saveAs: false, onload: () => URL.revokeObjectURL(blobUrl), onerror: (e) => { console.warn('GM_download 失败,尝试普通下载:', e); URL.revokeObjectURL(blobUrl); this.nativeDownload(blob, fileName); } }); } catch (e) { console.warn('GM_download 初始化失败:', e); this.nativeDownload(blob, fileName); } } else { this.nativeDownload(blob, fileName); } } nativeDownload(blob, fileName) { const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; a.download = fileName; a.style.display = 'none'; document.body.appendChild(a); a.click(); document.body.removeChild(a); setTimeout(() => URL.revokeObjectURL(url), 1000); } getDownloads() { return Array.from(this.downloads.values()); } } // ==================== UI层 ==================== class UIManager { constructor() { this.downloadManager = null; this.container = null; this.panel = null; this.floatingButton = null; this.badge = null; this.isVisible = false; this.detectedVideos = new Map(); this.concurrentDisplay = null; this.concurrentSlider = null; this.themeBtn = null; this.init(); } setDownloadManager(manager) { this.downloadManager = manager; const currentConcurrent = Utils.getConfig().maxConcurrent; this.updateConcurrentDisplay(currentConcurrent); } init() { this.applyTheme(); this.createContainer(); this.createFloatingButton(); this.createPanel(); this.loadPosition(); this.hideAll(); } applyTheme() { const config = Utils.getConfig(); const theme = config.theme; const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches; const isDark = theme === 'dark' || (theme === 'auto' && prefersDark); document.documentElement.style.setProperty('--m3u8-bg', isDark ? '#1e1e1e' : '#ffffff'); document.documentElement.style.setProperty('--m3u8-text', isDark ? '#e0e0e0' : '#333333'); document.documentElement.style.setProperty('--m3u8-border', isDark ? '#333' : '#eee'); document.documentElement.style.setProperty('--m3u8-card-bg', isDark ? '#2d2d2d' : '#f8f9fa'); } createContainer() { this.container = document.createElement('div'); this.container.id = 'm3u8-downloader-pro-container'; Object.assign(this.container.style, { all: 'initial', position: 'fixed', zIndex: '2147483647', top: '20px', right: '20px', fontFamily: '-apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif' }); document.documentElement.appendChild(this.container); } createFloatingButton() { this.floatingButton = document.createElement('div'); Object.assign(this.floatingButton.style, { all: 'initial', position: 'fixed', zIndex: '2147483647', top: '20px', right: '20px', width: '50px', height: '50px', background: 'linear-gradient(135deg, #667eea 0%, #764ba2 100%)', borderRadius: '50%', boxShadow: '0 4px 15px rgba(102, 126, 234, 0.4)', cursor: 'pointer', display: 'flex', alignItems: 'center', justifyContent: 'center', transition: 'transform 0.2s' }); this.floatingButton.innerHTML = ` <svg width="26" height="26" viewBox="0 0 24 24" fill="none" stroke="white" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <rect x="2" y="4" width="20" height="16" rx="2" ry="2"></rect> <polygon points="10 8 16 12 10 16 10 8"></polygon> </svg> `; this.badge = document.createElement('div'); Object.assign(this.badge.style, { position: 'absolute', top: '-2px', right: '-2px', background: '#ff4757', color: 'white', fontSize: '12px', fontWeight: 'bold', minWidth: '20px', height: '20px', borderRadius: '10px', display: 'none', alignItems: 'center', justifyContent: 'center', padding: '0 4px', border: '2px solid white', boxSizing: 'border-box' }); this.floatingButton.appendChild(this.badge); this.floatingButton.addEventListener('click', () => this.showPanel()); let isDragging = false, startX, startY, initX, initY; this.floatingButton.addEventListener('mousedown', (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; const rect = this.floatingButton.getBoundingClientRect(); initX = rect.left; initY = rect.top; e.preventDefault(); e.stopPropagation(); }); const moveHandler = (e) => { if (!isDragging) return; this.floatingButton.style.left = `${initX + e.clientX - startX}px`; this.floatingButton.style.top = `${initY + e.clientY - startY}px`; this.floatingButton.style.right = 'auto'; this.floatingButton.style.bottom = 'auto'; }; const upHandler = () => { isDragging = false; }; document.addEventListener('mousemove', moveHandler); document.addEventListener('mouseup', upHandler); document.documentElement.appendChild(this.floatingButton); } createPanel() { this.panel = document.createElement('div'); Object.assign(this.panel.style, { background: 'var(--m3u8-bg, #ffffff)', color: 'var(--m3u8-text, #333)', backdropFilter: 'blur(10px)', borderRadius: '12px', boxShadow: '0 8px 32px rgba(0,0,0,0.15)', width: '380px', maxHeight: '650px', overflow: 'hidden', border: '1px solid var(--m3u8-border, #eee)' }); this.panel.innerHTML = ` <div style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); padding: 16px; color: white; cursor: move; user-select: none;"> <div style="display: flex; justify-content: space-between; align-items: center;"> <div style="display: flex; align-items: center; gap: 10px;"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="flex:none;"><rect x="2" y="4" width="20" height="16" rx="2" ry="2"></rect><polygon points="10 8 16 12 10 16 10 8"></polygon></svg> <span style="font-weight: 600; font-size: 16px;">M3U8 下载器 v4.3</span> </div> <div style="display: flex; gap: 4px;"> <button id="m3u8-theme-btn" title="切换深色/浅色模式" style="background: none; border: none; color: white; cursor: pointer; padding: 4px; border-radius: 4px; line-height:1;"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="5"/><line x1="12" y1="1" x2="12" y2="3"/><line x1="12" y1="21" x2="12" y2="23"/><line x1="4.22" y1="4.22" x2="5.64" y2="5.64"/><line x1="18.36" y1="18.36" x2="19.78" y2="19.78"/><line x1="1" y1="12" x2="3" y2="12"/><line x1="21" y1="12" x2="23" y2="12"/><line x1="4.22" y1="19.78" x2="5.64" y2="18.36"/><line x1="18.36" y1="5.64" x2="19.78" y2="4.22"/></svg> </button> <button id="m3u8-collapse-btn" title="最小化" style="background: none; border: none; color: white; cursor: pointer; padding: 4px; border-radius: 4px; line-height:1;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M19 12H5M12 19l-7-7 7-7"></path></svg> </button> <button id="m3u8-close-btn" title="隐藏" style="background: none; border: none; color: white; cursor: pointer; padding: 4px; border-radius: 4px; line-height:1;"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="18" y1="6" x2="6" y2="18"></line><line x1="6" y1="6" x2="18" y2="18"></line></svg> </button> </div> </div> </div> <div id="m3u8-concurrent-control" style="padding: 12px 16px; border-bottom: 1px solid var(--m3u8-border); background: var(--m3u8-card-bg);"> <div style="display: flex; align-items: center; justify-content: space-between; margin-bottom: 8px;"> <span style="font-size: 13px; font-weight: 500;">下载并发数</span> <span id="m3u8-concurrent-value" style="font-size: 14px; color: #667eea; font-weight: 600;">6</span> </div> <input type="range" id="m3u8-concurrent-slider" min="1" max="16" value="6" step="1" style="width: 100%; height: 6px; background: #e9ecef; border-radius: 3px; outline: none; -webkit-appearance: none;"> <div style="display: flex; justify-content: space-between; font-size: 11px; color: #888; margin-top: 4px;"> <span>1 (稳定)</span> <span>16 (高速)</span> </div> </div> <div style="padding: 8px 16px; border-bottom: 1px solid var(--m3u8-border); display: flex; gap: 8px;"> <button id="m3u8-pause-all" style="background: #ff9800; border: none; color: white; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;">全部暂停</button> <button id="m3u8-resume-all" style="background: #28a745; border: none; color: white; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;">全部恢复</button> <button id="m3u8-cancel-all" style="background: #dc3545; border: none; color: white; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;">取消所有</button> <button id="m3u8-clear-finished" style="background: #6c757d; border: none; color: white; padding: 4px 10px; border-radius: 4px; cursor: pointer; font-size: 12px;">清除已完成</button> </div> <div id="m3u8-content" style="max-height: 400px; overflow-y: auto;"> <div id="m3u8-videos-section" style="padding: 16px; border-bottom: 1px solid var(--m3u8-border);"> <div style="font-weight: 600; margin-bottom: 12px;">检测到的视频 (<span id="m3u8-video-count">0</span>)</div> <div id="m3u8-videos-list" style="display: flex; flex-direction: column; gap: 8px;"></div> </div> <div id="m3u8-downloads-section" style="padding: 16px;"> <div style="font-weight: 600; margin-bottom: 12px;">下载任务 (<span id="m3u8-download-count">0</span>)</div> <div id="m3u8-downloads-list" style="display: flex; flex-direction: column; gap: 12px;"></div> </div> </div> `; // 按钮事件 this.panel.querySelector('#m3u8-close-btn').addEventListener('click', () => this.hideAll()); this.panel.querySelector('#m3u8-collapse-btn').addEventListener('click', () => this.hidePanel()); this.themeBtn = this.panel.querySelector('#m3u8-theme-btn'); this.themeBtn.addEventListener('click', () => this.toggleTheme()); this.concurrentDisplay = this.panel.querySelector('#m3u8-concurrent-value'); this.concurrentSlider = this.panel.querySelector('#m3u8-concurrent-slider'); const currentConcurrent = Utils.getConfig().maxConcurrent; this.concurrentSlider.value = currentConcurrent; this.concurrentDisplay.textContent = currentConcurrent; this.concurrentSlider.addEventListener('input', (e) => { this.concurrentDisplay.textContent = e.target.value; }); this.concurrentSlider.addEventListener('change', (e) => { this.downloadManager?.updateConcurrent(parseInt(e.target.value)); }); // 批量操作 this.panel.querySelector('#m3u8-pause-all').addEventListener('click', () => { this.downloadManager?.downloads.forEach((dl, id) => { if (dl.status === 'downloading') this.downloadManager.pauseDownload(id); }); this.showToast('已暂停所有下载'); }); this.panel.querySelector('#m3u8-resume-all').addEventListener('click', () => { this.downloadManager?.downloads.forEach((dl, id) => { if (dl.status === 'paused') this.downloadManager.resumeDownload(id); }); this.showToast('已恢复所有下载'); }); this.panel.querySelector('#m3u8-cancel-all').addEventListener('click', () => { this.downloadManager?.downloads.forEach((dl, id) => { if (!['completed', 'cancelled', 'error'].includes(dl.status)) this.downloadManager.cancelDownload(id); }); this.showToast('已取消所有进行中的下载'); }); this.panel.querySelector('#m3u8-clear-finished').addEventListener('click', () => { const toDelete = []; this.downloadManager?.downloads.forEach((dl, id) => { if (['completed', 'cancelled', 'error'].includes(dl.status)) toDelete.push(id); }); toDelete.forEach(id => this.downloadManager.deleteDownload(id)); this.showToast(`已清除 ${toDelete.length} 个任务`); }); // 滑块样式 const style = document.createElement('style'); style.textContent = ` #m3u8-concurrent-slider::-webkit-slider-thumb { -webkit-appearance: none; width: 18px; height: 18px; border-radius: 50%; background: #667eea; cursor: pointer; box-shadow: 0 2px 4px rgba(102,126,234,0.3); } `; this.panel.appendChild(style); // 拖拽 const header = this.panel.querySelector('div[style*="background: linear-gradient"]'); let isDragging = false, startX, startY, initX, initY; header.addEventListener('mousedown', (e) => { isDragging = true; startX = e.clientX; startY = e.clientY; const rect = this.container.getBoundingClientRect(); initX = rect.left; initY = rect.top; e.preventDefault(); }); document.addEventListener('mousemove', (e) => { if (!isDragging) return; this.container.style.left = `${initX + e.clientX - startX}px`; this.container.style.top = `${initY + e.clientY - startY}px`; this.container.style.right = 'auto'; }); document.addEventListener('mouseup', () => isDragging = false); this.container.appendChild(this.panel); } toggleTheme() { const config = Utils.getConfig(); const newTheme = config.theme === 'dark' ? 'light' : 'dark'; Utils.saveConfig({ theme: newTheme }); this.applyTheme(); this.updatePanelColors(); } updatePanelColors() { const panel = this.panel; if (panel) { panel.style.background = 'var(--m3u8-bg)'; panel.style.color = 'var(--m3u8-text)'; panel.style.border = '1px solid var(--m3u8-border)'; // 可进一步调整内部元素颜色,此处省略细节 } } updateConcurrentDisplay(count) { if (this.concurrentDisplay) this.concurrentDisplay.textContent = count; if (this.concurrentSlider) this.concurrentSlider.value = count; } loadPosition() { try { const pos = GM_getValue('m3u8-pos'); if (pos) { this.container.style.left = pos.left; this.container.style.top = pos.top; this.container.style.right = 'auto'; } } catch(e) {} } hidePanel() { this.container.style.display = 'none'; this.isVisible = false; if (this.detectedVideos.size > 0) { this.floatingButton.style.display = 'flex'; } } showPanel() { this.container.style.display = 'block'; this.floatingButton.style.display = 'none'; this.isVisible = true; } hideAll() { this.container.style.display = 'none'; this.floatingButton.style.display = 'none'; this.isVisible = false; } updateBadge() { const count = this.detectedVideos.size; if (this.badge) { if (count > 0) { this.badge.style.display = 'flex'; this.badge.textContent = count > 99 ? '99+' : count; } else { this.badge.style.display = 'none'; } } const panelCount = this.panel.querySelector('#m3u8-video-count'); if (panelCount) panelCount.textContent = count; if (count === 0) { // 不强制隐藏浮动按钮,让用户自己决定 } else { if (!this.isVisible) { this.floatingButton.style.display = 'flex'; } } } addDetectedVideo(url, type = 'm3u8', info = {}) { if (this.detectedVideos.has(url)) return; this.detectedVideos.set(url, { id: Utils.generateId(), url, type, status: 'parsing', ...info }); this.updateVideosList(); this.updateBadge(); } updateDetectedVideoInfo(url, info) { if (!this.detectedVideos.has(url)) return; const video = this.detectedVideos.get(url); this.detectedVideos.set(url, { ...video, ...info }); this.updateVideosList(); } updateVideosList() { const list = this.panel.querySelector('#m3u8-videos-list'); if (!list) return; list.innerHTML = ''; this.detectedVideos.forEach((video, url) => { const item = document.createElement('div'); Object.assign(item.style, { background: 'var(--m3u8-card-bg)', borderRadius: '8px', padding: '12px', display: 'flex', justifyContent: 'space-between', alignItems: 'center' }); let statusTag = ''; if (video.status === 'parsing') { statusTag = `<span style="background: #ffc107; color: #333; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; margin-left: 4px;">解析中</span>`; } else if (video.status === 'parse_failed') { statusTag = `<span style="background: #dc3545; color: #fff; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600; margin-left: 4px;">解析失败</span>`; } let metaText = '等待解析...'; if (video.status === 'ready') { const dur = video.totalDuration ? `时长: ${Utils.formatTime(video.totalDuration)}` : ''; const size = video.estimatedSize ? ` | 预估: ${Utils.formatSize(video.estimatedSize)}` : ''; metaText = video.isLive ? '直播流 (实时)' : (dur + size); } else if (video.status === 'parse_failed') { metaText = '点击下载尝试强行解析'; } item.innerHTML = ` <div style="flex: 1; min-width: 0; margin-right: 10px;"> <div style="display: flex; align-items: center; gap: 4px; flex-wrap: wrap;"> <span style="background: #667eea; color: white; padding: 2px 6px; border-radius: 4px; font-size: 10px; font-weight: 600;">${video.type.toUpperCase()}</span> ${statusTag} </div> <div style="color: var(--m3u8-text); font-size: 13px; font-weight: 500; margin-top: 4px;">${metaText}</div> <div style="color: #888; font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; margin-top: 2px;" title="${url}">${url.substring(0, 50)}...</div> </div> <button class="m3u8-download-btn" data-url="${url}" style="background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); border: none; color: white; padding: 6px 12px; border-radius: 6px; cursor: pointer; font-size: 12px; flex-shrink: 0;">下载</button> `; list.appendChild(item); }); list.querySelectorAll('.m3u8-download-btn').forEach(btn => { btn.addEventListener('click', (e) => { e.stopPropagation(); const url = e.target.dataset.url; this.downloadManager.addDownload(url); this.showToast('已添加下载任务'); }); }); } updateDownloads() { if (!this.downloadManager) return; const list = this.panel.querySelector('#m3u8-downloads-list'); const count = this.panel.querySelector('#m3u8-download-count'); if (!list || !count) return; const downloads = this.downloadManager.getDownloads(); count.textContent = downloads.length; list.innerHTML = ''; downloads.forEach(d => { const item = document.createElement('div'); item.style.cssText = `background: var(--m3u8-card-bg); border-radius: 8px; padding: 12px; color: var(--m3u8-text);`; const statusMap = { preparing: { color: '#ffc107', text: '准备中' }, downloading: { color: '#667eea', text: '下载中' }, paused: { color: '#ff9800', text: '已暂停' }, merging: { color: '#17a2b8', text: '合并中' }, completed: { color: '#28a745', text: '已完成' }, error: { color: '#dc3545', text: '失败' }, cancelled: { color: '#6c757d', text: '已取消' } }; const status = statusMap[d.status] || statusMap.preparing; const isFinished = ['completed', 'error', 'cancelled'].includes(d.status); let infoLine = ''; if (d.status === 'downloading') { const totalSizeDisplay = d.estimatedTotalSize > 0 ? ` / 约${Utils.formatSize(d.estimatedTotalSize)}` : ''; const etaDisplay = d.remainingTime > 0 ? ` • 剩余${Utils.formatTime(d.remainingTime)}` : ''; infoLine = `<span style="color: #888; font-size: 12px;">${Utils.formatSize(d.size)}${totalSizeDisplay} • ${Utils.formatSize(d.speed)}/s${etaDisplay}</span>`; } else if (d.status === 'completed') { infoLine = `<span style="color: #888; font-size: 12px;">总大小: ${Utils.formatSize(d.size)}</span>`; } item.innerHTML = ` <div style="display: flex; justify-content: space-between; align-items: start;"> <div style="flex: 1; min-width: 0;"> <div style="font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${d.title}">${d.title}</div> <div style="margin-top: 4px;">${infoLine}</div> ${d.error ? `<div style="color: #dc3545; font-size: 11px; margin-top: 4px; word-break: break-all;">错误: ${d.error}</div>` : ''} </div> <span style="background: ${status.color}; color: white; padding: 2px 8px; border-radius: 4px; font-size: 12px; margin-left: 8px; flex-shrink:0;">${status.text}</span> </div> ${!isFinished ? ` <div style="margin-top: 8px; background: #e9ecef; border-radius: 4px; height: 6px; overflow: hidden;"> <div style="width: ${d.progress}%; height: 100%; background: ${status.color}; transition: width 0.1s ease;"></div> </div> ` : ''} <div style="display: flex; gap: 4px; margin-top: 8px; justify-content: flex-end;"> ${d.status === 'downloading' ? ` <button data-id="${d.id}" class="m3u8-pause-btn" style="background: #ff9800; border: none; color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">暂停</button> ` : d.status === 'paused' ? ` <button data-id="${d.id}" class="m3u8-resume-btn" style="background: #28a745; border: none; color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">恢复</button> ` : ''} ${!isFinished ? ` <button data-id="${d.id}" class="m3u8-cancel-btn" style="background: #dc3545; border: none; color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">取消</button> ` : ''} <button data-id="${d.id}" class="m3u8-delete-btn" style="background: #6c757d; border: none; color: white; padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 12px;">删除</button> </div> `; list.appendChild(item); }); list.querySelectorAll('.m3u8-pause-btn').forEach(btn => btn.onclick = (e) => this.downloadManager.pauseDownload(e.target.dataset.id)); list.querySelectorAll('.m3u8-resume-btn').forEach(btn => btn.onclick = (e) => this.downloadManager.resumeDownload(e.target.dataset.id)); list.querySelectorAll('.m3u8-cancel-btn').forEach(btn => btn.onclick = (e) => this.downloadManager.cancelDownload(e.target.dataset.id)); list.querySelectorAll('.m3u8-delete-btn').forEach(btn => btn.onclick = (e) => this.downloadManager.deleteDownload(e.target.dataset.id)); } showVariantSelector(playlists) { return new Promise(resolve => { const modal = document.createElement('div'); modal.style.cssText = 'position:fixed;z-index:2147483647;left:0;top:0;width:100%;height:100%;background:rgba(0,0,0,0.5);display:flex;align-items:center;justify-content:center;'; const content = document.createElement('div'); content.style.cssText = 'background:var(--m3u8-bg);padding:20px;border-radius:12px;max-width:400px;width:90%;color:var(--m3u8-text);'; let html = '<h3 style="margin:0 0 12px">选择清晰度</h3>'; playlists.forEach((pl, idx) => { const res = pl.attributes.RESOLUTION || {}; const bw = pl.attributes.BANDWIDTH ? ` ${(pl.attributes.BANDWIDTH/1000).toFixed(0)}kbps` : ''; html += `<button data-index="${idx}" style="display:block;width:100%;padding:8px;margin-bottom:6px;background:var(--m3u8-card-bg);border:1px solid var(--m3u8-border);border-radius:6px;cursor:pointer;text-align:left;">${res.width||'?'}x${res.height||'?'}${bw}</button>`; }); html += '<button id="m3u8-variant-cancel" style="margin-top:8px;background:#dc3545;color:white;border:none;padding:8px 16px;border-radius:6px;cursor:pointer;">取消</button>'; content.innerHTML = html; modal.appendChild(content); document.body.appendChild(modal); content.querySelectorAll('button[data-index]').forEach(btn => { btn.onclick = (e) => { const idx = parseInt(e.target.dataset.index); document.body.removeChild(modal); resolve(playlists[idx].uri); }; }); content.querySelector('#m3u8-variant-cancel').onclick = () => { document.body.removeChild(modal); resolve(null); }; modal.addEventListener('click', (e) => { if (e.target === modal) { document.body.removeChild(modal); resolve(null); } }); }); } promptFileName(defaultName) { return new Promise(resolve => { const newName = prompt('请输入文件名 (不包含扩展名 .ts):', defaultName.replace('.ts', '')); resolve(newName ? newName + '.ts' : defaultName); }); } showToast(message, duration = 2000) { const toast = document.createElement('div'); Object.assign(toast.style, { position: 'fixed', zIndex: '2147483647', bottom: '30px', left: '50%', transform: 'translateX(-50%)', background: 'rgba(0,0,0,0.8)', color: 'white', padding: '10px 20px', borderRadius: '6px', fontSize: '14px', opacity: '0', transition: 'opacity 0.3s ease' }); toast.textContent = message; document.documentElement.appendChild(toast); requestAnimationFrame(() => toast.style.opacity = '1'); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, duration); } } // ==================== 启动逻辑 ==================== const init = async () => { try { const parserLib = await waitForParser(); console.log('【M3U8下载器】初始化完成 (v4.3)'); const uiManager = new UIManager(); const downloadManager = new DownloadManager(); downloadManager.setParserLib(parserLib); downloadManager.setUI(uiManager); uiManager.setDownloadManager(downloadManager); const detectedSet = new Set(); const processUrl = (url) => { if (!url || typeof url !== 'string') return; if (url.includes('.m3u8') || url.includes('mpegurl') || (url.includes('hls') && url.includes('manifest'))) { let cleanUrl = url.trim().replace(/^['"]|['"]$/g, ''); if (!detectedSet.has(cleanUrl)) { detectedSet.add(cleanUrl); console.log('【M3U8下载器】检测到链接:', cleanUrl); uiManager.addDetectedVideo(cleanUrl, 'm3u8'); const context = { isCancelled: false }; downloadManager.parser.getVideoInfo(cleanUrl, context, parserLib).then(info => { if (info.success) { uiManager.updateDetectedVideoInfo(cleanUrl, { status: 'ready', ...info }); } else { uiManager.updateDetectedVideoInfo(cleanUrl, { status: 'parse_failed' }); } }).catch(() => { uiManager.updateDetectedVideoInfo(cleanUrl, { status: 'parse_failed' }); }); } } }; // 使用 MutationObserver 替代定时扫描 const observer = new MutationObserver(() => { if (document.body) { const html = document.documentElement.innerHTML; const matches = html.match(/https?:\/\/[^\s<>"']+/g); if (matches) matches.forEach(processUrl); } }); if (document.body) { observer.observe(document.body, { childList: true, subtree: true }); } else { // 等待 body 出现 const waitBody = setInterval(() => { if (document.body) { clearInterval(waitBody); observer.observe(document.body, { childList: true, subtree: true }); } }, 500); } const originalXHROpen = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function(method, url) { processUrl(url); return originalXHROpen.apply(this, arguments); }; const originalFetch = window.fetch; window.fetch = function(input, init) { const url = typeof input === 'string' ? input : input.url; processUrl(url); return originalFetch.apply(this, arguments); }; } catch (error) { console.error('【M3U8下载器】启动失败:', error); if (typeof window !== 'undefined') { alert('M3U8下载器启动失败: ' + error.message + '\n请检查脚本是否正确安装,以及是否允许访问 cdn.jsdelivr.net'); } } }; init(); })();