// ==UserScript== // @name 115网盘视频预览-115视频太多?一键16帧截图预览,秒懂视频内容! // @version 6.3.1 // @description 在115网盘文件列表中,为视频文件生成4x4截图预览弹窗(阿里云OSS截帧版) // @author WorkBuddy // @match https://115.com/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant unsafeWindow // @connect 115.com // @connect 115vod.com // @connect aps.115.com // @connect webapi.115.com // @connect proapi.115.com // @connect anxia.com // @connect dl.115cdn.net // @connect cdnfhnfile.115cdn.net // @connect cpats01.115.com // @connect cpats02.115.com // @connect v.115.com // @run-at document-end // @license MIT // ==/UserScript== (function () { 'use strict'; function log() { var args = Array.prototype.slice.call(arguments); args.unshift('[115预览]'); console.log.apply(console, args); } // ===== 从 Fiber 中提取文件数据 ===== function getFiberKey(el) { for (var k in el) { if (k.indexOf('__reactFiber') === 0) return k; } return null; } // 递归从 fiber 中找 file prop(最多向上 10 层) function extractFileFromFiber(el) { if (!el) return null; var key = getFiberKey(el); if (!key) return null; var fiber = el[key]; for (var i = 0; i < 10 && fiber; i++) { var mp = fiber.memoizedProps; if (mp && mp.file && typeof mp.file === 'object') { return mp.file; } fiber = fiber.return; } return null; } // 判断是否是视频文件 function isVideoFile(fileData) { if (!fileData) return false; // iv === 1 表示视频 if (fileData.iv === 1) return true; // 通过 ico 判断(ico 包含 "video" 或 "mp4" 等) if (fileData.ico && typeof fileData.ico === 'string') { var lc = fileData.ico.toLowerCase(); if (lc.indexOf('video') !== -1 || lc.indexOf('mp4') !== -1) return true; } // 通过文件名扩展名判断 if (fileData.n && typeof fileData.n === 'string') { var ext = fileData.n.split('.').pop().toLowerCase(); var videoExts = ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'ts', 'rmvb', 'rm', '3gp', 'mts', 'm2ts', 'vob', 'ogv', 'divx', 'xvid']; if (videoExts.indexOf(ext) !== -1) return true; } return false; } // 从文件名匹配 pick_code(通过 115 API 的 pc 字段,即 pick_code 缩写) function getPickCode(fileData) { if (!fileData) return null; return fileData.pc || fileData.pick_code || null; } // ===== 扫描 DOM 获取所有视频文件 ===== function scanVideoFiles() { var items = document.querySelectorAll('.file-list-item'); var videos = []; for (var i = 0; i < items.length; i++) { // file-list-item 本身可能不是直接带 file prop 的 fiber, // 它的子元素(class="group relative...")才是 var item = items[i]; var fileData = extractFileFromFiber(item); // 如果 item 本身没找到,找第一个子元素 if (!fileData && item.children.length > 0) { fileData = extractFileFromFiber(item.children[0]); } // 还没找到,再深入一层 if (!fileData && item.children.length > 0 && item.children[0].children.length > 0) { for (var j = 0; j < item.children[0].children.length; j++) { fileData = extractFileFromFiber(item.children[0].children[j]); if (fileData) break; } } if (fileData && isVideoFile(fileData)) { videos.push({ el: item, fileData: fileData, pickCode: getPickCode(fileData), fileName: fileData.n || '未知文件' }); } } return videos; } // ===== 注入预览按钮 ===== function injectPreviewButton(video) { var item = video.el; // 检查是否已经注入过 if (item.querySelector('.x115-preview-btn')) return; var btn = document.createElement('button'); btn.className = 'x115-preview-btn'; btn.title = '生成16帧视频预览'; btn.style.cssText = [ 'margin-left:10px', 'padding:3px 12px', 'font-size:11px', 'font-weight:500', 'color:#fff', 'border:none', 'border-radius:6px', 'cursor:pointer', 'white-space:nowrap', 'z-index:10', 'position:relative', 'background:linear-gradient(135deg,#667eea 0%,#764ba2 100%)', 'box-shadow:0 2px 8px rgba(102,126,234,0.35)', 'transition:all 0.25s cubic-bezier(0.4,0,0.2,1)', 'display:inline-flex', 'align-items:center', 'gap:4px', 'line-height:1.4', 'letter-spacing:0.3px' ].join(';'); // SVG 图标(播放三角 + 网格) var iconSVG = ''; btn.innerHTML = iconSVG + '预览'; // hover 效果 btn.addEventListener('mouseenter', function () { btn.style.transform = 'translateY(-1px)'; btn.style.boxShadow = '0 4px 16px rgba(102,126,234,0.5)'; btn.style.background = 'linear-gradient(135deg,#7b93f5 0%,#8b5fbf 100%)'; }); btn.addEventListener('mouseleave', function () { btn.style.transform = 'translateY(0)'; btn.style.boxShadow = '0 2px 8px rgba(102,126,234,0.35)'; btn.style.background = 'linear-gradient(135deg,#667eea 0%,#764ba2 100%)'; }); // 点击反馈 btn.addEventListener('mousedown', function () { btn.style.transform = 'translateY(0) scale(0.96)'; btn.style.boxShadow = '0 1px 4px rgba(102,126,234,0.3)'; }); btn.addEventListener('mouseup', function () { btn.style.transform = 'translateY(-1px) scale(1)'; btn.style.boxShadow = '0 4px 16px rgba(102,126,234,0.5)'; }); btn.addEventListener('click', function (e) { e.preventDefault(); e.stopPropagation(); showPreview(video); }); // 插入到文件名旁边 var nameEl = item.querySelector('[class*="name"], [class*="file-name"], span'); if (nameEl) { nameEl.appendChild(btn); } else { item.appendChild(btn); } } // ===== 获取 m3u8 地址 ===== function fetchM3u8(pickCode, callback) { // 115 的 m3u8 API 直接返回 m3u8 文本,不是 JSON var url = 'https://115.com/api/video/m3u8/' + pickCode + '.m3u8'; log('请求m3u8:', url); GM_xmlhttpRequest({ method: 'GET', url: url, onload: function (resp) { var text = resp.responseText; log('m3u8响应长度:', text.length, '前200字符:', text.substring(0, 200)); // 检查是否是 m3u8 内容(#EXTM3U 开头) if (text.indexOf('#EXTM3U') === 0) { // 打印完整 m3u8 内容前 3000 字符方便调试 log('=== m3u8 完整内容 ==='); log(text.substring(0, 3000)); log('=== m3u8 内容结束 ==='); // 检查是否是主 m3u8(含 #EXT-X-STREAM-INF)指向子 m3u8 if (text.indexOf('#EXT-X-STREAM-INF') !== -1) { log('这是主m3u8(含多码率),需要解析子m3u8地址'); var processed = processM3u8Content(text, url); // 提取第一个子 m3u8 URL 并递归获取 var lines = processed.split('\n'); var childM3u8Url = null; for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); if (line && line.charAt(0) !== '#' && line.indexOf('://') !== -1) { childM3u8Url = line; break; } } if (childM3u8Url) { log('获取子m3u8:', childM3u8Url); GM_xmlhttpRequest({ method: 'GET', url: childM3u8Url, onload: function (resp2) { var text2 = resp2.responseText; log('=== 子m3u8 内容(前2000) ==='); log(text2.substring(0, 2000)); log('=== 子m3u8 结束 ==='); var processed2 = processM3u8Content(text2, childM3u8Url); callback(null, processed2); }, onerror: function (err) { callback(err, null); } }); return; } } // 直接是 ts 列表的 m3u8 var processed = processM3u8Content(text, url); callback(null, processed); return; } // 可能还是 JSON 格式(备用) try { var data = JSON.parse(text); callback(null, data); } catch (e) { // 无法识别 callback(new Error('未知响应格式: ' + text.substring(0, 200)), null); } }, onerror: function (err) { callback(err, null); } }); } // 处理 m3u8 内容:补全 ts 片段 URL 为绝对路径 // baseUrl 应该是 m3u8 文件所在的完整 URL(用于相对路径补全) function processM3u8Content(content, m3u8Url) { // 从 m3u8Url 提取 m3u8 所在目录 // 例如 https://cpats01.115.com/.../xxx.m3u8?u=... → https://cpats01.115.com/.../ var baseDir = m3u8Url.substring(0, m3u8Url.lastIndexOf('/') + 1); // 提取 origin var originMatch = m3u8Url.match(/^(https?:\/\/[^\/]+)/); var origin = originMatch ? originMatch[1] : ''; var lines = content.split('\n'); var result = []; for (var i = 0; i < lines.length; i++) { var line = lines[i]; // 保留原始换行符信息,只处理非注释的非空行 var trimmed = line.trim(); if (trimmed && trimmed.charAt(0) !== '#' && trimmed.indexOf('://') === -1) { if (trimmed.charAt(0) === '/') { // 绝对路径(相对于域名根) line = origin + trimmed; } else { // 相对路径(相对于 m3u8 所在目录) line = baseDir + trimmed; } } result.push(line); } return result.join('\n'); } // 从 m3u8 响应中提取最佳播放地址(JSON格式时用,现在基本不走这里) function extractBestM3u8Url(data) { if (!data) return null; if (typeof data === 'string') return data; // 已经是处理好的 m3u8 文本 // 尝试多种 JSON 格式 if (data.m3u8_url) return data.m3u8_url; if (data.url) return data.url; if (data.data && data.data.m3u8_url) return data.data.m3u8_url; if (data.data && data.data.url) return data.data.url; if (data.video_url) { var urls = data.video_url; if (urls.sd) return urls.sd; if (urls.hd) return urls.hd; if (urls['720p']) return urls['720p']; if (urls['480p']) return urls['480p']; for (var k in urls) { if (urls[k]) return urls[k]; } } return null; } // ===== 创建预览弹窗 ===== function showPreview(video) { if (!video.pickCode) { alert('无法获取该视频的 pick_code,请确认文件数据完整。\n\n文件名: ' + video.fileName); return; } // 先显示加载弹窗 var overlay = document.createElement('div'); overlay.className = 'x115-overlay'; overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.7);z-index:99999;display:flex;align-items:center;justify-content:center;'; var dialog = document.createElement('div'); dialog.className = 'x115-dialog'; dialog.style.cssText = 'background:#1a1a2e;border-radius:12px;padding:20px;max-width:95vw;max-height:95vh;overflow:auto;box-shadow:0 20px 60px rgba(0,0,0,0.5);'; var header = document.createElement('div'); header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;margin-bottom:16px;color:#fff;'; header.innerHTML = '
'; var grid = document.createElement('div'); grid.className = 'x115-grid'; grid.style.cssText = 'display:grid;grid-template-columns:repeat(4,1fr);gap:8px;min-width:640px;'; // 加载提示 var loadingMsg = document.createElement('div'); loadingMsg.style.cssText = 'grid-column:1/-1;text-align:center;padding:40px;color:#aaa;font-size:14px;'; loadingMsg.textContent = '正在获取视频流...'; grid.appendChild(loadingMsg); dialog.appendChild(header); dialog.appendChild(grid); overlay.appendChild(dialog); document.body.appendChild(overlay); // 关闭按钮 var closeBtn = dialog.querySelector('.x115-close'); closeBtn.addEventListener('click', function () { overlay.remove(); cleanupVideo(); }); overlay.addEventListener('click', function (e) { if (e.target === overlay) { overlay.remove(); cleanupVideo(); } }); document.addEventListener('keydown', function escHandler(e) { if (e.key === 'Escape') { overlay.remove(); cleanupVideo(); document.removeEventListener('keydown', escHandler); } }); // 获取 m3u8 并生成截图 fetchM3u8(video.pickCode, function (err, data) { if (err || !data) { grid.innerHTML = '