// ==UserScript== // @name 115网盘视频预览 // @version 7.8.7 // @description 在115网盘文件列表中,为视频文件生成4x4截图预览弹窗(阿里云OSS截帧版) // @author WorkBuddy // @match https://115.com/* // @noframes // @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'; // ===== 调试日志面板 ===== var __logBuffer = []; var __debugPanel = null; function log() { var args = Array.prototype.slice.call(arguments); var msg = args.map(function(a) { if (typeof a === 'object') { try { return JSON.stringify(a); } catch(e) { return String(a); } } return String(a); }).join(' '); var line = '[115预览] ' + msg; console.log.apply(console, [line]); // 写入缓冲区 __logBuffer.push(line); if (__logBuffer.length > 200) __logBuffer.shift(); // 节流更新调试面板(最多每 400ms 一次,避免卡顿) if (__debugPanel && !__panelUpdateScheduled) { __panelUpdateScheduled = true; setTimeout(function() { __panelUpdateScheduled = false; updateDebugPanel(); }, 400); } } var __panelUpdateScheduled = false; function createDebugPanel() { if (__debugPanel) return; var panel = document.createElement('div'); panel.id = '__115_debug_panel'; panel.style.cssText = [ 'position:fixed', 'bottom:10px', 'right:10px', 'width:520px', 'max-height:320px', 'background:rgba(20,20,30,0.95)', 'border-radius:8px', 'z-index:2147483647', 'font-family:Consolas,Monaco,monospace', 'font-size:11px', 'color:#c0c0c0', 'box-shadow:0 4px 24px rgba(0,0,0,0.5)', 'display:flex', 'flex-direction:column', 'border:1px solid rgba(100,120,200,0.3)' ].join(';'); // 标题栏 var header = document.createElement('div'); header.style.cssText = 'display:flex;justify-content:space-between;align-items:center;padding:6px 10px;background:rgba(60,70,120,0.4);border-bottom:1px solid rgba(100,120,200,0.2);cursor:move;'; header.innerHTML = '🔍 115预览调试日志 v7.8.6'; var btnGroup = document.createElement('div'); btnGroup.style.cssText = 'display:flex;gap:4px;'; var copyBtn = document.createElement('button'); copyBtn.textContent = '📋 复制'; copyBtn.style.cssText = 'background:#4a6fa5;color:#fff;border:none;border-radius:4px;padding:2px 8px;cursor:pointer;font-size:10px;'; copyBtn.onclick = function() { var text = __logBuffer.join('\n'); // 也包含 iframe 内部的日志 try { var iframeLogs = getIframeLogs(); if (iframeLogs) text += '\n\n=== IFRAME 内部日志 ===\n' + iframeLogs; } catch(e) {} copyToClipboard(text); copyBtn.textContent = '✅ 已复制!'; setTimeout(function() { copyBtn.textContent = '📋 复制'; }, 1500); }; var toggleBtn = document.createElement('button'); toggleBtn.textContent = '−'; toggleBtn.style.cssText = 'background:#555;color:#fff;border:none;border-radius:4px;width:22px;cursor:pointer;font-size:11px;'; toggleBtn.onclick = function() { var body = panel.querySelector('.__dbody'); if (body.style.display === 'none') { body.style.display = 'block'; panel.style.maxHeight = '320px'; toggleBtn.textContent = '−'; } else { body.style.display = 'none'; panel.style.maxHeight = 'none'; toggleBtn.textContent = '+'; } }; var closeBtn = document.createElement('button'); closeBtn.textContent = '✕'; closeBtn.style.cssText = 'background:#a33;color:#fff;border:none;border-radius:4px;width:22px;cursor:pointer;font-size:11px;'; closeBtn.onclick = function() { panel.style.display = 'none'; }; btnGroup.appendChild(copyBtn); btnGroup.appendChild(toggleBtn); btnGroup.appendChild(closeBtn); header.appendChild(btnGroup); // 日志内容区 var body = document.createElement('div'); body.className = '__dbody'; body.style.cssText = 'overflow-y:auto;padding:8px;flex:1;min-height:80px;max-height:260px;word-break:break-all;white-space:pre-wrap;line-height:1.5;'; body.textContent = '(等待日志...)'; panel.appendChild(header); panel.appendChild(body); document.body.appendChild(panel); __debugPanel = panel; // 拖拽功能 makeDraggable(panel, header); } function updateDebugPanel() { if (!__debugPanel) return; var body = __debugPanel.querySelector('.__dbody'); if (body) { body.textContent = __logBuffer.join('\n'); body.scrollTop = body.scrollHeight; } } function makeDraggable(panel, handle) { var isDragging = false, startX, startY, origX, origY; handle.addEventListener('mousedown', function(e) { if (e.target.tagName === 'BUTTON' || e.target.tagName === 'SPAN') return; isDragging = true; startX = e.clientX; startY = e.clientY; var rect = panel.getBoundingClientRect(); origX = rect.left; origY = rect.top; e.preventDefault(); }); document.addEventListener('mousemove', function(e) { if (!isDragging) return; var dx = e.clientX - startX, dy = e.clientY - startY; panel.style.left = (origX + dx) + 'px'; panel.style.top = (origY + dy) + 'px'; panel.style.right = 'auto'; panel.style.bottom = 'auto'; }); document.addEventListener('mouseup', function() { isDragging = false; }); } function copyToClipboard(text) { var ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } function getIframeLogs() { var iframes = document.querySelectorAll('iframe'); for (var i = 0; i < iframes.length; i++) { try { var doc = iframes[i].contentDocument; if (doc && doc.__115_log_buffer) { return doc.__115_log_buffer.join('\n'); } } catch(e) {} } return null; } // 暴露日志缓冲给外部读取 document.__115_log_buffer = __logBuffer; // ===== 从 Fiber 中提取文件数据 ===== var __fiberKeyCache = null; // WeakMap 缓存 function getFiberKey(el) { if (!__fiberKeyCache) __fiberKeyCache = new WeakMap(); if (__fiberKeyCache.has(el)) return __fiberKeyCache.get(el); var result = null; for (var k in el) { if (k.indexOf('__reactFiber') === 0 || k.indexOf('__reactInternalInstance') === 0) { result = k; break; } } __fiberKeyCache.set(el, result); return result; } // 检测元素是否有 Fiber(用于诊断) function hasFiber(el) { return !!getFiberKey(el); } // 递归从 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 表示视频(115 原生属性为字符串 "1") if (fileData.iv === 1 || 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; } // ===== 115 原生文件行提取(主页面用,原生 HTML 属性)===== function getFileDataFromEl(el) { if (!el || !el.getAttribute) return null; var pcAttr = el.getAttribute('pick_code') || el.getAttribute('pickcode') || el.getAttribute('pick-code'); var relAttr = el.getAttribute('rel'); var isFileRow = pcAttr || relAttr === 'item' || relAttr === 'file'; if (!isFileRow) return null; return { pc: pcAttr || '', n: el.getAttribute('title') || el.getAttribute('file_name') || el.getAttribute('name') || '', ico: el.getAttribute('ico') || '', iv: el.getAttribute('iv') || '', file_id: el.getAttribute('file_id') || '', file_size: el.getAttribute('file_size') || '', cid: el.getAttribute('cid') || '' }; } // ===== 从网络响应文本解析文件列表(JSON 或 HTML 属性,字段名无关)===== // 新版 115 的 Next.js 界面字段名可能与旧版不同,这里用候选字段集合 + 启发式识别 var __NAME_KEYS = ['n', 'name', 'fn', 'file_name', 'filename', 'fileName', 'title', 'f_name']; var __CODE_KEYS = ['pick_code', 'pickcode', 'pickCode', 'code', 'pc', 'sha1', 'sha', 'file_id', 'fid', 'fc']; var __VIDEO_EXT = ['mp4', 'mkv', 'avi', 'mov', 'wmv', 'flv', 'webm', 'm4v', 'ts', 'rmvb', 'rm', '3gp', 'mts', 'm2ts', 'mpg', 'mpeg', 'vob', 'ogv', 'm3u8', 'f4v', 'asf', 'm2v']; function __extOf(s) { if (!s || typeof s !== 'string') return ''; var parts = s.split('.'); return parts.length > 1 ? parts.pop().toLowerCase() : ''; } function __isVideoName(s) { return __VIDEO_EXT.indexOf(__extOf(s)) !== -1; } function __isVideoIco(ico) { if (!ico) return false; var s = String(ico).toLowerCase(); return __VIDEO_EXT.indexOf(s) !== -1 || /video/.test(s); } // 判断一个对象是否"像文件":有文件名/代号字段,且二者之一符合文件特征 function __looksLikeFileObj(o) { if (!o || typeof o !== 'object' || Array.isArray(o)) return null; var nameVal = null; for (var i = 0; i < __NAME_KEYS.length; i++) { if (o[__NAME_KEYS[i]] != null && typeof o[__NAME_KEYS[i]] === 'string' && o[__NAME_KEYS[i]].length > 0) { nameVal = o[__NAME_KEYS[i]]; break; } } var codeVal = null; for (var j = 0; j < __CODE_KEYS.length; j++) { if (o[__CODE_KEYS[j]] != null && typeof o[__CODE_KEYS[j]] === 'string' && o[__CODE_KEYS[j]].length > 0) { codeVal = o[__CODE_KEYS[j]]; break; } } if (!nameVal && !codeVal) return null; var ok = false; if (nameVal && __isVideoName(nameVal)) ok = true; // 视频文件名 if (!ok && codeVal && /^[A-Za-z0-9_\-]{6,}$/.test(codeVal)) ok = true; // 像 pick_code 的代号 if (!ok && nameVal && /[.][a-z0-9]{1,6}$/i.test(nameVal)) ok = true; // 带扩展名 if (!ok) return null; return { pc: codeVal || '', n: nameVal || '', ico: (o.ico != null ? o.ico : '') || (o.file_type != null ? o.file_type : '') || (o.type != null ? o.type : ''), iv: (o.iv != null ? o.iv : ''), file_id: o.file_id || o.fid || '', file_size: o.file_size || o.fs || o.size || '' }; } function __extractFilesFromText(text) { if (!text || typeof text !== 'string') return []; var files = []; try { var json = JSON.parse(text); var fileLike = 0; (function walk(obj, depth) { if (!obj || depth > 16) return; if (Array.isArray(obj)) { for (var i = 0; i < obj.length; i++) walk(obj[i], depth + 1); return; } if (typeof obj === 'object') { var fo = __looksLikeFileObj(obj); if (fo) { fileLike++; if (__isVideoName(fo.n) || (fo.iv && (fo.iv === 1 || fo.iv === '1')) || __isVideoIco(fo.ico)) { files.push(fo); } } for (var k in obj) { if (obj[k] && typeof obj[k] === 'object') walk(obj[k], depth + 1); } } })(json, 0); if (fileLike) log('[解析] 疑似文件对象: ' + fileLike + ' 其中视频: ' + files.length); } catch (e) { // HTML 回退(旧接口直接在 HTML 里写 pick_code 属性) var tagRe = /]*\bpick_code="([^"]+)"[^>]*>/g, m; while ((m = tagRe.exec(text)) !== null) { var tag = m[0]; var t = /title="([^"]*)"/.exec(tag), ic = /ico="([^"]*)"/.exec(tag), iv = /iv="([^"]*)"/.exec(tag), fid = /file_id="([^"]*)"/.exec(tag); if (__isVideoName(t ? t[1] : '') || (iv && iv[1] === '1') || __isVideoIco(ic ? ic[1] : '')) { files.push({ pc: m[1], n: t ? t[1] : '', ico: ic ? ic[1] : '', iv: iv ? iv[1] : '', file_id: fid ? fid[1] : '' }); } } } var seen = {}, out = []; for (var i = 0; i < files.length; i++) { var kk = files[i].pc || files[i].n; if (kk && !seen[kk]) { seen[kk] = 1; out.push(files[i]); } } return out; } // 在 el 子树里找到"最内层仍包含目标文本"的元素(用于精准定位文件名单元格) function __deepestMatch(el, text) { if (!el || !el.children) return el; var best = el; for (var i = 0; i < el.children.length; i++) { var c = el.children[i]; var ct = (c.getAttribute && c.getAttribute('title')) || (c.textContent || ''); if (ct.toLowerCase().indexOf(text) !== -1) { var deeper = __deepestMatch(c, text); if (deeper) best = deeper; } } return best; } // 根据文件数据查找对应的 DOM 文件行 function __findRowEl(fd) { if (fd.file_id) { var a = document.querySelector('[file_id="' + fd.file_id + '"]') || document.querySelector('[data-file-id="' + fd.file_id + '"]') || document.querySelector('[data-fid="' + fd.file_id + '"]'); if (a) return a; } if (fd.pc) { var b = document.querySelector('[pick_code="' + fd.pc + '"]') || document.querySelector('[data-pick-code="' + fd.pc + '"]'); if (b) return b; } if (fd.n) { var target = fd.n.toLowerCase(); // 旧接口:按 title 属性精确匹配 var rows = document.querySelectorAll('li[rel="item"], [pick_code]'); for (var i = 0; i < rows.length; i++) { if ((rows[i].getAttribute('title') || '').toLowerCase() === target) return rows[i]; } // 新 React 界面:用 TreeWalker 找到包含文件名文本的元素,再向上定位“行”容器 try { var walker = document.createTreeWalker(document.body, NodeFilter.SHOW_TEXT, null, false); var wn; while ((wn = walker.nextNode())) { var tnode = (wn.nodeValue || '').toLowerCase(); if (tnode.indexOf(target) !== -1) { var el = wn.parentElement; while (el && el !== document.body) { // 行特征:父节点有多个兄弟(属于列表),且自身子节点不多(不是大容器) if (el.parentElement && el.parentElement.children.length > 1 && el.children.length <= 8) { return el; } el = el.parentElement; } if (wn.parentElement) return wn.parentElement; } } } catch (e) {} } return null; } // 待匹配的 API 视频(DOM 尚未渲染) var __pendingApiFiles = {}; // 从解析出的文件列表注入预览按钮 function __injectFromFiles(files, source) { var injectedCount = 0, pendingCount = 0, names = []; for (var i = 0; i < files.length; i++) { var f = files[i]; var fd = { pc: f.pc, n: f.n, ico: f.ico, iv: f.iv, file_id: f.file_id, file_size: f.file_size }; if (!isVideoFile(fd)) continue; if (injectedVideos[f.pc]) continue; var el = __findRowEl(fd); if (el) { injectedVideos[f.pc] = true; injectPreviewButton({ el: el, fileData: fd, pickCode: f.pc, fileName: f.n }); injectedCount++; names.push(f.n); } else { __pendingApiFiles[f.pc] = { fd: fd, fileName: f.n }; pendingCount++; } } if (injectedCount) log('[' + source + '] 注入 ' + injectedCount + ' 个预览按钮: ' + names.slice(0, 3).join(', ')); if (pendingCount) log('[' + source + '] ' + pendingCount + ' 个视频暂未匹配DOM,等待渲染...'); } // 主页面网络拦截器:捕获 115 文件列表 API(绕开 iframe/DOM 限制) // 新版 115 用 Next.js,API 路径/字段名未知。这里做“全量网络嗅探”: // - 记录每一个 XHR/fetch 请求的 URL(暴露真实 API 端点,不再靠关键词猜) // - 对每个 JSON 响应做字段无关提取;非文件类 JSON 也保留样本供诊断 var __allReqs = []; // 全部请求 URL(滚动,最多 120) var __respSamples = []; // 疑似 JSON 响应样本(滚动,最多 30) var __diagReqCount = 0; function __noteReq(method, url) { if (!url) return; try { __allReqs.push((method || '?') + ' ' + String(url).substring(0, 220)); if (__allReqs.length > 120) __allReqs.shift(); } catch (e) {} } function __diagnoseResponse(url, text, tag) { if (!url) return; var u = String(url); var t = (text || '').replace(/\s+/g, ' '); if (t && t.length > 2) { // 字段无关提取:任何像文件列表的 JSON 都尝试 var files = __extractFilesFromText(t); if (files.length) { log('[' + tag + '解析成功] ' + u.substring(0, 140) + ' 文件数:' + files.length); __injectFromFiles(files, tag); } else if (/^[\[{]/.test(t)) { // 记录其它 JSON 响应样本,便于定位真实 API(去重限 30) var key = u.split('?')[0].replace(/[0-9]+/g, '#'); var dup = false; for (var d = 0; d < __respSamples.length; d++) { if (__respSamples[d].indexOf(key) === 0) { dup = true; break; } } if (!dup) { __respSamples.push(u.substring(0, 140) + ' | ' + t.substring(0, 400)); if (__respSamples.length > 30) __respSamples.shift(); } } } } function __installNetworkInterceptor() { try { var _open = XMLHttpRequest.prototype.open; XMLHttpRequest.prototype.open = function (m, u) { this.__115u = u; try { __noteReq(m, u); } catch (e) {} return _open.apply(this, arguments); }; var _send = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.send = function () { var self = this; this.addEventListener('load', function () { try { __diagnoseResponse(self.__115u, self.responseText || '', 'XHR'); } catch (e) {} }); this.addEventListener('error', function () { try { __diagnoseResponse(self.__115u, '(请求失败)', 'XHR'); } catch (e) {} }); return _send.apply(this, arguments); }; } catch (e) { log('XHR拦截器安装失败:', e.message); } try { var __origFetch = window.fetch; function __wrapFetch(orig) { return function () { var a = arguments; var url = (typeof a[0] === 'string') ? a[0] : (a[0] && a[0].url); var method = (a[1] && a[1].method) || 'GET'; try { __noteReq(method, url); } catch (e) {} return orig.apply(this, a).then(function (resp) { try { var c = resp.clone(); c.text().then(function (txt) { try { __diagnoseResponse(url, txt || '', 'fetch'); } catch (e) {} }).catch(function () {}); } catch (e) {} return resp; }, function (e) { return Promise.reject(e); }); }; } // 用 getter/setter 包裹 window.fetch:即使 115 打包代码提前捕获了原始 fetch 引用, // 每次读取 window.fetch 都会拿到我们的包装,彻底防止绕过(解释“0 请求捕获”现象) Object.defineProperty(window, 'fetch', { configurable: true, enumerable: true, get: function () { return __wrapFetch(__origFetch); }, set: function (v) { __origFetch = v; } }); log('fetch拦截器已安装 (getter/setter 防绕过)'); } catch (e) { log('fetch拦截器安装失败:', e.message); } } // 深度诊断:打印当前页面 URL 与结构,定位文件列表到底在哪 function __deepDiagnose() { try { log('=== 深度诊断 开始 ==='); log('当前URL:', location.href); var body = document.body; if (!body) { log('body 不存在'); return; } var probe = { ul: document.querySelectorAll('ul').length, ol: document.querySelectorAll('ol').length, li: document.querySelectorAll('li').length, li_rel_item: document.querySelectorAll('li[rel="item"]').length, table: document.querySelectorAll('table').length, tr: document.querySelectorAll('tr').length, pick_code: document.querySelectorAll('[pick_code]').length, div_list: document.querySelectorAll('div[class*="list"]').length, div_file: document.querySelectorAll('div[class*="file"]').length, div_item: document.querySelectorAll('div[class*="item"]').length, div_row: document.querySelectorAll('div[class*="row"]').length, div_name: document.querySelectorAll('div[class*="name"]').length, totalEls: document.querySelectorAll('*').length }; log('结构统计:', JSON.stringify(probe)); var shadowHits = 0; (function scanShadow(root) { if (!root.querySelectorAll) return; var els = root.querySelectorAll('*'); for (var i = 0; i < els.length; i++) { if (els[i].shadowRoot) { shadowHits++; scanShadow(els[i].shadowRoot); } } })(document); log('Shadow DOM 根:', shadowHits); log('页面含 "pick_code" 文本:', document.documentElement.outerHTML.indexOf('pick_code') !== -1); var kids = []; for (var c = 0; c < body.children.length && c < 12; c++) { kids.push(body.children[c].tagName + '.' + (body.children[c].className || '').toString().substring(0, 30)); } log('body子节点:', kids.join(' | ')); // ===== 关键:把全量网络嗅探结果打出来,暴露真实 API 端点 ===== log('--- 网络嗅探: 共捕获 ' + __allReqs.length + ' 个请求 ---'); var reqDump = __allReqs.slice(-50); for (var r = 0; r < reqDump.length; r++) { log(' REQ#' + r + ': ' + reqDump[r]); } log('--- 网络嗅探 JSON 响应样本 (' + __respSamples.length + ' 个) ---'); for (var sp = 0; sp < __respSamples.length; sp++) { log(' JSON#' + sp + ': ' + __respSamples[sp]); } // 兜底:扫描页面内联