XJTU PPT Helper (multi-site)

在学习空间/课堂平台上列出 PPT 直链与预览,并一键复制 CSV/文本;

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Greasemonkey lub Violentmonkey.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, wymagana jest instalacje jednego z następujących rozszerzeń: Tampermonkey, Violentmonkey.

Aby zainstalować ten skrypt, wymagana będzie instalacja rozszerzenia Tampermonkey lub Userscripts.

You will need to install an extension such as Tampermonkey to install this script.

Aby zainstalować ten skrypt, musisz zainstalować rozszerzenie menedżera skryptów użytkownika.

(Mam już menedżera skryptów użytkownika, pozwól mi to zainstalować!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Będziesz musiał zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

Musisz zainstalować rozszerzenie menedżera stylów użytkownika, aby zainstalować ten styl.

(Mam już menedżera stylów użytkownika, pozwól mi to zainstalować!)

Advertisement:

// ==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();
})();