// ==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 = '
' + escapeHtml(video.fileName) + '
'; 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 = '
获取视频流失败: ' + (err ? err.toString() : '无数据') + '
可能需要115会员权限
'; return; } var m3u8Content = extractBestM3u8Url(data); if (!m3u8Content) { grid.innerHTML = '
无法解析视频地址
API响应: ' + (typeof data === 'string' ? data.substring(0, 300) : JSON.stringify(data).substring(0, 300)) + '
'; return; } log('获取到m3u8内容, 长度:', m3u8Content.length); // 传入子m3u8文本,使用 HLS.js 播放截图 generateThumbnailsHLS(m3u8Content, grid, video.fileName); }); } // ===== 阿里云 OSS 视频截帧方案 ===== // 115 使用阿里云 OSS 存储视频,OSS 支持 x-oss-process=video/snapshot 参数直接截帧 // 不需要 hls.js、不需要 video 标签、不需要下载完整 ts 文件 // 从子m3u8文本中解析 ts 列表(URL + 时长 + 累积时间) function parseM3u8(m3u8Text) { var lines = m3u8Text.split('\n'); var tsList = []; var totalDuration = 0; var currentDuration = 0; for (var i = 0; i < lines.length; i++) { var line = lines[i].trim(); var extinfMatch = line.match(/^#EXTINF:\s*([\d.]+)/); if (extinfMatch) { currentDuration = parseFloat(extinfMatch[1]); } if (line && line.charAt(0) !== '#' && line.indexOf('://') !== -1) { tsList.push({ url: line, duration: currentDuration, startTime: totalDuration }); totalDuration += currentDuration; currentDuration = 0; } } return { tsList: tsList, totalDuration: totalDuration }; } // 对 ts URL 附加 OSS 截帧参数 // ts URL 格式: https://cpats01.115.com/.../xxx.ts?u=...&se=...&s=...&ck= // OSS 截帧: &x-oss-process=video/snapshot,t_7000,f_jpg,w_320,h_180 function buildSnapshotUrl(tsUrl, timeMs) { return tsUrl + '&x-oss-process=video/snapshot,t_' + timeMs + ',f_jpg,w_320,h_180'; } // 生成截图(OSS截帧版) function generateThumbnailsHLS(m3u8Text, grid, fileName) { var parsed = parseM3u8(m3u8Text); var tsList = parsed.tsList; var totalDuration = parsed.totalDuration; log('解析到 ' + tsList.length + ' 个ts, 总时长:', Math.round(totalDuration), 's'); if (tsList.length === 0) { grid.innerHTML = '
m3u8中没有找到ts片段
'; return; } // 计算16帧的时间点 var count = 16; var interval = totalDuration / count; var timestamps = []; for (var i = 0; i < count; i++) { timestamps.push(interval * i + interval / 2); } // 每帧找到对应的 ts 片段和片内偏移 var frameInfos = []; for (var i = 0; i < count; i++) { var t = timestamps[i]; var tsIdx = -1; var offsetMs = 0; for (var j = 0; j < tsList.length; j++) { if (t >= tsList[j].startTime && t < tsList[j].startTime + tsList[j].duration) { tsIdx = j; offsetMs = Math.round((t - tsList[j].startTime) * 1000); break; } } if (tsIdx < 0) { // 边界情况:使用最后一个ts tsIdx = tsList.length - 1; offsetMs = Math.round(tsList[tsIdx].duration * 500); } // offset 不能为0(OSS截帧t=0可能失败),至少100ms if (offsetMs < 100) offsetMs = 100; frameInfos.push({ index: i, tsIdx: tsIdx, offsetMs: offsetMs, pct: Math.round((t / totalDuration) * 100), timeStr: formatTime(t) }); } // 初始化 grid grid.innerHTML = ''; var frames = new Array(count); for (var i = 0; i < count; i++) { var cell = document.createElement('div'); cell.style.cssText = 'position:relative;background:#000;border-radius:6px;overflow:hidden;aspect-ratio:16/9;'; cell.innerHTML = '
加载中...
'; grid.appendChild(cell); frames[i] = cell; } var loadedCount = 0; var errorCount = 0; // 并发请求所有16帧(OSS截帧API) frameInfos.forEach(function (info) { var tsUrl = tsList[info.tsIdx].url; var snapshotUrl = buildSnapshotUrl(tsUrl, info.offsetMs); log('帧#' + info.index + ' OSS截帧: ts[' + info.tsIdx + '] offset=' + info.offsetMs + 'ms'); fetch(snapshotUrl, { credentials: 'include' }).then(function (resp) { if (!resp.ok) { throw new Error('HTTP ' + resp.status); } return resp.blob(); }).then(function (blob) { if (blob.size < 500) { // 图片太小,可能是错误响应 throw new Error('图片过小(' + blob.size + 'B)'); } var imgUrl = URL.createObjectURL(blob); var cell = frames[info.index]; cell.innerHTML = ''; var img = document.createElement('img'); img.src = imgUrl; img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;'; var label = document.createElement('div'); label.style.cssText = 'position:absolute;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,0.7));color:#fff;font-size:10px;padding:4px 6px;text-align:center;'; label.textContent = info.timeStr; cell.appendChild(img); cell.appendChild(label); cell.addEventListener('click', function () { showEnlarged(imgUrl, info.timeStr); }); loadedCount++; log('帧#' + info.index + ' OSS截帧成功 (' + info.timeStr + ')'); }).catch(function (e) { errorCount++; log('帧#' + info.index + ' OSS截帧失败:', e.message); // 回退:下载完整 ts 后用 canvas 截图 log('帧#' + info.index + ' 回退到下载ts+video截图'); fallbackCapture(info, tsUrl, frames); }); }); // 回退方案:下载 ts 用 video 截图(单个片段) function fallbackCapture(info, tsUrl, frames) { fetch(tsUrl, { credentials: 'include' }).then(function (resp) { if (!resp.ok) throw new Error('HTTP ' + resp.status); return resp.arrayBuffer(); }).then(function (buf) { if (buf.byteLength < 1000) throw new Error('ts太小'); var blob = new Blob([buf], { type: 'video/mp2t' }); var blobUrl = URL.createObjectURL(blob); var v = document.createElement('video'); v.crossOrigin = 'anonymous'; v.muted = true; v.playsInline = true; v.preload = 'auto'; v.style.display = 'none'; document.body.appendChild(v); var done = false; var canvas = document.createElement('canvas'); canvas.width = 320; canvas.height = 180; var ctx = canvas.getContext('2d'); function finish(success, imgUrl) { if (done) return; done = true; if (v.parentNode) v.parentNode.removeChild(v); URL.revokeObjectURL(blobUrl); var cell = frames[info.index]; if (success && imgUrl) { cell.innerHTML = ''; var img = document.createElement('img'); img.src = imgUrl; img.style.cssText = 'width:100%;height:100%;object-fit:cover;display:block;'; var label = document.createElement('div'); label.style.cssText = 'position:absolute;bottom:0;left:0;right:0;background:linear-gradient(transparent,rgba(0,0,0,0.7));color:#fff;font-size:10px;padding:4px 6px;text-align:center;'; label.textContent = info.timeStr; cell.appendChild(img); cell.appendChild(label); cell.addEventListener('click', function () { showEnlarged(imgUrl, info.timeStr); }); log('帧#' + info.index + ' 回退截图成功'); } else { cell.innerHTML = '
加密/错误
'; } } function trySnap() { try { ctx.drawImage(v, 0, 0, canvas.width, canvas.height); var imgUrl = canvas.toDataURL('image/jpeg', 0.85); finish(true, imgUrl); } catch (e) { finish(false); } } v.addEventListener('canplay', function () { if (done) return; var seekTime = Math.max(0.1, v.duration / 2); v.currentTime = seekTime; }); v.addEventListener('seeked', function () { if (done) return; trySnap(); }); v.addEventListener('error', function () { if (done) return; // error但可能readyState有数据 if (v.readyState >= 1) { trySnap(); } else { finish(false); } }); setTimeout(function () { if (!done) finish(false); }, 8000); v.src = blobUrl; }).catch(function () { frames[info.index].innerHTML = '
加密/错误
'; }); } } function cleanupVideo() { // OSS方案不需要清理video } function showEnlarged(imgUrl, timeLabel) { var overlay = document.createElement('div'); overlay.style.cssText = 'position:fixed;top:0;left:0;width:100%;height:100%;background:rgba(0,0,0,0.85);z-index:999999;display:flex;align-items:center;justify-content:center;flex-direction:column;'; var img = document.createElement('img'); img.src = imgUrl; img.style.cssText = 'max-width:90vw;max-height:85vh;border-radius:8px;box-shadow:0 10px 40px rgba(0,0,0,0.5);'; var label = document.createElement('div'); label.style.cssText = 'color:#fff;font-size:14px;margin-top:12px;'; label.textContent = timeLabel; overlay.appendChild(img); overlay.appendChild(label); document.body.appendChild(overlay); overlay.addEventListener('click', function () { overlay.remove(); }); document.addEventListener('keydown', function escHandler(e) { if (e.key === 'Escape') { overlay.remove(); document.removeEventListener('keydown', escHandler); } }); } function formatTime(seconds) { var h = Math.floor(seconds / 3600); var m = Math.floor((seconds % 3600) / 60); var s = Math.floor(seconds % 60); if (h > 0) { return pad(h) + ':' + pad(m) + ':' + pad(s); } return pad(m) + ':' + pad(s); } function pad(n) { return n < 10 ? '0' + n : '' + n; } function escapeHtml(str) { var div = document.createElement('div'); div.textContent = str; return div.innerHTML; } // ===== 主流程 ===== var SCAN_INTERVAL = 2000; var MAX_SCANS = 15; var scanCount = 0; var injectedVideos = {}; function mainScan() { scanCount++; var videos = scanVideoFiles(); log('扫描到 ' + videos.length + ' 个视频文件 (第' + scanCount + '次)'); var newCount = 0; videos.forEach(function (v) { var key = v.pickCode || v.fileName; if (!injectedVideos[key]) { injectedVideos[key] = true; injectPreviewButton(v); newCount++; } }); if (newCount > 0) { log('注入了 ' + newCount + ' 个新预览按钮'); } if (videos.length === 0 && scanCount < MAX_SCANS) { setTimeout(mainScan, SCAN_INTERVAL); } else if (scanCount < 3) { // 即使找到了也再扫几次,因为可能有懒加载 setTimeout(mainScan, SCAN_INTERVAL * 2); } } // 启动 log('v6.3.0 脚本已加载 (阿里云OSS截帧版)'); // 等待页面渲染完成 if (document.readyState === 'complete' || document.readyState === 'interactive') { setTimeout(mainScan, 1500); } else { window.addEventListener('load', function () { setTimeout(mainScan, 1500); }); } // 监听 DOM 变化(切换目录等) var observer = new MutationObserver(function () { if (scanCount >= 2) { // 已经完成初始扫描后,DOM 变化时重新扫描 mainScan(); } }); setTimeout(function () { observer.observe(document.body, { childList: true, subtree: true }); }, 3000); })();