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 (
{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=