From a36691483f5f238feca40a361977a47d0e84d8a0 Mon Sep 17 00:00:00 2001 From: Joey Yakimowich-Payne Date: Tue, 14 Oct 2025 10:25:03 -0600 Subject: [PATCH] Make download button work --- background.js | 161 ++++++++++++++++++++++++++++++++++ content.js | 233 +++++++++++++++++++++++++++++++++++++++++++++++++ manifest.json | 22 +++++ offscreen.html | 10 +++ offscreen.js | 51 +++++++++++ 5 files changed, 477 insertions(+) create mode 100644 background.js create mode 100644 content.js create mode 100644 offscreen.html create mode 100644 offscreen.js diff --git a/background.js b/background.js new file mode 100644 index 0000000..a7fd41b --- /dev/null +++ b/background.js @@ -0,0 +1,161 @@ +const pendingResponses = new Map(); +const executionContext = { + mode: null, + windowId: null, + tabId: null +}; +let contextReadyPromise = null; +let contextReadyResolver = null; + +function createReadyPromise() { + contextReadyPromise = new Promise((resolve) => { + contextReadyResolver = resolve; + }); +} + +function resolveReadyPromise() { + if (contextReadyResolver) { + contextReadyResolver(); + contextReadyResolver = null; + } + if (!contextReadyPromise) { + contextReadyPromise = Promise.resolve(); + } +} + +async function ensureDocumentContext() { + const hasOffscreenApi = Boolean(chrome.offscreen?.createDocument); + if (hasOffscreenApi) { + try { + if (chrome.offscreen.hasDocument) { + const exists = await chrome.offscreen.hasDocument(); + if (exists) { + executionContext.mode = "offscreen"; + if (!contextReadyPromise) { + contextReadyPromise = Promise.resolve(); + } + await contextReadyPromise; + return; + } + } + } catch (error) { + // ignore and fall back + } + + try { + createReadyPromise(); + await chrome.offscreen.createDocument({ + url: chrome.runtime.getURL("offscreen.html"), + reasons: ["DOM_PARSER"], + justification: "Process O'Reilly pages to build EPUB downloads" + }); + executionContext.mode = "offscreen"; + await contextReadyPromise; + return; + } catch (error) { + const alreadyExists = String(error?.message || "").includes("already exists"); + if (!alreadyExists) { + console.warn("SafariBooks Downloader: Offscreen document failed, falling back to hidden window.", error); + } else { + executionContext.mode = "offscreen"; + if (!contextReadyPromise) { + contextReadyPromise = Promise.resolve(); + } + await contextReadyPromise; + return; + } + } + } + + if (executionContext.mode === "window") { + if (executionContext.tabId != null) { + try { + await chrome.tabs.get(executionContext.tabId); + if (!contextReadyPromise) { + contextReadyPromise = Promise.resolve(); + } + await contextReadyPromise; + return; + } catch (_) { + executionContext.tabId = null; + } + } + } + + const popupUrl = chrome.runtime.getURL("offscreen.html#window"); + const createdWindow = await chrome.windows.create({ + url: popupUrl, + type: "popup", + focused: false, + width: 420, + height: 520 + }); + + executionContext.mode = "window"; + executionContext.windowId = createdWindow.id; + executionContext.tabId = createdWindow.tabs && createdWindow.tabs.length ? createdWindow.tabs[0].id : null; + createReadyPromise(); + await contextReadyPromise; +} + +chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { + if (message?.type === "downloadBook") { + ensureDocumentContext() + .then(() => { + const requestId = crypto.randomUUID(); + pendingResponses.set(requestId, sendResponse); + chrome.runtime + .sendMessage({ + type: "offscreen-download", + requestId, + bookId: message.bookId, + options: { + theme: message.theme ?? "none", + kindle: Boolean(message.kindle) + } + }) + .catch((error) => { + const responder = pendingResponses.get(requestId); + if (responder) { + responder({ ok: false, error: error.message }); + pendingResponses.delete(requestId); + } + }); + }) + .catch((error) => { + sendResponse({ ok: false, error: error.message }); + }); + return true; + } + + if (message?.type === "offscreen-ready") { + resolveReadyPromise(); + return false; + } + + if (message?.type === "offscreen-download-complete") { + const { requestId, ok, error } = message; + const responder = pendingResponses.get(requestId); + if (responder) { + responder({ ok, error }); + pendingResponses.delete(requestId); + } + + if (executionContext.mode === "window" && pendingResponses.size === 0 && executionContext.windowId != null) { + chrome.windows + .remove(executionContext.windowId) + .catch(() => {}) + .finally(() => { + executionContext.windowId = null; + executionContext.tabId = null; + executionContext.mode = null; + contextReadyPromise = null; + contextReadyResolver = null; + }); + } + + return false; + } + + return undefined; +}); diff --git a/content.js b/content.js new file mode 100644 index 0000000..4a1594e --- /dev/null +++ b/content.js @@ -0,0 +1,233 @@ +(function () { + const BUTTON_ID = "reilly-safaribooks-download"; + const BOOK_PATH_PART = "/library/view/"; + let observerInstance = null; + let ensureIntervalId = null; + let initialized = false; + + 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 handleDownload(button, bookId) { + if (!bookId) { + return; + } + const originalText = button.textContent; + button.disabled = true; + button.style.opacity = "0.7"; + button.textContent = "Downloading…"; + + chrome.runtime.sendMessage({ type: "downloadBook", bookId }, (response) => { + const error = chrome.runtime.lastError; + if (error) { + console.error("SafariBooks Downloader:", error); + button.textContent = "Failed (extension error)"; + } else if (response?.ok) { + button.textContent = "Download started"; + } else { + button.textContent = response?.error ? `Failed: ${response.error}` : "Download failed"; + } + + setTimeout(() => { + button.textContent = originalText; + button.disabled = false; + button.style.opacity = ""; + }, response?.ok ? 2000 : 4000); + }); + } + + 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/manifest.json b/manifest.json index ee10de3..7098b4d 100644 --- a/manifest.json +++ b/manifest.json @@ -3,10 +3,32 @@ "name": "Reilly SafariBooks Downloader", "description": "Download O'Reilly learning content as EPUB files using a port of safaribooks.py.", "version": "0.1.0", + "background": { + "service_worker": "background.js", + "type": "module" + }, + "offscreen": { + "page": "offscreen.html", + "reasons": [ + "DOM_PARSER" + ], + "justification": "Parse book HTML to build EPUB files in a DOM-enabled context" + }, "action": { "default_title": "SafariBooks Downloader", "default_popup": "index.html" }, + "content_scripts": [ + { + "matches": [ + "https://learning.oreilly.com/library/view/*" + ], + "js": [ + "content.js" + ], + "run_at": "document_idle" + } + ], "permissions": [ "downloads", "cookies", diff --git a/offscreen.html b/offscreen.html new file mode 100644 index 0000000..c8d49df --- /dev/null +++ b/offscreen.html @@ -0,0 +1,10 @@ + + + + + SafariBooks Downloader Offscreen + + + + + diff --git a/offscreen.js b/offscreen.js new file mode 100644 index 0000000..e097a4d --- /dev/null +++ b/offscreen.js @@ -0,0 +1,51 @@ +import { SafariBooksDownloader } from "./safaribooks/downloader.js"; +import { collectSessionCookies } from "./safaribooks/cookies.js"; + +const downloader = new SafariBooksDownloader((message) => { + console.info(`[SafariBooksDownloader] ${message}`); +}); + +async function handleOffscreenDownload(message) { + const { requestId, bookId, options = {} } = message; + if (!bookId) { + chrome.runtime.sendMessage({ + type: "offscreen-download-complete", + requestId, + ok: false, + error: "Missing book ID." + }); + return; + } + + try { + await collectSessionCookies(); + await downloader.download(bookId, { + theme: options.theme ?? "none", + kindle: Boolean(options.kindle) + }); + chrome.runtime.sendMessage({ + type: "offscreen-download-complete", + requestId, + ok: true + }); + } catch (error) { + chrome.runtime.sendMessage({ + type: "offscreen-download-complete", + requestId, + ok: false, + error: error.message + }); + } +} + +chrome.runtime.onMessage.addListener((message) => { + if (message?.type === "offscreen-download") { + handleOffscreenDownload(message); + } +}); + +chrome.runtime + .sendMessage({ type: "offscreen-ready" }) + .catch(() => { + // ignore errors if background not ready yet + });