Skip to content

Instantly share code, notes, and snippets.

@brad
Created August 22, 2026 18:21
Show Gist options
  • Select an option

  • Save brad/b3754b21328cbce524b68e19b89e94e1 to your computer and use it in GitHub Desktop.

Select an option

Save brad/b3754b21328cbce524b68e19b89e94e1 to your computer and use it in GitHub Desktop.
NookRip - Nook Ebook to EPUB & Audiobook Ripper
// ==UserScript==
// @name NookRip - Nook Ebook to EPUB & Audiobook Ripper
// @namespace http://tampermonkey.net/
// @version 1.0.0
// @description Reconstructs a reflowable EPUB from Barnes & Noble Nook's web reader, and downloads audiobooks as a zip of ID3-tagged MP3s with metadata (nook.barnesandnoble.com)
// @author you
// @license MIT
// @match *://*.nook.barnesandnoble.com/*
// @match *://*.webdelivery.barnesandnoble.com/*
// @connect unpkg.com
// @connect cdn.jsdelivr.net
// @run-at document-start
// @grant none
// ==/UserScript==
(function () {
'use strict';
const MSG_NS = 'nookrip';
const isTop = (window.top === window.self);
const isDeliveryHost = /webdelivery\.barnesandnoble\.com$/.test(location.hostname);
const isNookHost = /nook\.barnesandnoble\.com$/.test(location.hostname);
const LOG_PREFIX = '[NookRip]';
/* =========================================
SHARED HELPERS
=========================================
*/
const esc = s => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
function getMimeType(fileName) {
const ext = fileName.split('.').pop().toLowerCase();
return ({
jpg: 'image/jpeg', jpeg: 'image/jpeg', png: 'image/png',
gif: 'image/gif', svg: 'image/svg+xml', webp: 'image/webp'
})[ext] || 'application/octet-stream';
}
// Some Nook/Findaway metadata stores titles with a leading article moved to the end
// for alphabetical-sort purposes, e.g. "Life-Changing Magic of Tidying Up, The" or
// "Handmaid's Tale, The". That's fine for a sorted list column but reads badly as a
// filename or in a book's displayed title, so restore natural reading order.
function normalizeTitleForDisplay(title) {
const s = String(title || '').trim();
const m = s.match(/^(.*),\s*(The|A|An)$/i);
if (m) {
const article = m[2];
const properCaseArticle = article.charAt(0).toUpperCase() + article.slice(1).toLowerCase();
return `${properCaseArticle} ${m[1].trim()}`;
}
return s;
}
// All XHTML content documents are flattened to live directly at OEBPS/<name>.xhtml
// (see buildEpub: `name: 'OEBPS/' + r.filename`). Images, however, may live in
// subfolders inside the original book (e.g. OEBPS/images/title.jpg, OEBPS/xhtml/foo.png).
// We preserve each image's original relative path inside the zip (see imgFiles below),
// so any <img src="..."> reference must be expressed RELATIVE TO OEBPS/, not just as a
// bare basename -- otherwise a subfolder-nested image's URL won't resolve correctly from
// a chapter file sitting at OEBPS/ root. This strips a leading "OEBPS/" (or "oebps/") if
// present, leaving any remaining subfolder structure intact.
function toOebpsRelative(path) {
return String(path).replace(/^OEBPS\//i, '');
}
const VOID_TAGS = new Set(['br', 'hr', 'img', 'input', 'meta', 'link', 'area', 'base', 'col', 'embed', 'source', 'track', 'wbr']);
const IMG_XSRC_RE = /<img\b([^>]*?)\bxsrc="([^"]+)"([^>]*?)\/?>/gi;
function fixInlineImages(html, imgSrcs) {
return html.replace(IMG_XSRC_RE, (full, before, xsrcPath, after) => {
imgSrcs.add(xsrcPath);
const relSrc = toOebpsRelative(xsrcPath);
let attrs = (before + ' ' + after)
.replace(/\bsrc="[^"]*"/i, '')
.replace(/\bxwidth="[^"]*"/i, '')
.replace(/\bxheight="[^"]*"/i, '')
.replace(/\s+/g, ' ')
.trim();
return `<img src="${esc(relSrc)}" ${attrs}/>`;
});
}
function serializeAttrs(attrs, overrideSrc) {
if (!Array.isArray(attrs)) return '';
return attrs
.filter(a => !(overrideSrc && (a.T === 'src' || a.T === 'xsrc' || a.T === 'xwidth' || a.T === 'xheight')))
.map(a => ` ${a.T}="${esc(a.d)}"`)
.join('') + (overrideSrc ? ` src="${esc(overrideSrc)}"` : '');
}
function serializeNode(node, imgSrcs) {
if (!node) return '';
if (node.B === '#text') return node.J || '';
const tag = node.B;
let overrideSrc = null;
if (tag === 'img' || tag === 'image') {
const srcAttr = (node.A || []).find(a => a.T === 'src' || a.T === 'xlink:href');
const xsrcAttr = (node.A || []).find(a => a.T === 'xsrc');
if (xsrcAttr) {
imgSrcs.add(xsrcAttr.d);
overrideSrc = toOebpsRelative(xsrcAttr.d);
} else if (srcAttr) {
imgSrcs.add(srcAttr.d);
overrideSrc = toOebpsRelative(srcAttr.d);
}
}
const attrStr = serializeAttrs(node.A, overrideSrc);
let inner = '';
if (Array.isArray(node.D)) {
inner = node.D.map(child => serializeNode(child, imgSrcs)).join('');
} else if (typeof node.J === 'string') {
inner = fixInlineImages(node.J, imgSrcs);
}
if (VOID_TAGS.has(tag) && !inner) {
return `<${tag}${attrStr}/>`;
}
return `<${tag}${attrStr}>${inner}</${tag}>`;
}
async function loadClientZip() {
if (window.downloadZip) return window.downloadZip;
await new Promise((resolve, reject) => {
const s = document.createElement('script');
s.src = 'https://unpkg.com/client-zip@2.5.0/worker.js';
s.onload = resolve;
s.onerror = reject;
document.head.appendChild(s);
});
return window.downloadZip;
}
// browser-id3-writer is published as an ES module. Confirmed via live testing:
// unpkg's dist/browser-id3-writer.js path 404s and CORS-fails on redirect, so we
// use jsdelivr's +esm shorthand instead, which correctly bundles it as a real ES
// module. The library exports a NAMED export `ID3Writer` (there is no default
// export) -- earlier code incorrectly fell back to `mod.default`, which is
// undefined, causing every tagging attempt to silently fail and fall back to the
// untagged original file.
let _ID3WriterPromise = null;
function loadID3Writer() {
if (!_ID3WriterPromise) {
_ID3WriterPromise = import('https://cdn.jsdelivr.net/npm/browser-id3-writer@6/+esm')
.then(mod => {
const Writer = mod.ID3Writer;
if (typeof Writer !== 'function') throw new Error('ID3Writer named export not found on module');
return Writer;
});
}
return _ID3WriterPromise;
}
function triggerDownload(blob, filename) {
const a = document.createElement('a');
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
a.remove();
URL.revokeObjectURL(a.href);
}
async function fetchWithRetry(url, fetchOpts, label, progress, maxAttempts = 3) {
let lastErr;
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
const res = await fetch(url, fetchOpts);
if (!res.ok) throw new Error('HTTP ' + res.status);
return res;
} catch (e) {
lastErr = e;
if (progress) progress(` retry ${attempt}/${maxAttempts} failed for ${label}: ${e.message}`);
if (attempt < maxAttempts) {
await new Promise(r => setTimeout(r, 500 * Math.pow(2, attempt - 1)));
}
}
}
throw lastErr;
}
/* =========================================
EBOOK: CORE EXTRACTION + BUILD PIPELINE
(runs inside the webdelivery.barnesandnoble.com iframe)
=========================================
*/
async function buildEpub(base, progress) {
if (!base) throw new Error('No base delivery URL was captured yet.');
progress('Fetching spine.json...');
const spineRes = await fetch(base + '/META-INF/spine.json', { credentials: 'include' });
if (!spineRes.ok) throw new Error('Could not fetch spine.json (HTTP ' + spineRes.status + ')');
const manifest = await spineRes.json();
const spineList = manifest.spine;
const navList = (manifest.toc && manifest.toc.navmap) || [];
const bookMeta = manifest.metadata || {};
progress(`Manifest loaded: ${spineList.length} documents, ${navList.length} TOC entries.`);
const results = new Array(spineList.length);
let idx = 0;
const CONCURRENCY = 8;
const allImgSrcs = new Set();
const allCssNames = new Set();
async function worker() {
while (true) {
const i = idx++;
if (i >= spineList.length) break;
const entry = spineList[i];
try {
const res = await fetch(base + '/' + entry.O, { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const json = await res.json();
const E = json.E;
(E.I || []).forEach(css => { if (css.H) allCssNames.add(css.H); });
const bodyXhtml = (E.L || []).map(n => serializeNode(n, allImgSrcs)).join('');
const attrsHtml = (E.A || []).map(a => `${a.T}="${esc(a.d)}"`).join(' ');
const xhtml = `<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE html>
<html ${attrsHtml}>
<head>
<meta charset="utf-8"/>
<title>${esc(E.b || bookMeta.title || 'Untitled')}</title>
${(E.I || []).map(css => `<link rel="stylesheet" type="text/css" href="${esc(toOebpsRelative(css.H))}"/>`).join('\n')}
</head>
<body>${bodyXhtml}</body>
</html>`;
results[i] = { ok: true, path: entry.T, filename: entry.T.split('/').pop(), xhtml };
} catch (e) {
results[i] = { ok: false, path: entry.T, error: e.message };
}
if ((i + 1) % 10 === 0 || i === spineList.length - 1) {
progress(`Fetched ${i + 1}/${spineList.length} content documents...`);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
const failed = results.filter(r => !r.ok);
if (failed.length) {
progress(`WARNING: ${failed.length} document(s) failed to fetch: ${failed.map(f => f.path).join(', ')}`);
}
progress(`Fetching ${allCssNames.size} stylesheet(s)...`);
const cssFiles = [];
for (const name of allCssNames) {
try {
// `name` here is whatever E.I[].H contained -- in every book seen so far this
// is just a bare filename (e.g. "book_css.css"), assumed to live at OEBPS/<name>.
const res = await fetch(base + '/OEBPS/' + name, { credentials: 'include' });
const text = await res.text();
cssFiles.push({ name: 'OEBPS/' + toOebpsRelative(name), input: text });
} catch (e) {
progress('Failed to fetch CSS ' + name + ': ' + e.message);
}
}
progress(`Fetching ${allImgSrcs.size} image(s)...`);
const imgFiles = [];
await Promise.all([...allImgSrcs].map(async (imgPath) => {
try {
const res = await fetch(base + '/' + imgPath, { credentials: 'include' });
if (!res.ok) throw new Error('HTTP ' + res.status);
const blob = await res.blob();
// Preserve the image's original relative path (minus a leading OEBPS/) both
// in the zip and in the manifest, so it matches whatever the rewritten
// <img src="..."> values now point to.
const relPath = toOebpsRelative(imgPath);
imgFiles.push({ name: 'OEBPS/' + relPath, relPath, input: blob, filename: relPath.split('/').pop() });
} catch (e) {
progress('Failed to fetch image ' + imgPath + ': ' + e.message);
}
}));
progress('Assembling EPUB...');
const rawTitle = bookMeta.title || (results.find(r => r.ok)?.xhtml.match(/<title>(.*?)<\/title>/) || [, 'Untitled'])[1];
const bookTitle = normalizeTitleForDisplay(rawTitle);
const author = bookMeta.author || bookMeta.creator || 'Unknown';
const lang = bookMeta.language || 'en';
const uid = bookMeta.isbn || bookMeta.identifier || ('urn:nookrip:' + Date.now());
const files = [];
files.push({ name: 'mimetype', input: 'application/epub+zip' });
files.push({
name: 'META-INF/container.xml', input:
`<?xml version="1.0" encoding="UTF-8"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles><rootfile full-path="OEBPS/content.opf" media-type="application/oebps-package+xml"/></rootfiles>
</container>`
});
const manifestItems = [];
const spineItemRefs = [];
const usedIds = new Set();
function makeId(prefix, seed) {
let base = prefix + '-' + seed.replace(/[^a-zA-Z0-9_-]/g, '_');
let id = base;
let n = 1;
while (usedIds.has(id)) { id = base + '-' + (n++); }
usedIds.add(id);
return id;
}
results.forEach((r, i) => {
if (!r.ok) return;
files.push({ name: 'OEBPS/' + r.filename, input: r.xhtml });
const id = 'item' + i;
manifestItems.push(`<item id="${id}" href="${esc(r.filename)}" media-type="application/xhtml+xml"/>`);
spineItemRefs.push(`<itemref idref="${id}"/>`);
});
cssFiles.forEach((c, i) => {
files.push(c);
manifestItems.push(`<item id="css${i}" href="${esc(c.name.replace('OEBPS/', ''))}" media-type="text/css"/>`);
});
let coverImageId = null;
imgFiles.forEach((img) => {
files.push({ name: img.name, input: img.input });
const id = makeId('img', img.relPath);
if (!coverImageId && /cover/i.test(img.filename)) coverImageId = id;
manifestItems.push(`<item id="${id}" href="${esc(img.relPath)}" media-type="${getMimeType(img.filename)}"${coverImageId === id ? ' properties="cover-image"' : ''}/>`);
});
manifestItems.push(`<item id="ncx" href="toc.ncx" media-type="application/x-dtbncx+xml"/>`);
manifestItems.push(`<item id="nav" href="nav.xhtml" media-type="application/xhtml+xml" properties="nav"/>`);
let playOrder = 0;
function navPointXml(entry) {
playOrder++;
const filename = entry.E.split('/').pop();
const sub = (entry.navpoints || []).map(navPointXml).join('');
return `<navPoint id="np${playOrder}" playOrder="${playOrder}">
<navLabel><text>${esc(entry.label)}</text></navLabel>
<content src="${esc(filename)}"/>
${sub}
</navPoint>`;
}
const navPointsXml = navList.map(navPointXml).join('');
files.push({
name: 'OEBPS/toc.ncx', input:
`<?xml version="1.0" encoding="UTF-8"?>
<ncx xmlns="http://www.daisy.org/z3986/2005/ncx/" version="2005-1">
<head><meta name="dtb:uid" content="${esc(uid)}"/></head>
<docTitle><text>${esc(bookTitle)}</text></docTitle>
<navMap>${navPointsXml}</navMap>
</ncx>`
});
function navLiXml(entry) {
const filename = entry.E.split('/').pop();
const sub = (entry.navpoints || []).length ? `<ol>${entry.navpoints.map(navLiXml).join('')}</ol>` : '';
return `<li><a href="${esc(filename)}">${esc(entry.label)}</a>${sub}</li>`;
}
const navLiXhtml = navList.map(navLiXml).join('');
files.push({
name: 'OEBPS/nav.xhtml', input:
`<?xml version="1.0" encoding="UTF-8"?>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:epub="http://www.idpf.org/2007/ops">
<head><title>Contents</title></head>
<body><nav epub:type="toc" id="toc"><h1>Contents</h1><ol>${navLiXhtml}</ol></nav></body></html>`
});
const coverMeta = coverImageId ? `<meta name="cover" content="${coverImageId}"/>` : '';
files.push({
name: 'OEBPS/content.opf', input:
`<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="3.0" unique-identifier="bookid" xml:lang="${esc(lang)}">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/">
<dc:identifier id="bookid">${esc(uid)}</dc:identifier>
<dc:title>${esc(bookTitle)}</dc:title>
<dc:creator>${esc(author)}</dc:creator>
<dc:language>${esc(lang)}</dc:language>
${coverMeta}
</metadata>
<manifest>${manifestItems.join('')}</manifest>
<spine toc="ncx">${spineItemRefs.join('')}</spine>
</package>`
});
progress(`Zipping ${files.length} files...`);
const makeZip = await loadClientZip();
if (typeof makeZip !== 'function') throw new Error('client-zip failed to load.');
const zipBlob = await makeZip(files).blob();
const filename = bookTitle.replace(/[\\/:*?"<>|]/g, '_') + '.epub';
triggerDownload(zipBlob, filename);
progress(`Done! Saved: ${filename} (${results.filter(r => r.ok).length}/${spineList.length} docs, ${imgFiles.length} images)`);
return filename;
}
/* =========================================
AUDIOBOOK: CORE EXTRACTION + BUILD PIPELINE
(runs in the top-level nook.barnesandnoble.com page --
no iframe involved for audiobooks)
=========================================
*/
// Populated by the fetch/XHR hooks below as soon as the page's own bn-audioplayer
// component makes its automatic (on-load) metadata GET and playlist POST calls.
// Both calls require an internal Session-Key header we cannot read or replicate
// from outside bn.audioplayer.js's scope, so we passively capture the RESPONSES
// rather than trying to re-issue the requests ourselves. Confirmed via testing:
// both endpoints 401 ("notauthorized") without that header, while the actual
// per-chapter mp3 CDN urls need NO auth at all (bare fetch with no credentials).
let capturedAudiobookMeta = null;
let capturedPlaylist = null;
function installAudiobookNetworkCapture() {
const METADATA_RE = /api\.findawayworld\.com\/v4\/accounts\/[^/]+\/audiobooks\/(\d+)$/;
const PLAYLIST_RE = /api\.findawayworld\.com\/v4\/audiobooks\/(\d+)\/playlists$/;
function handleResponseText(url, text) {
try {
if (METADATA_RE.test(url)) {
const json = JSON.parse(text);
if (json && json.audiobook) {
capturedAudiobookMeta = json;
console.log(LOG_PREFIX, 'captured audiobook metadata for', json.audiobook.title);
document.dispatchEvent(new CustomEvent('nookrip-audiobook-meta-ready'));
}
} else if (PLAYLIST_RE.test(url)) {
const json = JSON.parse(text);
if (json && Array.isArray(json.playlist)) {
capturedPlaylist = json;
console.log(LOG_PREFIX, 'captured playlist:', json.playlist.length, 'chapters, expires', json.expires);
document.dispatchEvent(new CustomEvent('nookrip-audiobook-playlist-ready'));
}
}
} catch (e) { /* not JSON or not the shape we expect -- ignore */ }
}
const origFetch = window.fetch;
window.fetch = function (...args) {
const resource = args[0];
const url = typeof resource === 'string' ? resource : (resource && resource.url);
const p = origFetch.apply(this, args);
if (url && (METADATA_RE.test(url) || PLAYLIST_RE.test(url))) {
p.then(res => res.clone().text().then(text => handleResponseText(url, text)).catch(() => {})).catch(() => {});
}
return p;
};
const origOpen = XMLHttpRequest.prototype.open;
const origSend = XMLHttpRequest.prototype.send;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
this.__nookripUrl = url;
return origOpen.call(this, method, url, ...rest);
};
XMLHttpRequest.prototype.send = function (body) {
const url = this.__nookripUrl || '';
if (METADATA_RE.test(url) || PLAYLIST_RE.test(url)) {
this.addEventListener('load', () => handleResponseText(url, this.responseText));
}
return origSend.call(this, body);
};
}
function getAudiobookPlayerElement() {
return document.querySelector('bn-audioplayer[abid]');
}
// The <bn-audioplayer> element is inserted by Angular only after its app bootstraps
// and renders -- this can happen well after document-start/DOMContentLoaded, on a
// timeline that varies with page load speed. A single point-in-time check for its
// presence races against Angular's bootstrap, which is exactly why the "Download
// Audiobook" button was intermittently getting stuck on the ebook-mode "waiting for
// reader..." message: injectUi() ran before <bn-audioplayer> existed yet, so
// audiobookMode was (incorrectly) false and it locked into ebook mode permanently.
// Fix: poll for either signal (audioplayer element OR an ebook-iframe request having
// fired) for a few seconds before committing to a UI mode.
function detectPageMode(timeoutMs = 8000, intervalMs = 200) {
return new Promise((resolve) => {
const start = Date.now();
function check() {
if (getAudiobookPlayerElement()) {
resolve('audiobook');
return;
}
// If any iframe pointing at webdelivery.barnesandnoble.com already exists,
// this is very likely an ebook page -- no need to keep waiting.
const hasDeliveryIframe = Array.from(document.querySelectorAll('iframe')).some(f => {
try { return /webdelivery\.barnesandnoble\.com/.test(f.src); } catch (e) { return false; }
});
if (hasDeliveryIframe) {
resolve('ebook');
return;
}
if (Date.now() - start >= timeoutMs) {
// Timed out with neither signal found. Default to ebook mode (the
// original/older behavior) so the button still does SOMETHING useful
// and isn't silently absent; its own iframe-ping loop will keep
// trying in the background regardless.
resolve('ebook');
return;
}
setTimeout(check, intervalMs);
}
check();
});
}
// Writes ID3v2.3 tags into a single chapter's mp3 ArrayBuffer and returns a new Blob.
// Never throws -- if tagging fails for any reason, the ORIGINAL untagged blob is
// returned so a tagging bug never turns into a missing/corrupted chapter (that would
// violate the fail-fast guarantee for a reason unrelated to network reliability).
async function tagChapterMp3(arrayBuffer, { book, displayTitle, author, narrator, seriesName, seriesIndex, chapterNumber, totalChapters, durationMs, coverBlob, progress }) {
try {
const ID3Writer = await loadID3Writer();
const writer = new ID3Writer(arrayBuffer);
const chapterTitle = chapterNumber === 0 ? `${displayTitle} - Opening Credits` : `Chapter ${chapterNumber}`;
writer.setFrame('TIT2', chapterTitle);
writer.setFrame('TALB', displayTitle);
writer.setFrame('TPE1', [author]);
// Set both narrator conventions for maximum compatibility across players
// (some read album-artist, some read composer, some read both).
writer.setFrame('TPE2', narrator);
writer.setFrame('TCOM', [narrator]);
writer.setFrame('TRCK', totalChapters ? `${chapterNumber}/${totalChapters}` : String(chapterNumber));
if (seriesIndex) writer.setFrame('TPOS', String(seriesIndex));
if (book.street_date) {
const yearMatch = String(book.street_date).match(/^(\d{4})/);
if (yearMatch) writer.setFrame('TYER', yearMatch[1]);
}
if (durationMs) writer.setFrame('TLEN', String(Math.round(durationMs)));
if (coverBlob) {
const coverArrayBuffer = await coverBlob.arrayBuffer();
writer.setFrame('APIC', {
type: 3, // "front cover"
data: coverArrayBuffer,
description: 'Cover'
});
}
writer.addTag();
return new Blob([writer.arrayBuffer], { type: 'audio/mpeg' });
} catch (e) {
if (progress) progress(` NOTE: ID3 tagging failed for chapter ${chapterNumber} (file kept untagged): ${e.message}`);
return new Blob([arrayBuffer], { type: 'audio/mpeg' });
}
}
async function buildAudiobook(progress) {
if (!capturedAudiobookMeta) throw new Error('Audiobook metadata not captured yet. Reload the page and wait a moment before retrying.');
if (!capturedPlaylist) throw new Error('Audiobook playlist not captured yet. Reload the page and wait a moment before retrying.');
const book = capturedAudiobookMeta.audiobook;
const displayTitle = normalizeTitleForDisplay(book.title);
progress(`Book: "${displayTitle}" by ${(book.authors || []).join(', ')}`);
// Warm the ID3Writer import now (in parallel with everything else) so a bad
// network/CDN path for it is surfaced early and clearly, instead of silently
// failing per-chapter later deep inside tagChapterMp3's catch block.
loadID3Writer()
.then(() => progress('ID3 tagging library loaded OK.'))
.catch(e => progress(`WARNING: ID3 tagging library failed to load (chapters will be saved untagged): ${e.message}`));
const chapters = [...capturedPlaylist.playlist].sort((a, b) => a.chapter_number - b.chapter_number);
const durationByChapter = {};
(book.chapters || []).forEach(c => { durationByChapter[c.chapter_number] = c.duration; });
let seriesName = '', seriesIndex = '';
if (Array.isArray(book.series) && book.series.length) {
const m = String(book.series[0]).match(/^(.*?)\s*#(\d+(?:\.\d+)?)\s*$/);
if (m) { seriesName = m[1].trim(); seriesIndex = m[2]; }
else { seriesName = book.series[0]; }
}
const author = (book.authors || []).join(', ') || 'Unknown';
const narrator = (book.narrators || []).join(', ') || 'Unknown';
// Fetch cover FIRST (before chapter downloads) since ID3 tagging needs it per
// chapter. Non-fatal: some titles genuinely have no cover published on
// Findaway's CDN (confirmed by the official Nook player also showing a broken
// image for at least one real-world title) -- this must NOT block the rest of
// the audiobook from being produced, tagged or otherwise.
let coverBlob = null;
try {
const coverUrl = (book.cover_url || `https://images.findawayworld.com/v1/image/cover/CD${book.id}`).replace(/^http:/, 'https:');
const coverRes = await fetchWithRetry(coverUrl, {}, 'cover image', progress);
coverBlob = await coverRes.blob();
progress(`Cover image fetched (${coverBlob.size} bytes).`);
} catch (e) {
progress(`NOTE: cover image unavailable (this may be a gap in the publisher's data, not a script error): ${e.message}`);
}
progress(`Downloading and tagging ${chapters.length} chapter(s)...`);
const results = new Array(chapters.length);
let idx = 0;
const CONCURRENCY = 6;
const totalBytes = { done: 0 };
async function worker() {
while (true) {
const i = idx++;
if (i >= chapters.length) break;
const ch = chapters[i];
const label = `chapter ${ch.chapter_number}`;
try {
// Confirmed: these CDN urls need NO credentials/headers at all.
// Sending credentials:'include' actively breaks them (CORS wildcard
// origin is incompatible with credentialed requests).
const res = await fetchWithRetry(ch.url, { method: 'GET' }, label, progress);
const arrayBuffer = await res.arrayBuffer();
totalBytes.done += arrayBuffer.byteLength;
const taggedBlob = await tagChapterMp3(arrayBuffer, {
book, displayTitle, author, narrator, seriesName, seriesIndex,
chapterNumber: ch.chapter_number,
totalChapters: chapters.length,
durationMs: durationByChapter[ch.chapter_number],
coverBlob, progress
});
const num = String(ch.chapter_number).padStart(2, '0');
results[i] = { ok: true, chapterNumber: ch.chapter_number, filename: `${num} - Chapter ${ch.chapter_number}.mp3`, blob: taggedBlob };
progress(`Fetched + tagged ${i + 1}/${chapters.length} (${label}, ${(totalBytes.done / 1e6).toFixed(1)} MB so far)`);
} catch (e) {
results[i] = { ok: false, chapterNumber: ch.chapter_number, error: e.message };
progress(`FAILED: ${label} - ${e.message}`);
}
}
}
await Promise.all(Array.from({ length: CONCURRENCY }, worker));
// FAIL FAST: never build a partial audiobook. If any chapter is missing after
// retries, abort entirely -- no zip is written. (Tagging failures do NOT count
// as chapter failures -- see tagChapterMp3's fallback-to-untagged-blob behavior.)
const failed = results.filter(r => !r.ok);
if (failed.length) {
const failedList = failed.map(f => `chapter ${f.chapterNumber} (${f.error})`).join(', ');
throw new Error(
`Aborted: ${failed.length}/${chapters.length} chapter(s) failed after retries: ${failedList}. ` +
`No zip was created. Click download again to retry (playlist valid until ${capturedPlaylist.expires}).`
);
}
progress(`All ${chapters.length} chapters downloaded successfully (${(totalBytes.done / 1e6).toFixed(1)} MB total).`);
// ---- Sidecar metadata files (Audiobookshelf / Calibre-compatible conventions) ----
// Kept alongside the embedded ID3 tags rather than replaced by them: some tools
// (Audiobookshelf in particular) prioritize folder-level metadata.opf/desc.txt/
// reader.txt over per-file ID3 tags, so having both maximizes compatibility.
const uid = 'urn:findaway:' + book.id;
const opf = `<?xml version="1.0" encoding="UTF-8"?>
<package xmlns="http://www.idpf.org/2007/opf" version="2.0" unique-identifier="bookid">
<metadata xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:opf="http://www.idpf.org/2007/opf">
<dc:identifier id="bookid" opf:scheme="findaway">${esc(uid)}</dc:identifier>
<dc:title>${esc(displayTitle)}</dc:title>
<dc:creator opf:role="aut">${esc(author)}</dc:creator>
<dc:contributor opf:role="nrt">${esc(narrator)}</dc:contributor>
<dc:publisher>${esc(book.publisher)}</dc:publisher>
<dc:language>${esc(book.language || 'en')}</dc:language>
<dc:description>${esc(book.description)}</dc:description>
<dc:date>${esc(book.street_date || '')}</dc:date>
${seriesName ? `<meta name="calibre:series" content="${esc(seriesName)}"/>` : ''}
${seriesIndex ? `<meta name="calibre:series_index" content="${esc(seriesIndex)}"/>` : ''}
<meta name="abridged" content="${esc(book.abridgement === 'Abridged' ? 'yes' : 'no')}"/>
</metadata>
<manifest/>
<spine/>
</package>`;
const descTxt = book.description || '';
const readerTxt = narrator;
const m3uLines = ['#EXTM3U'];
results.forEach(r => {
const durMs = durationByChapter[r.chapterNumber];
const durSec = durMs ? Math.round(durMs / 1000) : -1;
m3uLines.push(`#EXTINF:${durSec},${displayTitle} - Chapter ${r.chapterNumber}`);
m3uLines.push(r.filename);
});
const m3u = m3uLines.join('\n');
progress('Assembling zip...');
const makeZip = await loadClientZip();
if (typeof makeZip !== 'function') throw new Error('client-zip failed to load.');
const files = [];
files.push({ name: 'metadata.opf', input: opf });
files.push({ name: 'desc.txt', input: descTxt });
files.push({ name: 'reader.txt', input: readerTxt });
files.push({ name: 'playlist.m3u', input: m3u });
if (coverBlob) {
files.push({ name: 'cover.jpg', input: coverBlob });
} else {
files.push({ name: 'cover_missing.txt', input: 'No cover image was available from the publisher/Findaway for this title at rip time.' });
}
results.forEach(r => files.push({ name: r.filename, input: r.blob }));
progress(`Zipping ${files.length} files...`);
const zipBlob = await makeZip(files).blob();
const safeTitle = displayTitle.replace(/[\\/:*?"<>|]/g, '_');
const filename = `${safeTitle}.zip`;
triggerDownload(zipBlob, filename);
progress(`Done! Saved: ${filename} (${chapters.length}/${chapters.length} chapters, ID3-tagged, cover: ${!!coverBlob})`);
return filename;
}
/* =========================================
DELIVERY-FRAME SIDE (runs in the ebook iframe)
IMPORTANT: frame_bn.htm's own location.href NEVER changes -- it is a persistent
SPA-style document that fetches book content (spine.json, per-chapter
content.json) via plain fetch()/XHR calls to a path containing
"epub.vN.j2.epubj" (N = version, varies per book/publisher) and renders results
into its own DOM. Detection watches outgoing network requests for that pattern.
=========================================
*/
if (isDeliveryHost && !isTop) {
console.log(LOG_PREFIX, 'delivery-frame script loaded at', location.href);
let bookModeActive = false;
let detectedBase = null;
// Version segment (v5, v7, v12, etc.) varies per book/publisher -- match any digits.
const BASE_RE = /^(https?:\/\/[^\/]+\/.*?epub\.v\d+\.j2\.epubj)\//;
function tryActivateFromUrl(url) {
if (bookModeActive || !url) return;
const m = String(url).match(BASE_RE);
if (m) {
detectedBase = m[1];
bookModeActive = true;
console.log(LOG_PREFIX, 'delivery-frame: book pipeline detected via network request. base =', detectedBase);
activateBookMode();
}
}
function activateBookMode() {
window.addEventListener('message', async (event) => {
if (!event.data || event.data.ns !== MSG_NS) return;
console.log(LOG_PREFIX, 'delivery-frame received message:', event.data.type);
if (event.data.type === 'PING') {
event.source.postMessage({ ns: MSG_NS, type: 'READY' }, '*');
} else if (event.data.type === 'START_BUILD') {
try {
await buildEpub(detectedBase, (msg) => {
console.log(LOG_PREFIX, 'progress:', msg);
event.source.postMessage({ ns: MSG_NS, type: 'PROGRESS', msg }, '*');
});
event.source.postMessage({ ns: MSG_NS, type: 'DONE' }, '*');
} catch (e) {
console.error(LOG_PREFIX, 'build failed:', e);
event.source.postMessage({ ns: MSG_NS, type: 'ERROR', msg: e.message }, '*');
}
}
});
try { window.top.postMessage({ ns: MSG_NS, type: 'READY' }, '*'); } catch (e) {}
}
// Hook fetch: watch every outgoing request for the real content base path.
const origFetch = window.fetch;
if (origFetch) {
window.fetch = function (...args) {
const url = (typeof args[0] === 'string') ? args[0] : (args[0] && args[0].url);
tryActivateFromUrl(url);
return origFetch.apply(this, args);
};
}
// Hook XHR too, since this reader uses XHR for at least some requests.
const origOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function (method, url, ...rest) {
tryActivateFromUrl(url);
return origOpen.call(this, method, url, ...rest);
};
// Also check anything already in the performance buffer, in case a relevant
// request fired before this script's hooks attached.
try {
performance.getEntriesByType('resource').forEach(e => tryActivateFromUrl(e.name));
} catch (e) {}
}
/* =========================================
NOOK HOST (TOP-LEVEL) SIDE
Installs the audiobook network-capture hooks as early as possible
(document-start), before bn.audioplayer.js makes its automatic
on-load metadata/playlist requests.
=========================================
*/
if (isTop && isNookHost) {
installAudiobookNetworkCapture();
}
/* =========================================
PARENT (READER PAGE) SIDE - UI
=========================================
*/
if (isTop && isNookHost) {
// Panel layout: a fixed, non-scrolling header row (title + close button) sits
// above a separately-scrollable log body. Previously the close button was
// absolutely positioned over the whole panel, so once the log content grew
// taller than the panel and got scrolled, the button scrolled out of view along
// with everything else. Splitting header/body into two flex children fixes this
// -- the header never scrolls, only #nookrip-panel-body does.
const CSS = `
#nookrip-btn {
position: fixed;
top: 12px;
right: 12px;
z-index: 999999;
background: #00693e;
color: white;
border: none;
border-radius: 4px;
padding: 8px 14px;
font-size: 13px;
font-family: sans-serif;
cursor: pointer;
box-shadow: 0 2px 6px rgba(0,0,0,0.3);
}
#nookrip-btn:disabled { background: #999; cursor: not-allowed; }
#nookrip-panel {
position: fixed;
top: 50px;
right: 12px;
z-index: 999999;
width: 340px;
max-height: 300px;
background: #1e1e1e;
color: #eee;
font-family: monospace;
font-size: 11px;
border-radius: 4px;
display: none;
flex-direction: column;
box-shadow: 0 2px 6px rgba(0,0,0,0.4);
overflow: hidden;
}
#nookrip-panel-header {
flex: 0 0 auto;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
background: #2a2a2a;
border-bottom: 1px solid #3a3a3a;
}
#nookrip-panel-title {
font-family: sans-serif;
font-size: 11px;
font-weight: bold;
color: #ccc;
}
#nookrip-panel-body {
flex: 1 1 auto;
overflow-y: auto;
padding: 8px 10px;
}
#nookrip-panel-body div.nookrip-line { margin-bottom: 4px; white-space: pre-wrap; word-break: break-word; }
#nookrip-panel-close {
cursor: pointer;
color: #aaa;
font-size: 14px;
font-family: sans-serif;
line-height: 1;
user-select: none;
flex: 0 0 auto;
padding: 0 2px;
}
#nookrip-panel-close:hover { color: #fff; }
`;
async function injectUi() {
const style = document.createElement('style');
style.textContent = CSS;
document.head.appendChild(style);
const btn = document.createElement('button');
btn.id = 'nookrip-btn';
btn.textContent = 'NookRip: detecting page type...';
btn.disabled = true;
document.body.appendChild(btn);
const panel = document.createElement('div');
panel.id = 'nookrip-panel';
const header = document.createElement('div');
header.id = 'nookrip-panel-header';
const title = document.createElement('span');
title.id = 'nookrip-panel-title';
title.textContent = 'NookRip';
header.appendChild(title);
const closeBtn = document.createElement('span');
closeBtn.id = 'nookrip-panel-close';
closeBtn.textContent = '\u2715';
closeBtn.title = 'Dismiss';
closeBtn.addEventListener('click', () => { panel.style.display = 'none'; });
header.appendChild(closeBtn);
panel.appendChild(header);
const body = document.createElement('div');
body.id = 'nookrip-panel-body';
panel.appendChild(body);
document.body.appendChild(panel);
function log(msg) {
const line = document.createElement('div');
line.className = 'nookrip-line';
line.textContent = msg;
body.appendChild(line);
body.scrollTop = body.scrollHeight;
panel.style.display = 'flex';
}
function clearLog() {
Array.from(body.querySelectorAll('.nookrip-line')).forEach(el => el.remove());
}
// Wait until we actually know which kind of page this is, instead of
// guessing once at injection time (see detectPageMode's comment above for
// why the old one-shot check raced against Angular's bootstrap).
const mode = await detectPageMode();
console.log(LOG_PREFIX, 'parent UI mode resolved to:', mode, 'on', location.href);
if (mode === 'audiobook') {
/* ---------------- AUDIOBOOK MODE ---------------- */
btn.textContent = capturedAudiobookMeta && capturedPlaylist
? 'Download Audiobook (NookRip)'
: 'NookRip: waiting for playlist...';
btn.disabled = !(capturedAudiobookMeta && capturedPlaylist);
function maybeEnable() {
if (capturedAudiobookMeta && capturedPlaylist && btn.disabled) {
btn.disabled = false;
btn.textContent = 'Download Audiobook (NookRip)';
}
}
document.addEventListener('nookrip-audiobook-meta-ready', maybeEnable);
document.addEventListener('nookrip-audiobook-playlist-ready', maybeEnable);
btn.addEventListener('click', async () => {
clearLog();
panel.style.display = 'flex';
btn.disabled = true;
btn.textContent = 'Building audiobook...';
try {
await buildAudiobook(log);
btn.textContent = 'Download Audiobook (NookRip)';
} catch (e) {
log('ERROR: ' + e.message);
btn.textContent = 'Download Audiobook (NookRip)';
}
btn.disabled = false;
});
} else {
/* ---------------- EBOOK MODE (original behavior) ---------------- */
btn.textContent = 'NookRip: waiting for reader...';
btn.disabled = true;
let deliveryFrameWindow = null;
function markReady(sourceWindow) {
if (deliveryFrameWindow) return;
deliveryFrameWindow = sourceWindow;
btn.disabled = false;
btn.textContent = 'Download EPUB (NookRip)';
console.log(LOG_PREFIX, 'parent: delivery frame found and marked ready.');
clearInterval(pingInterval);
}
window.addEventListener('message', (event) => {
if (!event.data || event.data.ns !== MSG_NS) return;
console.log(LOG_PREFIX, 'parent received message:', event.data.type, 'from', event.origin);
if (event.data.type === 'READY') {
markReady(event.source);
} else if (event.data.type === 'PROGRESS') {
log(event.data.msg);
} else if (event.data.type === 'DONE') {
btn.disabled = false;
btn.textContent = 'Download EPUB (NookRip)';
} else if (event.data.type === 'ERROR') {
log('ERROR: ' + event.data.msg);
btn.disabled = false;
btn.textContent = 'Download EPUB (NookRip)';
}
});
// Actively ping every iframe on the page every second until one answers.
// Also re-checks for a late-appearing <bn-audioplayer> in case
// detectPageMode's timeout fired before Angular finished bootstrapping on
// an unusually slow load -- if it shows up later, flip modes rather than
// staying stuck.
function pingAllFrames() {
if (getAudiobookPlayerElement()) {
console.log(LOG_PREFIX, 'audiobook player appeared after ebook-mode fallback; reloading UI in audiobook mode.');
clearInterval(pingInterval);
btn.remove();
panel.remove();
injectUi();
return;
}
const iframes = document.querySelectorAll('iframe');
iframes.forEach(f => {
try {
f.contentWindow.postMessage({ ns: MSG_NS, type: 'PING' }, '*');
} catch (e) { /* ignore inaccessible frames */ }
});
}
const pingInterval = setInterval(() => {
if (deliveryFrameWindow) { clearInterval(pingInterval); return; }
pingAllFrames();
}, 1000);
pingAllFrames();
btn.addEventListener('click', () => {
if (!deliveryFrameWindow) {
log('Reader frame not ready yet. Open a book page and flip to a chapter first, then try again.');
return;
}
clearLog();
panel.style.display = 'flex';
btn.disabled = true;
btn.textContent = 'Building EPUB...';
deliveryFrameWindow.postMessage({ ns: MSG_NS, type: 'START_BUILD' }, '*');
});
}
}
if (document.body) injectUi();
else document.addEventListener('DOMContentLoaded', injectUi);
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment