feat: surface detailed download progress

Changelog:
- forward download progress from background/offscreen workers to content frames
- add progress UI and button state management in Safari Books page script
- emit structured progress callbacks and faster image fetching in SafariBooksDownloader
- sync Firefox bundle with progress plumbing and concurrency improvements
- ignore packaged extension archives

Description:
Expose progress events through the downloader so the content script can present real-time status, including chapter and asset fetching milestones. Added concurrency controls, page detection, and packaging telemetry, while keeping Firefox build behavior in sync and ignoring generated .xpi bundles.
This commit is contained in:
Joey Yakimowich-Payne 2025-10-16 10:30:18 -06:00
commit 7a44902433
12 changed files with 895 additions and 106 deletions

1
.gitignore vendored Normal file
View file

@ -0,0 +1 @@
*.xpi

View file

@ -8,6 +8,22 @@ let contextReadyPromise = null;
let contextReadyResolver = null; let contextReadyResolver = null;
let preferWindowContext = true; let preferWindowContext = true;
function forwardProgressToClient(pending, message) {
if (!pending || pending.tabId == null) {
return;
}
const payload = {
type: "download-progress",
requestId: message.requestId,
payload: message.payload
};
if (pending.frameId != null) {
chrome.tabs.sendMessage(pending.tabId, payload, { frameId: pending.frameId }).catch(() => {});
} else {
chrome.tabs.sendMessage(pending.tabId, payload).catch(() => {});
}
}
function createReadyPromise() { function createReadyPromise() {
contextReadyPromise = new Promise((resolve) => { contextReadyPromise = new Promise((resolve) => {
contextReadyResolver = resolve; contextReadyResolver = resolve;
@ -147,7 +163,7 @@ function shouldRetryInWindow(error, pending) {
return message.toLowerCase().includes("domparser"); return message.toLowerCase().includes("domparser");
} }
async function startDownloadTask(bookId, options, sendResponse, attempt = 0) { async function startDownloadTask(bookId, options, sendResponse, attempt = 0, clientInfo = {}) {
try { try {
await ensureDocumentContext(); await ensureDocumentContext();
} catch (error) { } catch (error) {
@ -157,7 +173,31 @@ async function startDownloadTask(bookId, options, sendResponse, attempt = 0) {
const requestId = crypto.randomUUID(); const requestId = crypto.randomUUID();
const contextMode = executionContext.mode ?? (preferWindowContext ? "window" : "offscreen"); const contextMode = executionContext.mode ?? (preferWindowContext ? "window" : "offscreen");
pendingResponses.set(requestId, { sendResponse, bookId, options, attempt, contextMode }); const tabId = typeof clientInfo.tabId === "number" ? clientInfo.tabId : null;
const frameId = typeof clientInfo.frameId === "number" ? clientInfo.frameId : null;
const pendingEntry = {
sendResponse,
bookId,
options,
attempt,
contextMode,
tabId,
frameId,
clientInfo
};
pendingResponses.set(requestId, pendingEntry);
if (tabId != null) {
forwardProgressToClient(pendingEntry, {
requestId,
payload: {
stage: "starting",
bookId
}
});
}
try { try {
await chrome.runtime.sendMessage({ await chrome.runtime.sendMessage({
@ -189,7 +229,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
kindle: Boolean(message.kindle) kindle: Boolean(message.kindle)
}; };
startDownloadTask(bookId, options, sendResponse); const clientInfo = {
tabId: sender?.tab?.id ?? null,
frameId: typeof sender?.frameId === "number" ? sender.frameId : null
};
startDownloadTask(bookId, options, sendResponse, 0, clientInfo);
return true; return true;
} }
@ -212,7 +257,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
.catch(() => {}) .catch(() => {})
.finally(() => { .finally(() => {
resetContextState(); resetContextState();
startDownloadTask(pending.bookId, pending.options, pending.sendResponse, pending.attempt + 1); startDownloadTask(
pending.bookId,
pending.options,
pending.sendResponse,
pending.attempt + 1,
pending.clientInfo || {}
);
}); });
return false; return false;
} }
@ -223,5 +274,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
return false; return false;
} }
if (message?.type === "offscreen-download-progress") {
const { requestId } = message;
const pending = requestId ? pendingResponses.get(requestId) : null;
if (pending) {
forwardProgressToClient(pending, message);
}
return false;
}
return undefined; return undefined;
}); });

View file

@ -103,30 +103,68 @@
return button; return button;
} }
function handleDownload(button, bookId) { function setButtonState(button, state, message) {
if (!button) {
return;
}
switch (state) {
case "busy":
button.disabled = true;
button.style.opacity = "0.7";
break;
case "idle":
button.disabled = false;
button.style.opacity = "";
break;
case "error":
button.disabled = false;
button.style.opacity = "";
break;
default:
break;
}
if (message) {
button.textContent = message;
}
}
function setProgressMessage(wrapper, message) {
if (!wrapper) {
return;
}
let status = wrapper.querySelector(".reilly-progress-status");
if (!status) {
return;
}
status.textContent = message;
}
function handleDownload(button, bookId, wrapper, frameId) {
if (!bookId) { if (!bookId) {
return; return;
} }
const originalText = button.textContent; const originalText = button.textContent;
button.disabled = true; setButtonState(button, "busy", "Downloading…");
button.style.opacity = "0.7"; setProgressMessage(wrapper, "Starting download...");
button.textContent = "Downloading…";
chrome.runtime.sendMessage({ type: "downloadBook", bookId }, (response) => { chrome.runtime.sendMessage({ type: "downloadBook", bookId, frameId }, (response) => {
const error = chrome.runtime.lastError; const error = chrome.runtime.lastError;
if (error) { if (error) {
console.error("SafariBooks Downloader:", error); console.error("SafariBooks Downloader:", error);
button.textContent = "Failed (extension error)"; setButtonState(button, "error", "Failed (extension error)");
setProgressMessage(wrapper, "Failed: extension error.");
} else if (response?.ok) { } else if (response?.ok) {
button.textContent = "Download started"; setButtonState(button, "busy", "Download started");
setProgressMessage(wrapper, "Packaging and saving EPUB...");
} else { } else {
button.textContent = response?.error ? `Failed: ${response.error}` : "Download failed"; const errorMessage = response?.error ? `Failed: ${response.error}` : "Download failed";
setButtonState(button, "error", errorMessage);
setProgressMessage(wrapper, errorMessage);
} }
setTimeout(() => { setTimeout(() => {
button.textContent = originalText; button.textContent = originalText;
button.disabled = false; setButtonState(button, "idle");
button.style.opacity = "";
}, response?.ok ? 2000 : 4000); }, response?.ok ? 2000 : 4000);
}); });
} }
@ -154,10 +192,6 @@
return; return;
} }
const button = createButton();
button.dataset.bookId = bookId;
button.addEventListener("click", () => handleDownload(button, bookId));
const wrapperId = `${BUTTON_ID}-wrapper`; const wrapperId = `${BUTTON_ID}-wrapper`;
let wrapper = document.getElementById(wrapperId); let wrapper = document.getElementById(wrapperId);
if (!wrapper) { if (!wrapper) {
@ -167,18 +201,133 @@
wrapper.style.alignItems = "center"; wrapper.style.alignItems = "center";
wrapper.style.gap = "12px"; wrapper.style.gap = "12px";
wrapper.style.marginLeft = "12px"; wrapper.style.marginLeft = "12px";
const button = createButton();
button.dataset.bookId = bookId;
button.addEventListener("click", () => handleDownload(button, bookId, wrapper));
wrapper.appendChild(button); wrapper.appendChild(button);
const progressContainer = document.createElement("span");
progressContainer.className = "reilly-progress";
progressContainer.style.display = "inline-flex";
progressContainer.style.flexDirection = "column";
progressContainer.style.background = "#e2e8f0";
progressContainer.style.borderRadius = "4px";
progressContainer.style.padding = "6px 8px";
progressContainer.style.minWidth = "160px";
progressContainer.style.border = "1px solid #cbd5e1";
progressContainer.style.fontSize = "0.75rem";
progressContainer.style.color = "#1e293b";
progressContainer.style.lineHeight = "1.4";
const progressLabel = document.createElement("span");
progressLabel.textContent = "Progress";
progressLabel.style.fontSize = "0.7rem";
progressLabel.style.textTransform = "uppercase";
progressLabel.style.fontWeight = "700";
progressLabel.style.color = "#475569";
progressLabel.style.letterSpacing = "0.05em";
const progressStatus = document.createElement("span");
progressStatus.className = "reilly-progress-status";
progressStatus.textContent = "Waiting";
progressContainer.appendChild(progressLabel);
progressContainer.appendChild(progressStatus);
wrapper.appendChild(progressContainer);
if (titleElement.parentElement) { if (titleElement.parentElement) {
titleElement.parentElement.insertBefore(wrapper, titleElement.nextSibling); titleElement.parentElement.insertBefore(wrapper, titleElement.nextSibling);
} else { } else {
titleElement.insertAdjacentElement("afterend", wrapper); titleElement.insertAdjacentElement("afterend", wrapper);
} }
} else if (!wrapper.contains(button)) { } else {
wrapper.appendChild(button); const button = wrapper.querySelector(`#${BUTTON_ID}`) || createButton();
button.dataset.bookId = bookId;
if (!wrapper.contains(button)) {
wrapper.appendChild(button);
}
} }
} }
chrome.runtime.onMessage.addListener((message) => {
if (!message || message.type !== "download-progress") {
return;
}
const wrapper = document.getElementById(`${BUTTON_ID}-wrapper`);
if (!wrapper) {
return;
}
const button = wrapper.querySelector(`#${BUTTON_ID}`);
const { payload = {} } = message;
if (!payload.stage) {
return;
}
switch (payload.stage) {
case "starting":
setButtonState(button, "busy", "Preparing...");
setProgressMessage(wrapper, "Preparing download...");
break;
case "session-check":
setProgressMessage(wrapper, "Verifying session...");
break;
case "session-ok":
setProgressMessage(wrapper, "Session verified.");
break;
case "metadata":
setProgressMessage(wrapper, payload.title ? `Metadata: ${payload.title}` : "Metadata loaded.");
break;
case "chapters-discovered":
setProgressMessage(
wrapper,
`Chapters: ${payload.total ?? "?"}${payload.total === 1 ? "" : " chapters"}`
);
break;
case "chapter-start":
setProgressMessage(
wrapper,
`Chapter ${payload.index}/${payload.total}: ${payload.title || ""}`
);
break;
case "images-start":
setProgressMessage(
wrapper,
`Images: 0/${payload.total} (max ${payload.concurrency})`
);
break;
case "images-progress":
setProgressMessage(
wrapper,
`Images: ${payload.completed}/${payload.total} (${payload.elapsedSeconds ?? 0}s)`
);
break;
case "images-complete":
setProgressMessage(wrapper, `Images complete (${payload.completed})`);
break;
case "css-images-start":
setProgressMessage(wrapper, `CSS assets: ${payload.total}`);
break;
case "packaging-complete":
setProgressMessage(wrapper, `Packaging done (${payload.durationMs}ms)`);
break;
case "download-start":
setProgressMessage(wrapper, "Saving EPUB...");
break;
case "complete":
setButtonState(button, "idle", "Download Complete");
setProgressMessage(wrapper, "Download complete." );
setTimeout(() => {
if (button) {
button.textContent = "Download EPUB";
}
setProgressMessage(wrapper, "Waiting");
}, 4000);
break;
default:
break;
}
});
function observeForTitle() { function observeForTitle() {
if (observerInstance) { if (observerInstance) {
return; return;

View file

@ -2,27 +2,94 @@ import { SafariBooksDownloader } from "./safaribooks/downloader.js";
import { collectSessionCookies } from "./safaribooks/cookies.js"; import { collectSessionCookies } from "./safaribooks/cookies.js";
const runtime = typeof browser !== "undefined" ? browser : chrome; const runtime = typeof browser !== "undefined" ? browser : chrome;
const pendingDownloads = new Map();
const downloader = new SafariBooksDownloader((message) => { const downloader = new SafariBooksDownloader((message) => {
console.log(`[BackgroundDownloader] ${message}`); console.log(`[BackgroundDownloader] ${message}`);
}, (payload) => {
const entry = payload?.bookId ? pendingDownloads.get(payload.bookId) : null;
if (!entry) {
return;
}
sendProgress(entry, payload);
}); });
runtime.runtime.onMessage.addListener(async (message) => { function sendProgress(entry, payload) {
if (message?.type !== "downloadBook") { if (!entry?.tabId) {
return;
}
const progressMessage = {
type: "download-progress",
payload
};
try {
if (typeof browser !== "undefined" && browser.tabs?.sendMessage) {
browser.tabs.sendMessage(entry.tabId, progressMessage).catch(() => {});
} else {
chrome.tabs.sendMessage(entry.tabId, progressMessage, () => {
const error = chrome.runtime.lastError;
if (error) {
console.debug("Progress send error", error.message);
}
});
}
} catch (error) {
console.debug("Unable to dispatch progress message", error);
}
}
runtime.runtime.onMessage.addListener((message, sender, sendResponse) => {
if (!message || message.type !== "downloadBook") {
return undefined; return undefined;
} }
const { bookId, theme = "none", kindle = false } = message; const bookId = message.bookId;
if (!bookId) { if (!bookId) {
return { ok: false, error: "Missing book ID." }; sendResponse({ ok: false, error: "Missing book ID." });
return false;
} }
if (pendingDownloads.has(bookId)) {
sendResponse({ ok: false, error: "Download already in progress." });
return false;
}
const entry = {
bookId,
options: {
theme: message.theme ?? "none",
kindle: Boolean(message.kindle)
},
tabId: sender?.tab?.id ?? null,
sendResponse
};
pendingDownloads.set(bookId, entry);
startDownload(entry).catch((error) => {
console.error("[BackgroundDownloader] unexpected failure", error);
finalizeDownload(entry, { ok: false, error: error.message || String(error) });
});
return true;
});
async function startDownload(entry) {
try { try {
await collectSessionCookies(); await collectSessionCookies();
await downloader.download(bookId, { theme, kindle }); await downloader.download(entry.bookId, entry.options);
return { ok: true }; finalizeDownload(entry, { ok: true });
} catch (error) { } catch (error) {
console.error("[BackgroundDownloader] Download failed:", error); console.error("[BackgroundDownloader] Download failed:", error);
return { ok: false, error: error.message }; finalizeDownload(entry, { ok: false, error: error.message || String(error) });
} }
}); }
function finalizeDownload(entry, response) {
pendingDownloads.delete(entry.bookId);
try {
entry.sendResponse?.(response);
} catch (error) {
console.debug("sendResponse failed", error);
}
}

View file

@ -4,8 +4,6 @@
let observerInstance = null; let observerInstance = null;
let ensureIntervalId = null; let ensureIntervalId = null;
let initialized = false; let initialized = false;
const runtime = typeof browser !== "undefined" ? browser : chrome;
const rawChrome = typeof chrome !== "undefined" ? chrome : null;
const TITLE_SELECTORS = [ const TITLE_SELECTORS = [
'[data-test="book-header-title"]', '[data-test="book-header-title"]',
@ -105,56 +103,96 @@
return button; return button;
} }
function sendRuntimeMessage(message) { function setButtonState(button, state, message) {
try { if (!button) {
const result = runtime.runtime.sendMessage(message); return;
if (result && typeof result.then === "function") { }
return result; switch (state) {
} case "busy":
} catch (error) { button.disabled = true;
return Promise.reject(error); button.style.opacity = "0.7";
break;
case "idle":
button.disabled = false;
button.style.opacity = "";
break;
case "error":
button.disabled = false;
button.style.opacity = "";
break;
default:
break;
}
if (message) {
button.textContent = message;
} }
return new Promise((resolve, reject) => {
runtime.runtime.sendMessage(message, (response) => {
const err = rawChrome?.runtime?.lastError;
if (err) {
reject(new Error(err.message));
} else {
resolve(response);
}
});
});
} }
function handleDownload(button, bookId) { function setProgressMessage(wrapper, message) {
if (!wrapper) {
return;
}
let status = wrapper.querySelector(".reilly-progress-status");
if (!status) {
return;
}
status.textContent = message;
}
function handleDownload(button, bookId, wrapper, frameId) {
if (!bookId) { if (!bookId) {
return; return;
} }
const originalText = button.textContent; const originalText = button.textContent;
button.disabled = true; setButtonState(button, "busy", "Downloading…");
button.style.opacity = "0.7"; setProgressMessage(wrapper, "Starting download...");
button.textContent = "Downloading…";
sendRuntimeMessage({ type: "downloadBook", bookId }) const runtime = typeof browser !== "undefined" ? browser : chrome;
.then((response) => { const message = { type: "downloadBook", bookId, frameId };
if (response?.ok) { const sendPromise = runtime.runtime.sendMessage(message);
button.textContent = "Download started";
} else { const handleResponse = (response) => {
button.textContent = response?.error ? `Failed: ${response.error}` : "Download failed"; if (response?.ok) {
} setButtonState(button, "busy", "Download started");
}) setProgressMessage(wrapper, "Packaging and saving EPUB...");
.catch((error) => { } else {
const errorMessage = response?.error ? `Failed: ${response.error}` : "Download failed";
setButtonState(button, "error", errorMessage);
setProgressMessage(wrapper, errorMessage);
}
setTimeout(() => {
button.textContent = originalText;
setButtonState(button, "idle");
}, response?.ok ? 2000 : 4000);
};
if (sendPromise && typeof sendPromise.then === "function") {
sendPromise.then(handleResponse).catch((error) => {
console.error("SafariBooks Downloader:", error); console.error("SafariBooks Downloader:", error);
button.textContent = "Failed (extension error)"; setButtonState(button, "error", "Failed (extension error)");
}) setProgressMessage(wrapper, "Failed: extension error.");
.finally(() => {
setTimeout(() => { setTimeout(() => {
button.textContent = originalText; button.textContent = originalText;
button.disabled = false; setButtonState(button, "idle");
button.style.opacity = ""; }, 4000);
}, 2000);
}); });
} else {
chrome.runtime.sendMessage(message, (response) => {
const error = chrome.runtime.lastError;
if (error) {
console.error("SafariBooks Downloader:", error);
setButtonState(button, "error", "Failed (extension error)");
setProgressMessage(wrapper, "Failed: extension error.");
setTimeout(() => {
button.textContent = originalText;
setButtonState(button, "idle");
}, 4000);
return;
}
handleResponse(response);
});
}
} }
function injectButton() { function injectButton() {
@ -180,10 +218,6 @@
return; return;
} }
const button = createButton();
button.dataset.bookId = bookId;
button.addEventListener("click", () => handleDownload(button, bookId));
const wrapperId = `${BUTTON_ID}-wrapper`; const wrapperId = `${BUTTON_ID}-wrapper`;
let wrapper = document.getElementById(wrapperId); let wrapper = document.getElementById(wrapperId);
if (!wrapper) { if (!wrapper) {
@ -193,18 +227,133 @@
wrapper.style.alignItems = "center"; wrapper.style.alignItems = "center";
wrapper.style.gap = "12px"; wrapper.style.gap = "12px";
wrapper.style.marginLeft = "12px"; wrapper.style.marginLeft = "12px";
const button = createButton();
button.dataset.bookId = bookId;
button.addEventListener("click", () => handleDownload(button, bookId, wrapper));
wrapper.appendChild(button); wrapper.appendChild(button);
const progressContainer = document.createElement("span");
progressContainer.className = "reilly-progress";
progressContainer.style.display = "inline-flex";
progressContainer.style.flexDirection = "column";
progressContainer.style.background = "#e2e8f0";
progressContainer.style.borderRadius = "4px";
progressContainer.style.padding = "6px 8px";
progressContainer.style.minWidth = "160px";
progressContainer.style.border = "1px solid #cbd5e1";
progressContainer.style.fontSize = "0.75rem";
progressContainer.style.color = "#1e293b";
progressContainer.style.lineHeight = "1.4";
const progressLabel = document.createElement("span");
progressLabel.textContent = "Progress";
progressLabel.style.fontSize = "0.7rem";
progressLabel.style.textTransform = "uppercase";
progressLabel.style.fontWeight = "700";
progressLabel.style.color = "#475569";
progressLabel.style.letterSpacing = "0.05em";
const progressStatus = document.createElement("span");
progressStatus.className = "reilly-progress-status";
progressStatus.textContent = "Waiting";
progressContainer.appendChild(progressLabel);
progressContainer.appendChild(progressStatus);
wrapper.appendChild(progressContainer);
if (titleElement.parentElement) { if (titleElement.parentElement) {
titleElement.parentElement.insertBefore(wrapper, titleElement.nextSibling); titleElement.parentElement.insertBefore(wrapper, titleElement.nextSibling);
} else { } else {
titleElement.insertAdjacentElement("afterend", wrapper); titleElement.insertAdjacentElement("afterend", wrapper);
} }
} else if (!wrapper.contains(button)) { } else {
wrapper.appendChild(button); const button = wrapper.querySelector(`#${BUTTON_ID}`) || createButton();
button.dataset.bookId = bookId;
if (!wrapper.contains(button)) {
wrapper.appendChild(button);
}
} }
} }
chrome.runtime.onMessage.addListener((message) => {
if (!message || message.type !== "download-progress") {
return;
}
const wrapper = document.getElementById(`${BUTTON_ID}-wrapper`);
if (!wrapper) {
return;
}
const button = wrapper.querySelector(`#${BUTTON_ID}`);
const { payload = {} } = message;
if (!payload.stage) {
return;
}
switch (payload.stage) {
case "starting":
setButtonState(button, "busy", "Preparing...");
setProgressMessage(wrapper, "Preparing download...");
break;
case "session-check":
setProgressMessage(wrapper, "Verifying session...");
break;
case "session-ok":
setProgressMessage(wrapper, "Session verified.");
break;
case "metadata":
setProgressMessage(wrapper, payload.title ? `Metadata: ${payload.title}` : "Metadata loaded.");
break;
case "chapters-discovered":
setProgressMessage(
wrapper,
`Chapters: ${payload.total ?? "?"}${payload.total === 1 ? "" : " chapters"}`
);
break;
case "chapter-start":
setProgressMessage(
wrapper,
`Chapter ${payload.index}/${payload.total}: ${payload.title || ""}`
);
break;
case "images-start":
setProgressMessage(
wrapper,
`Images: 0/${payload.total} (max ${payload.concurrency})`
);
break;
case "images-progress":
setProgressMessage(
wrapper,
`Images: ${payload.completed}/${payload.total} (${payload.elapsedSeconds ?? 0}s)`
);
break;
case "images-complete":
setProgressMessage(wrapper, `Images complete (${payload.completed})`);
break;
case "css-images-start":
setProgressMessage(wrapper, `CSS assets: ${payload.total}`);
break;
case "packaging-complete":
setProgressMessage(wrapper, `Packaging done (${payload.durationMs}ms)`);
break;
case "download-start":
setProgressMessage(wrapper, "Saving EPUB...");
break;
case "complete":
setButtonState(button, "idle", "Download Complete");
setProgressMessage(wrapper, "Download complete." );
setTimeout(() => {
if (button) {
button.textContent = "Download EPUB";
}
setProgressMessage(wrapper, "Waiting");
}, 4000);
break;
default:
break;
}
});
function observeForTitle() { function observeForTitle() {
if (observerInstance) { if (observerInstance) {
return; return;

View file

@ -75,9 +75,6 @@ button {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
}
button {
background: #3a7bd5; background: #3a7bd5;
color: #fff; color: #fff;
} }

View file

@ -33,6 +33,10 @@ const KINDLE_CSS =
"#sbo-rt-content table,#sbo-rt-content pre{overflow-x:unset!important;overflow:unset!important;" + "#sbo-rt-content table,#sbo-rt-content pre{overflow-x:unset!important;overflow:unset!important;" +
"overflow-y:unset!important;white-space:pre-wrap!important;}"; "overflow-y:unset!important;white-space:pre-wrap!important;}";
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, Math.max(ms, 0)));
}
function formatTwoDigits(value) { function formatTwoDigits(value) {
return value.toString().padStart(2, "0"); return value.toString().padStart(2, "0");
} }
@ -75,11 +79,12 @@ function toAbsoluteUrl(link, base) {
} }
export class SafariBooksDownloader { export class SafariBooksDownloader {
constructor(log = console.log) { constructor(log = console.log, onProgress) {
this.log = (message) => { this.log = (message) => {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
log(`[${timestamp}] ${message}`); log(`[${timestamp}] ${message}`);
}; };
this.onProgress = typeof onProgress === "function" ? onProgress : null;
this.reset(); this.reset();
} }
@ -110,6 +115,19 @@ export class SafariBooksDownloader {
this.imageDownloads = new Map(); // relative path -> absolute url this.imageDownloads = new Map(); // relative path -> absolute url
this.cssImageDownloads = new Map(); // path within Styles -> absolute url this.cssImageDownloads = new Map(); // path within Styles -> absolute url
this.imagesFromLinks = new Set(); this.imagesFromLinks = new Set();
this.reportProgress("reset", { message: "Preparing download..." });
}
reportProgress(stage, data = {}) {
if (typeof this.onProgress !== "function") {
return;
}
try {
this.onProgress({ stage, bookId: this.bookId, ...data });
} catch (error) {
console.warn("SafariBooksDownloader progress callback error", error);
}
} }
getReferrerUrl() { getReferrerUrl() {
@ -141,7 +159,7 @@ export class SafariBooksDownloader {
async applyRateLimit() { async applyRateLimit() {
const waitTime = this.nextRequestTime - Date.now(); const waitTime = this.nextRequestTime - Date.now();
if (waitTime > 0) { if (waitTime > 0) {
await new Promise((resolve) => setTimeout(resolve, waitTime)); await sleep(waitTime);
} }
} }
@ -179,8 +197,8 @@ export class SafariBooksDownloader {
computeRetryDelay(response, attempt, options = {}) { computeRetryDelay(response, attempt, options = {}) {
const status = response?.status ?? 0; const status = response?.status ?? 0;
const base = options.baseRetryDelay ?? 750; const base = options.baseRetryDelay ?? 200;
const cap = options.maxRetryDelay ?? 8000; const cap = options.maxRetryDelay ?? 2000;
const growth = Math.min(cap, base * Math.pow(2, attempt)); const growth = Math.min(cap, base * Math.pow(2, attempt));
const jitter = Math.random() * 250; const jitter = Math.random() * 250;
if (status === 429 || status === 503) { if (status === 429 || status === 503) {
@ -284,11 +302,18 @@ export class SafariBooksDownloader {
this.updateReferrer(`${SAFARI_BASE_URL}/library/view/${this.bookId}/`); this.updateReferrer(`${SAFARI_BASE_URL}/library/view/${this.bookId}/`);
updateRequestContext({ referer: this.getReferrerUrl() }); updateRequestContext({ referer: this.getReferrerUrl() });
this.reportProgress("starting", { bookId: this.bookId });
this.log(`Verifying session by loading profile page...`); this.log(`Verifying session by loading profile page...`);
this.reportProgress("session-check");
await this.checkLogin(); await this.checkLogin();
this.reportProgress("session-ok");
this.log(`Retrieving book metadata for ${bookId}...`); this.log(`Retrieving book metadata for ${bookId}...`);
this.bookInfo = await this.fetchBookInfo(bookId); this.bookInfo = await this.fetchBookInfo(bookId);
this.reportProgress("metadata", {
title: this.bookInfo.title,
authors: (this.bookInfo.authors || []).map((author) => author.name).filter(Boolean)
});
this.baseUrl = this.bookInfo.web_url || this.bookInfo.url; this.baseUrl = this.bookInfo.web_url || this.bookInfo.url;
this.updateReferrer(this.bookInfo.web_url || this.bookInfo.url); this.updateReferrer(this.bookInfo.web_url || this.bookInfo.url);
updateRequestContext({ referer: this.getReferrerUrl() }); updateRequestContext({ referer: this.getReferrerUrl() });
@ -298,24 +323,70 @@ export class SafariBooksDownloader {
this.outputFileName = makeFileFriendlyName(this.bookInfo.title, this.primaryAuthors, this.bookId); this.outputFileName = makeFileFriendlyName(this.bookInfo.title, this.primaryAuthors, this.bookId);
this.log(`Fetching table of chapters...`); this.log(`Fetching table of chapters...`);
this.reportProgress("chapters-fetch");
this.bookChapters = await this.fetchChapters(this.bookInfo.chapters); this.bookChapters = await this.fetchChapters(this.bookInfo.chapters);
this.log(`Chapters discovered: ${this.bookChapters.length}`); this.log(`Chapters discovered: ${this.bookChapters.length}`);
this.reportProgress("chapters-discovered", { total: this.bookChapters.length });
await this.processChapters(); await this.processChapters();
this.reportProgress("chapters-complete", { total: this.chapterDocuments.length });
const cssSourceCount = this.cssSources.length;
this.log(
cssSourceCount
? `Fetching ${cssSourceCount} stylesheet source${cssSourceCount === 1 ? "" : "s"}...`
: "No external stylesheet sources to fetch."
);
this.reportProgress("styles-start", { total: cssSourceCount });
await this.fetchCssSources(); await this.fetchCssSources();
this.reportProgress("styles-complete", { total: this.cssContentMap.size });
const fontCount = this.fontDownloads.size;
this.log(
fontCount
? `Fetching ${fontCount} font asset${fontCount === 1 ? "" : "s"}...`
: "No font assets to fetch."
);
this.reportProgress("fonts-start", { total: fontCount });
await this.fetchFonts(); await this.fetchFonts();
this.reportProgress("fonts-complete", { total: this.fontDownloads.size });
const totalImages = Array.from(this.imageDownloads.values()).filter((value) => value).length;
const pendingImages = Array.from(this.imageDownloads.values()).filter((value) => !value?.data).length;
this.log(
pendingImages
? `Fetching ${pendingImages} image asset${pendingImages === 1 ? "" : "s"} (registered: ${totalImages})...`
: "No image assets to fetch."
);
await this.fetchImages(); await this.fetchImages();
const cssImageTotal = this.cssImageDownloads.size;
this.log(
cssImageTotal
? `Fetching ${cssImageTotal} CSS-linked asset${cssImageTotal === 1 ? "" : "s"}...`
: "No CSS-linked assets to fetch."
);
this.reportProgress("css-images-start", { total: cssImageTotal });
await this.fetchCssImages(); await this.fetchCssImages();
this.reportProgress("css-images-complete", { total: cssImageTotal });
const zip = await this.assembleEpub(); const zip = await this.assembleEpub();
const blob = new Blob([zip.generate()], { type: "application/epub+zip" }); this.log("Packaging EPUB zip contents...");
const zipStart = Date.now();
const zipData = zip.generate();
const zipDuration = Date.now() - zipStart;
this.log(`EPUB archive generated in ${zipDuration}ms. Preparing download...`);
this.reportProgress("packaging-complete", { durationMs: zipDuration });
const blob = new Blob([zipData], { type: "application/epub+zip" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const filename = `${this.cleanTitle}/${this.outputFileName}.epub`; const filename = `${this.cleanTitle}/${this.outputFileName}.epub`;
this.reportProgress("download-start", { filename });
await chrome.downloads.download({ url, filename, saveAs: false }); await chrome.downloads.download({ url, filename, saveAs: false });
this.log(`EPUB saved as ${filename}`); this.log(`EPUB saved as ${filename}`);
this.reportProgress("complete", { filename });
} }
async checkLogin() { async checkLogin() {
@ -434,7 +505,7 @@ export class SafariBooksDownloader {
: normalized.split("/").slice(-1)[0]; : normalized.split("/").slice(-1)[0];
let trimmed = relCandidate.replace(/^\/+/, ""); let trimmed = relCandidate.replace(/^\/+/, "");
for (const dir of imageDirs) { for (const dir of imageDirs) {
if (trimmed.startsWith(dir)) { if (trimmed.toLowerCase().startsWith(dir)) {
trimmed = trimmed.slice(dir.length); trimmed = trimmed.slice(dir.length);
break; break;
} }
@ -475,6 +546,17 @@ export class SafariBooksDownloader {
const chapter = this.bookChapters[index]; const chapter = this.bookChapters[index];
const filename = this.getChapterFilename(chapter).replace(".html", ".xhtml"); const filename = this.getChapterFilename(chapter).replace(".html", ".xhtml");
const assets = chapter.related_assets || {}; const assets = chapter.related_assets || {};
const chapterTitle = chapter.title || `Chapter ${index + 1}`;
this.log(
`Loading chapter ${index + 1}/${this.bookChapters.length}: ${chapterTitle}`
);
const chapterFetchStart = Date.now();
this.reportProgress("chapter-start", {
index: index + 1,
total: this.bookChapters.length,
title: chapterTitle
});
if (Array.isArray(assets.images)) { if (Array.isArray(assets.images)) {
assets.images.forEach((img) => { assets.images.forEach((img) => {
@ -496,8 +578,35 @@ export class SafariBooksDownloader {
} }
const htmlRoot = await this.loadChapterDocument(chapter.content_url); const htmlRoot = await this.loadChapterDocument(chapter.content_url);
const fetchDurationMs = Date.now() - chapterFetchStart;
this.log(
`Fetched chapter ${index + 1}/${this.bookChapters.length} in ${fetchDurationMs}ms`
);
this.reportProgress("chapter-fetched", {
index: index + 1,
total: this.bookChapters.length,
title: chapterTitle,
durationMs: fetchDurationMs
});
const chapterParseStart = Date.now();
const firstPage = index === 0; const firstPage = index === 0;
const { cssMarkup, xhtml, detectedCover } = this.parseHtml(htmlRoot, chapterStyles, firstPage); const { cssMarkup, xhtml, detectedCover, pageCount } = this.parseHtml(
htmlRoot,
chapterStyles,
firstPage
);
const parseDurationMs = Date.now() - chapterParseStart;
const pageCountLabel = typeof pageCount === "number" ? pageCount : "unknown";
this.log(
`Processed chapter ${index + 1}/${this.bookChapters.length}: ${chapterTitle} (pages detected: ${pageCountLabel}) in ${parseDurationMs}ms`
);
this.reportProgress("chapter-processed", {
index: index + 1,
total: this.bookChapters.length,
title: chapterTitle,
durationMs: parseDurationMs,
pages: pageCountLabel
});
if (detectedCover && !this.coverPath) { if (detectedCover && !this.coverPath) {
const coverRegistered = this.registerImage(detectedCover); const coverRegistered = this.registerImage(detectedCover);
if (coverRegistered) { if (coverRegistered) {
@ -595,6 +704,28 @@ export class SafariBooksDownloader {
detectedCover = this.detectCoverImage(bookContent); detectedCover = this.detectCoverImage(bookContent);
} }
const pageSelectors = [
"[data-type='page']",
"[data-role='page']",
"[data-page-number]",
"[data-page]",
"[role='doc-pagebreak']",
"[epub\\:type='pagebreak']",
".sbo-page"
];
const pageElements = new Set();
for (const selector of pageSelectors) {
try {
bookContent.querySelectorAll(selector).forEach((element) => pageElements.add(element));
} catch (_) {
// ignore selectors not supported in this document
}
}
if (pageElements.size === 0) {
bookContent.querySelectorAll(".page").forEach((element) => pageElements.add(element));
}
const pageCount = pageElements.size > 0 ? pageElements.size : null;
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
const xhtml = serializer.serializeToString(bookContent); const xhtml = serializer.serializeToString(bookContent);
const documentHtml = buildXhtml( const documentHtml = buildXhtml(
@ -608,7 +739,8 @@ export class SafariBooksDownloader {
return { return {
cssMarkup: pageCssBlocks.join("\n"), cssMarkup: pageCssBlocks.join("\n"),
xhtml: documentHtml, xhtml: documentHtml,
detectedCover detectedCover,
pageCount
}; };
} }
@ -730,12 +862,60 @@ export class SafariBooksDownloader {
} }
async fetchImages() { async fetchImages() {
for (const [relative, url] of this.imageDownloads.entries()) { const pendingEntries = Array.from(this.imageDownloads.entries()).filter(([, entry]) => {
const result = await this.fetchImageWithFallback(relative, url); if (!entry) {
if (result) { return true;
this.imageDownloads.set(relative, result);
} }
return !entry.data;
});
if (!pendingEntries.length) {
this.reportProgress("images-complete", { total: 0, completed: 0 });
return;
} }
const total = pendingEntries.length;
const maxConcurrency = this.options?.imageConcurrency ?? 10;
const concurrency = Math.min(Math.max(1, maxConcurrency), total);
const startTime = Date.now();
let completed = 0;
this.reportProgress("images-start", { total, concurrency });
const worker = async () => {
while (pendingEntries.length) {
const nextEntry = pendingEntries.pop();
if (!nextEntry) {
return;
}
const [relative, value] = nextEntry;
const primaryUrl = typeof value === "string" ? value : value?.sourceUrl;
if (!primaryUrl) {
completed += 1;
continue;
}
const result = await this.fetchImageWithFallback(relative, primaryUrl);
if (result) {
this.imageDownloads.set(relative, result);
}
completed += 1;
if (completed === total || completed % 25 === 0) {
const elapsedSeconds = Math.round((Date.now() - startTime) / 1000);
this.log(
`Fetched ${completed}/${total} image assets (${elapsedSeconds}s elapsed)`
);
this.reportProgress("images-progress", {
completed,
total,
elapsedSeconds
});
}
}
};
await Promise.all(Array.from({ length: concurrency }).map(() => worker()));
const totalDurationMs = Date.now() - startTime;
this.reportProgress("images-complete", { total, completed: total, durationMs: totalDurationMs });
} }
async fetchImageWithFallback(relative, primaryUrl) { async fetchImageWithFallback(relative, primaryUrl) {

View file

@ -63,7 +63,7 @@ select {
font-size: 0.9rem; font-size: 0.9rem;
} }
.buttons { .actions {
display: flex; display: flex;
} }
@ -75,9 +75,6 @@ button {
font-size: 0.95rem; font-size: 0.95rem;
font-weight: 600; font-weight: 600;
cursor: pointer; cursor: pointer;
}
button {
background: #3a7bd5; background: #3a7bd5;
color: #fff; color: #fff;
} }

View file

@ -30,7 +30,7 @@
<input id="kindle" type="checkbox" /> <input id="kindle" type="checkbox" />
<span>Add Kindle compatibility tweaks</span> <span>Add Kindle compatibility tweaks</span>
</label> </label>
<div class="buttons"> <div class="actions">
<button id="start-download" type="submit">Verify Session &amp; Download</button> <button id="start-download" type="submit">Verify Session &amp; Download</button>
</div> </div>
</form> </form>

View file

@ -13,7 +13,6 @@ function appendLog(message) {
logView.textContent += `[${timestamp}] ${message}\n`; logView.textContent += `[${timestamp}] ${message}\n`;
logView.scrollTop = logView.scrollHeight; logView.scrollTop = logView.scrollHeight;
} }
const downloader = new SafariBooksDownloader(appendLog); const downloader = new SafariBooksDownloader(appendLog);
function setDisabled(disabled) { function setDisabled(disabled) {

View file

@ -3,8 +3,20 @@ import { collectSessionCookies } from "./safaribooks/cookies.js";
const downloader = new SafariBooksDownloader((message) => { const downloader = new SafariBooksDownloader((message) => {
console.info(`[SafariBooksDownloader] ${message}`); console.info(`[SafariBooksDownloader] ${message}`);
}, (payload) => {
try {
chrome.runtime.sendMessage({
type: "offscreen-download-progress",
requestId: currentRequestId,
payload
});
} catch (_) {
// ignore communication errors
}
}); });
let currentRequestId = null;
async function handleOffscreenDownload(message) { async function handleOffscreenDownload(message) {
const { requestId, bookId, options = {} } = message; const { requestId, bookId, options = {} } = message;
if (!bookId) { if (!bookId) {
@ -18,6 +30,7 @@ async function handleOffscreenDownload(message) {
} }
try { try {
currentRequestId = requestId;
await collectSessionCookies(); await collectSessionCookies();
await downloader.download(bookId, { await downloader.download(bookId, {
theme: options.theme ?? "none", theme: options.theme ?? "none",
@ -36,6 +49,7 @@ async function handleOffscreenDownload(message) {
error: error.message error: error.message
}); });
} }
currentRequestId = null;
} }
chrome.runtime.onMessage.addListener((message) => { chrome.runtime.onMessage.addListener((message) => {

View file

@ -79,11 +79,12 @@ function toAbsoluteUrl(link, base) {
} }
export class SafariBooksDownloader { export class SafariBooksDownloader {
constructor(log = console.log) { constructor(log = console.log, onProgress) {
this.log = (message) => { this.log = (message) => {
const timestamp = new Date().toISOString(); const timestamp = new Date().toISOString();
log(`[${timestamp}] ${message}`); log(`[${timestamp}] ${message}`);
}; };
this.onProgress = typeof onProgress === "function" ? onProgress : null;
this.reset(); this.reset();
} }
@ -114,6 +115,19 @@ export class SafariBooksDownloader {
this.imageDownloads = new Map(); // relative path -> absolute url this.imageDownloads = new Map(); // relative path -> absolute url
this.cssImageDownloads = new Map(); // path within Styles -> absolute url this.cssImageDownloads = new Map(); // path within Styles -> absolute url
this.imagesFromLinks = new Set(); this.imagesFromLinks = new Set();
this.reportProgress("reset", { message: "Preparing download..." });
}
reportProgress(stage, data = {}) {
if (typeof this.onProgress !== "function") {
return;
}
try {
this.onProgress({ stage, bookId: this.bookId, ...data });
} catch (error) {
console.warn("SafariBooksDownloader progress callback error", error);
}
} }
getReferrerUrl() { getReferrerUrl() {
@ -183,8 +197,8 @@ export class SafariBooksDownloader {
computeRetryDelay(response, attempt, options = {}) { computeRetryDelay(response, attempt, options = {}) {
const status = response?.status ?? 0; const status = response?.status ?? 0;
const base = options.baseRetryDelay ?? 750; const base = options.baseRetryDelay ?? 200;
const cap = options.maxRetryDelay ?? 8000; const cap = options.maxRetryDelay ?? 2000;
const growth = Math.min(cap, base * Math.pow(2, attempt)); const growth = Math.min(cap, base * Math.pow(2, attempt));
const jitter = Math.random() * 250; const jitter = Math.random() * 250;
if (status === 429 || status === 503) { if (status === 429 || status === 503) {
@ -288,11 +302,18 @@ export class SafariBooksDownloader {
this.updateReferrer(`${SAFARI_BASE_URL}/library/view/${this.bookId}/`); this.updateReferrer(`${SAFARI_BASE_URL}/library/view/${this.bookId}/`);
updateRequestContext({ referer: this.getReferrerUrl() }); updateRequestContext({ referer: this.getReferrerUrl() });
this.reportProgress("starting", { bookId: this.bookId });
this.log(`Verifying session by loading profile page...`); this.log(`Verifying session by loading profile page...`);
this.reportProgress("session-check");
await this.checkLogin(); await this.checkLogin();
this.reportProgress("session-ok");
this.log(`Retrieving book metadata for ${bookId}...`); this.log(`Retrieving book metadata for ${bookId}...`);
this.bookInfo = await this.fetchBookInfo(bookId); this.bookInfo = await this.fetchBookInfo(bookId);
this.reportProgress("metadata", {
title: this.bookInfo.title,
authors: (this.bookInfo.authors || []).map((author) => author.name).filter(Boolean)
});
this.baseUrl = this.bookInfo.web_url || this.bookInfo.url; this.baseUrl = this.bookInfo.web_url || this.bookInfo.url;
this.updateReferrer(this.bookInfo.web_url || this.bookInfo.url); this.updateReferrer(this.bookInfo.web_url || this.bookInfo.url);
updateRequestContext({ referer: this.getReferrerUrl() }); updateRequestContext({ referer: this.getReferrerUrl() });
@ -302,24 +323,70 @@ export class SafariBooksDownloader {
this.outputFileName = makeFileFriendlyName(this.bookInfo.title, this.primaryAuthors, this.bookId); this.outputFileName = makeFileFriendlyName(this.bookInfo.title, this.primaryAuthors, this.bookId);
this.log(`Fetching table of chapters...`); this.log(`Fetching table of chapters...`);
this.reportProgress("chapters-fetch");
this.bookChapters = await this.fetchChapters(this.bookInfo.chapters); this.bookChapters = await this.fetchChapters(this.bookInfo.chapters);
this.log(`Chapters discovered: ${this.bookChapters.length}`); this.log(`Chapters discovered: ${this.bookChapters.length}`);
this.reportProgress("chapters-discovered", { total: this.bookChapters.length });
await this.processChapters(); await this.processChapters();
this.reportProgress("chapters-complete", { total: this.chapterDocuments.length });
const cssSourceCount = this.cssSources.length;
this.log(
cssSourceCount
? `Fetching ${cssSourceCount} stylesheet source${cssSourceCount === 1 ? "" : "s"}...`
: "No external stylesheet sources to fetch."
);
this.reportProgress("styles-start", { total: cssSourceCount });
await this.fetchCssSources(); await this.fetchCssSources();
this.reportProgress("styles-complete", { total: this.cssContentMap.size });
const fontCount = this.fontDownloads.size;
this.log(
fontCount
? `Fetching ${fontCount} font asset${fontCount === 1 ? "" : "s"}...`
: "No font assets to fetch."
);
this.reportProgress("fonts-start", { total: fontCount });
await this.fetchFonts(); await this.fetchFonts();
this.reportProgress("fonts-complete", { total: this.fontDownloads.size });
const totalImages = Array.from(this.imageDownloads.values()).filter((value) => value).length;
const pendingImages = Array.from(this.imageDownloads.values()).filter((value) => !value?.data).length;
this.log(
pendingImages
? `Fetching ${pendingImages} image asset${pendingImages === 1 ? "" : "s"} (registered: ${totalImages})...`
: "No image assets to fetch."
);
await this.fetchImages(); await this.fetchImages();
const cssImageTotal = this.cssImageDownloads.size;
this.log(
cssImageTotal
? `Fetching ${cssImageTotal} CSS-linked asset${cssImageTotal === 1 ? "" : "s"}...`
: "No CSS-linked assets to fetch."
);
this.reportProgress("css-images-start", { total: cssImageTotal });
await this.fetchCssImages(); await this.fetchCssImages();
this.reportProgress("css-images-complete", { total: cssImageTotal });
const zip = await this.assembleEpub(); const zip = await this.assembleEpub();
const blob = new Blob([zip.generate()], { type: "application/epub+zip" }); this.log("Packaging EPUB zip contents...");
const zipStart = Date.now();
const zipData = zip.generate();
const zipDuration = Date.now() - zipStart;
this.log(`EPUB archive generated in ${zipDuration}ms. Preparing download...`);
this.reportProgress("packaging-complete", { durationMs: zipDuration });
const blob = new Blob([zipData], { type: "application/epub+zip" });
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const filename = `${this.cleanTitle}/${this.outputFileName}.epub`; const filename = `${this.cleanTitle}/${this.outputFileName}.epub`;
this.reportProgress("download-start", { filename });
await chrome.downloads.download({ url, filename, saveAs: false }); await chrome.downloads.download({ url, filename, saveAs: false });
this.log(`EPUB saved as ${filename}`); this.log(`EPUB saved as ${filename}`);
this.reportProgress("complete", { filename });
} }
async checkLogin() { async checkLogin() {
@ -438,7 +505,7 @@ export class SafariBooksDownloader {
: normalized.split("/").slice(-1)[0]; : normalized.split("/").slice(-1)[0];
let trimmed = relCandidate.replace(/^\/+/, ""); let trimmed = relCandidate.replace(/^\/+/, "");
for (const dir of imageDirs) { for (const dir of imageDirs) {
if (trimmed.startsWith(dir)) { if (trimmed.toLowerCase().startsWith(dir)) {
trimmed = trimmed.slice(dir.length); trimmed = trimmed.slice(dir.length);
break; break;
} }
@ -479,6 +546,17 @@ export class SafariBooksDownloader {
const chapter = this.bookChapters[index]; const chapter = this.bookChapters[index];
const filename = this.getChapterFilename(chapter).replace(".html", ".xhtml"); const filename = this.getChapterFilename(chapter).replace(".html", ".xhtml");
const assets = chapter.related_assets || {}; const assets = chapter.related_assets || {};
const chapterTitle = chapter.title || `Chapter ${index + 1}`;
this.log(
`Loading chapter ${index + 1}/${this.bookChapters.length}: ${chapterTitle}`
);
const chapterFetchStart = Date.now();
this.reportProgress("chapter-start", {
index: index + 1,
total: this.bookChapters.length,
title: chapterTitle
});
if (Array.isArray(assets.images)) { if (Array.isArray(assets.images)) {
assets.images.forEach((img) => { assets.images.forEach((img) => {
@ -500,8 +578,35 @@ export class SafariBooksDownloader {
} }
const htmlRoot = await this.loadChapterDocument(chapter.content_url); const htmlRoot = await this.loadChapterDocument(chapter.content_url);
const fetchDurationMs = Date.now() - chapterFetchStart;
this.log(
`Fetched chapter ${index + 1}/${this.bookChapters.length} in ${fetchDurationMs}ms`
);
this.reportProgress("chapter-fetched", {
index: index + 1,
total: this.bookChapters.length,
title: chapterTitle,
durationMs: fetchDurationMs
});
const chapterParseStart = Date.now();
const firstPage = index === 0; const firstPage = index === 0;
const { cssMarkup, xhtml, detectedCover } = this.parseHtml(htmlRoot, chapterStyles, firstPage); const { cssMarkup, xhtml, detectedCover, pageCount } = this.parseHtml(
htmlRoot,
chapterStyles,
firstPage
);
const parseDurationMs = Date.now() - chapterParseStart;
const pageCountLabel = typeof pageCount === "number" ? pageCount : "unknown";
this.log(
`Processed chapter ${index + 1}/${this.bookChapters.length}: ${chapterTitle} (pages detected: ${pageCountLabel}) in ${parseDurationMs}ms`
);
this.reportProgress("chapter-processed", {
index: index + 1,
total: this.bookChapters.length,
title: chapterTitle,
durationMs: parseDurationMs,
pages: pageCountLabel
});
if (detectedCover && !this.coverPath) { if (detectedCover && !this.coverPath) {
const coverRegistered = this.registerImage(detectedCover); const coverRegistered = this.registerImage(detectedCover);
if (coverRegistered) { if (coverRegistered) {
@ -599,6 +704,28 @@ export class SafariBooksDownloader {
detectedCover = this.detectCoverImage(bookContent); detectedCover = this.detectCoverImage(bookContent);
} }
const pageSelectors = [
"[data-type='page']",
"[data-role='page']",
"[data-page-number]",
"[data-page]",
"[role='doc-pagebreak']",
"[epub\\:type='pagebreak']",
".sbo-page"
];
const pageElements = new Set();
for (const selector of pageSelectors) {
try {
bookContent.querySelectorAll(selector).forEach((element) => pageElements.add(element));
} catch (_) {
// ignore selectors not supported in this document
}
}
if (pageElements.size === 0) {
bookContent.querySelectorAll(".page").forEach((element) => pageElements.add(element));
}
const pageCount = pageElements.size > 0 ? pageElements.size : null;
const serializer = new XMLSerializer(); const serializer = new XMLSerializer();
const xhtml = serializer.serializeToString(bookContent); const xhtml = serializer.serializeToString(bookContent);
const documentHtml = buildXhtml( const documentHtml = buildXhtml(
@ -612,7 +739,8 @@ export class SafariBooksDownloader {
return { return {
cssMarkup: pageCssBlocks.join("\n"), cssMarkup: pageCssBlocks.join("\n"),
xhtml: documentHtml, xhtml: documentHtml,
detectedCover detectedCover,
pageCount
}; };
} }
@ -734,12 +862,60 @@ export class SafariBooksDownloader {
} }
async fetchImages() { async fetchImages() {
for (const [relative, url] of this.imageDownloads.entries()) { const pendingEntries = Array.from(this.imageDownloads.entries()).filter(([, entry]) => {
const result = await this.fetchImageWithFallback(relative, url); if (!entry) {
if (result) { return true;
this.imageDownloads.set(relative, result);
} }
return !entry.data;
});
if (!pendingEntries.length) {
this.reportProgress("images-complete", { total: 0, completed: 0 });
return;
} }
const total = pendingEntries.length;
const maxConcurrency = this.options?.imageConcurrency ?? 10;
const concurrency = Math.min(Math.max(1, maxConcurrency), total);
const startTime = Date.now();
let completed = 0;
this.reportProgress("images-start", { total, concurrency });
const worker = async () => {
while (pendingEntries.length) {
const nextEntry = pendingEntries.pop();
if (!nextEntry) {
return;
}
const [relative, value] = nextEntry;
const primaryUrl = typeof value === "string" ? value : value?.sourceUrl;
if (!primaryUrl) {
completed += 1;
continue;
}
const result = await this.fetchImageWithFallback(relative, primaryUrl);
if (result) {
this.imageDownloads.set(relative, result);
}
completed += 1;
if (completed === total || completed % 25 === 0) {
const elapsedSeconds = Math.round((Date.now() - startTime) / 1000);
this.log(
`Fetched ${completed}/${total} image assets (${elapsedSeconds}s elapsed)`
);
this.reportProgress("images-progress", {
completed,
total,
elapsedSeconds
});
}
}
};
await Promise.all(Array.from({ length: concurrency }).map(() => worker()));
const totalDurationMs = Date.now() - startTime;
this.reportProgress("images-complete", { total, completed: total, durationMs: totalDurationMs });
} }
async fetchImageWithFallback(relative, primaryUrl) { async fetchImageWithFallback(relative, primaryUrl) {