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 <noreply@anthropic.com>
This commit is contained in:
Alex 2026-07-27 18:01:31 +02:00
commit 11d41d1be1
2 changed files with 258 additions and 0 deletions

253
book-links.user.js Normal file
View file

@ -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 `<div>✓ <a href="${result.url}" target="_blank" style="color:#7eb8f7;text-decoration:none">${label}</a></div>`;
}
if (result.found === false) {
return `<div style="color:#666">✗ ${label} — not found</div>`;
}
// null = couldn't check, show link anyway
return `<div>? <a href="${result.url}" target="_blank" style="color:#888;text-decoration:none">${label}</a> <span style="color:#555;font-size:11px">(couldn't verify)</span></div>`;
}
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 = `
<div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:6px">
<strong style="color:#a8d8ea">Book Links</strong>
<span id="__bl-close" style="cursor:pointer;color:#555;font-size:18px;line-height:1;padding-left:10px">×</span>
</div>
<div id="__bl-isbn" style="color:#888;font-size:11px;margin-bottom:6px"></div>
<div id="__bl-results"></div>
`;
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 =
'<div style="color:#e07b5a">Couldn\'t find book info on this page.</div>';
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();
}
})();

5
specs.md Normal file
View file

@ -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.