CYCU ilearning 2.0 PDF downloader

在中原大學 iLearning 2.0 平台自動新增「⬇️ 下載」「⬇️ 下載全部」按鈕

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==UserScript==
// @name         CYCU ilearning 2.0 PDF downloader
// @namespace    https://github.com/Mono0713/CYCU-ilearning-2.0-pdf-downloader
// @version      1.0.7
// @description  在中原大學 iLearning 2.0 平台自動新增「⬇️ 下載」「⬇️ 下載全部」按鈕
// @description:en  Adds “⬇️ Download” & “⬇️ Download All” to CYCU iLearning 2.0
// @license      MIT
// @match        https://i-learning.cycu.edu.tw/*
// @match        https://ilearning.cycu.edu.tw/*
// @run-at       document-start
// @grant        none
// ==/UserScript==

(() => {
  'use strict';

  // ---------- helpers ----------
  const $  = (sel, root = document) => root.querySelector(sel);
  const $$ = (sel, root = document) => Array.from(root.querySelectorAll(sel));
  const sleep = (ms) => new Promise(r => setTimeout(r, ms));

  const IS_PDF_VIEW    = /\/mod\/pdfannotator\/view\.php/i.test(location.pathname + location.search);
  const IS_COURSE_VIEW = /\/course\/view\.php/i.test(location.pathname + location.search);

  const sanitize = (s = '') =>
    (s || '')
      .toString()
      .replace(/&/g, '&')
      .replace(/[\\/:*?"<>|]+/g, '_')
      .replace(/\s+/g, ' ')
      .trim()
      .slice(0, 120);

  // --- 只動這一條:若被判為 php,強制用 pdf(保留你原本唯一修正)
  function withExt(basename, ext) {
    if (!ext) return basename;
    let need = `.${ext.toLowerCase()}`;
    if (ext.toLowerCase() === 'php') need = '.pdf';
    return basename.toLowerCase().endsWith(need) ? basename : (basename + need);
  }

  function extFromHeadersOrUrl(cd, url, ct) {
    if (cd) {
      let m = /filename\*\s*=\s*[^']+'[^']*'([^;]+)$/i.exec(cd);
      if (m) {
        const n = decodeURIComponent(m[1] || '');
        const mm = /\.([A-Za-z0-9]{2,5})$/.exec(n);
        if (mm) return mm[1].toLowerCase();
      }
      m = /filename\s*=\s*"?(.*?)"?\s*(?:;|$)/i.exec(cd);
      if (m) {
        const n = m[1] || '';
        const mm = /\.([A-Za-z0-9]{2,5})$/.exec(n);
        if (mm) return mm[1].toLowerCase();
      }
    }
    if (url) {
      try {
        const u = new URL(url, location.href);
        const last = decodeURIComponent((u.pathname.split('/').pop() || ''));
        let mm = /\.([A-Za-z0-9]{2,5})(?:$|\?)/.exec(last);
        if (mm) return mm[1].toLowerCase();
        const qsName = new URLSearchParams(u.search).get('filename');
        if (qsName) {
          mm = /\.([A-Za-z0-9]{2,5})$/.exec(decodeURIComponent(qsName));
          if (mm) return mm[1].toLowerCase();
        }
      } catch {}
    }
    if (ct) {
      const mp = {
        'application/pdf': 'pdf',
        'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
        'application/vnd.ms-powerpoint': 'ppt',
      };
      const t = ct.split(';')[0].trim().toLowerCase();
      if (mp[t]) return mp[t];
    }
    return '';
  }

  // --- 規範化 URL:同網域 + 修正錯誤的 /mod/pdfannotator/pluginfile.php 路徑
  function sameHost(url) {
    try {
      const u = new URL(url, location.href);
      u.protocol = location.protocol;
      u.host = location.host; // i-learning 與 ilearning 統一為當前登入主機
      return u.href;
    } catch { return url; }
  }

  function fixPluginfileUrl(url) {
    try {
      const u = new URL(url, location.href);
      // 先統一主機
      u.protocol = location.protocol;
      u.host = location.host;
      // 把錯誤路徑 /mod/pdfannotator/pluginfile.php 更正為 /pluginfile.php
      u.pathname = u.pathname.replace(/\/mod\/pdfannotator\/pluginfile\.php/i, '/pluginfile.php');
      return u.href;
    } catch {
      return url;
    }
  }

    // 取代原本的 extractPdfUrlFromHtml()
    function extractFileUrlFromHtml(html, baseUrl, exts = ['pdf','ppt','pptx']) {
        const base = new URL(baseUrl, location.href);
        const extRe = exts.map(e => e.replace('.', '')).join('|'); // pdf|ppt|pptx

        // A) 直接出現 pluginfile…(pdf|ppt|pptx)
        let m = html.match(new RegExp(`(pluginfile\\.php[^"'<>]+\\.(${extRe})[^"'<>]*)`, 'i'));
        if (m) return fixPluginfileUrl(new URL(m[1], base).href);

        // B) PDF.js viewer: viewer.html?file=<encoded URL>(若 file 指向的是上述副檔名)
        m = html.match(/viewer\.html\?file=([^"'&<>]+)/i);
        if (m) {
            try {
                const decoded = decodeURIComponent(m[1]);
                if (new RegExp(`\\.(${extRe})(?:$|[?#])`, 'i').test(decoded)) {
                    return fixPluginfileUrl(new URL(decoded, base).href);
                }
            } catch {}
        }

        // C) data-* 或 href/src 屬性裡的 (pdf|ppt|pptx) 連結(單/雙引號)
        const attrs = html.match(/(?:href|src|data-url|data-href|data-file|data-pdf)\s*=\s*(['"])(.*?)\1/gi) || [];
        for (const attr of attrs) {
            const mm = /['"](.*?)['"]/.exec(attr);
            if (!mm) continue;
            const cand = mm[1];
            if (new RegExp(`\\.(${extRe})(?:$|[?#])`, 'i').test(cand)) {
                return fixPluginfileUrl(new URL(cand, base).href);
            }
        }

        // D) JS 變數:fileUrl = "….(pdf|ppt|pptx)"
        m = html.match(new RegExp(`fileUrl\\s*[:=]\\s*['"]([^'"]+\\.(${extRe})[^'"]*)['"]`, 'i'));
        if (m) return fixPluginfileUrl(new URL(m[1], base).href);

        return '';
    }

  // ---------- filename helpers ----------
  function nameFromLink(a) {
    const inst = a.querySelector('.instancename');
    if (inst) {
      const txtNodes = Array.from(inst.childNodes).filter(n => n.nodeType === Node.TEXT_NODE);
      const t = txtNodes.map(n => n.textContent || '').join(' ');
      return sanitize(t || inst.textContent || a.textContent);
    }
    return sanitize(a.textContent);
  }

    // 取代原本的 downloadBlob()
    async function downloadBlob(url, name) {
        url = sameHost(url);

        const r = await fetch(url, { credentials: 'include', redirect: 'follow' });
        if (!r.ok) throw new Error(`HTTP ${r.status}`);

        const ct = (r.headers.get('content-type') || '').toLowerCase();

        // 直接接受三種正確 MIME
        const isPDF  = /application\/pdf/.test(ct);
        const isPPTX = /application\/vnd\.openxmlformats-officedocument\.presentationml\.presentation/.test(ct);
        const isPPT  = /application\/vnd\.ms-powerpoint/.test(ct);

        if (!(isPDF || isPPT || isPPTX)) {
            // 不是上述三種 ⇒ 很可能是 HTML 容器或登入頁,從 HTML 解析出真正檔案直鏈(pdf|ppt|pptx)
            const html = await r.text();
            const realUrl = extractFileUrlFromHtml(html, r.url || url); // ← 同時找 pdf/ppt/pptx
            if (!realUrl) throw new Error('Not a PDF/PPT and no file link found in HTML');
            return await downloadBlob(realUrl, name); // 重新抓真正檔案
        }

        // 真正是檔案 → 正確命名並儲存
        const cd = r.headers.get('content-disposition') || '';
        const finalUrl = r.url || url;
        const ext = extFromHeadersOrUrl(cd, finalUrl, ct);
        const niceName = withExt(sanitize(name), ext);

        const blob = await r.blob();
        const obj = URL.createObjectURL(blob);
        try {
            const a = document.createElement('a');
            a.href = obj;
            a.download = niceName;
            document.body.appendChild(a);
            a.click();
            a.remove();
        } finally {
            URL.revokeObjectURL(obj);
        }
    }


  // 解析 pdfannotator/view.php → 真正的 pluginfile 直鏈
  async function resolveAnnotatorDirect(href) {
    try {
      const res = await fetch(sameHost(href), { credentials: 'include', redirect: 'follow' });
      const finalUrl = res.url || href;

      // A) 已被轉址為 pluginfile…pdf
      if (/\/pluginfile\.php\/.+\.pdf(?:$|[?#])/i.test(finalUrl)) {
        return fixPluginfileUrl(finalUrl);
      }

      // B) 從 HTML 抽出 PDF 直鏈
      const html = await res.text();
      const realUrl = extractPdfUrlFromHtml(html, finalUrl);
      if (realUrl) return fixPluginfileUrl(realUrl);
    } catch (e) {
      console.warn('[iLearn] annotator resolve failed', e);
    }
    return href; // 找不到就讓後續流程處理(downloadBlob 仍會再解析一次)
  }

  // ---------- UI: single (viewer) ----------
  const viewerTitle = () => {
    const h = $('#page-header .page-header-headings h1') || $('header h1') || $('h1');
    const t = (h && h.textContent) || document.title.replace(/\s*\|.*$/, '') || 'document';
    return sanitize(t);
  };

  async function handleSingleDownload() {
    const title = viewerTitle();

    // 先嘗試 PDF.js
    try {
      const app = (window.PDFViewerApplication || {});
      if (app && app.pdfDocument && typeof app.pdfDocument.getData === 'function') {
        const u = (app.url || app.appConfig?.defaultUrl || '');
        if (u && !String(u).startsWith('blob:')) {
          await downloadBlob(u, title);
          return;
        } else {
          const u8 = await app.pdfDocument.getData();
          const blob = new Blob([u8], { type: 'application/pdf' });
          const obj = URL.createObjectURL(blob);
          const a = document.createElement('a');
          a.href = obj; a.download = withExt(title, 'pdf');
          document.body.appendChild(a); a.click(); a.remove();
          URL.revokeObjectURL(obj);
          return;
        }
      }
    } catch {}

    // 從 performance 或 DOM 找到 pluginfile.pdf
    let url = '';
    try {
      const hit = performance.getEntriesByType('resource')
        .map(e => e.name).reverse()
        .find(u => /\/pluginfile\.php\/.+\.pdf(?:$|\?)/i.test(u));
      if (hit) url = hit;
    } catch {}
    if (!url) {
      const a = $('a[href*="/pluginfile.php/"][href*=".pdf"]');
      if (a) url = a.href;
    }
    if (!url) { alert('找不到本頁 PDF,請先翻頁讓檔案載入後再試一次。'); return; }

    await downloadBlob(url, title);
  }

  function mountSingleButton() {
    if (document.getElementById('ilearn-dl-one')) return;
    const btn = document.createElement('button');
    btn.id = 'ilearn-dl-one';
    btn.textContent = '⬇️ 下載';
    Object.assign(btn.style, {
      position:'fixed', right:'14px', bottom:'14px', zIndex:2147483647,
      padding:'10px 14px', background:'#0ea5e9', color:'#fff',
      border:'none', borderRadius:'10px', boxShadow:'0 6px 16px rgba(0,0,0,.2)', cursor:'pointer'
    });
    btn.addEventListener('click', () => { btn.disabled = true; handleSingleDownload().finally(()=>btn.disabled=false); }, {passive:true});
    document.documentElement.appendChild(btn);
  }

  // ---------- UI: course page bulk ----------
  function pickResourceLinks() {
    const res = $$('li.activity.resource.modtype_resource a.aalink[href*="/mod/resource/view.php?id="]');
    const pdf = $$('li.activity.modtype_pdfannotator a.aalink[href*="/mod/pdfannotator/view.php?id="]');
    return [...res, ...pdf];
  }

  async function resolveOne(a) {
    const uiName = nameFromLink(a) || 'file';

    let href = a.href;
    if (/\/mod\/pdfannotator\/view\.php/i.test(href)) {
      href = await resolveAnnotatorDirect(href);
    }
    href = sameHost(href);

    const r = await fetch(href, { credentials:'include', redirect:'follow' });
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    const cd = r.headers.get('content-disposition') || '';
    const ct = (r.headers.get('content-type') || '').toLowerCase();
    const finalUrl = r.url || href;

    const looksPdf =
      /application\/pdf/.test(ct) ||
      /\.pdf(?:$|[?#])/i.test(finalUrl) ||
      /\.pdf/i.test(cd);

    const ext = looksPdf ? 'pdf' : extFromHeadersOrUrl(cd, finalUrl, ct);
    return { url: finalUrl, name: withExt(uiName, ext) };
  }

  async function handleBulkDownload() {
    const links = pickResourceLinks();
    if (!links.length) { alert('這一頁沒有可下載的檔案型資源'); return; }

    // 先解析每個連結的最終 href(含 annotator → pluginfile)
    const items = await Promise.all(links.map(async (a) => {
      const name = nameFromLink(a) || 'file';
      const isAnnotator = /\/mod\/pdfannotator\/view\.php/i.test(a.href);
      let href = isAnnotator ? await resolveAnnotatorDirect(a.href) : a.href;
      href = sameHost(href);
      return { name, href };
    }));

    // 用解析後的 href 去重(避免同檔案重複)
    const seen = new Set();
    const jobs = [];
    for (const it of items) {
      const key = (it.href || '').split('#')[0];
      if (seen.has(key)) continue;
      seen.add(key);
      jobs.push(it);
    }

    for (const { href, name } of jobs) {
      try {
        await downloadBlob(href, name);
      } catch (e) {
        console.warn('下載失敗:', href, e);
      }
      await sleep(300);
    }
  }

  function mountBulkButton() {
    if (document.getElementById('ilearn-dl-all')) return;
    const btn = document.createElement('button');
    btn.id = 'ilearn-dl-all';
    btn.textContent = '⬇️ 下載全部';
    Object.assign(btn.style, {
      position:'fixed', right:'14px', bottom:'14px', zIndex:2147483647,
      padding:'10px 14px', background:'#16a34a', color:'#fff',
      border:'none', borderRadius:'10px', boxShadow:'0 6px 16px rgba(0,0,0,.2)', cursor:'pointer'
    });
    btn.addEventListener('click', () => { btn.disabled = true; handleBulkDownload().finally(()=>btn.disabled=false); }, {passive:true});
    document.documentElement.appendChild(btn);
  }

  // ---------- start ----------
  function start() {
    if (IS_PDF_VIEW) {
      if (document.readyState === 'loading') addEventListener('DOMContentLoaded', mountSingleButton, { once:true });
      else mountSingleButton();
    } else if (IS_COURSE_VIEW) {
      if (document.readyState === 'loading') addEventListener('DOMContentLoaded', mountBulkButton, { once:true });
      else mountBulkButton();
    }
  }
  start();
})();