Download a McGraw Hill Education eTextbook — 2026 fix: moved /v1/lti endpoint, and the output is finally a valid ePub (the mimetype entry was missing)
Download a McGraw Hill Education eTextbook
If you purchase a textbook from McGraw Hill, the website to view it is clunky and only works on some devices. You can't go to specific page numbers, the search is super slow, and on Linux Chrome it frequently doesn't work at all. This script downloads the textbook as an ePub file for your own viewing.
Using this script is legal. McGraw Hill publicly hosts their ebooks online in order for their own web client to download them, and to use this you must already have purchased the book, so it is legally yours to read as you please. However, it IS illegal to use this for piracy. DO NOT DISTRIBUTE ANY TEXTBOOKS YOU DOWNLOAD USING THIS SCRIPT.
2026 update — read this if the script stopped working
If you ran the original script and it sat forever on "Starting file download...", or it finished but the .epub refused to open in your reader, those are two separate bugs. Both are fixed here.
1. The login endpoint moved. The script asked player-api.mheducation.com/lti for the book's location. That host still answers, so nothing visibly errored — but it no longer carries your reader session, so it returned no custom_epub_url. The script then fetched undefined/META-INF/container.xml forever with no error message. The current endpoint is prod.reader.prod.mheducation.com/v1/lti. This version tries that first and falls back to the old hosts, then to the bookUrl in the page URL, then asks you.
2. The downloaded file was never a valid ePub. The mimetype entry was missing from the zip entirely. The ePub spec requires it as the very first entry, stored uncompressed — it's how a reader identifies the file at all. Calibre is lenient enough to open the book anyway, which is why this went unnoticed for years, but Apple Books, Kindle, and most Android readers just reject it. Confirmed with the official W3C validator:
# what the original script produced
ERROR(PKG-006): Mimetype file entry is missing or is not the first file in the archive.
$ file old.epub -> Zip archive data # not even recognised as an ebook
# what this version produces
Messages: 0 fatals / 0 errors
$ file new.epub -> EPUB document
Also fixed along the way:
OPS/was hardcoded. Newer books put their content inEPUB/and failed outright. The real path is now read fromMETA-INF/container.xml, the way an ePub reader does it.- Failed downloads were written into the book. There was no HTTP status check, so a 403 or 404 error page got saved as the stylesheet or the page itself. This is the cause of the recurring "it downloaded but all the formatting is gone" reports. Every request is now status-checked and retried, and whatever is still missing is listed at the end, split into content / styling / cosmetic so you know whether re-running is worth it.
- Large textbooks ran out of memory. JSZip held the whole book in RAM twice, which is why books over about 2 GB died with "can't create blob". The book now streams straight to disk.
- No JSZip, no CDN. A small zip writer is built into the script, so a Content-Security-Policy on the reader page can't block it any more.
- Filenames are URL-decoded, so hrefs containing
%20produce files the book can actually reference. - Downloads run 6 at a time instead of one by one.
Instructions
Open your textbook in the McGraw-Hill reader the way you normally do. Then:
Method 1 — developer console (recommended, most reliable)
- Press F12 (or Ctrl+Shift+J) to open DevTools, and click the Console tab.
- If this is your first time pasting into the console, Chrome will refuse and ask you to type
allow pastingand press Enter. Do that. - Paste the entire contents of
script.jsand press Enter. - Click Start, then Save as… and choose where to put the file.
Method 2 — address bar
- Type
javascript:into the address bar. You cannot copy-paste this part; browsers strip it. - Paste this immediately after the
javascript:you just typed:
var x=new XMLHttpRequest();x.onload=function(){eval(x.responseText)};x.open('GET','https://gist.githubusercontent.com/huhwhatbruh/90cd8eaa1688089646c186c8cd37fd40/raw/script.js');x.send();- Press Enter, then click Start.
Method 2 can be blocked by the page's Content-Security-Policy on some McGraw-Hill platforms. If nothing happens, use Method 1.
A progress screen covers the page while it works. Refresh at any time to get the reader back.
Reading the file
Any ePub reader works now that the file is valid. Calibre and Thorium are good on desktop; Lithium is the one most people in the comments found reliable on Android. Calibre can also convert to PDF.
Troubleshooting
"Could not find the textbook automatically." Your platform uses a different reader (McGraw Hill Edge India, Connect ED, Wonders K-5 and others are not the platform this was written against). Open the Network tab, reload the reader, and find the request for a .opf or container.xml file. Paste the part of that URL up to the folder containing META-INF/ into the prompt the script shows.
Some files failed. Re-run it; these are usually transient. The summary tells you whether what failed actually matters — missing content means absent pages, missing stylesheets means ugly formatting, missing cosmetic files mean nothing.
It asks where to save and then nothing happens. Check the Console tab for a red error and open an issue with it.
Credits
Original script by 101arrowz. The moved endpoint was tracked down by jeanpierre679 and confirmed by Seraph-Genesis in the comments there; jimmckeeth wrote the first version with retry handling. This fork adds the ePub validity fix, container-path detection, streaming to disk, and the dependency removal.
| /* | |
| * McGraw-Hill Education eTextbook downloader | |
| * ========================================== | |
| * Fixed 2026-10-05. Fork of https://gist.github.com/101arrowz/88156556326106a6ccd58ecb4526498c | |
| * | |
| * What was broken, and what this changes: | |
| * | |
| * 1. THE LTI ENDPOINT MOVED. This is why everyone is stuck at | |
| * "Starting file download...". The old host still answers | |
| * (player-api.mheducation.com/lti -> 401) but no longer carries the reader | |
| * session, so the JSON came back without `custom_epub_url`. IMPORT_URL | |
| * became `undefined`, every later fetch went to "undefined/META-INF/..." | |
| * and the script hung with no error. Now resolved against the current | |
| * endpoint (prod.reader.prod.mheducation.com/v1/lti) with fallbacks. | |
| * | |
| * 2. THE OUTPUT WAS NOT A VALID EPUB. The `mimetype` entry was missing | |
| * entirely. The EPUB OCF spec requires it as the first entry, stored | |
| * uncompressed. Without it Apple Books, Kindle and most Android readers | |
| * refuse to open the file -- Calibre is just lenient enough to hide the | |
| * bug. It is now written correctly. | |
| * | |
| * 3. "OPS/" WAS HARDCODED. Newer McGraw-Hill books use "EPUB/", so they | |
| * failed outright. The container path is now read from | |
| * META-INF/container.xml like a real EPUB reader does. | |
| * | |
| * 4. FAILED DOWNLOADS WERE WRITTEN INTO THE BOOK. There was no response.ok | |
| * check, so a 403/404 HTML error page got saved as the stylesheet or page | |
| * itself -- the cause of the recurring "it downloaded but the formatting | |
| * is gone" reports. Every request is now status-checked, retried with | |
| * backoff, and anything still missing is listed at the end, separated | |
| * into content / styling / cosmetic so you know whether to re-run. | |
| * | |
| * 5. NO JSZIP, NO CDN. A small store-only ZIP writer is built in. A strict | |
| * Content-Security-Policy on the reader page can no longer block the | |
| * script, and the book streams straight to disk through the File System | |
| * Access API instead of being held in RAM twice. That removes the ~2 GB | |
| * ceiling that made large textbooks die with "can't create blob". | |
| * ZIP64 is emitted when needed, so >4 GB works too. | |
| * | |
| * 6. Entry names are URL-decoded (hrefs with %20 used to produce files the | |
| * XHTML couldn't reference), downloads run 6-at-a-time instead of one by | |
| * one, and the blob URL is no longer revoked before the browser has | |
| * finished saving it. | |
| * | |
| * HOW TO RUN | |
| * Open your textbook in the McGraw-Hill reader as you normally would, then | |
| * open DevTools (F12) -> Console, paste this entire file and press Enter. | |
| * The first time you paste into the console Chrome will make you type | |
| * "allow pasting" and press Enter first. | |
| * | |
| * This is for a book you have already paid for. Don't redistribute it. | |
| */ | |
| (() => { | |
| 'use strict'; | |
| const LTI_ENDPOINTS = [ | |
| 'https://prod.reader.prod.mheducation.com/v1/lti', | |
| 'https://player-api.mheducation.com/lti', | |
| 'https://player-api.prod.mheducation.com/lti', | |
| ]; | |
| const OPF_GUESSES = ['OPS/content.opf', 'EPUB/content.opf', 'EPUB/package.opf', 'OEBPS/content.opf']; | |
| const CONCURRENCY = 6; | |
| const RETRIES = 4; | |
| const ENC = new TextEncoder(); | |
| const DEC = new TextDecoder(); | |
| /* ------------------------------------------------------------------ CRC32 */ | |
| const CRC_TABLE = (() => { | |
| const t = new Uint32Array(256); | |
| for (let i = 0; i < 256; i++) { | |
| let c = i; | |
| for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); | |
| t[i] = c >>> 0; | |
| } | |
| return t; | |
| })(); | |
| function crc32(buf) { | |
| let c = 0xFFFFFFFF; | |
| for (let i = 0; i < buf.length; i++) c = CRC_TABLE[(c ^ buf[i]) & 0xFF] ^ (c >>> 8); | |
| return (c ^ 0xFFFFFFFF) >>> 0; | |
| } | |
| /* -------------------------------------------------------- store-only ZIP */ | |
| const MAX32 = 0xFFFFFFFE; | |
| class ZipWriter { | |
| constructor(sink) { | |
| this.sink = sink; | |
| this.offset = 0; | |
| this.entries = []; | |
| const d = new Date(); | |
| this.time = ((d.getHours() << 11) | (d.getMinutes() << 5) | (d.getSeconds() >> 1)) & 0xFFFF; | |
| this.date = (((d.getFullYear() - 1980) << 9) | ((d.getMonth() + 1) << 5) | d.getDate()) & 0xFFFF; | |
| } | |
| async _write(u8) { | |
| await this.sink.write(u8); | |
| this.offset += u8.length; | |
| } | |
| async add(name, data) { | |
| const nb = ENC.encode(name); | |
| let utf8 = false; | |
| for (const b of nb) if (b > 127) { utf8 = true; break; } | |
| const flags = utf8 ? 0x800 : 0; | |
| const crc = crc32(data); | |
| const size = data.length; | |
| const localOffset = this.offset; | |
| // The mimetype entry must carry no extra field, and is always tiny. | |
| const z64 = size > MAX32; | |
| const extraLen = z64 ? 20 : 0; | |
| const head = new Uint8Array(30 + nb.length + extraLen); | |
| const v = new DataView(head.buffer); | |
| v.setUint32(0, 0x04034B50, true); // local file header signature | |
| v.setUint16(4, z64 ? 45 : 20, true); // version needed | |
| v.setUint16(6, flags, true); | |
| v.setUint16(8, 0, true); // method: store | |
| v.setUint16(10, this.time, true); | |
| v.setUint16(12, this.date, true); | |
| v.setUint32(14, crc, true); | |
| v.setUint32(18, z64 ? 0xFFFFFFFF : size, true); // compressed | |
| v.setUint32(22, z64 ? 0xFFFFFFFF : size, true); // uncompressed | |
| v.setUint16(26, nb.length, true); | |
| v.setUint16(28, extraLen, true); | |
| head.set(nb, 30); | |
| if (z64) { | |
| const o = 30 + nb.length; | |
| v.setUint16(o, 0x0001, true); | |
| v.setUint16(o + 2, 16, true); | |
| v.setBigUint64(o + 4, BigInt(size), true); | |
| v.setBigUint64(o + 12, BigInt(size), true); | |
| } | |
| await this._write(head); | |
| await this._write(data); | |
| this.entries.push({ nb, flags, crc, size, localOffset }); | |
| } | |
| async close() { | |
| const cdStart = this.offset; | |
| for (const e of this.entries) { | |
| const z64 = e.size > MAX32 || e.localOffset > MAX32; | |
| const extraLen = z64 ? 28 : 0; | |
| const rec = new Uint8Array(46 + e.nb.length + extraLen); | |
| const v = new DataView(rec.buffer); | |
| v.setUint32(0, 0x02014B50, true); // central directory signature | |
| v.setUint16(4, 0x031E, true); // version made by: unix, 3.0 | |
| v.setUint16(6, z64 ? 45 : 20, true); | |
| v.setUint16(8, e.flags, true); | |
| v.setUint16(10, 0, true); // method: store | |
| v.setUint16(12, this.time, true); | |
| v.setUint16(14, this.date, true); | |
| v.setUint32(16, e.crc, true); | |
| v.setUint32(20, z64 ? 0xFFFFFFFF : e.size, true); | |
| v.setUint32(24, z64 ? 0xFFFFFFFF : e.size, true); | |
| v.setUint16(28, e.nb.length, true); | |
| v.setUint16(30, extraLen, true); | |
| v.setUint16(32, 0, true); // comment length | |
| v.setUint16(34, 0, true); // disk number start | |
| v.setUint16(36, 0, true); // internal attrs | |
| v.setUint32(38, 0x81A40000, true); // external attrs: regular file, mode 0644 | |
| v.setUint32(42, z64 ? 0xFFFFFFFF : e.localOffset, true); | |
| rec.set(e.nb, 46); | |
| if (z64) { | |
| const o = 46 + e.nb.length; | |
| v.setUint16(o, 0x0001, true); | |
| v.setUint16(o + 2, 24, true); | |
| v.setBigUint64(o + 4, BigInt(e.size), true); | |
| v.setBigUint64(o + 12, BigInt(e.size), true); | |
| v.setBigUint64(o + 20, BigInt(e.localOffset), true); | |
| } | |
| await this._write(rec); | |
| } | |
| const cdSize = this.offset - cdStart; | |
| const n = this.entries.length; | |
| const needZip64 = n > 0xFFFE || cdStart > MAX32 || cdSize > MAX32; | |
| if (needZip64) { | |
| const z64Start = this.offset; | |
| const z = new Uint8Array(56 + 20); | |
| const v = new DataView(z.buffer); | |
| v.setUint32(0, 0x06064B50, true); // zip64 end of central directory | |
| v.setBigUint64(4, 44n, true); // size of this record - 12 | |
| v.setUint16(12, 0x031E, true); | |
| v.setUint16(14, 45, true); | |
| v.setUint32(16, 0, true); // this disk | |
| v.setUint32(20, 0, true); // disk with cd | |
| v.setBigUint64(24, BigInt(n), true); | |
| v.setBigUint64(32, BigInt(n), true); | |
| v.setBigUint64(40, BigInt(cdSize), true); | |
| v.setBigUint64(48, BigInt(cdStart), true); | |
| v.setUint32(56, 0x07064B50, true); // zip64 locator | |
| v.setUint32(60, 0, true); | |
| v.setBigUint64(64, BigInt(z64Start), true); | |
| v.setUint32(72, 1, true); | |
| await this._write(z); | |
| } | |
| const eocd = new Uint8Array(22); | |
| const v = new DataView(eocd.buffer); | |
| v.setUint32(0, 0x06054B50, true); | |
| v.setUint16(4, 0, true); | |
| v.setUint16(6, 0, true); | |
| v.setUint16(8, n > 0xFFFE ? 0xFFFF : n, true); | |
| v.setUint16(10, n > 0xFFFE ? 0xFFFF : n, true); | |
| v.setUint32(12, cdSize > MAX32 ? 0xFFFFFFFF : cdSize, true); | |
| v.setUint32(16, cdStart > MAX32 ? 0xFFFFFFFF : cdStart, true); | |
| v.setUint16(20, 0, true); | |
| await this._write(eocd); | |
| await this.sink.close(); | |
| } | |
| } | |
| /* ------------------------------------------------------------------ sinks */ | |
| function streamSink(writable) { | |
| return { | |
| kind: 'disk', | |
| write: (u8) => writable.write(u8), | |
| close: () => writable.close(), | |
| abort: () => writable.abort().catch(() => {}), | |
| }; | |
| } | |
| function blobSink(filename) { | |
| const parts = []; | |
| return { | |
| kind: 'memory', | |
| write(u8) { parts.push(u8); }, | |
| async close() { | |
| const blob = new Blob(parts, { type: 'application/epub+zip' }); | |
| parts.length = 0; | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = filename; | |
| a.rel = 'noopener'; | |
| document.body.appendChild(a); | |
| a.click(); | |
| a.remove(); | |
| // The original revoked immediately, which could cancel the save. | |
| setTimeout(() => URL.revokeObjectURL(url), 120000); | |
| }, | |
| abort() { parts.length = 0; }, | |
| }; | |
| } | |
| /* --------------------------------------------------------------------- UI */ | |
| const ui = (() => { | |
| const root = document.createElement('div'); | |
| root.style.cssText = [ | |
| 'position:fixed', 'inset:0', 'z-index:2147483647', | |
| 'background:#14161a', 'color:#e6e8eb', | |
| 'font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace', | |
| 'padding:24px', 'overflow:auto', 'box-sizing:border-box', | |
| ].join(';'); | |
| const h = document.createElement('div'); | |
| h.style.cssText = 'font-size:15px;font-weight:600;margin-bottom:4px;color:#fff'; | |
| h.textContent = 'McGraw-Hill eTextbook Downloader'; | |
| const sub = document.createElement('div'); | |
| sub.style.cssText = 'color:#8b949e;margin-bottom:16px'; | |
| sub.textContent = 'Refresh the page at any time to get the reader back.'; | |
| const action = document.createElement('div'); | |
| action.style.cssText = 'margin-bottom:16px'; | |
| const status = document.createElement('div'); | |
| status.style.cssText = 'margin-bottom:6px;color:#fff;min-height:1.55em'; | |
| const bar = document.createElement('div'); | |
| bar.style.cssText = 'height:6px;background:#2d333b;border-radius:3px;overflow:hidden;margin-bottom:14px'; | |
| const fill = document.createElement('div'); | |
| fill.style.cssText = 'height:100%;width:0;background:#2f81f7;transition:width .15s'; | |
| bar.appendChild(fill); | |
| const log = document.createElement('div'); | |
| log.style.cssText = 'white-space:pre-wrap;color:#8b949e'; | |
| root.append(h, sub, action, status, bar, log); | |
| (document.body || document.documentElement).appendChild(root); | |
| const lines = []; | |
| return { | |
| action, | |
| button(label, onClick) { | |
| const b = document.createElement('button'); | |
| b.textContent = label; | |
| b.style.cssText = [ | |
| 'font:inherit', 'font-weight:600', 'padding:9px 18px', | |
| 'background:#2f81f7', 'color:#fff', 'border:0', | |
| 'border-radius:6px', 'cursor:pointer', | |
| ].join(';'); | |
| b.onclick = () => { b.disabled = true; b.style.opacity = '.5'; b.style.cursor = 'default'; onClick(); }; | |
| action.replaceChildren(b); | |
| return b; | |
| }, | |
| clearAction() { action.replaceChildren(); }, | |
| status(text) { status.textContent = text; }, | |
| progress(done, total) { fill.style.width = total ? (done / total * 100).toFixed(2) + '%' : '0'; }, | |
| log(text, color, href) { | |
| console.log('[mhe]', text); | |
| lines.push({ text, color, href }); | |
| if (lines.length > 400) lines.splice(0, lines.length - 400); | |
| log.replaceChildren(...lines.map(l => { | |
| const d = document.createElement(l.href ? 'a' : 'div'); | |
| if (l.href) { | |
| d.href = l.href; | |
| d.target = '_blank'; | |
| d.rel = 'noopener noreferrer'; | |
| d.style.cssText = 'display:block;word-break:break-all'; | |
| } | |
| if (l.color) d.style.color = l.color; | |
| d.textContent = l.text; | |
| return d; | |
| })); | |
| log.lastElementChild?.scrollIntoView({ block: 'nearest' }); | |
| }, | |
| links(urls) { for (const u of urls) this.log(u, '#58a6ff', u); }, | |
| error(text) { this.log(text, '#f85149'); }, | |
| warn(text) { this.log(text, '#d29922'); }, | |
| ok(text) { this.log(text, '#3fb950'); }, | |
| }; | |
| })(); | |
| /* ------------------------------------------------------------- helpers */ | |
| const sleep = (ms) => new Promise(r => setTimeout(r, ms)); | |
| function humanBytes(n) { | |
| const u = ['B', 'KB', 'MB', 'GB']; | |
| let i = 0; | |
| while (n >= 1024 && i < u.length - 1) { n /= 1024; i++; } | |
| return n.toFixed(i ? 1 : 0) + ' ' + u[i]; | |
| } | |
| async function fetchBytes(url) { | |
| let lastErr; | |
| for (let attempt = 1; attempt <= RETRIES; attempt++) { | |
| try { | |
| const res = await fetch(url, { credentials: 'include' }); | |
| if (!res.ok) throw new Error('HTTP ' + res.status + ' ' + res.statusText); | |
| return new Uint8Array(await res.arrayBuffer()); | |
| } catch (e) { | |
| lastErr = e; | |
| if (attempt < RETRIES) await sleep(250 * attempt * attempt); | |
| } | |
| } | |
| throw lastErr; | |
| } | |
| function firstTag(doc, local) { | |
| return doc.getElementsByTagNameNS('*', local)[0] || doc.getElementsByTagName(local)[0] || null; | |
| } | |
| function normalize(path) { | |
| const out = []; | |
| for (const seg of path.split('/')) { | |
| if (!seg || seg === '.') continue; | |
| if (seg === '..') out.pop(); | |
| else out.push(seg); | |
| } | |
| return out.join('/'); | |
| } | |
| function classify(name) { | |
| const ext = (name.split('.').pop() || '').toLowerCase(); | |
| if (['xhtml', 'html', 'htm', 'opf', 'ncx', 'xml', 'smil'].includes(ext)) return 'content'; | |
| if (ext === 'css') return 'styling'; | |
| return 'cosmetic'; | |
| } | |
| function safeFilename(s) { | |
| return (s || '') | |
| .replace(/\s+/g, ' ') | |
| .replace(/[\\/:*?"<>|\u0000-\u001F]/g, '-') | |
| .replace(/^[.\s-]+|[.\s-]+$/g, '') | |
| .slice(0, 120) || 'textbook'; | |
| } | |
| /* ------------------------------------------------- locate the EPUB base */ | |
| async function resolveBase() { | |
| for (const ep of LTI_ENDPOINTS) { | |
| try { | |
| const res = await fetch(ep, { credentials: 'include' }); | |
| if (!res.ok) { ui.log(' ' + ep + ' -> HTTP ' + res.status); continue; } | |
| const json = await res.json(); | |
| if (json && json.custom_epub_url) { | |
| ui.ok(' ' + ep + ' -> ok'); | |
| return json.custom_epub_url; | |
| } | |
| ui.warn(' ' + ep + ' -> 200, but no custom_epub_url (stale session?)'); | |
| } catch (e) { | |
| ui.log(' ' + ep + ' -> ' + e.message); | |
| } | |
| } | |
| // Some reader builds put the book URL straight in the page URL. | |
| const m = /[?&#]bookUrl=([^&#]+)/i.exec(location.href); | |
| if (m) { | |
| const guess = decodeURIComponent(m[1]); | |
| ui.warn(' falling back to bookUrl from the page URL'); | |
| return guess; | |
| } | |
| return null; | |
| } | |
| /* -------------------------------------------------------------- the work */ | |
| async function discover() { | |
| ui.status('Locating your textbook...'); | |
| ui.log('Resolving the EPUB location:'); | |
| let base = await resolveBase(); | |
| if (!base) { | |
| ui.error('Could not find the textbook automatically.'); | |
| ui.log(''); | |
| ui.log('Open the Network tab, reload the reader, and look for a request to'); | |
| ui.log('a .opf or container.xml file. Paste the part of that URL up to and'); | |
| ui.log('including the folder that contains META-INF/ into the prompt.'); | |
| base = prompt('EPUB base URL (ends with a slash):'); | |
| if (!base) throw new Error('No EPUB base URL supplied.'); | |
| } | |
| if (!base.endsWith('/')) base += '/'; | |
| ui.log('Base: ' + base); | |
| // Find the real package document instead of assuming OPS/content.opf. | |
| let opfPath = null; | |
| let containerBytes = null; | |
| try { | |
| containerBytes = await fetchBytes(base + 'META-INF/container.xml'); | |
| const doc = new DOMParser().parseFromString(DEC.decode(containerBytes), 'application/xml'); | |
| const rootfile = firstTag(doc, 'rootfile'); | |
| const fullPath = rootfile && rootfile.getAttribute('full-path'); | |
| if (fullPath) { | |
| opfPath = normalize(fullPath); | |
| ui.ok('container.xml -> ' + opfPath); | |
| } else { | |
| ui.warn('container.xml has no rootfile; falling back to guesses.'); | |
| } | |
| } catch (e) { | |
| ui.warn('Could not read META-INF/container.xml (' + e.message + '); guessing.'); | |
| } | |
| let opfBytes = null; | |
| if (opfPath) { | |
| try { | |
| opfBytes = await fetchBytes(base + opfPath); | |
| } catch (e) { | |
| ui.warn(opfPath + ' failed (' + e.message + '); trying the usual locations.'); | |
| opfPath = null; | |
| } | |
| } | |
| if (!opfBytes) { | |
| for (const guess of OPF_GUESSES) { | |
| try { | |
| opfBytes = await fetchBytes(base + guess); | |
| opfPath = guess; | |
| ui.ok('Found the package document at ' + guess); | |
| break; | |
| } catch { /* keep trying */ } | |
| } | |
| } | |
| if (!opfBytes) throw new Error('Could not find the EPUB package (.opf) document.'); | |
| if (!containerBytes) { | |
| containerBytes = ENC.encode( | |
| '<?xml version="1.0" encoding="UTF-8"?>\n' + | |
| '\n' + | |
| ' \n' + | |
| ' \n' + | |
| ' </rootfiles>\n' + | |
| '</container>\n' | |
| ); | |
| ui.log('Generated a replacement META-INF/container.xml.'); | |
| } | |
| const opf = new DOMParser().parseFromString(DEC.decode(opfBytes), 'application/xml'); | |
| if (firstTag(opf, 'parsererror')) throw new Error('The package document is not valid XML.'); | |
| const manifest = firstTag(opf, 'manifest'); | |
| if (!manifest) throw new Error('The package document has no .'); | |
| const opfDir = opfPath.includes('/') ? opfPath.slice(0, opfPath.lastIndexOf('/') + 1) : ''; | |
| const titleEl = (() => { | |
| const meta = firstTag(opf, 'metadata'); | |
| if (!meta) return null; | |
| for (const t of meta.getElementsByTagNameNS('*', 'title')) return t; | |
| for (const t of meta.getElementsByTagName('dc:title')) return t; | |
| return null; | |
| })(); | |
| const title = safeFilename(titleEl && titleEl.textContent); | |
| // Build the download list. | |
| const seen = new Set(['mimetype', 'META-INF/container.xml', opfPath]); | |
| const items = []; | |
| for (const item of manifest.children) { | |
| const href = item.getAttribute && item.getAttribute('href'); | |
| if (!href) continue; | |
| const clean = href.split('#')[0].split('?')[0]; | |
| if (!clean) continue; | |
| let url, name; | |
| if (/^[a-z][a-z0-9+.-]*:\/\//i.test(clean)) { | |
| // Absolute href (occasionally a second CDN host). Keep the path shape. | |
| try { | |
| const u = new URL(clean); | |
| url = u.href; | |
| name = normalize(opfDir + u.pathname.split('/').pop()); | |
| } catch { continue; } | |
| } else { | |
| try { | |
| url = new URL(clean, base + opfPath).href; | |
| } catch { continue; } | |
| name = normalize(opfDir + clean); | |
| } | |
| name = decodeURIComponent(name); | |
| if (seen.has(name)) continue; | |
| seen.add(name); | |
| items.push({ name, url }); | |
| } | |
| // Font obfuscation lives here. Absent on most McGraw books, so probe once | |
| // and quietly -- going through fetchBytes retried a guaranteed 403 four times. | |
| let encryptionBytes = null; | |
| try { | |
| const r = await fetch(base + 'META-INF/encryption.xml', { credentials: 'include' }); | |
| if (r.ok) { | |
| encryptionBytes = new Uint8Array(await r.arrayBuffer()); | |
| ui.log('Included META-INF/encryption.xml.'); | |
| } | |
| } catch { /* normally absent */ } | |
| return { base, opfPath, opfBytes, containerBytes, encryptionBytes, items, title }; | |
| } | |
| async function download(book, sink) { | |
| const { opfPath, opfBytes, containerBytes, encryptionBytes, items } = book; | |
| const zip = new ZipWriter(sink); | |
| const failures = []; | |
| let done = 0; | |
| let bytes = 0; | |
| // mimetype MUST come first and be stored uncompressed. This is the bit | |
| // that was missing, and the reason the file wouldn't open. | |
| await zip.add('mimetype', ENC.encode('application/epub+zip')); | |
| await zip.add('META-INF/container.xml', containerBytes); | |
| if (encryptionBytes) await zip.add('META-INF/encryption.xml', encryptionBytes); | |
| await zip.add(opfPath, opfBytes); | |
| bytes = zip.offset; | |
| const total = items.length; | |
| const inflight = new Map(); | |
| const launch = (i) => { | |
| if (i < total) inflight.set(i, fetchBytes(items[i].url).then( | |
| data => ({ data }), | |
| err => ({ err }), | |
| )); | |
| }; | |
| for (let i = 0; i < Math.min(CONCURRENCY, total); i++) launch(i); | |
| for (let i = 0; i < total; i++) { | |
| const { name } = items[i]; | |
| const res = await inflight.get(i); | |
| inflight.delete(i); | |
| launch(i + CONCURRENCY); | |
| if (res.err) { | |
| const kind = classify(name); | |
| failures.push({ name, kind, url: items[i].url, message: res.err.message }); | |
| ui.error('FAILED (' + kind + ') ' + name + ' - ' + res.err.message); | |
| } else { | |
| await zip.add(name, res.data); | |
| bytes += res.data.length; | |
| } | |
| done++; | |
| ui.progress(done, total); | |
| if (done % 25 === 0 || done === total) { | |
| ui.status('Downloading ' + done + ' / ' + total + ' files - ' + humanBytes(bytes) + | |
| (failures.length ? ' - ' + failures.length + ' failed' : '')); | |
| } | |
| } | |
| ui.status('Finalising the EPUB...'); | |
| const entries = zip.entries.length; | |
| await zip.close(); | |
| return { failures, bytes: zip.offset, total, entries }; | |
| } | |
| /* ------------------------------------------------------------------ main */ | |
| async function main() { | |
| ui.clearAction(); | |
| let book; | |
| try { | |
| book = await discover(); | |
| } catch (e) { | |
| ui.status('Failed.'); | |
| ui.error(String(e && e.message || e)); | |
| return; | |
| } | |
| ui.ok('Found "' + book.title + '" - ' + book.items.length + ' files.'); | |
| ui.status('Ready to download ' + book.items.length + ' files.'); | |
| const filename = book.title + '.epub'; | |
| const useDisk = typeof window.showSaveFilePicker === 'function'; | |
| ui.button(useDisk ? 'Save as ' + filename : 'Download ' + filename, async () => { | |
| let sink; | |
| if (useDisk) { | |
| try { | |
| const handle = await window.showSaveFilePicker({ | |
| suggestedName: filename, | |
| types: [{ description: 'EPUB ebook', accept: { 'application/epub+zip': ['.epub'] } }], | |
| }); | |
| sink = streamSink(await handle.createWritable()); | |
| ui.log('Streaming straight to disk (no memory limit).'); | |
| } catch (e) { | |
| if (e && e.name === 'AbortError') { ui.status('Cancelled.'); ui.button('Try again', main); return; } | |
| ui.warn('Could not open a file for writing (' + e.message + '); buffering in memory instead.'); | |
| } | |
| } | |
| if (!sink) { | |
| sink = blobSink(filename); | |
| ui.warn('Buffering in memory. Very large textbooks may run out of RAM.'); | |
| } | |
| try { | |
| const { failures, bytes, entries } = await download(book, sink); | |
| const counts = { content: 0, styling: 0, cosmetic: 0 }; | |
| for (const f of failures) counts[f.kind]++; | |
| ui.progress(1, 1); | |
| ui.status('Done - ' + humanBytes(bytes) + ' written.'); | |
| ui.ok('Wrote ' + entries + ' entries (' + humanBytes(bytes) + ').'); | |
| if (!failures.length) { | |
| ui.ok('Every file downloaded. The EPUB is complete and valid.'); | |
| } else { | |
| ui.warn(failures.length + ' file(s) failed: ' + | |
| counts.content + ' content, ' + counts.styling + ' stylesheet, ' + counts.cosmetic + ' cosmetic.'); | |
| if (counts.content) { | |
| ui.error('Missing CONTENT files - some pages will be absent.'); | |
| } else if (counts.styling) { | |
| ui.warn('Missing stylesheets - all the text is there, but pages may render unstyled.'); | |
| } else { | |
| ui.ok('Only cosmetic assets are missing. The book is fine to read.'); | |
| } | |
| ui.log(''); | |
| ui.log('Re-run once: a file that fails only sometimes is just a hiccup.'); | |
| ui.log('A file that fails EVERY time is not in the published package at all -'); | |
| ui.log('the web reader supplies it, and no downloader can recover it.'); | |
| ui.log('Open these directly to check. A plain tab is not subject to the CORS'); | |
| ui.log('rule that blocks the script, so if one downloads you can add it to the'); | |
| ui.log('.epub by hand at the path shown above.'); | |
| ui.links(failures.map(f => f.url)); | |
| } | |
| ui.button('Run again', main); | |
| } catch (e) { | |
| if (sink.abort) sink.abort(); | |
| ui.status('Failed.'); | |
| ui.error(String(e && e.message || e)); | |
| ui.button('Try again', main); | |
| } | |
| }); | |
| } | |
| ui.log('Ready. This reads the EPUB your reader is already streaming and repackages it.'); | |
| ui.button('Start', main); | |
| })(); |
| /* | |
| * This file used to hold the readable source that was transpiled into script.js | |
| * (hence the regenerator-runtime bundle that script.js used to carry). | |
| * | |
| * That split no longer serves a purpose: every browser this script can run in has | |
| * supported async/await for years, so there is nothing to transpile. script.js is | |
| * now both the readable source and the file you run. | |
| * | |
| * ---> Use script.js. | |
| * | |
| * The original pre-2026 version of this file is preserved upstream at | |
| * https://gist.github.com/101arrowz/88156556326106a6ccd58ecb4526498c | |
| */ |