您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
在学习空间/课堂平台上列出 PPT 直链与预览,并一键复制 CSV/文本;
// ==UserScript== // @name XJTU PPT Helper (multi-site) // @namespace https://XJTUPPTHelper.com/ // @version 1.0 // @description 在学习空间/课堂平台上列出 PPT 直链与预览,并一键复制 CSV/文本; // @author Monika & Noan Cliffe(在此作者基础上修改而来) // @match https://lms.xjtu.edu.cn/* // @match http://lms.xjtu.edu.cn/* // @match https://ispace.xjtu.edu.cn/* // @match http://ispace.xjtu.edu.cn/* // @match https://v-ispace.xjtu.edu.cn:*/* // @match http://v-ispace.xjtu.edu.cn:*/* // @match https://class.xjtu.edu.cn/* // @match http://class.xjtu.edu.cn/* // @match https://v-class.xjtu.edu.cn:*/* // @match http://v-class.xjtu.edu.cn:*/* // @run-at document-end // @license GPL // @grant none // ==/UserScript== (function () { 'use strict'; // ---------- utils ---------- const sleep = (ms) => new Promise(r => setTimeout(r, ms)); const qs = (s, r = document) => r.querySelector(s); const json = (x) => { try { return JSON.parse(x); } catch { return null; } }; const csvEscape = (s='') => `"${String(s).replace(/"/g,'""')}"`; const now = () => new Date().toLocaleTimeString(); // ---------- style ---------- const css = ` #xjtu-ppt-helper{ position:fixed; top:30px; right:30px; z-index:2147483647 !important; pointer-events:auto !important; background:rgba(28,28,28,.92); color:#eee; border:1px solid #444; border-radius:12px; box-shadow:0 10px 30px rgba(0,0,0,.35); width:520px; max-width:70vw; max-height:62vh; min-width:280px; min-height:140px; overflow:auto; resize:both; font:12px/1.4 system-ui,Segoe UI,Arial; } #xjtu-ppt-helper .hd{ display:flex; align-items:center; justify-content:space-between; padding:10px 12px; border-bottom:1px solid #3a3a3a; cursor:move; user-select:none; -webkit-user-select:none; } #xjtu-ppt-helper .title{font-size:13px; font-weight:700} #xjtu-ppt-helper .actions a{color:#bbb; text-decoration:none; margin-left:12px} #xjtu-ppt-helper .actions a:hover{color:#fff} #xjtu-ppt-helper .toolbar{padding:8px 12px; border-bottom:1px dashed #3a3a3a} #xjtu-ppt-helper .toolbar .btn{display:inline-block; margin-right:10px} #xjtu-ppt-helper .bd{padding:8px 12px} #xjtu-ppt-helper .row{border-top:1px dashed #444; padding:10px 0} #xjtu-ppt-helper .row:first-child{margin-top:6px} /* 首行下移,避免头部遮挡错觉 */ #xjtu-ppt-helper .name{font-weight:600} #xjtu-ppt-helper a{color:#9bd; text-decoration:none} #xjtu-ppt-helper a:hover{text-decoration:underline} #xjtu-ppt-helper .muted{opacity:.8} `; const style = document.createElement('style'); style.textContent = css; document.documentElement.append(style); // ---------- state ---------- let currentActivityId = null; /** @type {{name:string, uploadId:number|null, refId:number|null, urlDirect:string|null, urlRef:string|null, urlAliyun:string|null}[]} */ let items = []; // ---------- panel ---------- function ensurePanel() { let p = qs('#xjtu-ppt-helper'); if (p) return p; p = document.createElement('div'); p.id = 'xjtu-ppt-helper'; p.innerHTML = ` <div class="hd" id="xjtu-ppt-handle"> <div class="title">📄 PPT 列表</div> <div class="actions"> <a href="javascript:void 0" id="xjtu-ppt-collapse" title="折叠/展开">—</a> <a href="javascript:void 0" id="xjtu-ppt-close" title="关闭">✕</a> </div> </div> <div class="toolbar"> <a class="btn" href="javascript:void 0" id="xjtu-copy-csv">📋 复制全部(CSV)</a> <a class="btn" href="javascript:void 0" id="xjtu-copy-text">📋 复制全部(文本)</a> <span class="muted" id="xjtu-status"></span> </div> <div class="bd"> <div id="xjtu-ppt-body">正在加载...</div> </div> `; document.body.appendChild(p); // 关闭 / 折叠 p.querySelector('#xjtu-ppt-close').onclick = () => p.remove(); p.querySelector('#xjtu-ppt-collapse').onclick = () => { const bd = p.querySelector('.bd'), tb = p.querySelector('.toolbar'); const disp = (bd.style.display === 'none'); bd.style.display = disp ? '' : 'none'; tb.style.display = disp ? '' : 'none'; }; // 拖拽(用 left/top 定位) makeDraggable(p, p.querySelector('#xjtu-ppt-handle')); // 复制按钮 p.querySelector('#xjtu-copy-csv').onclick = () => copyAll('csv'); p.querySelector('#xjtu-copy-text').onclick = () => copyAll('text'); return p; } function makeDraggable(el, handle) { let startX=0, startY=0, startLeft=0, startTop=0, dragging=false; const pt = (e) => e.touches?.[0] ? {x:e.touches[0].clientX, y:e.touches[0].clientY} : {x:e.clientX, y:e.clientY}; const onDown = (e) => { e.preventDefault(); const p = pt(e); startX = p.x; startY = p.y; startLeft = el.offsetLeft; startTop = el.offsetTop; dragging = true; document.addEventListener('mousemove', onMove); document.addEventListener('mouseup', onUp); document.addEventListener('touchmove', onMove, {passive:false}); document.addEventListener('touchend', onUp); // 改用 left/top 自由拖动 el.style.right = 'auto'; el.style.bottom = 'auto'; }; const onMove = (e) => { if (!dragging) return; e.preventDefault(); const p = pt(e); const dx = p.x - startX, dy = p.y - startY; el.style.left = Math.max(0, startLeft + dx) + 'px'; el.style.top = Math.max(0, startTop + dy) + 'px'; }; const onUp = () => { dragging = false; document.removeEventListener('mousemove', onMove); document.removeEventListener('mouseup', onUp); document.removeEventListener('touchmove', onMove); document.removeEventListener('touchend', onUp); }; handle.addEventListener('mousedown', onDown); handle.addEventListener('touchstart', onDown, {passive:false}); } function setStatus(msg) { const el = qs('#xjtu-status'); if (el) el.textContent = `[${now()}] ${msg}`; } function renderList() { const panel = ensurePanel(); const body = panel.querySelector('#xjtu-ppt-body'); if (!items.length) { body.textContent = '未找到附件。'; return; } body.innerHTML = ''; for (const it of items) { const row = document.createElement('div'); row.className = 'row'; row.innerHTML = ` <div class="name">${it.name} ${it.urlDirect ? '' : '<span class="muted">(缺少 upload_id)</span>'}</div> <div class="links"> ${it.urlDirect ? `<a class="btn" href="${it.urlDirect}" target="_blank" title="同域直链下载">⬇️ 直下</a>` : ''} ${it.urlRef ? `<a class="btn" href="${it.urlRef}" target="_blank" title="引用下载(备用)">🧩 引用</a>` : ''} ${it.urlAliyun ? `<a class="btn" href="${it.urlAliyun}" target="_blank" title="阿里云 WebOffice 预览">🖥 预览</a>` : ''} </div> <div class="muted">upload_id: ${it.uploadId ?? 'N/A'} · ref_id: ${it.refId ?? 'N/A'}</div> `; body.appendChild(row); } setStatus(`共 ${items.length} 个附件`); } async function copyAll(kind) { if (!items.length) { setStatus('无可复制内容'); return; } let text = ''; if (kind === 'csv') { const header = ['name','upload_id','ref_id','direct_url','ref_url','aliyun_preview']; text += header.map(csvEscape).join(',') + '\n'; for (const it of items) { text += [ csvEscape(it.name), csvEscape(it.uploadId ?? ''), csvEscape(it.refId ?? ''), csvEscape(it.urlDirect ?? ''), csvEscape(it.urlRef ?? ''), csvEscape(it.urlAliyun ?? '') ].join(',') + '\n'; } } else { // 文本:每行 "name TAB direct_url" text = items.map(it => `${it.name}\t${it.urlDirect || it.urlRef || ''}`).join('\n'); } // Clipboard API(需安全上下文,用户手势触发更稳妥):contentReference[oaicite:2]{index=2} try { if (navigator.clipboard?.writeText) { await navigator.clipboard.writeText(text); } else { // 退化方案:临时 textarea 选中复制 const ta = document.createElement('textarea'); ta.value = text; ta.style.position='fixed'; ta.style.opacity='0'; document.body.appendChild(ta); ta.focus(); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } setStatus(kind === 'csv' ? '已复制 CSV' : '已复制文本'); } catch (e) { setStatus('复制失败:' + e.message); } } // ---------- data adapters ---------- function upsertFromUploadReferences(activityId, refs=[]) { const map = new Map(items.map(it => [String(it.uploadId||'r:'+it.refId), it])); for (const ref of refs) { const name = ref.name || ref.upload?.name || '未命名'; const uploadId = ref.upload_id || ref.upload?.id || null; const refId = ref.id ?? null; const itKey = String(uploadId ?? `r:${refId ?? Math.random()}`); const urlDirect = uploadId ? `/api/uploads/${uploadId}/blob` : null; const urlRef = refId ? `/api/uploads/reference/${refId}/blob` : null; const urlAliyun = uploadId ? `/api/uploads/${uploadId}/preview/aliyun?preview=true&refer_id=${activityId}&refer_type=learning_activity` : null; const base = map.get(itKey) || {}; map.set(itKey, { ...base, name, uploadId, refId, urlDirect, urlRef, urlAliyun }); } items = Array.from(map.values()); renderList(); } function upsertFromUploads(activityId, uploads=[]) { const map = new Map(items.map(it => [String(it.uploadId||'r:'+it.refId), it])); for (const u of uploads) { const name = u.name || '未命名'; const uploadId = u.id || null; const refId = u.reference_id || null; const itKey = String(uploadId ?? `r:${refId ?? Math.random()}`); const urlDirect = uploadId ? `/api/uploads/${uploadId}/blob` : null; const urlRef = refId ? `/api/uploads/reference/${refId}/blob` : null; const urlAliyun = uploadId ? `/api/uploads/${uploadId}/preview/aliyun?preview=true&refer_id=${activityId}&refer_type=learning_activity` : null; const base = map.get(itKey) || {}; map.set(itKey, { ...base, name, uploadId, refId, urlDirect, urlRef, urlAliyun }); } items = Array.from(map.values()); renderList(); } // ---------- proactive fetch for common patterns ---------- function getActivityIdHeuristics() { // LMS: /course/21625/learning-activity#/95143 -> hash 里的数字 const h = location.hash || ''; const m = h.match(/#\/(\d+)/); if (m) return m[1]; // 兜底:路径末尾数字 const p = location.pathname.match(/(\d+)(?:\/?$)/); return p ? p[1] : null; } async function tryFetchCommonEndpoints() { currentActivityId = getActivityIdHeuristics(); if (!currentActivityId) return; // 最常见:/api/activities/{id}/upload_references try { const r1 = await fetch(`/api/activities/${currentActivityId}/upload_references`, { credentials: 'same-origin' }); if (r1.ok) { const d = await r1.json().catch(()=>null); if (d?.references?.length) { upsertFromUploadReferences(currentActivityId, d.references); setStatus(`主动拉取 /upload_references 成功`); return; } } } catch {} // 次常见:/api/activities/{id}(含 uploads 列表) try { const r2 = await fetch(`/api/activities/${currentActivityId}`, { credentials: 'same-origin' }); if (r2.ok) { const d = await r2.json().catch(()=>null); if (d?.uploads?.length) { upsertFromUploads(currentActivityId, d.uploads); setStatus(`主动拉取 /activities 含 uploads 成功`); return; } } } catch {} } // ---------- passive sniffing (read-only) ---------- function hookNetwork() { // fetch const _fetch = window.fetch; window.fetch = async (...args) => { const res = await _fetch(...args); try { const url = String(args[0]?.url || args[0] || ''); if (/\/api\/activities\/\d+\/upload_references/.test(url)) { const cloned = res.clone(); const d = await cloned.json().catch(()=>null); if (d?.references) { const aid = (url.match(/\/api\/activities\/(\d+)\/upload_references/)||[])[1] || getActivityIdHeuristics(); upsertFromUploadReferences(aid, d.references); setStatus('被动捕获 upload_references'); } } else if (/\/api\/activities\/\d+(\?|$)/.test(url)) { const cloned = res.clone(); const d = await cloned.json().catch(()=>null); if (d?.uploads) { const aid = (url.match(/\/api\/activities\/(\d+)/)||[])[1] || getActivityIdHeuristics(); upsertFromUploads(aid, d.uploads); setStatus('被动捕获 activities.uploads'); } } } catch {} return res; }; // XHR const _open = XMLHttpRequest.prototype.open; const _send = XMLHttpRequest.prototype.send; XMLHttpRequest.prototype.open = function(m, u, ...rest){ this._xjtu_url = String(u || ''); return _open.call(this, m, u, ...rest); }; XMLHttpRequest.prototype.send = function(...rest){ this.addEventListener('load', function(){ try { const url = this._xjtu_url || this.responseURL || ''; if (this.status >= 200 && this.status < 300 && this.responseType === '' || this.responseType === 'text') { if (/\/api\/activities\/\d+\/upload_references/.test(url)) { const d = json(this.responseText); if (d?.references) { const aid = (url.match(/\/api\/activities\/(\d+)\/upload_references/)||[])[1] || getActivityIdHeuristics(); upsertFromUploadReferences(aid, d.references); setStatus('被动捕获 upload_references (XHR)'); } } else if (/\/api\/activities\/\d+(\?|$)/.test(url)) { const d = json(this.responseText); if (d?.uploads) { const aid = (url.match(/\/api\/activities\/(\d+)/)||[])[1] || getActivityIdHeuristics(); upsertFromUploads(aid, d.uploads); setStatus('被动捕获 activities.uploads (XHR)'); } } } } catch {} }); return _send.apply(this, rest); }; } // ---------- boot ---------- async function boot() { ensurePanel(); hookNetwork(); // 初次尝试拉取常见接口 await tryFetchCommonEndpoints(); // SPA 路由切换(hashchange)时再试一次(LMS 场景常见):contentReference[oaicite:3]{index=3} window.addEventListener('hashchange', () => setTimeout(tryFetchCommonEndpoints, 200)); } boot(); })();