254 lines
8.6 KiB
JavaScript
254 lines
8.6 KiB
JavaScript
// ==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();
|
||
}
|
||
})();
|