// ==UserScript==
// @name CYCU ilearning 2.0 PDF downloader
// @namespace https://github.com/Mono0713/CYCU-ilearning-2.0-pdf-downloader
// @version 1.0.3-fixed
// @description 在中原大學 iLearning 2.0 平台自動新增「⬇️ 下載」與「⬇️ 下載全部」按鈕。
// @license MIT
// @match https://ilearning.cycu.edu.tw/*
// @run-at document-start
// @grant none
// ==/UserScript==
// 功能說明:
// 1. 在 PDF 檢視頁,可直接下載當前 PDF 檔案。
// 2. 在課程頁,一鍵下載所有 PDF / PPT / PPTX 檔案,並自動處理檔名、避免重複。
// @license MIT
// @match https://ilearning.cycu.edu.tw/*
// @run-at document-start
// @grant none
// ==/UserScript==
(() => {
'use strict';
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);
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 lastSeg = decodeURIComponent((u.pathname.split('/').pop() || ''));
let mm = /\.([A-Za-z0-9]{2,5})(?:$|\?)/.exec(lastSeg);
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 map = {
'application/pdf': 'pdf',
'application/vnd.openxmlformats-officedocument.presentationml.presentation': 'pptx',
'application/vnd.ms-powerpoint': 'ppt',
};
const t = ct.split(';')[0].trim().toLowerCase();
if (map[t]) return map[t];
}
return '';
}
const withExt = (basename, ext) => {
if (!ext) return basename;
let need = `.${ext.toLowerCase()}`;
// === 修正重點:如果 ext 被判斷成 php,直接換成 pdf ===
if (ext.toLowerCase() === 'php') {
need = '.pdf';
}
return basename.toLowerCase().endsWith(need) ? basename : (basename + need);
};
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);
}
async function downloadBlob(url, name) {
const r = await fetch(url, { 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') || '';
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);
}
}
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();
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 {}
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() {
const ID = 'ilearn-dl-one';
if (document.getElementById(ID)) return;
const btn = document.createElement('button');
btn.id = ID;
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);
}
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 resolveAnnotatorDirect(href) {
try {
const res = await fetch(href, { credentials: 'include' });
const html = await res.text();
const div = document.createElement('div'); div.innerHTML = html;
const a = div.querySelector('a[href*="/pluginfile.php/"][href*=".pdf"]');
if (a) return new URL(a.getAttribute('href'), location.href).href;
} catch (e) { console.warn('[iLearn] annotator resolve failed', e); }
return href;
}
async function resolveOne(a) {
const uiName = nameFromLink(a) || 'file';
const isAnnotator = /\/mod\/pdfannotator\/view\.php/i.test(a.href);
const href = isAnnotator ? await resolveAnnotatorDirect(a.href) : a.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') || '';
const finalUrl = r.url || href;
const ext = extFromHeadersOrUrl(cd, finalUrl, ct);
return { url: finalUrl, name: withExt(uiName, ext) };
}
async function handleBulkDownload() {
const links = pickResourceLinks();
if (!links.length) { alert('這一頁沒有可下載的檔案型資源'); return; }
const seen = new Set();
const jobs = [];
for (const a of links) {
const base = (nameFromLink(a) || 'file').toLowerCase();
if (seen.has(base)) continue;
seen.add(base);
jobs.push(a);
}
for (const a of jobs) {
try {
const { url, name } = await resolveOne(a);
await downloadBlob(url, name);
} catch (e) {
console.warn('下載失敗:', a, e);
}
await sleep(300);
}
}
function mountBulkButton() {
const ID = 'ilearn-dl-all';
if (document.getElementById(ID)) return;
const btn = document.createElement('button');
btn.id = ID;
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);
}
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();
})();