diff --git a/apps/web/src/lib/books.js b/apps/web/src/lib/books.js index f4b8df8..a84d9ca 100644 --- a/apps/web/src/lib/books.js +++ b/apps/web/src/lib/books.js @@ -362,9 +362,65 @@ const editionCache = new Map(); const EDITION_TTL_MS = 24 * 3600 * 1000; /** - * One edition from Open Library, by ISBN: title, authors, publisher, pages, - * cover and the work it belongs to. Keyless and CC0. Null when Open Library - * has never heard of it, which still leaves every seller's ISBN link working. + * One edition from Google Books. Null when Google has no such book, undefined + * when it did not answer. GOOGLE_BOOKS_API_KEY lifts the shared keyless quota. + */ +async function googleBooksEdition(isbn13, get) { + const key = process.env.GOOGLE_BOOKS_API_KEY; + const url = `https://www.googleapis.com/books/v1/volumes?q=isbn:${isbn13}${key ? `&key=${encodeURIComponent(key)}` : ''}`; + const json = await get(url).catch(() => null); + if (!json) return undefined; + const v = json.items?.[0]?.volumeInfo; + if (!v?.title) return null; + const cover = v.imageLinks?.thumbnail ?? v.imageLinks?.smallThumbnail ?? null; + return { + title: v.title, + subtitle: v.subtitle ?? null, + authors: Array.isArray(v.authors) ? v.authors : [], + publishers: v.publisher ? [v.publisher] : [], + publishDate: v.publishedDate ?? null, + pages: v.pageCount || null, + cover: cover ? cover.replace(/^http:/, 'https:') : null, + subjects: (v.categories ?? []).slice(0, 10), + openLibrary: null, + googleBooks: v.infoLink ?? null, + editionKey: null, + workKey: null, + }; +} + +/** Registration groups, longest prefix first. Not exhaustive: an unknown group simply says nothing. */ +const ISBN_GROUPS = [ + ['9798', 'United States'], + ['97910', 'France'], + ['97911', 'South Korea'], + ['97912', 'Italy'], + ['9780', 'English-speaking countries'], + ['9781', 'English-speaking countries'], + ['9782', 'French-speaking countries'], + ['9783', 'German-speaking countries'], + ['9784', 'Japan'], + ['9785', 'Russia and former Soviet states'], + ['9787', 'China'], +]; + +/** What the number itself says, true of any ISBN even when no catalogue has heard of the book. */ +export function isbnFacts(isbn13) { + const group = ISBN_GROUPS.find(([prefix]) => isbn13.startsWith(prefix)); + return { + registeredIn: group?.[1] ?? null, + selfPublishingLikely: isbn13.startsWith('9798'), + }; +} + +/** + * One edition by ISBN: title, authors, publisher, pages, cover. Open Library + * first (keyless, CC0, and it knows the work), then Google Books, which holds + * many self-published titles Open Library never saw. Null when neither has it, + * which still leaves every seller's ISBN link working. + * + * "Nobody has it" is remembered for a day. "Google would not say" (a spent + * quota, an outage) is not, or one bad minute would blank a book for a day. */ export async function isbnEdition(isbn13, { fetchJson = null } = {}) { const hit = editionCache.get(isbn13); @@ -379,11 +435,13 @@ export async function isbnEdition(isbn13, { fetchJson = null } = {}) { return res.ok ? res.json() : null; }); const key = `ISBN:${isbn13}`; + const openLibraryAnswered = { ok: true }; const json = await get( `https://openlibrary.org/api/books?bibkeys=${key}&format=json&jscmd=data`, ).catch(() => null); + if (!json) openLibraryAnswered.ok = false; const d = json?.[key]; - const value = d + let value = d ? { title: d.title ?? null, subtitle: d.subtitle ?? null, @@ -403,6 +461,13 @@ export async function isbnEdition(isbn13, { fetchJson = null } = {}) { const ed = await get(`https://openlibrary.org${value.editionKey}.json`).catch(() => null); value.workKey = ed?.works?.[0]?.key ?? null; } + let certain = openLibraryAnswered.ok; + if (!value) { + const google = await googleBooksEdition(isbn13, get); + if (google === undefined) certain = false; + else value = google; + } + if (!value && !certain) return null; if (editionCache.size > 5000) editionCache.delete(editionCache.keys().next().value); editionCache.set(isbn13, { at: Date.now(), value }); return value; @@ -470,14 +535,24 @@ export async function answer({ book, row, zip }) { }; } } - return { book, edition, item, zip: near?.zip ?? '', near, stores, zipError }; + return { + book, + edition, + item, + zip: near?.zip ?? '', + near, + stores, + zipError, + facts: book.isbn ? isbnFacts(book.isbn.isbn13) : null, + }; } export const buyAnswer = (a) => ({ isbn: a.book.isbn, - title: a.edition?.title ?? a.book.title ?? null, + title: a.edition?.title || a.book.title || null, author: a.book.author || null, edition: a.edition, + facts: a.facts ?? null, item: a.item ? { id: a.item.id, title: a.item.title, url: `/i/${a.item.id}` } : null, online: buyLinks(a.book), inStore: a.zip diff --git a/apps/web/src/lib/books.test.js b/apps/web/src/lib/books.test.js index a86d3c8..e56eb55 100644 --- a/apps/web/src/lib/books.test.js +++ b/apps/web/src/lib/books.test.js @@ -2,8 +2,16 @@ import { describe, expect, test } from 'bun:test'; process.env.DATABASE_URL ??= 'postgres://test:test@localhost:5432/test'; -const { bookQuery, buyLinks, isbnEdition, normaliseIsbn, storeFinders, storesNear, zipPoint } = - await import('./books.js'); +const { + bookQuery, + buyLinks, + isbnEdition, + isbnFacts, + normaliseIsbn, + storeFinders, + storesNear, + zipPoint, +} = await import('./books.js'); describe('normaliseIsbn', () => { test('a hyphenated ISBN-13 gives both forms', () => { @@ -103,6 +111,20 @@ describe('in store', () => { }); }); +describe('isbnFacts', () => { + test('says what the number itself says, and nothing it does not', () => { + expect(isbnFacts('9798162954880')).toEqual({ + registeredIn: 'United States', + selfPublishingLikely: true, + }); + expect(isbnFacts('9780140328721')).toEqual({ + registeredIn: 'English-speaking countries', + selfPublishingLikely: false, + }); + expect(isbnFacts('9786000000000')).toEqual({ registeredIn: null, selfPublishingLikely: false }); + }); +}); + describe('isbnEdition', () => { test('reads the data view and the work key, then caches', async () => { let calls = 0; @@ -133,4 +155,53 @@ describe('isbnEdition', () => { test('an unknown ISBN is null', async () => { expect(await isbnEdition('9780000000019', { fetchJson: async () => ({}) })).toBeNull(); }); + test('Google Books fills in what Open Library never saw', async () => { + const fetchJson = async (url) => + url.includes('googleapis') + ? { + items: [ + { + volumeInfo: { + title: 'A Self-Published Book', + authors: ['An Author'], + publisher: 'Independently published', + publishedDate: '2024-01-02', + pageCount: 212, + categories: ['Fiction'], + imageLinks: { thumbnail: 'http://books.google.com/c.jpg' }, + infoLink: 'https://books.google.com/books?id=x', + }, + }, + ], + } + : {}; + expect(await isbnEdition('9798000000012', { fetchJson })).toMatchObject({ + title: 'A Self-Published Book', + authors: ['An Author'], + publishers: ['Independently published'], + pages: 212, + cover: 'https://books.google.com/c.jpg', + googleBooks: 'https://books.google.com/books?id=x', + }); + }); + test('a spent quota is not remembered as an unknown book', async () => { + let up = false; + const fetchJson = async (url) => { + if (!url.includes('googleapis')) return {}; + return up ? { items: [{ volumeInfo: { title: 'Back Again' } }] } : null; + }; + expect(await isbnEdition('9798000000029', { fetchJson })).toBeNull(); + up = true; + expect((await isbnEdition('9798000000029', { fetchJson }))?.title).toBe('Back Again'); + }); + test('a book nobody has is remembered, so each view does not ask twice', async () => { + let calls = 0; + const fetchJson = async () => { + calls++; + return {}; + }; + await isbnEdition('9798000000036', { fetchJson }); + await isbnEdition('9798000000036', { fetchJson }); + expect(calls).toBe(2); + }); }); diff --git a/apps/web/src/routes/books.js b/apps/web/src/routes/books.js index 90f9e01..eec9f31 100644 --- a/apps/web/src/routes/books.js +++ b/apps/web/src/routes/books.js @@ -43,6 +43,7 @@ export function registerBooks(app) { canonical={a.book.isbn ? `/isbn/${a.book.isbn.isbn13}` : '/books/buy'} book={a.book} edition={a.edition} + facts={a.facts} item={a.item} zip={a.zip || (c.req.query('zip') ?? '')} near={a.near} diff --git a/apps/web/src/views/books.jsx b/apps/web/src/views/books.jsx index 49238a8..37ceb3a 100644 --- a/apps/web/src/views/books.jsx +++ b/apps/web/src/views/books.jsx @@ -126,13 +126,14 @@ export const BuyPage = ({ canonical, book, edition = null, + facts = null, item = null, zip = '', near = null, stores = null, error = null, }) => { - const title = edition?.title ?? book.title ?? (book.isbn ? `ISBN ${book.isbn.isbn13}` : 'A book'); + const title = edition?.title || book.title || (book.isbn ? `ISBN ${book.isbn.isbn13}` : 'A book'); const links = buyLinks(book); return ( {book.author}

) : null} + {edition?.subtitle ?

{edition.subtitle}

: null} + {edition?.subjects?.length ? ( + + ) : null} + {!edition && book.isbn ? ( +

+ No catalogue we can read has a record for this edition yet, so its title and author are + not known here. + {facts?.registeredIn ? ` The number was registered in ${facts.registeredIn}.` : ''} + {facts?.selfPublishingLikely + ? ' Numbers starting 979-8 are often issued to self-published books.' + : ''}{' '} + The sellers below can still look it up, and if you know the book you can{' '} + + add it to Open Library + + . +

+ ) : null} {book.isbn ? (

ISBN-13 {book.isbn.isbn13} @@ -178,6 +204,14 @@ export const BuyPage = ({ ) : null} + {edition?.googleBooks ? ( + <> + {' · '} + + Google Books ↗ + + + ) : null} {item ? ( <> {' · '} @@ -243,7 +277,7 @@ export const BuyPage = ({

- Edition data from Open Library (CC0). The same answer as JSON:{' '} + Edition data from Open Library (CC0) and Google Books. The same answer as JSON:{' '} GET /api/v1/books/buy?isbn=&title=&author=&zip=