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