Created
August 22, 2026 18:21
-
-
Save brad/b3754b21328cbce524b68e19b89e94e1 to your computer and use it in GitHub Desktop.
NookRip - Nook Ebook to EPUB & Audiobook Ripper
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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 => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[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