Make download button work
This commit is contained in:
parent
7c3fd294a5
commit
a36691483f
5 changed files with 477 additions and 0 deletions
161
background.js
Normal file
161
background.js
Normal file
|
|
@ -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;
|
||||||
|
});
|
||||||
233
content.js
Normal file
233
content.js
Normal file
|
|
@ -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();
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
|
@ -3,10 +3,32 @@
|
||||||
"name": "Reilly SafariBooks Downloader",
|
"name": "Reilly SafariBooks Downloader",
|
||||||
"description": "Download O'Reilly learning content as EPUB files using a port of safaribooks.py.",
|
"description": "Download O'Reilly learning content as EPUB files using a port of safaribooks.py.",
|
||||||
"version": "0.1.0",
|
"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": {
|
"action": {
|
||||||
"default_title": "SafariBooks Downloader",
|
"default_title": "SafariBooks Downloader",
|
||||||
"default_popup": "index.html"
|
"default_popup": "index.html"
|
||||||
},
|
},
|
||||||
|
"content_scripts": [
|
||||||
|
{
|
||||||
|
"matches": [
|
||||||
|
"https://learning.oreilly.com/library/view/*"
|
||||||
|
],
|
||||||
|
"js": [
|
||||||
|
"content.js"
|
||||||
|
],
|
||||||
|
"run_at": "document_idle"
|
||||||
|
}
|
||||||
|
],
|
||||||
"permissions": [
|
"permissions": [
|
||||||
"downloads",
|
"downloads",
|
||||||
"cookies",
|
"cookies",
|
||||||
|
|
|
||||||
10
offscreen.html
Normal file
10
offscreen.html
Normal file
|
|
@ -0,0 +1,10 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<title>SafariBooks Downloader Offscreen</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<script type="module" src="offscreen.js"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
51
offscreen.js
Normal file
51
offscreen.js
Normal file
|
|
@ -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
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue