|
// McGraw-Hill EPUB downloader: |
|
|
|
// Wrap everything in an async IIFE so it can run as a one-off script |
|
javascript:(async () => { |
|
// Prevent multiple instances of the downloader from running at once |
|
if (window.__mheDlRunning) return; |
|
window.__mheDlRunning = true; |
|
|
|
// Create and inject a small floating UI panel into the page |
|
const addUI = () => { |
|
const box = document.createElement('div'); |
|
box.id = 'mhe-dl'; |
|
box.style = 'position:fixed;z-index:2147483647;right:16px;bottom:16px;width:320px;padding:12px;background:#0b172a;color:#f0f4ff;font:12px/1.4 system-ui;border:1px solid #243b5a;border-radius:8px;box-shadow:0 8px 24px rgba(0,0,0,0.35)'; |
|
box.innerHTML = ` |
|
<div style="font-weight:600;margin-bottom:6px;">McGraw-Hill EPUB Downloader</div> |
|
<div id="mhe-status" style="margin-bottom:6px;">Starting…</div> |
|
<div style="height:8px;background:#12233d;border-radius:4px;overflow:hidden;"> |
|
<div id="mhe-bar" style="height:100%;width:0%;background:#3ad1ff;transition:width 0.15s;"></div> |
|
</div> |
|
<div id="mhe-sub" style="margin-top:6px;opacity:0.8;">Idle</div> |
|
<div id="mhe-err" style="margin-top:6px;color:#ffb3b3;display:none;"></div> |
|
<div style="margin-top:8px;border-top:1px solid #243b5a;padding-top:6px;"> |
|
<div id="mhe-log-toggle" style="cursor:pointer;opacity:0.7;font-size:11px;margin-bottom:4px;">▼ Log</div> |
|
<div id="mhe-log" style="display:block;max-height:200px;overflow-y:auto;background:#0a1220;padding:6px;border-radius:4px;font-size:11px;font-family:monospace;line-height:1.4;"></div> |
|
</div> |
|
`; |
|
// Attach the UI to the current page |
|
document.body.appendChild(box); |
|
const toggleBtn = document.getElementById('mhe-log-toggle'); |
|
const logEl = document.getElementById('mhe-log'); |
|
toggleBtn.addEventListener('click', () => { |
|
const isHidden = logEl.style.display === 'none'; |
|
logEl.style.display = isHidden ? 'block' : 'none'; |
|
toggleBtn.textContent = isHidden ? '▼ Log' : '▲ Log'; |
|
}); |
|
// Return helpers to update the UI from the rest of the script |
|
return { |
|
setStatus: (t) => (document.getElementById('mhe-status').textContent = t), |
|
setSub: (t) => (document.getElementById('mhe-sub').textContent = t), |
|
setBar: (p) => (document.getElementById('mhe-bar').style.width = `${Math.max(0, Math.min(100, p))}%`), |
|
showErr: (t) => { |
|
const el = document.getElementById('mhe-err'); |
|
el.textContent = t; |
|
el.style.display = 'block'; |
|
}, |
|
log: (msg, type = 'info') => { |
|
const el = document.getElementById('mhe-log'); |
|
const time = new Date().toLocaleTimeString(); |
|
const colors = { info: '#a0c4ff', success: '#90ee90', warn: '#ffd700', error: '#ff6b6b' }; |
|
const entry = document.createElement('div'); |
|
entry.style.color = colors[type] || colors.info; |
|
entry.style.marginBottom = '2px'; |
|
entry.textContent = `[${time}] ${msg}`; |
|
el.appendChild(entry); |
|
el.scrollTop = el.scrollHeight; |
|
}, |
|
close: () => box.remove(), |
|
}; |
|
}; |
|
|
|
const ui = addUI(); |
|
// Simple delay helper for retries and pacing |
|
const sleep = (ms) => new Promise((r) => setTimeout(r, ms)); |
|
// Clean up a string so it is safe to use as a filename on most systems |
|
const sanitizeName = (s) => (s || 'textbook').replace(/[<>:"/\\|?*\x00-\x1F]/g, '').trim().slice(0, 80) || 'textbook'; |
|
|
|
try { |
|
ui.setStatus('Loading JSZip…'); |
|
ui.log('Loading JSZip library from CDN...', 'info'); |
|
// Dynamically load JSZip from a CDN (no local dependency needed) |
|
const mod = await import('https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js'); |
|
const JSZip = mod?.default || mod?.JSZip || window.JSZip; |
|
if (!JSZip) throw new Error('JSZip failed to load'); |
|
ui.log('JSZip loaded successfully', 'success'); |
|
|
|
ui.setStatus('Fetching base URL…'); |
|
ui.log('Fetching EPUB base URL from player API...', 'info'); |
|
// Ask the McGraw-Hill player API for the base URL of the current book’s EPUB assets |
|
const baseUrl = (await (await fetch('https://player-api.mheducation.com/lti', { credentials: 'include' })).json()).custom_epub_url; |
|
ui.log(`Base URL: ${baseUrl}`, 'success'); |
|
|
|
// Create the EPUB folder structure inside a new ZIP |
|
const zip = new JSZip(); |
|
const meta = zip.folder('META-INF'); |
|
const ops = zip.folder('OPS'); |
|
|
|
ui.setStatus('Fetching container & OPF…'); |
|
ui.log('Fetching META-INF/container.xml...', 'info'); |
|
meta.file('container.xml', await (await fetch(baseUrl + 'META-INF/container.xml', { credentials: 'include' })).text()); |
|
ui.log('Fetching OPS/content.opf...', 'info'); |
|
const opfText = await (await fetch(baseUrl + 'OPS/content.opf', { credentials: 'include' })).text(); |
|
ops.file('content.opf', opfText); |
|
ui.log('Parsing OPF manifest...', 'info'); |
|
|
|
// Parse the OPF file to get the manifest (list of all book files) |
|
const opf = new DOMParser().parseFromString(opfText, 'application/xml'); |
|
const items = [...opf.querySelectorAll('manifest item')]; |
|
const total = items.length; |
|
ui.log(`Found ${total} files in manifest`, 'success'); |
|
let done = 0; |
|
const errors = []; |
|
const concurrency = 6; |
|
const retries = 2; |
|
|
|
// Download a single manifest item with retry logic |
|
const fetchFile = async (href) => { |
|
let lastErr; |
|
for (let i = 0; i <= retries; i++) { |
|
try { |
|
if (i > 0) ui.log(`Retrying ${href} (attempt ${i + 1}/${retries + 1})...`, 'warn'); |
|
const res = await fetch(baseUrl + 'OPS/' + href, { credentials: 'include' }); |
|
if (!res.ok) throw new Error('HTTP ' + res.status); |
|
const data = await res.arrayBuffer(); |
|
if (i > 0) ui.log(`Successfully downloaded ${href} after retry`, 'success'); |
|
return data; |
|
} catch (e) { |
|
lastErr = e; |
|
if (i < retries) await sleep(200 * (i + 1)); |
|
} |
|
} |
|
throw lastErr; |
|
}; |
|
|
|
ui.setStatus('Downloading files…'); |
|
ui.log(`Starting download with ${concurrency} concurrent workers...`, 'info'); |
|
const queue = [...items]; |
|
// Worker loop: repeatedly take the next item from the queue and download it |
|
const worker = async () => { |
|
while (queue.length) { |
|
const item = queue.shift(); |
|
const href = item.getAttribute('href'); |
|
try { |
|
ui.log(`Downloading: ${href}`, 'info'); |
|
const data = await fetchFile(href); |
|
ops.file(href, data); |
|
done++; |
|
ui.setBar((done / total) * 100); |
|
ui.setSub(`Downloaded ${done}/${total}: ${href}`); |
|
ui.log(`✓ ${href} (${(data.byteLength / 1024).toFixed(1)} KB)`, 'success'); |
|
} catch (e) { |
|
errors.push({ href, error: e }); |
|
ui.setSub(`Failed ${href}`); |
|
ui.log(`✗ Failed: ${href} - ${e.message}`, 'error'); |
|
} |
|
} |
|
}; |
|
await Promise.all(Array.from({ length: concurrency }, worker)); |
|
ui.log(`Download complete: ${done}/${total} files, ${errors.length} failed`, errors.length > 0 ? 'warn' : 'success'); |
|
|
|
// If some files failed, warn the user but still produce an EPUB |
|
if (errors.length) { |
|
ui.showErr(`Skipped ${errors.length} file(s); EPUB may miss assets.`); |
|
ui.log(`Warning: ${errors.length} files failed to download`, 'warn'); |
|
} |
|
|
|
ui.setStatus('Compressing EPUB…'); |
|
ui.log('Compressing EPUB archive...', 'info'); |
|
// Compress everything into a single EPUB (ZIP) blob |
|
const blob = await zip.generateAsync( |
|
{ type: 'blob', compression: 'DEFLATE', compressionOptions: { level: 6 } }, |
|
(meta) => { |
|
ui.setBar(meta.percent || 0); |
|
if (meta.percent % 10 < 1) ui.log(`Compression: ${Math.floor(meta.percent)}%`, 'info'); |
|
} |
|
); |
|
ui.log(`EPUB compressed: ${(blob.size / 1024 / 1024).toFixed(2)} MB`, 'success'); |
|
|
|
ui.setStatus('Saving…'); |
|
// Use the textbook title as the EPUB filename |
|
const title = sanitizeName(opf.querySelector('metadata title')?.textContent); |
|
ui.log(`Saving as: ${title}.epub`, 'info'); |
|
const url = URL.createObjectURL(blob); |
|
const a = document.createElement('a'); |
|
a.href = url; |
|
a.download = `${title}.epub`; |
|
a.click(); |
|
URL.revokeObjectURL(url); |
|
|
|
ui.setBar(100); |
|
ui.setSub('Done'); |
|
ui.setStatus('EPUB ready'); |
|
ui.log('Download complete!', 'success'); |
|
await sleep(1500); |
|
ui.close(); |
|
} catch (err) { |
|
// Handle any unexpected fatal error and keep the UI consistent |
|
ui.showErr(String(err)); |
|
ui.setStatus('Failed'); |
|
ui.log(`Fatal error: ${err.message}`, 'error'); |
|
} finally { |
|
// Allow the script to be run again in the future |
|
window.__mheDlRunning = false; |
|
} |
|
})(); |