|
// ==UserScript== |
|
// @name Goodreads Libby Availability Checker |
|
// @namespace https://gist.github.com/Lydon-01 |
|
// @version 1.0.5 |
|
// @description Check if books from your Goodreads lists are available in your Libby library |
|
// @author Lydon Carter |
|
// @homepage https://gist.github.com/Lydon-01 |
|
// @supportURL https://gist.github.com/Lydon-01 |
|
// @license MIT |
|
// @match https://www.goodreads.com/review/list/* |
|
// @match https://www.goodreads.com/book/show/* |
|
// @grant GM_xmlhttpRequest |
|
// @grant GM_setValue |
|
// @grant GM_getValue |
|
// @grant GM_registerMenuCommand |
|
// @connect thunder.api.overdrive.com |
|
// @run-at document-idle |
|
// ==/UserScript== |
|
|
|
(function() { |
|
'use strict'; |
|
|
|
// ===== CONFIGURATION ===== |
|
// Change this to your library's key (find it in your Libby URL) |
|
// Example: https://libbyapp.com/library/westerncape -> 'westerncape' |
|
const LIBRARY_KEY = GM_getValue('library_key', 'westerncape'); |
|
const DELAY_MS = 500; |
|
const CACHE_DAYS = 7; |
|
const PREFER_AUDIOBOOK = GM_getValue('prefer_audiobook', true); |
|
|
|
// Register menu commands |
|
GM_registerMenuCommand('⚙️ Configure Library', configureLibrary); |
|
GM_registerMenuCommand('🗑️ Clear Cache', clearCache); |
|
GM_registerMenuCommand('🔄 Toggle Format Preference', toggleFormatPreference); |
|
|
|
function configureLibrary() { |
|
const newKey = prompt('Enter your Libby library key (from your Libby URL):', LIBRARY_KEY); |
|
if (newKey && newKey.trim()) { |
|
GM_setValue('library_key', newKey.trim()); |
|
alert('Library updated! Refresh the page to apply changes.'); |
|
} |
|
} |
|
|
|
function clearCache() { |
|
if (confirm('Clear all cached Libby results?')) { |
|
const keys = GM_getValue('cache_keys', []); |
|
keys.forEach(key => GM_setValue(key, null)); |
|
GM_setValue('cache_keys', []); |
|
alert('Cache cleared! Refresh to check books again.'); |
|
} |
|
} |
|
|
|
function toggleFormatPreference() { |
|
const current = GM_getValue('prefer_audiobook', true); |
|
GM_setValue('prefer_audiobook', !current); |
|
alert(`Now preferring: ${!current ? 'Audiobooks' : 'eBooks'}\nRefresh to apply.`); |
|
} |
|
|
|
// ===== UTILITY FUNCTIONS ===== |
|
function normalizeTitle(title) { |
|
return title.toLowerCase() |
|
.replace(/[:\-—–]/g, ' ') |
|
.replace(/\s+/g, ' ') |
|
.replace(/®|™|©/g, '') |
|
.trim(); |
|
} |
|
|
|
function extractMainTitle(title) { |
|
// Remove common subtitle patterns |
|
return title |
|
.replace(/[:\-—–].*/g, '') // Remove everything after : or - |
|
.replace(/\(.*?\)/g, '') // Remove parentheses |
|
.trim(); |
|
} |
|
|
|
function authorMatch(goodreadsAuthor, libbyAuthors) { |
|
// Libby often includes narrators, so check if Goodreads author appears in Libby's author list |
|
const grAuthor = normalizeTitle(goodreadsAuthor); |
|
const libbyAuthor = normalizeTitle(libbyAuthors); |
|
|
|
// Check if Goodreads author is contained in Libby authors |
|
if (libbyAuthor.includes(grAuthor)) { |
|
return 1.0; |
|
} |
|
|
|
// Check word overlap (original method) |
|
const wordsA = new Set(grAuthor.split(' ').filter(w => w.length > 2)); |
|
const wordsB = new Set(libbyAuthor.split(' ').filter(w => w.length > 2)); |
|
const intersection = new Set([...wordsA].filter(x => wordsB.has(x))); |
|
|
|
return wordsA.size === 0 ? 0 : intersection.size / wordsA.size; // Changed denominator to wordsA.size |
|
} |
|
|
|
function titleMatch(goodreadsTitle, libbyTitle) { |
|
// Decode HTML entities |
|
const cleanLibby = libbyTitle.replace(/®|™|®|™/g, ''); |
|
|
|
// Try full title match |
|
const fullScore = titleSimilarity(goodreadsTitle, cleanLibby); |
|
if (fullScore >= 0.7) return fullScore; |
|
|
|
// Try main title only (without subtitle) |
|
const mainScore = titleSimilarity(extractMainTitle(goodreadsTitle), extractMainTitle(cleanLibby)); |
|
return Math.max(fullScore, mainScore); |
|
} |
|
|
|
function titleSimilarity(s1, s2) { |
|
const a = normalizeTitle(s1); |
|
const b = normalizeTitle(s2); |
|
const wordsA = new Set(a.split(' ').filter(w => w.length > 2)); |
|
const wordsB = new Set(b.split(' ').filter(w => w.length > 2)); |
|
const intersection = new Set([...wordsA].filter(x => wordsB.has(x))); |
|
return wordsA.size === 0 ? 0 : intersection.size / Math.max(wordsA.size, wordsB.size); |
|
} |
|
|
|
function getCacheKey(title, author) { |
|
return `libby_${LIBRARY_KEY}_${normalizeTitle(title)}_${normalizeTitle(author)}`; |
|
} |
|
|
|
function getCache(title, author) { |
|
const key = getCacheKey(title, author); |
|
const cached = GM_getValue(key); |
|
if (!cached) return null; |
|
|
|
try { |
|
const data = JSON.parse(cached); |
|
const age = Date.now() - data.timestamp; |
|
if (age > CACHE_DAYS * 24 * 60 * 60 * 1000) { |
|
GM_setValue(key, null); |
|
return null; |
|
} |
|
return data.result; |
|
} catch (e) { |
|
return null; |
|
} |
|
} |
|
|
|
function setCache(title, author, result) { |
|
const key = getCacheKey(title, author); |
|
GM_setValue(key, JSON.stringify({ |
|
timestamp: Date.now(), |
|
result: result |
|
})); |
|
|
|
// Track cache keys for clearing |
|
const keys = GM_getValue('cache_keys', []); |
|
if (!keys.includes(key)) { |
|
keys.push(key); |
|
GM_setValue('cache_keys', keys); |
|
} |
|
} |
|
|
|
// ===== EXTRACTION ===== |
|
function extractBooksFromList() { |
|
const books = []; |
|
const rows = document.querySelectorAll('tr.bookalike'); |
|
|
|
for (const row of rows) { |
|
const titleEl = row.querySelector('.title a'); |
|
const authorEl = row.querySelector('.author a'); |
|
|
|
if (titleEl && authorEl) { |
|
const title = titleEl.textContent.trim(); |
|
const author = authorEl.textContent.trim(); |
|
|
|
// Skip if already processed |
|
if (!row.querySelector('.libby-indicator')) { |
|
books.push({ title, author, row }); |
|
} |
|
} |
|
} |
|
return books; |
|
} |
|
|
|
function extractBookInfo() { |
|
const titleEl = document.querySelector('h1[data-testid="bookTitle"]'); |
|
const authorEl = document.querySelector('[data-testid="name"]'); |
|
return titleEl && authorEl ? { |
|
title: titleEl.textContent.trim(), |
|
author: authorEl.textContent.trim() |
|
} : null; |
|
} |
|
|
|
// ===== API ===== |
|
async function searchLibbyAPI(query) { |
|
const url = `https://thunder.api.overdrive.com/v2/libraries/${LIBRARY_KEY}/media?query=${encodeURIComponent(query)}`; |
|
|
|
return new Promise((resolve, reject) => { |
|
GM_xmlhttpRequest({ |
|
method: 'GET', |
|
url: url, |
|
headers: { 'Accept': 'application/json' }, |
|
timeout: 10000, |
|
onload: (response) => { |
|
if (response.status !== 200) { |
|
reject(new Error(`API returned ${response.status}`)); |
|
return; |
|
} |
|
try { |
|
resolve(JSON.parse(response.responseText)); |
|
} catch (e) { |
|
reject(new Error('Invalid JSON response')); |
|
} |
|
}, |
|
onerror: () => reject(new Error('Network error')), |
|
ontimeout: () => reject(new Error('Request timeout')) |
|
}); |
|
}); |
|
} |
|
|
|
function findAllMatches(bookTitle, bookAuthor, apiResults) { |
|
if (!apiResults?.items) return []; |
|
|
|
console.log(`Searching for: "${bookTitle}" by "${bookAuthor}"`); |
|
|
|
const matches = []; |
|
for (const item of apiResults.items) { |
|
const title = item.title || ''; |
|
const authors = item.creators?.map(c => c.name).join(', ') || ''; |
|
const titleScore = titleMatch(bookTitle, title); |
|
const authorScore = authorMatch(bookAuthor, authors); |
|
|
|
console.log(` "${title}" by ${authors}`); |
|
console.log(` Title: ${titleScore.toFixed(2)}, Author: ${authorScore.toFixed(2)}`); |
|
|
|
if (titleScore >= 0.5 && authorScore >= 0.4) { |
|
console.log(` ✓ MATCH`); |
|
matches.push({ |
|
titleId: item.id, |
|
format: item.type?.id || 'unknown', |
|
score: titleScore + authorScore |
|
}); |
|
} else { |
|
console.log(` ✗ No match (need title≥0.5 AND author≥0.4)`); |
|
} |
|
} |
|
|
|
console.log(`Found ${matches.length} matches`); |
|
return matches; |
|
} |
|
|
|
function selectBestMatch(matches) { |
|
if (matches.length === 0) return { found: false }; |
|
|
|
const audiobooks = matches.filter(m => m.format === 'audiobook'); |
|
const ebooks = matches.filter(m => m.format === 'ebook'); |
|
|
|
const hasAudio = audiobooks.length > 0; |
|
const hasEbook = ebooks.length > 0; |
|
|
|
let best; |
|
if (PREFER_AUDIOBOOK && hasAudio) { |
|
best = audiobooks.sort((a, b) => b.score - a.score)[0]; |
|
} else if (hasEbook) { |
|
best = ebooks.sort((a, b) => b.score - a.score)[0]; |
|
} else if (hasAudio) { |
|
best = audiobooks[0]; |
|
} else { |
|
best = matches[0]; |
|
} |
|
|
|
return { |
|
found: true, |
|
titleId: best.titleId, |
|
format: best.format, |
|
hasAudio: hasAudio, |
|
hasEbook: hasEbook |
|
}; |
|
} |
|
|
|
// ===== UI ===== |
|
function addLibbyButtonToList(row, match, isLoading = false) { |
|
const coverCell = row.querySelector('.cover'); |
|
if (!coverCell) return; |
|
|
|
let button = row.querySelector('.libby-indicator'); |
|
if (!button) { |
|
button = document.createElement('div'); |
|
button.className = 'libby-indicator'; |
|
button.style.cssText = 'margin-top: 5px; padding: 5px; border-radius: 3px; text-align: center; font-size: 11px;'; |
|
coverCell.appendChild(button); |
|
} |
|
|
|
if (isLoading) { |
|
button.style.background = '#f0f0f0'; |
|
button.style.color = '#666'; |
|
button.textContent = '⏳'; |
|
return; |
|
} |
|
|
|
if (match.found) { |
|
const libbyUrl = `https://libbyapp.com/library/${LIBRARY_KEY}/everything/page-1/${match.titleId}`; |
|
button.style.background = '#00635d'; |
|
|
|
let icon = '📚'; |
|
if (match.hasAudio && match.hasEbook) icon = '📚🎧'; |
|
else if (match.format === 'audiobook') icon = '🎧'; |
|
|
|
button.innerHTML = `<a href="${libbyUrl}" target="_blank" style="color: white; text-decoration: none; display: block; font-size: 9px; line-height: 1.2;" title="Available in Libby">${icon}<br>Libby</a>`; |
|
} else { |
|
button.style.background = '#ddd'; |
|
button.style.color = '#666'; |
|
button.style.fontSize = '9px'; |
|
button.style.lineHeight = '1.2'; |
|
button.innerHTML = '✗<br>Libby'; |
|
button.title = 'Not found in Libby'; |
|
} |
|
} |
|
|
|
function addLibbyIndicatorToBook(match, isLoading = false) { |
|
const container = document.querySelector('[data-testid="bookTitle"]')?.parentElement; |
|
if (!container) return; |
|
|
|
let indicator = container.querySelector('.libby-indicator'); |
|
if (!indicator) { |
|
indicator = document.createElement('div'); |
|
indicator.className = 'libby-indicator'; |
|
indicator.style.cssText = 'margin-top: 10px; padding: 10px; border-radius: 5px;'; |
|
container.appendChild(indicator); |
|
} |
|
|
|
if (isLoading) { |
|
indicator.style.background = '#f0f0f0'; |
|
indicator.style.color = '#666'; |
|
indicator.textContent = '⏳ Checking Libby availability...'; |
|
return; |
|
} |
|
|
|
if (match.found) { |
|
const libbyUrl = `https://libbyapp.com/library/${LIBRARY_KEY}/everything/page-1/${match.titleId}`; |
|
indicator.style.background = '#00635d'; |
|
indicator.style.color = 'white'; |
|
|
|
let formats = []; |
|
if (match.hasEbook) formats.push('eBook'); |
|
if (match.hasAudio) formats.push('Audiobook'); |
|
|
|
indicator.innerHTML = `✓ Available in Libby (${formats.join(' & ')}) |
|
<a href="${libbyUrl}" target="_blank" style="color: #fff; text-decoration: underline; margin-left: 10px;">View in Libby</a>`; |
|
} else if (match.error) { |
|
indicator.style.background = '#ffebee'; |
|
indicator.style.color = '#c62828'; |
|
indicator.innerHTML = `⚠️ Error checking Libby: ${match.error}`; |
|
} else { |
|
indicator.style.background = '#ccc'; |
|
indicator.style.color = '#333'; |
|
indicator.innerHTML = '✗ Not found in Libby'; |
|
} |
|
} |
|
|
|
// ===== MAIN LOGIC ===== |
|
async function checkBook(book) { |
|
const cached = getCache(book.title, book.author); |
|
if (cached) { |
|
return cached; |
|
} |
|
|
|
try { |
|
// Try full query first |
|
let query = `${book.author} ${book.title}`; |
|
console.log(`Query 1: "${query}"`); |
|
let results = await searchLibbyAPI(query); |
|
let matches = findAllMatches(book.title, book.author, results); |
|
|
|
// If no matches, try author + main title only |
|
if (matches.length === 0) { |
|
query = `${book.author} ${extractMainTitle(book.title)}`; |
|
console.log(`Query 2 (main title): "${query}"`); |
|
results = await searchLibbyAPI(query); |
|
matches = findAllMatches(book.title, book.author, results); |
|
} |
|
|
|
// If still no matches, try just main title |
|
if (matches.length === 0) { |
|
query = extractMainTitle(book.title); |
|
console.log(`Query 3 (title only): "${query}"`); |
|
results = await searchLibbyAPI(query); |
|
matches = findAllMatches(book.title, book.author, results); |
|
} |
|
|
|
const match = selectBestMatch(matches); |
|
setCache(book.title, book.author, match); |
|
return match; |
|
} catch (error) { |
|
console.error(`Error checking ${book.title}:`, error); |
|
return { found: false, error: error.message }; |
|
} |
|
} |
|
|
|
async function initList() { |
|
const books = extractBooksFromList(); |
|
if (books.length === 0) return; |
|
|
|
console.log(`[Libby Checker] Processing ${books.length} books...`); |
|
|
|
// First pass: show cached results immediately |
|
const uncachedBooks = []; |
|
for (const book of books) { |
|
const cached = getCache(book.title, book.author); |
|
if (cached) { |
|
addLibbyButtonToList(book.row, cached, false); |
|
} else { |
|
addLibbyButtonToList(book.row, null, true); |
|
uncachedBooks.push(book); |
|
} |
|
} |
|
|
|
if (uncachedBooks.length === 0) { |
|
console.log('[Libby Checker] All results from cache!'); |
|
return; |
|
} |
|
|
|
console.log(`[Libby Checker] Checking ${uncachedBooks.length} new books...`); |
|
|
|
// Second pass: check uncached books |
|
for (let i = 0; i < uncachedBooks.length; i++) { |
|
const book = uncachedBooks[i]; |
|
try { |
|
const query = `${book.author} ${book.title}`; |
|
const results = await searchLibbyAPI(query); |
|
const matches = findAllMatches(book.title, book.author, results); |
|
const match = selectBestMatch(matches); |
|
setCache(book.title, book.author, match); |
|
addLibbyButtonToList(book.row, match, false); |
|
} catch (error) { |
|
console.error(`Error checking ${book.title}:`, error); |
|
const errorMatch = { found: false, error: error.message }; |
|
addLibbyButtonToList(book.row, errorMatch, false); |
|
} |
|
|
|
if (i < uncachedBooks.length - 1) { |
|
await new Promise(resolve => setTimeout(resolve, DELAY_MS)); |
|
} |
|
} |
|
|
|
console.log('[Libby Checker] Complete!'); |
|
} |
|
|
|
async function initBook() { |
|
const bookInfo = extractBookInfo(); |
|
if (!bookInfo) return; |
|
|
|
// Check if already processed |
|
if (document.querySelector('.libby-indicator')) return; |
|
|
|
addLibbyIndicatorToBook(null, true); |
|
const match = await checkBook(bookInfo); |
|
addLibbyIndicatorToBook(match, false); |
|
} |
|
|
|
function init() { |
|
if (window.location.pathname.includes('/review/list/')) { |
|
initList(); |
|
} else if (window.location.pathname.includes('/book/show/')) { |
|
initBook(); |
|
} |
|
} |
|
|
|
// Run on page load and when navigating (for SPA behavior) |
|
if (document.readyState === 'loading') { |
|
document.addEventListener('DOMContentLoaded', init); |
|
} else { |
|
init(); |
|
} |
|
|
|
// Watch for dynamic content changes |
|
let lastUrl = location.href; |
|
new MutationObserver(() => { |
|
if (location.href !== lastUrl) { |
|
lastUrl = location.href; |
|
setTimeout(init, 500); |
|
} |
|
}).observe(document.body, { childList: true, subtree: true }); |
|
})(); |