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:
commit
11d41d1be1
2 changed files with 258 additions and 0 deletions
253
book-links.user.js
Normal file
253
book-links.user.js
Normal 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
5
specs.md
Normal 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.
|
||||||
Loading…
Add table
Add a link
Reference in a new issue