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();
|
||||
}
|
||||
})();
|
||||
Loading…
Add table
Add a link
Reference in a new issue