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 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() {
contextReadyPromise = new Promise((resolve) => {
contextReadyResolver = resolve;
@ -147,7 +163,7 @@ function shouldRetryInWindow(error, pending) {
return message.toLowerCase().includes("domparser");
}
async function startDownloadTask(bookId, options, sendResponse, attempt = 0) {
async function startDownloadTask(bookId, options, sendResponse, attempt = 0, clientInfo = {}) {
try {
await ensureDocumentContext();
} catch (error) {
@ -157,7 +173,31 @@ async function startDownloadTask(bookId, options, sendResponse, attempt = 0) {
const requestId = crypto.randomUUID();
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 {
await chrome.runtime.sendMessage({
@ -189,7 +229,12 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
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;
}
@ -212,7 +257,13 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
.catch(() => {})
.finally(() => {
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;
}
@ -223,5 +274,14 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => {
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;
});

View file

@ -103,30 +103,68 @@
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) {
return;
}
const originalText = button.textContent;
button.disabled = true;
button.style.opacity = "0.7";
button.textContent = "Downloading…";
setButtonState(button, "busy", "Downloading…");
setProgressMessage(wrapper, "Starting download...");
chrome.runtime.sendMessage({ type: "downloadBook", bookId }, (response) => {
chrome.runtime.sendMessage({ type: "downloadBook", bookId, frameId }, (response) => {
const error = chrome.runtime.lastError;
if (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) {
button.textContent = "Download started";
setButtonState(button, "busy", "Download started");
setProgressMessage(wrapper, "Packaging and saving EPUB...");
} 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(() => {
button.textContent = originalText;
button.disabled = false;
button.style.opacity = "";
setButtonState(button, "idle");
}, response?.ok ? 2000 : 4000);
});
}
@ -154,10 +192,6 @@
return;
}
const button = createButton();
button.dataset.bookId = bookId;
button.addEventListener("click", () => handleDownload(button, bookId));
const wrapperId = `${BUTTON_ID}-wrapper`;
let wrapper = document.getElementById(wrapperId);
if (!wrapper) {
@ -167,18 +201,133 @@
wrapper.style.alignItems = "center";
wrapper.style.gap = "12px";
wrapper.style.marginLeft = "12px";
const button = createButton();
button.dataset.bookId = bookId;
button.addEventListener("click", () => handleDownload(button, bookId, wrapper));
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) {
titleElement.parentElement.insertBefore(wrapper, titleElement.nextSibling);
} else {
titleElement.insertAdjacentElement("afterend", wrapper);
}
} else if (!wrapper.contains(button)) {
wrapper.appendChild(button);
} else {
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() {
if (observerInstance) {
return;

View file

@ -2,27 +2,94 @@ import { SafariBooksDownloader } from "./safaribooks/downloader.js";
import { collectSessionCookies } from "./safaribooks/cookies.js";
const runtime = typeof browser !== "undefined" ? browser : chrome;
const pendingDownloads = new Map();
const downloader = new SafariBooksDownloader((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) => {
if (message?.type !== "downloadBook") {
function sendProgress(entry, payload) {
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;
}
const { bookId, theme = "none", kindle = false } = message;
const bookId = message.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 {
await collectSessionCookies();
await downloader.download(bookId, { theme, kindle });
return { ok: true };
await downloader.download(entry.bookId, entry.options);
finalizeDownload(entry, { ok: true });
} catch (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 ensureIntervalId = null;
let initialized = false;
const runtime = typeof browser !== "undefined" ? browser : chrome;
const rawChrome = typeof chrome !== "undefined" ? chrome : null;
const TITLE_SELECTORS = [
'[data-test="book-header-title"]',
@ -105,56 +103,96 @@
return button;
}
function sendRuntimeMessage(message) {
try {
const result = runtime.runtime.sendMessage(message);
if (result && typeof result.then === "function") {
return result;
}
} catch (error) {
return Promise.reject(error);
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;
}
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) {
return;
}
const originalText = button.textContent;
button.disabled = true;
button.style.opacity = "0.7";
button.textContent = "Downloading…";
setButtonState(button, "busy", "Downloading…");
setProgressMessage(wrapper, "Starting download...");
sendRuntimeMessage({ type: "downloadBook", bookId })
.then((response) => {
if (response?.ok) {
button.textContent = "Download started";
} else {
button.textContent = response?.error ? `Failed: ${response.error}` : "Download failed";
}
})
.catch((error) => {
const runtime = typeof browser !== "undefined" ? browser : chrome;
const message = { type: "downloadBook", bookId, frameId };
const sendPromise = runtime.runtime.sendMessage(message);
const handleResponse = (response) => {
if (response?.ok) {
setButtonState(button, "busy", "Download started");
setProgressMessage(wrapper, "Packaging and saving EPUB...");
} 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);
button.textContent = "Failed (extension error)";
})
.finally(() => {
setButtonState(button, "error", "Failed (extension error)");
setProgressMessage(wrapper, "Failed: extension error.");
setTimeout(() => {
button.textContent = originalText;
button.disabled = false;
button.style.opacity = "";
}, 2000);
setButtonState(button, "idle");
}, 4000);
});
} 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() {
@ -180,10 +218,6 @@
return;
}
const button = createButton();
button.dataset.bookId = bookId;
button.addEventListener("click", () => handleDownload(button, bookId));
const wrapperId = `${BUTTON_ID}-wrapper`;
let wrapper = document.getElementById(wrapperId);
if (!wrapper) {
@ -193,18 +227,133 @@
wrapper.style.alignItems = "center";
wrapper.style.gap = "12px";
wrapper.style.marginLeft = "12px";
const button = createButton();
button.dataset.bookId = bookId;
button.addEventListener("click", () => handleDownload(button, bookId, wrapper));
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) {
titleElement.parentElement.insertBefore(wrapper, titleElement.nextSibling);
} else {
titleElement.insertAdjacentElement("afterend", wrapper);
}
} else if (!wrapper.contains(button)) {
wrapper.appendChild(button);
} else {
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() {
if (observerInstance) {
return;

View file

@ -75,9 +75,6 @@ button {
font-size: 0.95rem;
font-weight: 600;
cursor: pointer;
}
button {
background: #3a7bd5;
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;" +
"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) {
return value.toString().padStart(2, "0");
}
@ -75,11 +79,12 @@ function toAbsoluteUrl(link, base) {
}
export class SafariBooksDownloader {
constructor(log = console.log) {
constructor(log = console.log, onProgress) {
this.log = (message) => {
const timestamp = new Date().toISOString();
log(`[${timestamp}] ${message}`);
};
this.onProgress = typeof onProgress === "function" ? onProgress : null;
this.reset();
}
@ -110,6 +115,19 @@ export class SafariBooksDownloader {
this.imageDownloads = new Map(); // relative path -> absolute url
this.cssImageDownloads = new Map(); // path within Styles -> absolute url
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() {
@ -141,7 +159,7 @@ export class SafariBooksDownloader {
async applyRateLimit() {
const waitTime = this.nextRequestTime - Date.now();
if (waitTime > 0) {
await new Promise((resolve) => setTimeout(resolve, waitTime));
await sleep(waitTime);
}
}
@ -179,8 +197,8 @@ export class SafariBooksDownloader {
computeRetryDelay(response, attempt, options = {}) {
const status = response?.status ?? 0;
const base = options.baseRetryDelay ?? 750;
const cap = options.maxRetryDelay ?? 8000;
const base = options.baseRetryDelay ?? 200;
const cap = options.maxRetryDelay ?? 2000;
const growth = Math.min(cap, base * Math.pow(2, attempt));
const jitter = Math.random() * 250;
if (status === 429 || status === 503) {
@ -284,11 +302,18 @@ export class SafariBooksDownloader {
this.updateReferrer(`${SAFARI_BASE_URL}/library/view/${this.bookId}/`);
updateRequestContext({ referer: this.getReferrerUrl() });
this.reportProgress("starting", { bookId: this.bookId });
this.log(`Verifying session by loading profile page...`);
this.reportProgress("session-check");
await this.checkLogin();
this.reportProgress("session-ok");
this.log(`Retrieving book metadata for ${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.updateReferrer(this.bookInfo.web_url || this.bookInfo.url);
updateRequestContext({ referer: this.getReferrerUrl() });
@ -298,24 +323,70 @@ export class SafariBooksDownloader {
this.outputFileName = makeFileFriendlyName(this.bookInfo.title, this.primaryAuthors, this.bookId);
this.log(`Fetching table of chapters...`);
this.reportProgress("chapters-fetch");
this.bookChapters = await this.fetchChapters(this.bookInfo.chapters);
this.log(`Chapters discovered: ${this.bookChapters.length}`);
this.reportProgress("chapters-discovered", { total: this.bookChapters.length });
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();
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();
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();
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();
this.reportProgress("css-images-complete", { total: cssImageTotal });
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 filename = `${this.cleanTitle}/${this.outputFileName}.epub`;
this.reportProgress("download-start", { filename });
await chrome.downloads.download({ url, filename, saveAs: false });
this.log(`EPUB saved as ${filename}`);
this.reportProgress("complete", { filename });
}
async checkLogin() {
@ -434,7 +505,7 @@ export class SafariBooksDownloader {
: normalized.split("/").slice(-1)[0];
let trimmed = relCandidate.replace(/^\/+/, "");
for (const dir of imageDirs) {
if (trimmed.startsWith(dir)) {
if (trimmed.toLowerCase().startsWith(dir)) {
trimmed = trimmed.slice(dir.length);
break;
}
@ -475,6 +546,17 @@ export class SafariBooksDownloader {
const chapter = this.bookChapters[index];
const filename = this.getChapterFilename(chapter).replace(".html", ".xhtml");
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)) {
assets.images.forEach((img) => {
@ -496,8 +578,35 @@ export class SafariBooksDownloader {
}
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 { 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) {
const coverRegistered = this.registerImage(detectedCover);
if (coverRegistered) {
@ -595,6 +704,28 @@ export class SafariBooksDownloader {
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 xhtml = serializer.serializeToString(bookContent);
const documentHtml = buildXhtml(
@ -608,7 +739,8 @@ export class SafariBooksDownloader {
return {
cssMarkup: pageCssBlocks.join("\n"),
xhtml: documentHtml,
detectedCover
detectedCover,
pageCount
};
}
@ -730,12 +862,60 @@ export class SafariBooksDownloader {
}
async fetchImages() {
for (const [relative, url] of this.imageDownloads.entries()) {
const result = await this.fetchImageWithFallback(relative, url);
if (result) {
this.imageDownloads.set(relative, result);
const pendingEntries = Array.from(this.imageDownloads.entries()).filter(([, entry]) => {
if (!entry) {
return true;
}
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) {

View file

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

View file

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

View file

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

View file

@ -3,8 +3,20 @@ import { collectSessionCookies } from "./safaribooks/cookies.js";
const downloader = new 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) {
const { requestId, bookId, options = {} } = message;
if (!bookId) {
@ -18,6 +30,7 @@ async function handleOffscreenDownload(message) {
}
try {
currentRequestId = requestId;
await collectSessionCookies();
await downloader.download(bookId, {
theme: options.theme ?? "none",
@ -36,6 +49,7 @@ async function handleOffscreenDownload(message) {
error: error.message
});
}
currentRequestId = null;
}
chrome.runtime.onMessage.addListener((message) => {

View file

@ -79,11 +79,12 @@ function toAbsoluteUrl(link, base) {
}
export class SafariBooksDownloader {
constructor(log = console.log) {
constructor(log = console.log, onProgress) {
this.log = (message) => {
const timestamp = new Date().toISOString();
log(`[${timestamp}] ${message}`);
};
this.onProgress = typeof onProgress === "function" ? onProgress : null;
this.reset();
}
@ -114,6 +115,19 @@ export class SafariBooksDownloader {
this.imageDownloads = new Map(); // relative path -> absolute url
this.cssImageDownloads = new Map(); // path within Styles -> absolute url
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() {
@ -183,8 +197,8 @@ export class SafariBooksDownloader {
computeRetryDelay(response, attempt, options = {}) {
const status = response?.status ?? 0;
const base = options.baseRetryDelay ?? 750;
const cap = options.maxRetryDelay ?? 8000;
const base = options.baseRetryDelay ?? 200;
const cap = options.maxRetryDelay ?? 2000;
const growth = Math.min(cap, base * Math.pow(2, attempt));
const jitter = Math.random() * 250;
if (status === 429 || status === 503) {
@ -288,11 +302,18 @@ export class SafariBooksDownloader {
this.updateReferrer(`${SAFARI_BASE_URL}/library/view/${this.bookId}/`);
updateRequestContext({ referer: this.getReferrerUrl() });
this.reportProgress("starting", { bookId: this.bookId });
this.log(`Verifying session by loading profile page...`);
this.reportProgress("session-check");
await this.checkLogin();
this.reportProgress("session-ok");
this.log(`Retrieving book metadata for ${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.updateReferrer(this.bookInfo.web_url || this.bookInfo.url);
updateRequestContext({ referer: this.getReferrerUrl() });
@ -302,24 +323,70 @@ export class SafariBooksDownloader {
this.outputFileName = makeFileFriendlyName(this.bookInfo.title, this.primaryAuthors, this.bookId);
this.log(`Fetching table of chapters...`);
this.reportProgress("chapters-fetch");
this.bookChapters = await this.fetchChapters(this.bookInfo.chapters);
this.log(`Chapters discovered: ${this.bookChapters.length}`);
this.reportProgress("chapters-discovered", { total: this.bookChapters.length });
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();
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();
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();
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();
this.reportProgress("css-images-complete", { total: cssImageTotal });
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 filename = `${this.cleanTitle}/${this.outputFileName}.epub`;
this.reportProgress("download-start", { filename });
await chrome.downloads.download({ url, filename, saveAs: false });
this.log(`EPUB saved as ${filename}`);
this.reportProgress("complete", { filename });
}
async checkLogin() {
@ -438,7 +505,7 @@ export class SafariBooksDownloader {
: normalized.split("/").slice(-1)[0];
let trimmed = relCandidate.replace(/^\/+/, "");
for (const dir of imageDirs) {
if (trimmed.startsWith(dir)) {
if (trimmed.toLowerCase().startsWith(dir)) {
trimmed = trimmed.slice(dir.length);
break;
}
@ -479,6 +546,17 @@ export class SafariBooksDownloader {
const chapter = this.bookChapters[index];
const filename = this.getChapterFilename(chapter).replace(".html", ".xhtml");
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)) {
assets.images.forEach((img) => {
@ -500,8 +578,35 @@ export class SafariBooksDownloader {
}
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 { 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) {
const coverRegistered = this.registerImage(detectedCover);
if (coverRegistered) {
@ -599,6 +704,28 @@ export class SafariBooksDownloader {
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 xhtml = serializer.serializeToString(bookContent);
const documentHtml = buildXhtml(
@ -612,7 +739,8 @@ export class SafariBooksDownloader {
return {
cssMarkup: pageCssBlocks.join("\n"),
xhtml: documentHtml,
detectedCover
detectedCover,
pageCount
};
}
@ -734,12 +862,60 @@ export class SafariBooksDownloader {
}
async fetchImages() {
for (const [relative, url] of this.imageDownloads.entries()) {
const result = await this.fetchImageWithFallback(relative, url);
if (result) {
this.imageDownloads.set(relative, result);
const pendingEntries = Array.from(this.imageDownloads.entries()).filter(([, entry]) => {
if (!entry) {
return true;
}
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) {