XJTU PPT Helper (multi-site)

在学习空间/课堂平台上列出 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();
})();