Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 81 additions & 6 deletions apps/web/src/lib/books.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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,
Expand All @@ -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;
Expand Down Expand Up @@ -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
Expand Down
75 changes: 73 additions & 2 deletions apps/web/src/lib/books.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
});
1 change: 1 addition & 0 deletions apps/web/src/routes/books.js
Original file line number Diff line number Diff line change
Expand Up @@ -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}
Expand Down
38 changes: 36 additions & 2 deletions apps/web/src/views/books.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
<Layout
Expand Down Expand Up @@ -161,6 +162,31 @@ export const BuyPage = ({
) : book.author ? (
<p class="item-meta">{book.author}</p>
) : null}
{edition?.subtitle ? <p>{edition.subtitle}</p> : null}
{edition?.subjects?.length ? (
<ul class="tags">
{edition.subjects.map((s) => (
<li key={s}>
<span class="tag">{s}</span>
</li>
))}
</ul>
) : null}
{!edition && book.isbn ? (
<p>
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{' '}
<a href="https://openlibrary.org/books/add" rel="noopener nofollow">
add it to Open Library
</a>
.
</p>
) : null}
{book.isbn ? (
<p class="small muted">
ISBN-13 <code>{book.isbn.isbn13}</code>
Expand All @@ -178,6 +204,14 @@ export const BuyPage = ({
</a>
</>
) : null}
{edition?.googleBooks ? (
<>
{' · '}
<a href={edition.googleBooks} rel="noopener nofollow">
Google Books ↗
</a>
</>
) : null}
{item ? (
<>
{' · '}
Expand Down Expand Up @@ -243,7 +277,7 @@ export const BuyPage = ({
</section>

<p class="small muted">
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:{' '}
<code>GET /api/v1/books/buy?isbn=&amp;title=&amp;author=&amp;zip=</code>
</p>
</article>
Expand Down
Loading