book-links/book-links.user.js
Alex f1cf418f63 Add README and MIT license
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-27 18:22:51 +02:00

254 lines
8.6 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// ==UserScript==
// @name Book Links
// @namespace http://tampermonkey.net/
// @version 1.1
// @description Show Anna's Archive and Waterstones links when browsing books
// @license MIT
// @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();
}
})();