|
/** |
|
* 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}`); |
|
} |
|
})(); |
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.