commit 11d41d1be10e257f41f66e54824a162518bcf649 Author: Alex Date: Mon Jul 27 18:01:31 2026 +0200 Initial commit: book-links userscript Shows Anna's Archive and Waterstones availability links when browsing books on Bookshop.org, Amazon, or Waterstones. Includes @updateURL/ @downloadURL for Violentmonkey auto-update. Co-Authored-By: Claude Sonnet 5 diff --git a/book-links.user.js b/book-links.user.js new file mode 100644 index 0000000..0610598 --- /dev/null +++ b/book-links.user.js @@ -0,0 +1,253 @@ +// ==UserScript== +// @name Book Links +// @namespace http://tampermonkey.net/ +// @version 1.0 +// @description Show Anna's Archive and Waterstones links when browsing books +// @match https://bookshop.org/p/books/* +// @match https://www.waterstones.com/book/* +// @match https://www.amazon.fr/dp/* +// @match https://www.amazon.fr/*/dp/* +// @match https://www.amazon.com/dp/* +// @match https://www.amazon.com/*/dp/* +// @connect annas-archive.gl +// @connect www.waterstones.com +// @updateURL https://raw.githubusercontent.com/alexture/book-links/main/book-links.user.js +// @downloadURL https://raw.githubusercontent.com/alexture/book-links/main/book-links.user.js +// @grant GM_xmlhttpRequest +// ==/UserScript== + +(function () { + 'use strict'; + + function isbn10to13(isbn10) { + const base = '978' + isbn10.slice(0, 9); + let sum = 0; + for (let i = 0; i < 12; i++) { + sum += parseInt(base[i]) * (i % 2 === 0 ? 1 : 3); + } + return base + String((10 - (sum % 10)) % 10); + } + + function extractISBN() { + const url = window.location.href; + const host = window.location.hostname; + + if (host.includes('waterstones.com')) { + const m = url.match(/\/(97[89]\d{10})(?:[/?#]|$)/); + return m ? m[1] : null; + } + + if (host.includes('amazon.')) { + // Numeric ASIN in URL = ISBN-10 + const asinMatch = url.match(/\/dp\/(\d{10})(?:[/?#]|$)/); + if (asinMatch) return isbn10to13(asinMatch[1]); + + const text = document.body?.innerText || ''; + + // ISBN-13 in product details + const m13 = text.match(/ISBN-?13[\s:‎‏ ]+([0-9][-0-9 ]{11,16}[0-9])/); + if (m13) { + const cleaned = m13[1].replace(/[-\s]/g, ''); + if (/^97[89]\d{10}$/.test(cleaned)) return cleaned; + } + + // ISBN-10 in product details + const m10 = text.match(/ISBN-?10[\s:‎‏ ]+(\d{9}[\dX])/); + if (m10 && /^\d{9}[\dX]$/.test(m10[1])) return isbn10to13(m10[1]); + + return null; + } + + if (host.includes('bookshop.org')) { + const el = document.querySelector('[itemprop="isbn"]'); + if (el && /^97[89]\d{10}$/.test(el.textContent.trim())) return el.textContent.trim(); + + for (const meta of document.querySelectorAll('meta')) { + const c = (meta.getAttribute('content') || '').trim(); + if (/^97[89]\d{10}$/.test(c)) return c; + } + + const m = (document.body?.innerText || '').match(/\b(97[89]\d{10})\b/); + return m ? m[1] : null; + } + + return null; + } + + function extractTitleAuthor() { + const host = window.location.hostname; + + if (host.includes('bookshop.org')) { + const title = ( + document.querySelector('[itemprop="name"]') || + document.querySelector('h1') + )?.textContent?.trim(); + const author = ( + document.querySelector('[itemprop="author"]') || + document.querySelector('.book-author a') || + document.querySelector('.contributors a') + )?.textContent?.trim(); + return { title, author }; + } + + if (host.includes('amazon.')) { + const title = ( + document.querySelector('#productTitle') || + document.querySelector('.product-title-word-break') + )?.textContent?.trim(); + const author = ( + document.querySelector('#bylineInfo .author a') || + document.querySelector('.contributorNameID') + )?.textContent?.trim(); + return { title, author }; + } + + if (host.includes('waterstones.com')) { + const title = ( + document.querySelector('[itemprop="name"]') || + document.querySelector('h1') + )?.textContent?.trim(); + const author = ( + document.querySelector('[itemprop="author"]') || + document.querySelector('.author-name a') || + document.querySelector('.book-authors a') + )?.textContent?.trim(); + return { title, author }; + } + + return { title: null, author: null }; + } + + function gmFetch(url) { + return new Promise((resolve) => { + GM_xmlhttpRequest({ + method: 'GET', + url, + timeout: 8000, + onload: (r) => resolve({ + ok: r.status >= 200 && r.status < 400, + text: r.responseText, + finalUrl: r.finalUrl || url, + }), + onerror: () => resolve({ ok: false, text: '', finalUrl: url }), + ontimeout: () => resolve({ ok: false, text: '', finalUrl: url }), + }); + }); + } + + async function checkAnna(isbn, title, author) { + const query = (title && author) ? `${title} ${author}` : isbn; + const url = `https://annas-archive.gl/search?q=${encodeURIComponent(query)}`; + const res = await gmFetch(url); + if (!res.ok) return { found: null, url }; + + const noResults = /no\s+results|0\s+results/i.test(res.text); + if (noResults) return { found: false, url }; + + // /md5/ followed by a 32-char hex hash = an actual file link, not site chrome + const hasFiles = /\/md5\/[0-9a-f]{32}/i.test(res.text); + if (hasFiles) return { found: true, url }; + + return { found: null, url }; + } + + async function checkWaterstones(isbn) { + const searchUrl = `https://www.waterstones.com/books/search/term/${isbn}`; + const res = await gmFetch(searchUrl); + if (!res.ok) return { found: null, url: searchUrl }; + + // Redirect to a product page means direct hit + if (res.finalUrl && /\/book\/[^/]+\/[^/]+\//.test(res.finalUrl)) { + return { found: true, url: res.finalUrl }; + } + + const noResults = /no results|didn.t find|0 results found/i.test(res.text); + if (noResults) return { found: false, url: searchUrl }; + + const hasResults = /book-wrap|data-isbn|"@type"\s*:\s*"Book"|class="book/i.test(res.text); + if (hasResults) return { found: true, url: searchUrl }; + + // Search results are JS-rendered — can't determine, show link anyway + return { found: null, url: searchUrl }; + } + + function renderRow(label, result) { + if (result.found === true) { + return `
${label}
`; + } + if (result.found === false) { + return `
✗ ${label} — not found
`; + } + // null = couldn't check, show link anyway + return `
? ${label} (couldn't verify)
`; + } + + function buildWidget() { + const div = document.createElement('div'); + div.id = '__book-links'; + div.style.cssText = ` + position: fixed; + bottom: 20px; + right: 20px; + background: #16213e; + color: #e0e0e0; + border: 1px solid #0f3460; + border-radius: 8px; + padding: 12px 14px; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-size: 13px; + line-height: 1.75; + z-index: 2147483647; + max-width: 270px; + box-shadow: 0 4px 20px rgba(0,0,0,0.5); + `; + div.innerHTML = ` +
+ Book Links + × +
+
+
+ `; + document.body.appendChild(div); + div.querySelector('#__bl-close').onclick = () => div.remove(); + return div; + } + + async function run() { + await new Promise(r => setTimeout(r, 800)); + + const isbn = extractISBN(); + const { title, author } = extractTitleAuthor(); + const widget = buildWidget(); + + if (!isbn && !title) { + widget.querySelector('#__bl-results').innerHTML = + '
Couldn\'t find book info on this page.
'; + return; + } + + widget.querySelector('#__bl-isbn').textContent = isbn ? `ISBN ${isbn}` : (title || ''); + widget.querySelector('#__bl-results').textContent = 'Checking…'; + + const isWaterstones = window.location.hostname.includes('waterstones.com'); + const checksToRun = [checkAnna(isbn, title, author)]; + if (!isWaterstones && isbn) checksToRun.push(checkWaterstones(isbn)); + + const results = await Promise.all(checksToRun); + const annaResult = results[0]; + const waterstonesResult = isWaterstones ? null : results[1]; + + let html = ''; + if (!isWaterstones) html += renderRow('Waterstones', waterstonesResult); + html += renderRow("Anna's Archive", annaResult); + + widget.querySelector('#__bl-results').innerHTML = html; + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', run); + } else { + run(); + } +})(); diff --git a/specs.md b/specs.md new file mode 100644 index 0000000..5f435d9 --- /dev/null +++ b/specs.md @@ -0,0 +1,5 @@ +A userscript (that I can use with TamperMonkey, etc. on Firefox) to: +- when I open a link to bookshop.org, suggest waterstones + anna's archive links for that isbn if they exist and flag if they don't exist +- when I open a link in amazon, suggest waterstones + anna's archive links for that isbn if they exist and flag if they don't exist +- when I open a link in waterstones, suggest anna's archive link for that isbn if it exists and flag if it doesn't exist +somewhere in the corner of the page. \ No newline at end of file