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