CYCU iLearning PDF downloader

在 i-learning 2.0 pdf viewer 頁右下角提供「下載 PDF」。

当前为 2025-09-14 提交的版本,查看 最新版本

// ==UserScript==
// @name         CYCU iLearning PDF downloader
// @name:en      CYCU iLearning PDF downloader
// @namespace    https://github.com/Mono0713/CYCU-ilearning-2.0-pdf-downloader
// @version      1.0.0
// @description        在 i-learning 2.0 pdf viewer 頁右下角提供「下載 PDF」。
// @description:en     Add a “Download PDF” button on iLearning 2.0 pages; prefer pluginfile.php URL, fallback to PDF.js bytes.
// @license      MIT
// @match        https://ilearning.cycu.edu.tw/*
// @run-at       document-start
// @grant        GM_download
// @grant        GM_notification
// @homepageURL  https://github.com/Mono0713/CYCU-ilearning-2.0-pdf-downloader
// @supportURL   https://github.com/Mono0713/CYCU-ilearning-2.0-pdf-downloader/issues
// @icon         https://ilearning.cycu.edu.tw/theme/image.php/boost/theme/1/favicon
// ==/UserScript==

(function () {
  'use strict';

  // ---------- 小工具 ----------
  const log = (...a) => console.log('[iLearn PDF]', ...a);
  const notify = (text) => {
    try { GM_notification({ title: 'iLearning PDF', text, timeout: 2500 }); } catch {}
    log(text);
  };
  const sanitize = (s) => (s || '')
    .toString()
    .replace(/[\\/:*?"<>|]+/g, '_')
    .trim()
    .slice(0, 120);

  const fileNameFromURL = (u) => {
    try {
      const p = new URL(u, location.href).pathname.split('/');
      return decodeURIComponent(p[p.length - 1] || 'document.pdf') || 'document.pdf';
    } catch { return 'document.pdf'; }
  };

  const courseTitle = () => {
    const h1 =
      document.querySelector('#page-header .page-header-headings h1') ||
      document.querySelector('header h1') ||
      document.querySelector('h1');
    const t = (h1 && h1.textContent.trim()) || document.title.replace(/\s+\|.*/, '');
    return sanitize(t || 'iLearning');
  };

  // ---------- 原始 URL 偵測(pluginfile.php) ----------
  const found = new Set();
  const candidates = [];  // 依時間排序的候選 {url, ts}

  const isPDFUrl = (u) =>
    /\/pluginfile\.php\/.+\.pdf(?:$|\?)/i.test(u) || /\.pdf(?:$|\?)/i.test(u);

  function recordUrl(url) {
    if (!url || !isPDFUrl(url) || found.has(url)) return;
    found.add(url);
    candidates.push({ url, ts: Date.now() });
    // 保留最近 20 筆
    if (candidates.length > 20) candidates.splice(0, candidates.length - 20);
    log('捕捉到 PDF URL:', url);
  }

  // 既有資源掃描 + 連結掃描
  function initialScan() {
    try {
      performance.getEntriesByType('resource')
        .forEach(e => isPDFUrl(e.name) && recordUrl(e.name));
    } catch {}
    document.querySelectorAll('a[href*="/pluginfile.php/"]').forEach(a => {
      const href = a.getAttribute('href');
      if (href) {
        try { recordUrl(new URL(href, location.href).href); }
        catch { recordUrl(href); }
      }
    });
  }

  // 持續監聽新資源
  try {
    new PerformanceObserver(list => {
      for (const e of list.getEntries()) {
        if (e.entryType === 'resource' && isPDFUrl(e.name)) recordUrl(e.name);
      }
    }).observe({ entryTypes: ['resource'] });
  } catch {}

  if (document.readyState === 'loading') {
    addEventListener('DOMContentLoaded', initialScan, { once: true });
  } else {
    initialScan();
  }

  function latestPdfUrl() {
    const arr = candidates.slice().sort((a, b) => b.ts - a.ts);
    const hit = arr.find(x => isPDFUrl(x.url));
    return hit && hit.url;
  }

  // ---------- PDF.js 直接取 bytes(跨所有 frame) ----------
  function findPDFViewerWindow() {
    const seen = new Set(), q = [window];
    while (q.length) {
      const w = q.shift();
      if (seen.has(w)) continue;
      seen.add(w);
      try {
        const app = w.PDFViewerApplication;
        if (app && app.pdfDocument && typeof app.pdfDocument.getData === 'function') {
          return w;
        }
      } catch {}
      try {
        for (let i = 0; i < w.frames.length; i++) q.push(w.frames[i]);
      } catch {}
    }
    return null;
  }

  async function getPdfBlobViaPDFJS() {
    const w = findPDFViewerWindow();
    if (!w) return { ok: false, error: 'no-app' };
    const app = w.PDFViewerApplication;

    // 如果有非 blob 的原始 URL,仍優先回傳 URL(更快)
    try {
      const url = (app.url || app.appConfig?.defaultUrl) || '';
      if (url && !String(url).startsWith('blob:') && isPDFUrl(url)) {
        return { ok: true, url };
      }
    } catch {}

    try {
      const u8 = await app.pdfDocument.getData();
      const blob = new Blob([u8], { type: 'application/pdf' });
      // 名稱盡量取自 app.url;否則用課程標題
      let name = '';
      try {
        if (app.url) name = fileNameFromURL(app.url);
      } catch {}
      if (!name) name = courseTitle() + '.pdf';
      return { ok: true, blob, name };
    } catch (e) {
      return { ok: false, error: 'getdata-fail' };
    }
  }

  // ---------- 下載實作 ----------
  function downloadURL(url, name) {
    const n = sanitize(name || fileNameFromURL(url));
    if (typeof GM_download === 'function') {
      GM_download({ url, name: n });
    } else {
      const a = document.createElement('a');
      a.href = url; a.download = n;
      document.body.appendChild(a); a.click(); a.remove();
    }
    notify('下載:' + n);
  }

  function downloadBlob(blob, name) {
    const n = sanitize(name || (courseTitle() + '.pdf'));
    const url = URL.createObjectURL(blob);
    if (typeof GM_download === 'function') {
      GM_download({
        url, name: n,
        onload: () => URL.revokeObjectURL(url),
        onerror: () => URL.revokeObjectURL(url)
      });
    } else {
      const a = document.createElement('a');
      a.href = url; a.download = n;
      document.body.appendChild(a); a.click(); a.remove();
      URL.revokeObjectURL(url);
    }
    notify('下載:' + n);
  }

  // ---------- 主流程(按鈕行為) ----------
  async function handleDownload() {
    // 1) 優先:已抓到的原始 URL
    const url = latestPdfUrl();
    if (url) {
      return downloadURL(url);
    }

    // 2) 退路:直接向 PDF.js 取 bytes
    const r = await getPdfBlobViaPDFJS();
    if (r.ok && r.url) {
      return downloadURL(r.url);
    }
    if (r.ok && r.blob) {
      return downloadBlob(r.blob, r.name);
    }

    // 3) 還是沒有 → 提示使用者觸發載入
    alert('尚未偵測到本頁的 PDF。\n請先翻到下一頁或重新整理,再按一次「下載 PDF」。');
  }

  // ---------- 右下角 UI ----------
  if (window.top === window) {
    const ID = 'ilearn-pdf-dl-btn';
    if (!document.getElementById(ID)) {
      const btn = document.createElement('button');
      btn.id = ID;
      btn.textContent = '⬇ 下載 PDF';
      Object.assign(btn.style, {
        position: 'fixed',
        right: '14px',
        bottom: '14px',
        zIndex: 2147483647,
        padding: '10px 14px',
        background: '#2563eb',
        color: '#fff',
        border: 'none',
        borderRadius: '10px',
        boxShadow: '0 6px 16px rgba(0,0,0,.2)',
        cursor: 'pointer',
        fontSize: '14px'
      });
      btn.addEventListener('click', handleDownload);
      document.documentElement.appendChild(btn);
    }
  }
})();

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址