Skip to content

Instantly share code, notes, and snippets.

@jimmckeeth
Forked from 101arrowz/README.md
Last active October 24, 2025 03:45
Show Gist options
  • Save jimmckeeth/47bc555346f1e3ddf1815acf205c16c1 to your computer and use it in GitHub Desktop.
Save jimmckeeth/47bc555346f1e3ddf1815acf205c16c1 to your computer and use it in GitHub Desktop.
Download a McGraw Hill Education eTextbook as ePub or Zip

Download a McGraw Hill Education ePub

UPDATE: Forked from the Gist by 101arrowz with a number of updates. It better handles files on multiple CDN servers, and the UI is updated. It allows you to choose which files to download (defaults to all, but I wanted to filter MP3 files), and gives you a choice to download as epub or zip. It also has detailed logging just in case some files fail. The script will attempt to repeat failed downloads. I've found it takes a few tries but usually works.

For educational and prersonal use only.

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, etc.

I believe this script is 100% legal. It doesn't bypass any copy protection. McGraw Hill publicly hosts their ebooks online in order for their web client to download it. This script time shifts with minor format shifting only books you've purchased for offline use. DO NOT DISTRIBUTE ANY TEXTBOOKS YOU DOWNLOAD USING THIS SCRIPT. Redistribution may be illegal depending on your jurisdiction. If you aren't sure then check with a local lawyer.

Instructions

  1. Open your textbook in the McGraw-Hill Connect website (how you normally open it) in a private/incognito window.
    • Use Chrome or Brave; this won't work at all in Firefox.
  2. Type javascript: into the address bar (note that you CANNOT copy-paste it in).
  3. Copy-paste the following into the address bar AFTER the javascript: part:
var x=new XMLHttpRequest();x.onload=function(){eval(x.responseText)};x.open('GET','https://gist.githubusercontent.com/jimmckeeth/47bc555346f1e3ddf1815acf205c16c1/raw/script.js');x.send();
  1. Press ENTER.
  2. Follow the instructions that appear on screen. Be patient! The download takes between 10 and 40 minutes depending on internet speed.
  3. Your textbook will download on its own.

If you found this useful, give it a star.

Thanks!

~Jim & 101arrowz

/**
* script.js
*
* Interactive downloader for McGraw-Hill textbooks.
* Features:
* - Intelligent absolute URL resolution.
* - Multi-panel UI for categorized logging.
* - Automatic retry mechanism + a final retry pass.
* - User selection for download format (EPUB or ZIP).
* - Enhanced error logging for troubleshooting trasnfer issues.
*/
(async function() {
'use strict';
// --- 1. UI Setup and Helper Functions ---
const overlay = createUiOverlay();
function createUiOverlay() {
const overlayElement = document.createElement("div");
Object.assign(overlayElement.style, {
background: "white",
color: "black",
position: "fixed",
width: "100vw",
height: "100vh",
top: "0",
left: "0",
zIndex: "1000000",
overflow: "hidden",
padding: "20px",
fontFamily: "sans-serif",
fontSize: "16px",
boxSizing: "border-box",
display: "flex",
flexDirection: "column"
});
document.body.appendChild(overlayElement);
return overlayElement;
}
function createLogPanel(title, borderColor) {
const panel = document.createElement('div');
panel.style.flex = '1';
panel.style.display = 'flex';
panel.style.flexDirection = 'column';
panel.style.minHeight = '0';
const header = document.createElement('h4');
header.textContent = title;
header.style.margin = '5px 0';
const logArea = document.createElement('div');
Object.assign(logArea.style, {
border: `1px solid ${borderColor}`, borderRadius: '5px', padding: '10px',
overflowY: 'auto', fontFamily: 'monospace', fontSize: '12px',
flex: '1', lineHeight: '1.4'
});
panel.appendChild(header);
panel.appendChild(logArea);
return { panel, logArea };
}
function buildLoggingUI() {
overlay.innerHTML = '';
overlay.style.padding = "10px";
const title = document.createElement('h2');
title.textContent = 'Download Progress';
title.style.margin = '0 0 10px 0';
overlay.appendChild(title);
const panelsContainer = document.createElement('div');
panelsContainer.style.display = 'flex';
panelsContainer.style.gap = '10px';
panelsContainer.style.flex = '1';
panelsContainer.style.minHeight = '0';
overlay.appendChild(panelsContainer);
const successPanel = createLogPanel('Success', 'green');
const skippedPanel = createLogPanel('Skipped', 'orange');
const failuresPanel = createLogPanel('Failures', 'red');
panelsContainer.appendChild(successPanel.panel);
panelsContainer.appendChild(skippedPanel.panel);
panelsContainer.appendChild(failuresPanel.panel);
const overallStatus = document.createElement('div');
overallStatus.style.fontFamily = 'monospace';
overallStatus.style.marginTop = '10px';
overlay.appendChild(overallStatus);
return {
success: successPanel.logArea,
skipped: skippedPanel.logArea,
failures: failuresPanel.logArea,
status: overallStatus
};
}
const logToPanel = (logArea, message, isHtml = false) => {
const entry = document.createElement('div');
if (isHtml) {
entry.innerHTML = message;
} else {
entry.textContent = message;
}
logArea.appendChild(entry);
logArea.scrollTop = logArea.scrollHeight;
};
function loadScript(url) {
return new Promise((resolve, reject) => {
const script = document.createElement("script");
script.src = url;
script.onload = () => resolve();
script.onerror = () => reject(new Error(`Failed to load script: ${url}`));
document.head.appendChild(script);
});
}
// --- 2. Robust Fetching & Interactive UI ---
async function fetchWithRetries(url, options, retries = 3, delay = 1000) {
for (let i = 0; i < retries; i++) {
try {
const response = await fetch(url, options);
// If it's a client error (4xx), don't retry, just return the failure.
if (!response.ok && response.status < 500) {
return response;
}
// If it's a server error (5xx), throw to trigger a retry.
if (!response.ok) {
throw new Error(`Server error: HTTP status ${response.status}`);
}
return response;
} catch (error) {
if (i === retries - 1) throw error;
await new Promise(res => setTimeout(res, delay * (i + 1)));
}
}
}
function buildSelectionUI(manifestItems, onStart) {
overlay.innerHTML = '';
const fileExtensions = [...new Set(manifestItems.map(item =>
(item.getAttribute("href") || '').split('.').pop().toLowerCase()
))].filter(Boolean).sort();
const uiContainer = document.createElement("div");
uiContainer.innerHTML = `<h2 style="margin-top:0;">Textbook Downloader</h2><p>Select the file types and final format for your download.</p>`;
const checkboxContainer = document.createElement("div");
Object.assign(checkboxContainer.style, {
display: "grid",
gridTemplateColumns: "repeat(auto-fill, minmax(120px, 1fr))",
gap: "10px",
marginBottom: "20px",
padding: "10px",
border: "1px solid #ccc",
borderRadius: "5px"
});
checkboxContainer.innerHTML = `<h3 style="margin: 0 0 10px 0; grid-column: 1 / -1;">1. Select File Types to Include</h3>`;
fileExtensions.forEach(ext => {
checkboxContainer.innerHTML += `<div><input type="checkbox" id="ext-${ext}" value="${ext}" checked><label for="ext-${ext}" style="margin-left:5px">.${ext}</label></div>`;
});
const formatContainer = document.createElement('div');
formatContainer.style.marginTop = '20px';
formatContainer.innerHTML = `
<h3 style="margin: 0 0 10px 0;">2. Select Download Format</h3>
<div>
<input type="radio" id="format-epub" name="download-format" value="epub" checked>
<label for="format-epub"><b>EPUB</b> (.epub) - Best for e-readers.</label>
</div>
<div style="margin-top: 5px;">
<input type="radio" id="format-zip" name="download-format" value="zip">
<label for="format-zip"><b>ZIP</b> (.zip) - A standard archive of all raw files.</label>
</div>
`;
const startButton = document.createElement("button");
Object.assign(startButton,{textContent: "Start Download", style: "padding:10px 20px; font-size:16px; cursor:pointer; border:none; border-radius:5px; background:#007bff; color:white; margin-top: 20px;"});
uiContainer.appendChild(checkboxContainer);
uiContainer.appendChild(formatContainer);
uiContainer.appendChild(startButton);
overlay.appendChild(uiContainer);
startButton.addEventListener("click", () => {
const selectedExtensions = new Set(
Array.from(checkboxContainer.querySelectorAll("input:checked")).map(cb => cb.value)
);
// Get the selected download format
const selectedFormat = uiContainer.querySelector('input[name="download-format"]:checked').value;
onStart(selectedExtensions, selectedFormat);
});
}
// --- 3. Main Download Logic ---
async function executeDownload(manifestItems, selectedExtensions, epubBaseUrl, contentDoc, downloadFormat) {
const logAreas = buildLoggingUI();
const zip = new JSZip();
const failedDownloads = []; // To store files for the retry pass
const contentOpfUrl = `${epubBaseUrl}OPS/content.opf`;
// Add core structure files
const containerXmlText = await (await fetch(`${epubBaseUrl}META-INF/container.xml`, { credentials: "include" })).text();
zip.folder("META-INF").file("container.xml", containerXmlText);
const contentOpfText = new XMLSerializer().serializeToString(contentDoc);
zip.folder("OPS").file("content.opf", contentOpfText);
const totalFiles = manifestItems.length;
// --- Initial Download Pass ---
logAreas.status.textContent = "Starting initial download pass...";
for (let i = 0; i < totalFiles; i++) {
const item = manifestItems[i];
const href = item.getAttribute("href");
if (!href) continue; // Skip items without an href
const extension = (href.split('.').pop() || '').toLowerCase();
const progress = `(${(i + 1)} of ${totalFiles})`;
if (!selectedExtensions.has(extension)) {
logToPanel(logAreas.skipped, `${href} ${progress}`);
continue;
}
const fileUrl = new URL(href, contentOpfUrl);
try {
const fileResponse = await fetchWithRetries(fileUrl.href, { credentials: "include" });
if (!fileResponse.ok) throw new Error(`Download failed with HTTP status: ${fileResponse.status}`);
const fileData = await fileResponse.arrayBuffer();
zip.file(`OPS/${href}`, fileData);
logToPanel(logAreas.success, `Downloaded: ${href} ${progress}`);
} catch (error) {
// Log the initial failure and add it to the retry list
failedDownloads.push({ href, fileUrl, progress, error });
let errorMessage = error.message;
if (error.message.includes('Failed to fetch')) {
errorMessage += ` <br><i style="padding-left:10px; color:#555;">(Hint: This is a CORS error. Check the Developer Console (F12) Network tab for details.)</i>`;
}
const failureMessage = `
<div><strong>FAIL:</strong> ${href} ${progress}</div>
<div style="padding-left: 10px;"><strong>URL:</strong> <a href="${fileUrl.href}" target="_blank">${fileUrl.href}</a></div>
<div style="padding-left: 10px;"><strong>Error:</strong> ${errorMessage}</div>
`;
logToPanel(logAreas.failures, failureMessage, true);
}
}
if (failedDownloads.length > 0) {
logAreas.status.textContent = `Initial pass complete. Retrying ${failedDownloads.length} failed downloads...`;
logToPanel(logAreas.failures, `--- STARTING FINAL RETRY PASS ---`);
for (const failedItem of failedDownloads) {
const { href, fileUrl, progress } = failedItem;
try {
// Use a single fetch attempt for the final retry
const fileResponse = await fetch(fileUrl.href, { credentials: "include" });
if (!fileResponse.ok) throw new Error(`Retry failed with HTTP status: ${fileResponse.status}`);
const fileData = await fileResponse.arrayBuffer();
zip.file(`OPS/${href}`, fileData);
logToPanel(logAreas.success, `✅ RETRY SUCCESS: ${href} ${progress}`);
logToPanel(logAreas.failures, `✅ Resolved: ${href}`);
} catch (error) {
const finalFailureMessage = `<div><strong>❌ FINAL FAIL:</strong> ${href} ${progress}</div><div style="padding-left: 10px;"><strong>Final Error:</strong> ${error.message}</div>`;
logToPanel(logAreas.failures, finalFailureMessage, true);
}
}
}
// --- Compression and Download ---
logAreas.status.textContent = "All files processed. Compressing into archive...";
const zipBlob = await zip.generateAsync({ type: "blob", compression: "STORE" }, (metadata) => {
logAreas.status.textContent = `Compressing... ${Math.floor(metadata.percent)}%`;
});
logAreas.status.textContent = "Compression complete! Triggering download...";
const blobUrl = URL.createObjectURL(zipBlob);
const downloadLink = document.createElement("a");
const titleElement = contentDoc.querySelector("metadata title");
const fileName = (titleElement ? titleElement.textContent : "textbook") + `.${downloadFormat}`;
downloadLink.href = blobUrl;
downloadLink.download = fileName;
downloadLink.click();
URL.revokeObjectURL(blobUrl);
logAreas.status.textContent = `Download of "${fileName}" has started! You can now refresh the page.`;
}
// --- 4. Initialization and Execution ---
async function initialize() {
try {
overlay.innerHTML = "Initializing downloader...";
const ltiResponse = await fetch("https://player-api.mheducation.com/lti", { credentials: "include" });
const ltiData = await ltiResponse.json();
const epubBaseUrl = ltiData.custom_epub_url;
if (!epubBaseUrl) throw new Error("Could not retrieve EPUB base URL.");
const contentOpfText = await (await fetch(`${epubBaseUrl}OPS/content.opf`, { credentials: "include" })).text();
const contentDoc = new DOMParser().parseFromString(contentOpfText, "application/xml");
const manifestItems = Array.from(contentDoc.querySelectorAll("manifest item"));
buildSelectionUI(manifestItems, (selectedExtensions, selectedFormat) => {
executeDownload(manifestItems, selectedExtensions, epubBaseUrl, contentDoc, selectedFormat);
});
} catch (error) {
overlay.innerHTML = '';
logToPanel(overlay, `A critical initialization error occurred: ${error.message}`);
logToPanel(overlay, "Please try refreshing the page and running the script again.");
}
}
try {
await loadScript("https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.5/jszip.min.js");
initialize();
} catch (error) {
alert(`Fatal Error: Could not load the JSZip library. Error: ${error.message}`);
}
})();
@manchesterfan88
Copy link

First of all, thanks for making an updated version of this script.

Second, it seems that it's still online dependent in some instances (namely the audio and video widgets). The audios are already downloaded but it seems they get re-downloaded every time the eBook is opened (they don't work anyways) and they're never used. (If you need a deeper explanation with examples, and the "fix" I used, feel free to contact me).

In contrast, the videos are fully online based. And although they can be viewed through an embed inside a browser, they won't work for the most part. The only way I managed to fix this issue was to manually download the video without audio divided in multiple parts, the audio, the translations and the script for these, to then later combine all of this data inside a video editor (remind you, its PER video). Although it was a massively tedious task, I wonder if its possible to automate it in some way.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment