|
/** |
|
* 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}`); |
|
} |
|
})(); |
I did the download of the audios manually, since the source code only provided a .json file which then provided a code that had to be inserted into a link that a .css file had, and then requested for an api key. I then had to modify the source code of each file so that it redirected to the downloaded .mp3 (In my case I manually downloaded around 400+ files and manually wired them). I then had to download some other .css files provided by the platform to make the widgets work like normal.
In addition to that, the all widgets required an internet connection regardless of whether the file was already downloaded or not, so I had to modify those files as well.
The only thing I still struggle with, are the videos. They have the same problems from above except that they provide the link directly to the file on their website, and doesn't require an apikey. yt-dlp doesn't seem to detect the files, nor the captions/descriptive transcripts. So a manual download of the descriptive transcripts file + writing down the captions was needed. After that, I had to wire manually the .mp4 files onto the links inside, and cut the internet connection (100+ files). A tedious task, and I'm not experienced enough to deal with automation, but I think it can be done.
I'll provide a video as an example. Link