From 7c3fd294a520c5ba4e91b4f62b22a17872aeac31 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 14 Oct 2025 09:55:33 -0600 Subject: [PATCH] Initial commit --- index.css | 106 ++++++ index.html | 45 +++ index.js | 50 +++ manifest.json | 21 ++ safaribooks/cookies.js | 46 +++ safaribooks/downloader.js | 762 ++++++++++++++++++++++++++++++++++++++ safaribooks/utils.js | 122 ++++++ safaribooks/zip.js | 151 ++++++++ 8 files changed, 1303 insertions(+) create mode 100644 index.css create mode 100644 index.html create mode 100644 index.js create mode 100644 manifest.json create mode 100644 safaribooks/cookies.js create mode 100644 safaribooks/downloader.js create mode 100644 safaribooks/utils.js create mode 100644 safaribooks/zip.js diff --git a/index.css b/index.css new file mode 100644 index 0000000..680e7a6 --- /dev/null +++ b/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/index.html b/index.html new file mode 100644 index 0000000..06cde58 --- /dev/null +++ b/index.html @@ -0,0 +1,45 @@ + + + + + SafariBooks Downloader + + + +
+
+

SafariBooks Downloader

+

Port of safaribooks.py for Chrome. Make sure you are signed in to O'Reilly in this profile.

+
+
+
+ + + +
+ +
+
+
+
+

Activity

+

+      
+
+ + + diff --git a/index.js b/index.js new file mode 100644 index 0000000..077704f --- /dev/null +++ b/index.js @@ -0,0 +1,50 @@ +import { SafariBooksDownloader } from "./safaribooks/downloader.js"; +import { collectSessionCookies } from "./safaribooks/cookies.js"; + +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; +} + +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); + } +}); diff --git a/manifest.json b/manifest.json new file mode 100644 index 0000000..ee10de3 --- /dev/null +++ b/manifest.json @@ -0,0 +1,21 @@ +{ + "manifest_version": 3, + "name": "Reilly SafariBooks Downloader", + "description": "Download O'Reilly learning content as EPUB files using a port of safaribooks.py.", + "version": "0.1.0", + "action": { + "default_title": "SafariBooks Downloader", + "default_popup": "index.html" + }, + "permissions": [ + "downloads", + "cookies", + "storage" + ], + "host_permissions": [ + "https://learning.oreilly.com/*", + "https://www.oreilly.com/*", + "https://api.oreilly.com/*" + ], + "web_accessible_resources": [] +} diff --git a/safaribooks/cookies.js b/safaribooks/cookies.js new file mode 100644 index 0000000..b07498f --- /dev/null +++ b/safaribooks/cookies.js @@ -0,0 +1,46 @@ +const PROFILE_URL = "https://learning.oreilly.com/profile/"; +const COOKIE_DOMAINS = [ + "learning.oreilly.com", + ".oreilly.com", + "www.oreilly.com" +]; + +async function gatherCookies() { + const collected = {}; + for (const domain of COOKIE_DOMAINS) { + const cookies = await chrome.cookies.getAll({ 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/safaribooks/downloader.js b/safaribooks/downloader.js new file mode 100644 index 0000000..fe785a4 --- /dev/null +++ b/safaribooks/downloader.js @@ -0,0 +1,762 @@ +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 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} + + +
${innerHtml}
+` + ); +} + +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 chrome.downloads.download({ 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 = `
Book cover
`; + 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 response = await fetch(url, { credentials: "include" }); + if (!response.ok) { + this.log(`Warning: unable to fetch image ${url} (status ${response.status})`); + continue; + } + const data = new Uint8Array(await response.arrayBuffer()); + this.imageDownloads.set(relative, { data, sourceUrl: url }); + } + } + + 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/safaribooks/utils.js b/safaribooks/utils.js new file mode 100644 index 0000000..e338d47 --- /dev/null +++ b/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/safaribooks/zip.js b/safaribooks/zip.js new file mode 100644 index 0000000..0bc744e --- /dev/null +++ b/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; + } +}