diff --git a/firefox/background.html b/firefox/background.html
new file mode 100644
index 0000000..20de2b5
--- /dev/null
+++ b/firefox/background.html
@@ -0,0 +1,10 @@
+
+
+
+
+ Reilly Downloader Background
+
+
+
+
+
diff --git a/firefox/background.js b/firefox/background.js
new file mode 100644
index 0000000..af97fbb
--- /dev/null
+++ b/firefox/background.js
@@ -0,0 +1,28 @@
+import { SafariBooksDownloader } from "./safaribooks/downloader.js";
+import { collectSessionCookies } from "./safaribooks/cookies.js";
+
+const runtime = typeof browser !== "undefined" ? browser : chrome;
+
+const downloader = new SafariBooksDownloader((message) => {
+ console.log(`[BackgroundDownloader] ${message}`);
+});
+
+runtime.runtime.onMessage.addListener(async (message) => {
+ if (message?.type !== "downloadBook") {
+ return undefined;
+ }
+
+ const { bookId, theme = "none", kindle = false } = message;
+ if (!bookId) {
+ return { ok: false, error: "Missing book ID." };
+ }
+
+ try {
+ await collectSessionCookies();
+ await downloader.download(bookId, { theme, kindle });
+ return { ok: true };
+ } catch (error) {
+ console.error("[BackgroundDownloader] Download failed:", error);
+ return { ok: false, error: error.message };
+ }
+});
diff --git a/firefox/content.js b/firefox/content.js
new file mode 100644
index 0000000..edbd50d
--- /dev/null
+++ b/firefox/content.js
@@ -0,0 +1,259 @@
+(function () {
+ const BUTTON_ID = "reilly-safaribooks-download";
+ const BOOK_PATH_PART = "/library/view/";
+ 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"]',
+ '[data-testid="book-title"]',
+ '[data-testid="content-header-title"]',
+ '[data-testid="content-header"] h1',
+ '[data-testid="book-detail-header"] h1',
+ '[data-ui-test="book-title"]',
+ '[role="heading"][aria-level="1"]',
+ "main h1",
+ "article h1",
+ "section h2",
+ "article h2",
+ "header h1",
+ "h1",
+ "h2"
+ ];
+
+ function extractBookId() {
+ const segments = window.location.pathname.split("/").filter(Boolean).reverse();
+ const match = segments.find((segment) => /^[0-9]{9,}$/.test(segment));
+ return match || null;
+ }
+
+ function isBookPage() {
+ return window.location.pathname.includes(BOOK_PATH_PART);
+ }
+
+ function findTitleElement() {
+ for (const selector of TITLE_SELECTORS) {
+ const element = document.querySelector(selector);
+ if (element && element.textContent.trim().length > 0) {
+ return element;
+ }
+ }
+ const allHeadings = Array.from(document.querySelectorAll("h1"))
+ .concat(Array.from(document.querySelectorAll('[role="heading"][aria-level="1"]')));
+ return allHeadings.find((element) => element.textContent.trim().length > 0) || null;
+ }
+
+ function ensureFloatingButton(bookId) {
+ const floatingId = `${BUTTON_ID}-floating`;
+ let floating = document.getElementById(floatingId);
+ if (!floating) {
+ floating = document.createElement("button");
+ floating.id = floatingId;
+ floating.type = "button";
+ floating.textContent = "Download EPUB";
+ floating.style.position = "fixed";
+ floating.style.top = "96px";
+ floating.style.right = "24px";
+ floating.style.zIndex = "2147483647";
+ floating.style.padding = "8px 14px";
+ floating.style.borderRadius = "999px";
+ floating.style.border = "none";
+ floating.style.background = "#3a7bd5";
+ floating.style.color = "#fff";
+ floating.style.fontSize = "0.95rem";
+ floating.style.cursor = "pointer";
+ floating.style.boxShadow = "0 10px 25px rgba(58,123,213,0.35)";
+ floating.addEventListener("mouseenter", () => {
+ floating.style.background = "#255ca0";
+ });
+ floating.addEventListener("mouseleave", () => {
+ floating.style.background = "#3a7bd5";
+ });
+ document.body.appendChild(floating);
+ }
+
+ floating.dataset.bookId = bookId;
+ floating.onclick = () => handleDownload(floating, bookId);
+ }
+
+ function createButton() {
+ const button = document.createElement("button");
+ button.id = BUTTON_ID;
+ button.type = "button";
+ button.textContent = "Download EPUB";
+ button.style.padding = "6px 12px";
+ button.style.borderRadius = "4px";
+ button.style.border = "none";
+ button.style.background = "#3a7bd5";
+ button.style.color = "#fff";
+ button.style.fontSize = "0.9rem";
+ button.style.cursor = "pointer";
+ button.style.display = "inline-flex";
+ button.style.alignItems = "center";
+ button.style.gap = "4px";
+ button.style.boxShadow = "0 1px 2px rgba(0,0,0,0.12)";
+ button.style.transition = "background 0.2s ease";
+ button.addEventListener("mouseenter", () => {
+ button.style.background = "#2f6bbd";
+ });
+ button.addEventListener("mouseleave", () => {
+ button.style.background = "#3a7bd5";
+ });
+ 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);
+ }
+
+ 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) {
+ if (!bookId) {
+ return;
+ }
+ const originalText = button.textContent;
+ button.disabled = true;
+ button.style.opacity = "0.7";
+ button.textContent = "Downloading…";
+
+ 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) => {
+ console.error("SafariBooks Downloader:", error);
+ button.textContent = "Failed (extension error)";
+ })
+ .finally(() => {
+ setTimeout(() => {
+ button.textContent = originalText;
+ button.disabled = false;
+ button.style.opacity = "";
+ }, 2000);
+ });
+ }
+
+ function injectButton() {
+ const bookId = extractBookId();
+ if (!bookId) {
+ return;
+ }
+
+ const titleElement = findTitleElement();
+ if (!titleElement) {
+ ensureFloatingButton(bookId);
+ return;
+ }
+
+ const floating = document.getElementById(`${BUTTON_ID}-floating`);
+ if (floating) {
+ floating.remove();
+ }
+
+ const existing = document.getElementById(BUTTON_ID);
+ if (existing) {
+ existing.dataset.bookId = bookId;
+ 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) {
+ wrapper = document.createElement("span");
+ wrapper.id = wrapperId;
+ wrapper.style.display = "inline-flex";
+ wrapper.style.alignItems = "center";
+ wrapper.style.gap = "12px";
+ wrapper.style.marginLeft = "12px";
+ wrapper.appendChild(button);
+
+ if (titleElement.parentElement) {
+ titleElement.parentElement.insertBefore(wrapper, titleElement.nextSibling);
+ } else {
+ titleElement.insertAdjacentElement("afterend", wrapper);
+ }
+ } else if (!wrapper.contains(button)) {
+ wrapper.appendChild(button);
+ }
+ }
+
+ function observeForTitle() {
+ if (observerInstance) {
+ return;
+ }
+
+ observerInstance = new MutationObserver(() => {
+ if (!isBookPage()) {
+ return;
+ }
+ injectButton();
+ });
+
+ observerInstance.observe(document.body, { childList: true, subtree: true });
+ }
+
+ function init() {
+ if (!isBookPage()) {
+ return;
+ }
+ console.debug("[SafariBooks Downloader] content script active on", window.location.href);
+ if (initialized) {
+ injectButton();
+ return;
+ }
+
+ initialized = true;
+ injectButton();
+ observeForTitle();
+ window.addEventListener("popstate", injectButton);
+ const originalPushState = history.pushState;
+ history.pushState = function (...args) {
+ originalPushState.apply(this, args);
+ setTimeout(injectButton, 50);
+ };
+ const originalReplaceState = history.replaceState;
+ history.replaceState = function (...args) {
+ originalReplaceState.apply(this, args);
+ setTimeout(injectButton, 50);
+ };
+ ensureIntervalId = window.setInterval(() => {
+ if (isBookPage()) {
+ injectButton();
+ }
+ }, 2000);
+ }
+
+ if (document.readyState === "loading") {
+ document.addEventListener("DOMContentLoaded", init, { once: true });
+ } else {
+ init();
+ }
+})();
diff --git a/firefox/firefox.zip b/firefox/firefox.zip
new file mode 100644
index 0000000..28f7772
Binary files /dev/null and b/firefox/firefox.zip differ
diff --git a/firefox/images/icon128.png b/firefox/images/icon128.png
new file mode 100644
index 0000000..dd84812
Binary files /dev/null and b/firefox/images/icon128.png differ
diff --git a/firefox/images/icon16.png b/firefox/images/icon16.png
new file mode 100644
index 0000000..1d7d3ed
Binary files /dev/null and b/firefox/images/icon16.png differ
diff --git a/firefox/images/icon32.png b/firefox/images/icon32.png
new file mode 100644
index 0000000..d062b33
Binary files /dev/null and b/firefox/images/icon32.png differ
diff --git a/firefox/images/icon48.png b/firefox/images/icon48.png
new file mode 100644
index 0000000..e97be9d
Binary files /dev/null and b/firefox/images/icon48.png differ
diff --git a/firefox/index.css b/firefox/index.css
new file mode 100644
index 0000000..680e7a6
--- /dev/null
+++ b/firefox/index.css
@@ -0,0 +1,106 @@
+* {
+ box-sizing: border-box;
+}
+
+body {
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
+ margin: 0;
+ padding: 0;
+ background: #f5f6fa;
+ color: #1e1e1e;
+ width: 480px;
+}
+
+main {
+ padding: 16px;
+}
+
+h1 {
+ font-size: 1.3rem;
+ margin: 0 0 8px;
+}
+
+h2 {
+ font-size: 1rem;
+ margin: 16px 0 8px;
+}
+
+header p {
+ margin: 0 0 16px;
+ font-size: 0.85rem;
+ color: #444;
+}
+
+form {
+ display: flex;
+ flex-direction: column;
+ gap: 8px;
+}
+
+.field {
+ display: flex;
+ flex-direction: column;
+ gap: 4px;
+}
+
+.field span {
+ font-size: 0.85rem;
+ font-weight: 600;
+}
+
+input[type="text"],
+select {
+ padding: 6px 8px;
+ border: 1px solid #ccc;
+ border-radius: 4px;
+ font-size: 0.95rem;
+}
+
+.checkbox {
+ flex-direction: row;
+ align-items: center;
+ gap: 8px;
+ font-size: 0.9rem;
+}
+
+.buttons {
+ display: flex;
+}
+
+button {
+ flex: 1;
+ padding: 8px 12px;
+ border: none;
+ border-radius: 4px;
+ font-size: 0.95rem;
+ font-weight: 600;
+ cursor: pointer;
+}
+
+button {
+ background: #3a7bd5;
+ color: #fff;
+}
+
+#start-download:disabled {
+ opacity: 0.6;
+ cursor: not-allowed;
+}
+
+.log-container {
+ border-top: 1px solid #dcdfe6;
+ margin-top: 16px;
+ padding-top: 8px;
+}
+
+#log {
+ background: #0f172a;
+ color: #f8fafc;
+ padding: 12px;
+ height: 200px;
+ overflow-y: auto;
+ font-family: "Fira Code", "Consolas", "Courier New", monospace;
+ font-size: 0.8rem;
+ border-radius: 4px;
+ white-space: pre-wrap;
+}
diff --git a/firefox/index.html b/firefox/index.html
new file mode 100644
index 0000000..06cde58
--- /dev/null
+++ b/firefox/index.html
@@ -0,0 +1,45 @@
+
+
+
+
+ SafariBooks Downloader
+
+
+
+
+
+
+
+
+
+
+
diff --git a/firefox/index.js b/firefox/index.js
new file mode 100644
index 0000000..88bc558
--- /dev/null
+++ b/firefox/index.js
@@ -0,0 +1,107 @@
+import { SafariBooksDownloader } from "./safaribooks/downloader.js";
+import { collectSessionCookies } from "./safaribooks/cookies.js";
+
+const runtime = typeof browser !== "undefined" ? browser : chrome;
+const rawChrome = typeof chrome !== "undefined" ? chrome : null;
+
+const form = document.getElementById("download-form");
+const bookIdInput = document.getElementById("book-id");
+const themeSelect = document.getElementById("theme");
+const kindleCheckbox = document.getElementById("kindle");
+const logView = document.getElementById("log");
+const downloadButton = document.getElementById("start-download");
+
+function appendLog(message) {
+ const timestamp = new Date().toLocaleTimeString();
+ logView.textContent += `[${timestamp}] ${message}\n`;
+ logView.scrollTop = logView.scrollHeight;
+}
+
+const downloader = new SafariBooksDownloader(appendLog);
+
+function setDisabled(disabled) {
+ downloadButton.disabled = disabled;
+ bookIdInput.disabled = disabled;
+ themeSelect.disabled = disabled;
+ kindleCheckbox.disabled = disabled;
+}
+
+function extractBookIdFromUrl(url) {
+ try {
+ const { pathname } = new URL(url);
+ const segments = pathname.split("/").filter(Boolean).reverse();
+ return segments.find((segment) => /^[0-9]{9,}$/.test(segment)) || null;
+ } catch (_) {
+ return null;
+ }
+}
+
+async function detectActiveBook() {
+ const tabsApi = runtime.tabs;
+ if (!tabsApi || typeof tabsApi.query !== "function") {
+ return;
+ }
+
+ try {
+ let activeTab;
+ if (tabsApi.query.length <= 1) {
+ const tabs = await tabsApi.query({ active: true, currentWindow: true });
+ activeTab = Array.isArray(tabs) ? tabs[0] : null;
+ } else {
+ activeTab = await new Promise((resolve, reject) => {
+ tabsApi.query({ active: true, currentWindow: true }, (tabs) => {
+ const err = rawChrome?.runtime?.lastError;
+ if (err) {
+ reject(new Error(err.message));
+ } else {
+ resolve(Array.isArray(tabs) ? tabs[0] : null);
+ }
+ });
+ });
+ }
+
+ if (!activeTab?.url || !activeTab.url.includes("/library/view/")) {
+ return;
+ }
+
+ const detectedBookId = extractBookIdFromUrl(activeTab.url);
+ if (!detectedBookId) {
+ return;
+ }
+
+ if (bookIdInput.value !== detectedBookId) {
+ bookIdInput.value = detectedBookId;
+ appendLog(`Detected book ID ${detectedBookId} from the active tab.`);
+ }
+ } catch (error) {
+ console.warn("SafariBooks Downloader: unable to detect active book.", error);
+ }
+}
+
+form.addEventListener("submit", async (event) => {
+ event.preventDefault();
+ const bookId = bookIdInput.value.trim();
+ if (!bookId) {
+ appendLog("Please enter a book ID.");
+ return;
+ }
+ setDisabled(true);
+ appendLog("Verifying O'Reilly session...");
+ try {
+ const session = await collectSessionCookies();
+ appendLog(`Session OK (cookies detected: ${session.count}).`);
+ appendLog(`Starting EPUB download for ${bookId}...`);
+ await downloader.download(bookId, {
+ theme: themeSelect.value,
+ kindle: kindleCheckbox.checked
+ });
+ appendLog("Download completed.");
+ } catch (error) {
+ console.error(error);
+ appendLog(`Download failed: ${error.message}`);
+ } finally {
+ setDisabled(false);
+ }
+});
+
+detectActiveBook();
diff --git a/firefox/manifest.json b/firefox/manifest.json
new file mode 100644
index 0000000..9940134
--- /dev/null
+++ b/firefox/manifest.json
@@ -0,0 +1,50 @@
+{
+ "manifest_version": 2,
+ "name": "Reilly SafariBooks Downloader",
+ "description": "Download O'Reilly learning content as EPUB files using a port of safaribooks.py.",
+ "version": "0.1.0",
+ "browser_specific_settings": {
+ "gecko": {
+ "id": "reilly@safaribooks",
+ "strict_min_version": "115.0"
+ }
+ },
+ "icons": {
+ "16": "images/icon16.png",
+ "32": "images/icon32.png",
+ "48": "images/icon48.png",
+ "128": "images/icon128.png"
+ },
+ "background": {
+ "page": "background.html"
+ },
+ "browser_action": {
+ "default_title": "SafariBooks Downloader",
+ "default_popup": "index.html",
+ "default_icon": {
+ "16": "images/icon16.png",
+ "32": "images/icon32.png",
+ "48": "images/icon48.png"
+ }
+ },
+ "content_scripts": [
+ {
+ "matches": [
+ "https://learning.oreilly.com/library/view/*"
+ ],
+ "js": [
+ "content.js"
+ ],
+ "run_at": "document_idle"
+ }
+ ],
+ "permissions": [
+ "downloads",
+ "cookies",
+ "tabs",
+ "storage",
+ "https://learning.oreilly.com/*",
+ "https://www.oreilly.com/*",
+ "https://api.oreilly.com/*"
+ ]
+}
diff --git a/firefox/safaribooks/cookies.js b/firefox/safaribooks/cookies.js
new file mode 100644
index 0000000..d5ac3a3
--- /dev/null
+++ b/firefox/safaribooks/cookies.js
@@ -0,0 +1,64 @@
+const runtime = typeof browser !== "undefined" ? browser : chrome;
+const rawChrome = typeof chrome !== "undefined" ? chrome : null;
+
+const PROFILE_URL = "https://learning.oreilly.com/profile/";
+const COOKIE_DOMAINS = ["learning.oreilly.com", ".oreilly.com", "www.oreilly.com"];
+
+function callCookiesGetAll(filter) {
+ const result = runtime.cookies.getAll(filter);
+ if (result && typeof result.then === "function") {
+ return result;
+ }
+
+ return new Promise((resolve, reject) => {
+ runtime.cookies.getAll(filter, (cookies) => {
+ const err = rawChrome?.runtime?.lastError;
+ if (err) {
+ reject(new Error(err.message));
+ } else {
+ resolve(cookies);
+ }
+ });
+ });
+}
+
+async function gatherCookies() {
+ const collected = {};
+ for (const domain of COOKIE_DOMAINS) {
+ const cookies = await callCookiesGetAll({ domain });
+ cookies.forEach((cookie) => {
+ collected[cookie.name] = cookie.value;
+ });
+ }
+ return collected;
+}
+
+export async function collectSessionCookies() {
+ let response;
+ try {
+ response = await fetch(PROFILE_URL, { credentials: "include" });
+ } catch (error) {
+ throw new Error(`Unable to reach O'Reilly profile page: ${error.message}`);
+ }
+
+ if (response.status === 401 || response.status === 403) {
+ throw new Error("You must be logged into learning.oreilly.com in this profile before downloading.");
+ }
+
+ if (!response.ok) {
+ throw new Error(`Profile check failed (status ${response.status}).`);
+ }
+
+ const jar = await gatherCookies();
+ const count = Object.keys(jar).length;
+ if (!count) {
+ throw new Error("No O'Reilly cookies detected. Make sure you visited learning.oreilly.com in this profile.");
+ }
+
+ return {
+ status: response.status,
+ count,
+ cookies: jar
+ };
+}
+
diff --git a/firefox/safaribooks/downloader.js b/firefox/safaribooks/downloader.js
new file mode 100644
index 0000000..5516faa
--- /dev/null
+++ b/firefox/safaribooks/downloader.js
@@ -0,0 +1,850 @@
+import {
+ makeFolderFriendlyName,
+ makeFileFriendlyName,
+ escapeXml,
+ isAbsoluteUrl,
+ isDocLink,
+ isImageLink,
+ makeValidId,
+ resolveStylesRelativePath,
+ ensureForwardSlashes,
+ uniqPush,
+ dataFromString,
+ isFontUrl,
+ isLikelyImageAsset
+} from "./utils.js";
+import { SimpleZip } from "./zip.js";
+
+const SAFARI_BASE_URL = "https://learning.oreilly.com";
+const API_V2_TEMPLATE = `${SAFARI_BASE_URL}/api/v2/epubs/urn:orm:book:`;
+const API_V1_TEMPLATE = `${SAFARI_BASE_URL}/api/v1/book/`;
+const PROFILE_URL = `${SAFARI_BASE_URL}/profile/`;
+const IMAGES_BASE = "Images/";
+
+const THEME_CSS = {
+ white: ".ucvMode-white{background:#ffffff;color:#101010;}",
+ sepia: ".ucvMode-sepia{background:#f4ecd8;color:#523f2a;} .ucvMode-sepia a{color:#274060;}",
+ black: ".ucvMode-black{background:#070707;color:#f4f4f5;} .ucvMode-black a{color:#60a5fa;}"
+};
+
+const KINDLE_CSS =
+ "#sbo-rt-content *{word-wrap:break-word!important;word-break:break-word!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;}";
+
+function downloadWithWebExtensionsApi(options) {
+ if (typeof browser !== "undefined" && browser?.downloads?.download) {
+ return browser.downloads.download(options);
+ }
+ if (typeof chrome !== "undefined" && chrome?.downloads?.download) {
+ return new Promise((resolve, reject) => {
+ chrome.downloads.download(options, (downloadId) => {
+ const err = chrome.runtime?.lastError;
+ if (err) {
+ reject(new Error(err.message));
+ } else {
+ resolve(downloadId);
+ }
+ });
+ });
+ }
+ return Promise.reject(new Error("Downloads API is not available in this browser."));
+}
+
+function formatTwoDigits(value) {
+ return value.toString().padStart(2, "0");
+}
+
+function buildXhtml(pageCss, innerHtml, themeMode, includeKindleCss, extraThemeCss = "") {
+ const kindleRule = includeKindleCss ? KINDLE_CSS : "";
+ const themeRule = THEME_CSS[themeMode] || "";
+ const additional = [themeRule, extraThemeCss].filter(Boolean).join("\n");
+ return (
+ `
+
+
+
+${pageCss}
+
+
+
+`
+ );
+}
+
+function isProbablySvgImage(element) {
+ return element.tagName?.toLowerCase() === "svg";
+}
+
+function toAbsoluteUrl(link, base) {
+ try {
+ return new URL(link, base).toString();
+ } catch (_) {
+ return link;
+ }
+}
+
+export class SafariBooksDownloader {
+ constructor(log = console.log) {
+ this.log = (message) => {
+ const timestamp = new Date().toISOString();
+ log(`[${timestamp}] ${message}`);
+ };
+ this.reset();
+ }
+
+ reset() {
+ this.bookInfo = null;
+ this.bookId = null;
+ this.bookChapters = [];
+ this.chapterDocuments = [];
+ this.cleanTitle = "";
+ this.primaryAuthors = "";
+ this.outputFileName = "download";
+ this.baseUrl = "";
+ this.coverPath = "";
+ this.themeMode = "white";
+ this.options = { theme: "none", kindle: false };
+
+ this.cssSources = [];
+ this.cssFilenameMap = new Map();
+ this.cssContentMap = new Map();
+
+ this.fontDownloads = new Map(); // local path -> absolute url
+ this.imageDownloads = new Map(); // relative path -> absolute url
+ this.cssImageDownloads = new Map(); // path within Styles -> absolute url
+ this.imagesFromLinks = new Set();
+ }
+
+ async download(bookId, options = {}) {
+ this.reset();
+ this.bookId = bookId;
+ this.options = {
+ theme: options.theme ?? "none",
+ kindle: Boolean(options.kindle)
+ };
+ this.themeMode = this.options.theme === "none" ? "white" : this.options.theme;
+
+ this.log(`Verifying session by loading profile page...`);
+ await this.checkLogin();
+
+ this.log(`Retrieving book metadata for ${bookId}...`);
+ this.bookInfo = await this.fetchBookInfo(bookId);
+ this.baseUrl = this.bookInfo.web_url || this.bookInfo.url;
+ this.cleanTitle = makeFolderFriendlyName(this.bookInfo.title, this.bookId);
+ const authorNames = (this.bookInfo.authors || []).map((author) => author.name).filter(Boolean);
+ this.primaryAuthors = authorNames.join(", ") || "Unknown Author";
+ this.outputFileName = makeFileFriendlyName(this.bookInfo.title, this.primaryAuthors, this.bookId);
+
+ this.log(`Fetching table of chapters...`);
+ this.bookChapters = await this.fetchChapters(this.bookInfo.chapters);
+ this.log(`Chapters discovered: ${this.bookChapters.length}`);
+
+ await this.processChapters();
+
+ await this.fetchCssSources();
+ await this.fetchFonts();
+ await this.fetchImages();
+ await this.fetchCssImages();
+
+ const zip = await this.assembleEpub();
+
+ const blob = new Blob([zip.generate()], { type: "application/epub+zip" });
+ const url = URL.createObjectURL(blob);
+
+ const filename = `${this.cleanTitle}/${this.outputFileName}.epub`;
+ await downloadWithWebExtensionsApi({ url, filename, saveAs: false });
+ this.log(`EPUB saved as ${filename}`);
+ }
+
+ async checkLogin() {
+ const response = await fetch(PROFILE_URL, { credentials: "include" });
+ if (!response.ok) {
+ throw new Error(`Authentication failed (status ${response.status}) when accessing ${PROFILE_URL}`);
+ }
+ const text = await response.text();
+ if (text.includes('user_type":"Expired"')) {
+ throw new Error("Authentication issue: account subscription expired.");
+ }
+ }
+
+ async fetchBookInfo(bookId) {
+ const infoResponse = await this.fetchJson(`${API_V2_TEMPLATE}${bookId}/`);
+ const v1Response = await this.fetchJson(`${API_V1_TEMPLATE}${bookId}/`);
+ const merged = { ...infoResponse };
+
+ for (const key of ["authors", "subjects", "topics", "rights", "publishers", "web_url"]) {
+ if (v1Response[key]) {
+ merged[key] = v1Response[key];
+ }
+ }
+ if (!merged.url && merged.web_url) {
+ merged.url = merged.web_url;
+ }
+ return merged;
+ }
+
+ async fetchChapters(initialUrl) {
+ if (!initialUrl) {
+ throw new Error("Missing chapter list URL in book metadata.");
+ }
+ const chapters = [];
+ let next = initialUrl;
+ while (next) {
+ const data = await this.fetchJson(next);
+ if (!Array.isArray(data.results)) {
+ throw new Error("Chapter response missing results array.");
+ }
+ chapters.push(...data.results);
+ next = data.next;
+ }
+ return chapters;
+ }
+
+ async fetchJson(url) {
+ const response = await fetch(url, { credentials: "include" });
+ if (!response.ok) {
+ throw new Error(`Request to ${url} failed with status ${response.status}`);
+ }
+ return response.json();
+ }
+
+ getChapterFilename(chapter) {
+ if (chapter.ourn) {
+ const lastPart = chapter.ourn.split("chapter:").pop();
+ return lastPart.split("%2f").pop();
+ }
+ return chapter.filename || `${chapter.title || "chapter"}.xhtml`;
+ }
+
+ registerCssSource(url) {
+ const key = ensureForwardSlashes(url);
+ if (!this.cssFilenameMap.has(key)) {
+ const index = uniqPush(this.cssSources, key);
+ const filename = `Style${formatTwoDigits(index)}.css`;
+ this.cssFilenameMap.set(key, filename);
+ }
+ return this.cssFilenameMap.get(key);
+ }
+
+ registerFont(baseUrl, relativePath) {
+ const localPath = resolveStylesRelativePath(relativePath);
+ if (!this.fontDownloads.has(localPath)) {
+ const absolute = toAbsoluteUrl(relativePath, baseUrl.endsWith("/") ? baseUrl : `${baseUrl}/`);
+ this.fontDownloads.set(localPath, absolute);
+ }
+ }
+
+ registerImage(fullUrl) {
+ if (!fullUrl) {
+ return null;
+ }
+ const absoluteUrl = isAbsoluteUrl(fullUrl)
+ ? fullUrl
+ : toAbsoluteUrl(fullUrl, `${this.bookInfo.url.replace(/\/$/, "")}/files/`);
+ const local = this.localImagePath(absoluteUrl);
+ if (!local.fileName) {
+ return null;
+ }
+ const relative = local.folder ? `${local.folder}/${local.fileName}` : local.fileName;
+ if (!this.imageDownloads.has(relative)) {
+ this.imageDownloads.set(relative, absoluteUrl);
+ }
+ return `${IMAGES_BASE}${relative}`;
+ }
+
+ localImagePath(url) {
+ const normalized = url.split("?")[0];
+ const imageDirs = ["images/", "graphics/", "assets/"];
+ if (!this.bookInfo?.url) {
+ const fileName = normalized.split("/").pop();
+ return { fileName, folder: "" };
+ }
+ const bookUrl = this.bookInfo.url.replace(/\/$/, "");
+ const components = bookUrl.split("/");
+ const bookIdSegment = components.pop();
+ const base = `${bookIdSegment}/files/`;
+ const relCandidate = normalized.includes(base)
+ ? normalized.split(base).pop()
+ : normalized.split("/").slice(-1)[0];
+ let trimmed = relCandidate.replace(/^\/+/, "");
+ for (const dir of imageDirs) {
+ if (trimmed.startsWith(dir)) {
+ trimmed = trimmed.slice(dir.length);
+ break;
+ }
+ }
+ const parts = trimmed.split("/");
+ const fileName = parts.pop();
+ return {
+ fileName,
+ folder: parts.join("/")
+ };
+ }
+
+ rewriteLink(link) {
+ if (!link || link.startsWith("mailto")) {
+ return link;
+ }
+ if (!isAbsoluteUrl(link)) {
+ if (!isDocLink(link) && isImageLink(link)) {
+ const base = `${this.bookInfo.url.replace(/\/$/, "")}/files/`;
+ const absolute = toAbsoluteUrl(link, base);
+ const updated = this.registerImage(absolute);
+ if (updated) {
+ this.imagesFromLinks.add(absolute);
+ return updated;
+ }
+ }
+ return link.split("/").pop().replace(".html", ".xhtml");
+ }
+ if (typeof this.bookInfo.url === "string" && link.includes(this.bookInfo.url)) {
+ const partial = link.replace(this.bookInfo.url, "").replace(/^\/+/, "");
+ return this.rewriteLink(partial);
+ }
+ return link;
+ }
+
+ async processChapters() {
+ for (let index = 0; index < this.bookChapters.length; index++) {
+ const chapter = this.bookChapters[index];
+ const filename = this.getChapterFilename(chapter).replace(".html", ".xhtml");
+ const assets = chapter.related_assets || {};
+
+ if (Array.isArray(assets.images)) {
+ assets.images.forEach((img) => {
+ const url = isAbsoluteUrl(img) ? img : `${this.baseUrl}/files/${img}`;
+ this.registerImage(url);
+ });
+ }
+
+ const chapterStyles = [];
+ if (Array.isArray(assets.stylesheets)) {
+ chapterStyles.push(
+ ...assets.stylesheets.map((cssUrl) => (isAbsoluteUrl(cssUrl) ? cssUrl : `${this.baseUrl}${cssUrl}`))
+ );
+ }
+ if (Array.isArray(assets.site_styles)) {
+ chapterStyles.push(
+ ...assets.site_styles.map((cssUrl) => (isAbsoluteUrl(cssUrl) ? cssUrl : `${this.baseUrl}${cssUrl}`))
+ );
+ }
+
+ const htmlRoot = await this.loadChapterDocument(chapter.content_url);
+ const firstPage = index === 0;
+ const { cssMarkup, xhtml, detectedCover } = this.parseHtml(htmlRoot, chapterStyles, firstPage);
+ if (detectedCover && !this.coverPath) {
+ const coverRegistered = this.registerImage(detectedCover);
+ if (coverRegistered) {
+ this.coverPath = coverRegistered;
+ }
+ }
+ this.chapterDocuments.push({
+ title: chapter.title || `Chapter ${index + 1}`,
+ filename,
+ cssMarkup,
+ xhtml
+ });
+ }
+
+ if (!this.coverPath && typeof this.bookInfo.cover === "string") {
+ this.log("Falling back to book metadata cover image.");
+ const registered = this.registerImage(this.bookInfo.cover);
+ if (registered) {
+ this.coverPath = registered;
+ }
+ }
+
+ const hasCoverChapter = this.chapterDocuments.some(
+ (chapter) =>
+ chapter.filename.toLowerCase().includes("cover") || chapter.title.toLowerCase().includes("cover")
+ );
+ if (!hasCoverChapter && this.coverPath) {
+ const coverMarkup = ``;
+ const coverDocument = buildXhtml("", coverMarkup, this.themeMode, this.options.kindle);
+ this.chapterDocuments.unshift({
+ title: "Cover",
+ filename: "default_cover.xhtml",
+ cssMarkup: "",
+ xhtml: coverDocument
+ });
+ }
+ }
+
+ async loadChapterDocument(url) {
+ const response = await fetch(url, { credentials: "include" });
+ if (!response.ok) {
+ throw new Error(`Failed to download chapter at ${url} (status ${response.status})`);
+ }
+ const htmlText = await response.text();
+ const parser = new DOMParser();
+ return parser.parseFromString(htmlText, "text/html");
+ }
+
+ parseHtml(rootDocument, chapterStylesheets, firstPage) {
+ const bookContent = rootDocument.querySelector("#sbo-rt-content");
+ if (!bookContent) {
+ throw new Error("Chapter markup missing #sbo-rt-content node.");
+ }
+
+ const pageCssBlocks = [];
+
+ for (const cssUrl of chapterStylesheets) {
+ const filename = this.registerCssSource(cssUrl);
+ pageCssBlocks.push(``);
+ }
+
+ const stylesheetLinks = rootDocument.querySelectorAll("link[rel='stylesheet']");
+ stylesheetLinks.forEach((link) => {
+ const href = link.getAttribute("href");
+ if (!href) {
+ return;
+ }
+ const cssUrl = href.startsWith("//") ? `https:${href}` : toAbsoluteUrl(href, this.baseUrl);
+ const filename = this.registerCssSource(cssUrl);
+ pageCssBlocks.push(``);
+ });
+
+ const inlineStyles = rootDocument.querySelectorAll("style");
+ inlineStyles.forEach((styleEl) => {
+ if (styleEl.hasAttribute("data-template")) {
+ styleEl.textContent = styleEl.getAttribute("data-template");
+ styleEl.removeAttribute("data-template");
+ }
+ pageCssBlocks.push(styleEl.outerHTML);
+ });
+
+ this.rewriteLinksInNode(bookContent);
+ this.fixOverconstrainedImages(bookContent);
+
+ let detectedCover = null;
+ if (firstPage) {
+ detectedCover = this.detectCoverImage(bookContent);
+ }
+
+ const serializer = new XMLSerializer();
+ const xhtml = serializer.serializeToString(bookContent);
+ const documentHtml = buildXhtml(
+ pageCssBlocks.join("\n"),
+ xhtml,
+ this.themeMode,
+ this.options.kindle,
+ this.options.theme !== "none" ? THEME_CSS[this.options.theme] : ""
+ );
+
+ return {
+ cssMarkup: pageCssBlocks.join("\n"),
+ xhtml: documentHtml,
+ detectedCover
+ };
+ }
+
+ rewriteLinksInNode(node) {
+ const elements = node.querySelectorAll("*");
+ elements.forEach((el) => {
+ for (const attr of Array.from(el.attributes)) {
+ if (attr.name === "href" || attr.name === "src" || attr.name.endsWith(":href")) {
+ const rewritten = this.rewriteLink(attr.value);
+ if (rewritten) {
+ el.setAttribute(attr.name, rewritten);
+ }
+ }
+ }
+ });
+ }
+
+ fixOverconstrainedImages(node) {
+ const images = node.querySelectorAll("img");
+ images.forEach((img) => {
+ const style = img.getAttribute("style");
+ if (style && (style.includes("width") || style.includes("height"))) {
+ img.removeAttribute("width");
+ img.removeAttribute("height");
+ }
+ });
+ const svgImages = node.querySelectorAll("svg image");
+ svgImages.forEach((imageEl) => {
+ const hrefAttr = Array.from(imageEl.attributes).find((attr) => attr.name.endsWith("href"));
+ if (hrefAttr) {
+ const img = node.ownerDocument.createElement("img");
+ img.setAttribute("src", this.rewriteLink(hrefAttr.value));
+ const parent = imageEl.parentElement?.parentElement;
+ if (parent) {
+ parent.appendChild(img);
+ }
+ }
+ });
+ }
+
+ detectCoverImage(node) {
+ const selector =
+ "img[id*='cover' i], img[class*='cover' i], img[name*='cover' i], img[src*='cover' i], img[alt*='cover' i]";
+ const coverImg = node.querySelector(selector);
+ if (coverImg) {
+ return coverImg.getAttribute("src");
+ }
+ const fallback = node.querySelector("img");
+ return fallback ? fallback.getAttribute("src") : null;
+ }
+
+ async fetchCssSources() {
+ for (const source of this.cssSources) {
+ if (this.cssContentMap.has(source)) {
+ continue;
+ }
+ const response = await fetch(source, { credentials: "include" });
+ if (!response.ok) {
+ this.log(`Warning: unable to retrieve CSS from ${source} (status ${response.status})`);
+ continue;
+ }
+ let cssContent = await response.text();
+ cssContent = cssContent.replace(/display\s*:\s*none/gi, "visibility: hidden");
+ this.cssContentMap.set(source, cssContent);
+
+ this.registerFontsAndCssImages(source, cssContent);
+ }
+ }
+
+ registerFontsAndCssImages(sourceUrl, cssContent) {
+ try {
+ const url = new URL(sourceUrl);
+ const base = `${url.origin}${url.pathname.substring(0, url.pathname.lastIndexOf("/") + 1)}`;
+ const urlRegex = /url\(([^)]+)\)/gi;
+ let match = urlRegex.exec(cssContent);
+ while (match) {
+ let raw = match[1].trim().replace(/^['"]|['"]$/g, "");
+ if (raw.startsWith("data:") || raw.startsWith("about:")) {
+ match = urlRegex.exec(cssContent);
+ continue;
+ }
+ const absolute = toAbsoluteUrl(raw, base);
+ if (isFontUrl(raw)) {
+ this.registerFont(base, raw);
+ } else if (isLikelyImageAsset(raw)) {
+ const localPath = resolveStylesRelativePath(raw);
+ if (!this.cssImageDownloads.has(localPath)) {
+ this.cssImageDownloads.set(localPath, absolute);
+ }
+ }
+ match = urlRegex.exec(cssContent);
+ }
+ } catch (_) {
+ // ignore malformed css urls
+ }
+ }
+
+ async fetchFonts() {
+ for (const [localPath, url] of this.fontDownloads.entries()) {
+ if (this.cssImageDownloads.has(localPath)) {
+ continue;
+ }
+ const response = await fetch(url, { credentials: "include" });
+ if (!response.ok) {
+ this.log(`Warning: unable to retrieve font ${url} (status ${response.status})`);
+ continue;
+ }
+ const data = new Uint8Array(await response.arrayBuffer());
+ this.cssImageDownloads.set(localPath, { data, inline: false, sourceUrl: url });
+ }
+ }
+
+ async fetchImages() {
+ for (const [relative, url] of this.imageDownloads.entries()) {
+ const result = await this.fetchImageWithFallback(relative, url);
+ if (result) {
+ this.imageDownloads.set(relative, result);
+ }
+ }
+ }
+
+ async fetchImageWithFallback(relative, primaryUrl) {
+ const attempts = [];
+ const tried = new Set();
+
+ const queue = [primaryUrl, ...this.buildImageFallbackUrls(relative, primaryUrl)];
+ for (const candidate of queue) {
+ if (!candidate || tried.has(candidate)) {
+ continue;
+ }
+ tried.add(candidate);
+ try {
+ const response = await fetch(candidate, { credentials: "include" });
+ if (!response.ok) {
+ attempts.push(`${candidate} (status ${response.status})`);
+ continue;
+ }
+ const data = new Uint8Array(await response.arrayBuffer());
+ if (candidate !== primaryUrl) {
+ this.log(`Info: fetched image ${relative} via fallback ${candidate}`);
+ }
+ return { data, sourceUrl: candidate };
+ } catch (error) {
+ attempts.push(`${candidate} (${error.message})`);
+ }
+ }
+
+ if (attempts.length) {
+ this.log(
+ `Warning: unable to fetch image ${primaryUrl}. Attempts: ${attempts.join("; ")}`
+ );
+ }
+ return null;
+ }
+
+ buildImageFallbackUrls(relative, url) {
+ const candidates = new Set();
+
+ if (url.includes("/Images/")) {
+ candidates.add(url.replace("/Images/", "/images/"));
+ }
+ if (url.includes("/images/")) {
+ candidates.add(url.replace("/images/", "/Images/"));
+ }
+
+ if (url.includes("/files/Images/")) {
+ const base = url.replace(/Images\/[^/]*$/i, "Images/");
+ ["cover.jpg", "cover.jpeg", "cover.png", "cover-large.jpg"].forEach((file) => {
+ candidates.add(`${base}${file}`);
+ });
+ }
+
+ const isbnMatch = url.match(/Images\/(\d{10,13})\.(jpg|jpeg|png|gif)$/i);
+ if (isbnMatch) {
+ const isbn = isbnMatch[1];
+ const sizes = ["600w", "400w", "250w", ""];
+ sizes.forEach((size) => {
+ const trimmedSize = size ? `/${size}` : "";
+ candidates.add(`https://learning.oreilly.com/library/cover/${isbn}${trimmedSize}/`);
+ });
+ }
+
+ if (this.bookInfo?.cover && url !== this.bookInfo.cover) {
+ candidates.add(this.bookInfo.cover);
+ }
+
+ if (relative && this.bookInfo?.cover_url) {
+ candidates.add(this.bookInfo.cover_url);
+ }
+
+ return Array.from(candidates).filter(Boolean);
+ }
+
+ async fetchCssImages() {
+ for (const [path, entry] of this.cssImageDownloads.entries()) {
+ if (entry?.data) {
+ continue;
+ }
+ const response = await fetch(entry, { credentials: "include" });
+ if (!response.ok) {
+ this.log(`Warning: unable to fetch CSS image ${entry} (status ${response.status})`);
+ continue;
+ }
+ const data = new Uint8Array(await response.arrayBuffer());
+ this.cssImageDownloads.set(path, { data, sourceUrl: entry });
+ }
+ }
+
+ createManifestAndSpine() {
+ const manifestEntries = [];
+ const spineEntries = [];
+ this.chapterDocuments.forEach((chapter) => {
+ const itemId = escapeXml(chapter.filename.replace(/\.xhtml$/i, ""));
+ manifestEntries.push(
+ ` `
+ );
+ spineEntries.push(``);
+ });
+
+ for (const [relative, image] of this.imageDownloads.entries()) {
+ if (!image?.data) {
+ continue;
+ }
+ const ext = relative.split(".").pop().toLowerCase();
+ const media = ext.includes("jp") ? "image/jpeg" : `image/${ext}`;
+ const itemId = escapeXml(`img_${relative.replace(/[\\/]/g, "_").split(".")[0]}`);
+ manifestEntries.push(
+ ` `
+ );
+ }
+
+ for (const [source, filename] of this.cssFilenameMap.entries()) {
+ const cssContent = this.cssContentMap.get(source);
+ if (!cssContent) {
+ continue;
+ }
+ manifestEntries.push(
+ ` `
+ );
+ }
+
+ for (const [path] of this.cssImageDownloads.entries()) {
+ const ext = path.split(".").pop().toLowerCase();
+ if (isFontUrl(path)) {
+ const mediaType = ext === "otf" ? "font/otf" : ext === "ttf" ? "font/ttf" : `font/${ext}`;
+ const id = escapeXml(path.replace(/[\\/]/g, "_"));
+ manifestEntries.push(
+ ` `
+ );
+ } else if (isLikelyImageAsset(path)) {
+ const media = ext.includes("jp") ? "image/jpeg" : `image/${ext}`;
+ const id = escapeXml(path.replace(/[\\/]/g, "_"));
+ manifestEntries.push(` `);
+ }
+ }
+
+ return {
+ manifest: manifestEntries.join("\n"),
+ spine: spineEntries.join("\n")
+ };
+ }
+
+ getCoverId() {
+ if (!this.coverPath) {
+ return "cover";
+ }
+ const withoutPrefix = this.coverPath.replace(/^Images\//, "");
+ const base = withoutPrefix.includes(".") ? withoutPrefix.split(".")[0] : withoutPrefix;
+ return `img_${base.replace(/[\\/]/g, "_")}`;
+ }
+
+ createContentOpf(manifest, spine) {
+ const authors = (this.bookInfo.authors || [])
+ .map((author) => `${escapeXml(author.name)}`)
+ .join("\n");
+ const subjects = (this.bookInfo.subjects || [])
+ .map((subject) => `${escapeXml(subject.name)}`)
+ .join("\n");
+
+ const descriptionContainer = this.bookInfo.descriptions || {};
+ const description = typeof descriptionContainer === "object" ? descriptionContainer["text/plain"] || "" : "";
+ const publishers = (this.bookInfo.publishers || []).map((publisher) => escapeXml(publisher.name)).join(", ");
+
+ return `
+
+
+${escapeXml(this.bookInfo.title)}
+${authors}
+${escapeXml(description)}
+${subjects}
+${publishers}
+${escapeXml(this.bookInfo.rights || "")}
+en-US
+${escapeXml(this.bookInfo.publication_date || "")}
+${escapeXml(this.bookInfo.isbn || this.bookId)}
+
+
+
+
+${manifest}
+
+
+${spine}
+
+
+`;
+ }
+
+ createTocNavMap(entries, count = { value: 0 }, maxDepth = { value: 0 }) {
+ if (!Array.isArray(entries)) {
+ return { navMap: "", maxDepth: maxDepth.value };
+ }
+ let navMap = "";
+ for (const entry of entries) {
+ count.value += 1;
+ const depth = Number(entry.depth || 0);
+ if (depth > maxDepth.value) {
+ maxDepth.value = depth;
+ }
+ const href = this.getChapterHref(entry);
+ const title = escapeXml(entry.title || entry.label || "Chapter");
+ const referenceId = entry.reference_id ? escapeXml(makeValidId(entry.reference_id)) : "";
+ const fragment = entry.fragment ? entry.fragment : referenceId;
+ let childrenXml = "";
+ if (Array.isArray(entry.children) && entry.children.length) {
+ const childResult = this.createTocNavMap(entry.children, count, maxDepth);
+ childrenXml = childResult.navMap;
+ maxDepth.value = Math.max(maxDepth.value, childResult.maxDepth);
+ }
+ navMap += `${title}${childrenXml}`;
+ }
+ return { navMap, maxDepth: maxDepth.value };
+ }
+
+ getChapterHref(entry) {
+ if (entry.href) {
+ return escapeXml(entry.href.replace(".html", ".xhtml"));
+ }
+ if (entry.ourn) {
+ const filename = entry.ourn.split("chapter:").pop().split("%2f").pop();
+ return escapeXml(filename.replace(".html", ".xhtml"));
+ }
+ return escapeXml(this.chapterDocuments[0]?.filename || "chapter.xhtml");
+ }
+
+ createToc() {
+ const sourceToc = this.bookInfo.table_of_contents || [];
+ const { navMap, maxDepth } = this.createTocNavMap(sourceToc);
+ const authors = (this.bookInfo.authors || []).map((author) => author.name).join(", ");
+ return `
+
+
+
+
+
+
+
+
+${escapeXml(this.bookInfo.title)}
+${escapeXml(authors)}
+${navMap}
+`;
+ }
+
+ async assembleEpub() {
+ const zip = new SimpleZip();
+ zip.addFile("mimetype", dataFromString("application/epub+zip"));
+ zip.addFile("META-INF/container.xml", dataFromString(''));
+
+ const { manifest, spine } = this.createManifestAndSpine();
+ const contentOpf = this.createContentOpf(manifest, spine);
+ zip.addFile("OEBPS/content.opf", dataFromString(contentOpf));
+ const toc = this.createToc();
+ zip.addFile("OEBPS/toc.ncx", dataFromString(toc));
+
+ for (const chapter of this.chapterDocuments) {
+ zip.addFile(`OEBPS/${chapter.filename}`, dataFromString(chapter.xhtml));
+ }
+
+ for (const [source, filename] of this.cssFilenameMap.entries()) {
+ const content = this.cssContentMap.get(source);
+ if (!content) {
+ continue;
+ }
+ zip.addFile(`OEBPS/Styles/${filename}`, dataFromString(content));
+ }
+
+ for (const [path, entry] of this.cssImageDownloads.entries()) {
+ if (!entry?.data) {
+ continue;
+ }
+ zip.addFile(`OEBPS/${path}`, entry.data);
+ }
+
+ for (const [relative, entry] of this.imageDownloads.entries()) {
+ if (!entry?.data) {
+ continue;
+ }
+ zip.addFile(`OEBPS/Images/${relative}`, entry.data);
+ }
+
+ return zip;
+ }
+}
diff --git a/firefox/safaribooks/utils.js b/firefox/safaribooks/utils.js
new file mode 100644
index 0000000..e338d47
--- /dev/null
+++ b/firefox/safaribooks/utils.js
@@ -0,0 +1,122 @@
+const INVALID_DIR_CHARS = /[~#%&*{}\\<>?/`'"|+:]/g;
+const IMAGE_EXTENSIONS = new Set(["jpg", "jpeg", "png", "gif", "svg", "webp"]);
+const FONT_EXTENSIONS = new Set(["otf", "ttf", "woff", "woff2", "eot"]);
+
+export function cleanDirectoryName(name) {
+ let sanitized = name;
+ if (sanitized.includes(":")) {
+ if (sanitized.indexOf(":") > 15) {
+ sanitized = sanitized.split(":")[0];
+ } else {
+ sanitized = sanitized.replace(/:/g, ",");
+ }
+ }
+ sanitized = sanitized.replace(INVALID_DIR_CHARS, "_");
+ return sanitized.replace(/\s+/g, " ").trim();
+}
+
+export function makeFolderFriendlyName(title, bookId) {
+ const safeTitle = cleanDirectoryName(title);
+ const prefix = safeTitle.split(",").slice(0, 2).join(",");
+ return `${prefix || safeTitle} (${bookId})`.trim();
+}
+
+export function makeFileFriendlyName(...segments) {
+ const cleaned = segments
+ .map((segment) => segment ?? "")
+ .map((segment) => cleanDirectoryName(String(segment)))
+ .filter((segment) => segment.length > 0);
+
+ if (!cleaned.length) {
+ return "download";
+ }
+
+ return cleaned.join(" - ").replace(/\s+/g, " ").trim();
+}
+
+export function escapeXml(value) {
+ return value
+ .replace(/&/g, "&")
+ .replace(//g, ">")
+ .replace(/"/g, """)
+ .replace(/'/g, "'");
+}
+
+export function isAbsoluteUrl(url) {
+ try {
+ const parsed = new URL(url);
+ return Boolean(parsed.protocol);
+ } catch (_) {
+ return false;
+ }
+}
+
+export function isDocLink(url) {
+ return [".html", ".xhtml", ".pdf"].some((ext) => url.includes(ext));
+}
+
+export function isImageLink(url) {
+ const lowered = url.toLowerCase();
+ if (lowered.includes("cover") || lowered.includes("images") || lowered.includes("graphics")) {
+ return true;
+ }
+ const ext = lowered.split(".").pop();
+ return IMAGE_EXTENSIONS.has(ext);
+}
+
+export function isFontUrl(url) {
+ const ext = url.toLowerCase().split(".").pop().split("?")[0];
+ return FONT_EXTENSIONS.has(ext);
+}
+
+export function isLikelyImageAsset(url) {
+ const ext = url.toLowerCase().split(".").pop().split("?")[0];
+ return IMAGE_EXTENSIONS.has(ext);
+}
+
+export function makeValidId(value) {
+ const sanitized = value.replace(/\W|^(?=\d)/g, "_");
+ return sanitized[0]?.match(/\d/) ? `_${sanitized}` : sanitized;
+}
+
+export function resolveStylesRelativePath(relativePath) {
+ const baseParts = ["Styles"];
+ const segments = relativePath.split("/").filter(Boolean);
+ const resolved = [];
+ for (const segment of segments) {
+ if (segment === ".") {
+ continue;
+ }
+ if (segment === "..") {
+ if (resolved.length) {
+ resolved.pop();
+ } else if (baseParts.length) {
+ baseParts.pop();
+ }
+ continue;
+ }
+ resolved.push(segment);
+ }
+ return [...baseParts, ...resolved].join("/");
+}
+
+export function ensureForwardSlashes(path) {
+ return path.replace(/\\/g, "/");
+}
+
+export function uniqPush(collection, value) {
+ if (!collection.includes(value)) {
+ collection.push(value);
+ }
+ return collection.indexOf(value);
+}
+
+export function dataFromString(value) {
+ const encoder = new TextEncoder();
+ return encoder.encode(value);
+}
+
+export function toHex(value, width = 8) {
+ return value.toString(16).padStart(width, "0");
+}
diff --git a/firefox/safaribooks/zip.js b/firefox/safaribooks/zip.js
new file mode 100644
index 0000000..0bc744e
--- /dev/null
+++ b/firefox/safaribooks/zip.js
@@ -0,0 +1,151 @@
+const CRC_TABLE = (() => {
+ const table = new Uint32Array(256);
+ for (let i = 0; i < 256; i++) {
+ let c = i;
+ for (let j = 0; j < 8; j++) {
+ c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ }
+ table[i] = c >>> 0;
+ }
+ return table;
+})();
+
+function crc32(data) {
+ let crc = 0xffffffff;
+ for (let i = 0; i < data.length; i++) {
+ const byte = data[i];
+ crc = CRC_TABLE[(crc ^ byte) & 0xff] ^ (crc >>> 8);
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+}
+
+const textEncoder = new TextEncoder();
+
+export class SimpleZip {
+ constructor() {
+ this.files = [];
+ }
+
+ addFile(path, data) {
+ if (!(data instanceof Uint8Array)) {
+ throw new TypeError(`File data for ${path} must be a Uint8Array.`);
+ }
+ const cleanPath = path.replace(/\\/g, "/");
+ const nameBytes = textEncoder.encode(cleanPath);
+ const checksum = crc32(data);
+ this.files.push({
+ name: cleanPath,
+ nameBytes,
+ data,
+ crc: checksum,
+ localHeaderOffset: 0
+ });
+ }
+
+ generate() {
+ let size = 0;
+ for (const file of this.files) {
+ const localSize = 30 + file.nameBytes.length + file.data.length;
+ size += localSize;
+ }
+
+ const centralDirectoryOffset = size;
+ let centralDirectorySize = 0;
+ for (const file of this.files) {
+ centralDirectorySize += 46 + file.nameBytes.length;
+ }
+ size += centralDirectorySize + 22;
+
+ const output = new Uint8Array(size);
+ const view = new DataView(output.buffer);
+ let offset = 0;
+
+ for (const file of this.files) {
+ file.localHeaderOffset = offset;
+ view.setUint32(offset, 0x04034b50, true);
+ offset += 4;
+ view.setUint16(offset, 20, true); // version needed to extract
+ offset += 2;
+ view.setUint16(offset, 0, true); // general purpose flag
+ offset += 2;
+ view.setUint16(offset, 0, true); // compression (store)
+ offset += 2;
+ view.setUint16(offset, 0, true); // mod time
+ offset += 2;
+ view.setUint16(offset, 0, true); // mod date
+ offset += 2;
+ view.setUint32(offset, file.crc, true);
+ offset += 4;
+ view.setUint32(offset, file.data.length, true);
+ offset += 4;
+ view.setUint32(offset, file.data.length, true);
+ offset += 4;
+ view.setUint16(offset, file.nameBytes.length, true);
+ offset += 2;
+ view.setUint16(offset, 0, true); // extra length
+ offset += 2;
+ output.set(file.nameBytes, offset);
+ offset += file.nameBytes.length;
+ output.set(file.data, offset);
+ offset += file.data.length;
+ }
+
+ let centralOffset = centralDirectoryOffset;
+ for (const file of this.files) {
+ view.setUint32(centralOffset, 0x02014b50, true);
+ centralOffset += 4;
+ view.setUint16(centralOffset, 0x0014, true); // version made by
+ centralOffset += 2;
+ view.setUint16(centralOffset, 20, true); // version needed to extract
+ centralOffset += 2;
+ view.setUint16(centralOffset, 0, true); // general purpose flag
+ centralOffset += 2;
+ view.setUint16(centralOffset, 0, true); // compression
+ centralOffset += 2;
+ view.setUint16(centralOffset, 0, true); // mod time
+ centralOffset += 2;
+ view.setUint16(centralOffset, 0, true); // mod date
+ centralOffset += 2;
+ view.setUint32(centralOffset, file.crc, true);
+ centralOffset += 4;
+ view.setUint32(centralOffset, file.data.length, true);
+ centralOffset += 4;
+ view.setUint32(centralOffset, file.data.length, true);
+ centralOffset += 4;
+ view.setUint16(centralOffset, file.nameBytes.length, true);
+ centralOffset += 2;
+ view.setUint16(centralOffset, 0, true); // extra field length
+ centralOffset += 2;
+ view.setUint16(centralOffset, 0, true); // comment length
+ centralOffset += 2;
+ view.setUint16(centralOffset, 0, true); // disk number start
+ centralOffset += 2;
+ view.setUint16(centralOffset, 0, true); // internal file attrs
+ centralOffset += 2;
+ view.setUint32(centralOffset, 0, true); // external file attrs
+ centralOffset += 4;
+ view.setUint32(centralOffset, file.localHeaderOffset, true);
+ centralOffset += 4;
+ output.set(file.nameBytes, centralOffset);
+ centralOffset += file.nameBytes.length;
+ }
+
+ view.setUint32(centralOffset, 0x06054b50, true);
+ centralOffset += 4;
+ view.setUint16(centralOffset, 0, true); // number of this disk
+ centralOffset += 2;
+ view.setUint16(centralOffset, 0, true); // disk where central directory starts
+ centralOffset += 2;
+ view.setUint16(centralOffset, this.files.length, true); // records on this disk
+ centralOffset += 2;
+ view.setUint16(centralOffset, this.files.length, true); // total records
+ centralOffset += 2;
+ view.setUint32(centralOffset, centralDirectorySize, true);
+ centralOffset += 4;
+ view.setUint32(centralOffset, centralDirectoryOffset, true);
+ centralOffset += 4;
+ view.setUint16(centralOffset, 0, true); // comment length
+
+ return output;
+ }
+}
diff --git a/scripts/build-xpi.ps1 b/scripts/build-xpi.ps1
new file mode 100644
index 0000000..225e514
--- /dev/null
+++ b/scripts/build-xpi.ps1
@@ -0,0 +1,24 @@
+param(
+ [string]$Source = "firefox",
+ [string]$Output = "firefox-extension.xpi"
+)
+
+Set-StrictMode -Version Latest
+$ErrorActionPreference = "Stop"
+
+$scriptRoot = Split-Path -Parent $MyInvocation.MyCommand.Path
+$repoRoot = Resolve-Path (Join-Path $scriptRoot "..")
+$sourcePath = Join-Path $repoRoot $Source
+$outputPath = Join-Path $repoRoot $Output
+
+if (-not (Test-Path -Path $sourcePath -PathType Container)) {
+ throw "Source folder '$sourcePath' was not found."
+}
+
+if (Test-Path -Path $outputPath -PathType Leaf) {
+ Remove-Item -Path $outputPath -Force
+}
+
+Compress-Archive -Path (Join-Path $sourcePath "*") -DestinationPath $outputPath -Force
+
+Write-Host "Created XPI at $outputPath"
diff --git a/scripts/build-xpi.sh b/scripts/build-xpi.sh
new file mode 100644
index 0000000..3271732
--- /dev/null
+++ b/scripts/build-xpi.sh
@@ -0,0 +1,29 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+SOURCE_DIR="${1:-firefox}"
+OUTPUT_FILE="${2:-firefox-extension.xpi}"
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+REPO_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
+SOURCE_PATH="${REPO_ROOT}/${SOURCE_DIR}"
+OUTPUT_PATH="${REPO_ROOT}/${OUTPUT_FILE}"
+
+if [[ ! -d "${SOURCE_PATH}" ]]; then
+ echo "Source folder '${SOURCE_PATH}' was not found." >&2
+ exit 1
+fi
+
+if ! command -v zip >/dev/null 2>&1; then
+ echo "The 'zip' command is required but was not found in PATH." >&2
+ exit 1
+fi
+
+rm -f "${OUTPUT_PATH}"
+
+(
+ cd "${SOURCE_PATH}"
+ zip -r "${OUTPUT_PATH}" . >/dev/null
+)
+
+echo "Created XPI at ${OUTPUT_PATH}"