mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
fix(metadata): stop Goodreads overwriting descriptions with snippets
Goodreads caps autocomplete blurbs near 180 characters and flags the cut, but the mapper kept them, so a detail page it could not reach replaced a full description with an ellipsised fragment. Drop a flagged snippet, retry a transient 503 within a search budget, and raise a provider cooldown on a bot challenge instead of degrading the whole batch.
This commit is contained in:
@@ -1365,6 +1365,34 @@ describe('MetadataFetchPipeline', () => {
|
|||||||
expect(resolved.description).toBe('First description');
|
expect(resolved.description).toBe('First description');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// A WAF-blocked Goodreads detail page used to leave the goodreads-first description rule holding
|
||||||
|
// the ellipsised autocomplete snippet, which then overwrote a full description from further down
|
||||||
|
// the provider order.
|
||||||
|
it('falls through to the next provider when the leading provider yields no description', async () => {
|
||||||
|
const prefs = createPreferences((fields) => {
|
||||||
|
fields.description = {
|
||||||
|
enabled: true,
|
||||||
|
providers: [MetadataProviderKey.GOODREADS, MetadataProviderKey.GOOGLE],
|
||||||
|
mergeStrategy: 'overwriteIfProvided',
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
preferencesService.getGlobal.mockResolvedValue(prefs);
|
||||||
|
resolver.resolve.mockReturnValue(prefs);
|
||||||
|
resolver.withForwardCompatibility.mockReturnValue(prefs);
|
||||||
|
registry.all.mockReturnValue([{ key: MetadataProviderKey.GOODREADS }, { key: MetadataProviderKey.GOOGLE }] as never);
|
||||||
|
fetchService.searchCandidates.mockReturnValue(
|
||||||
|
of(
|
||||||
|
candidate(MetadataProviderKey.GOODREADS, 'gr1', { pageCount: 404 }),
|
||||||
|
candidate(MetadataProviderKey.GOOGLE, 'g1', { description: 'The whole blurb, all the way to the end.' }),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
const resolved = await pipeline.run({ title: 'Query' }, {});
|
||||||
|
|
||||||
|
expect(resolved.description).toBe('The whole blurb, all the way to the end.');
|
||||||
|
});
|
||||||
|
|
||||||
it('passes through series memberships when series name and index resolve from the same provider', async () => {
|
it('passes through series memberships when series name and index resolve from the same provider', async () => {
|
||||||
const prefs = createPreferences((fields) => {
|
const prefs = createPreferences((fields) => {
|
||||||
fields.seriesName = {
|
fields.seriesName = {
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
export class ProviderThrottleError extends Error {
|
export class ProviderThrottleError extends Error {
|
||||||
constructor(readonly retryAfterSeconds?: number) {
|
constructor(
|
||||||
super('Provider throttled (HTTP 429)');
|
readonly retryAfterSeconds?: number,
|
||||||
|
reason = 'HTTP 429',
|
||||||
|
) {
|
||||||
|
super(`Provider throttled (${reason})`);
|
||||||
this.name = 'ProviderThrottleError';
|
this.name = 'ProviderThrottleError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -255,7 +255,7 @@ describe('GoodreadsMapper', () => {
|
|||||||
avgRating: '4.42',
|
avgRating: '4.42',
|
||||||
ratingsCount: 987_654,
|
ratingsCount: 987_654,
|
||||||
imageUrl: 'https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1617768316i/68428._SY75_.jpg',
|
imageUrl: 'https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1617768316i/68428._SY75_.jpg',
|
||||||
description: { html: 'For a thousand years the ash fell & no flowers bloomed.<br/><br/>Once, a hero rose…', truncated: true },
|
description: { html: 'For a thousand years the ash fell & no flowers bloomed.<br/><br/>Once, a hero rose.' },
|
||||||
},
|
},
|
||||||
'68428',
|
'68428',
|
||||||
);
|
);
|
||||||
@@ -266,7 +266,7 @@ describe('GoodreadsMapper', () => {
|
|||||||
title: 'Mistborn',
|
title: 'Mistborn',
|
||||||
subtitle: 'The Final Empire',
|
subtitle: 'The Final Empire',
|
||||||
authors: ['Brandon Sanderson'],
|
authors: ['Brandon Sanderson'],
|
||||||
description: 'For a thousand years the ash fell & no flowers bloomed.\n\nOnce, a hero rose…',
|
description: 'For a thousand years the ash fell & no flowers bloomed.\n\nOnce, a hero rose.',
|
||||||
pageCount: 541,
|
pageCount: 541,
|
||||||
coverUrl: 'https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1617768316i/68428.jpg',
|
coverUrl: 'https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1617768316i/68428.jpg',
|
||||||
sourceUrl: 'https://www.goodreads.com/book/show/68428',
|
sourceUrl: 'https://www.goodreads.com/book/show/68428',
|
||||||
@@ -332,6 +332,47 @@ describe('GoodreadsMapper', () => {
|
|||||||
expect(result?.pageCount).toBeUndefined();
|
expect(result?.pageCount).toBeUndefined();
|
||||||
expect(result?.coverUrl).toBeUndefined();
|
expect(result?.coverUrl).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Real `/book/auto_complete` payload for goodreads.com/book/show/229004506. The endpoint caps
|
||||||
|
// the blurb near 180 characters and flags the cut with `truncated`.
|
||||||
|
it('drops a description the autocomplete payload flags as truncated', () => {
|
||||||
|
const result = mapGoodreadsAutocompleteItem(
|
||||||
|
{
|
||||||
|
bookId: '229004506',
|
||||||
|
bookUrl: '/book/show/229004506-the-widow',
|
||||||
|
title: 'The Widow',
|
||||||
|
bookTitleBare: 'The Widow',
|
||||||
|
author: { name: 'John Grisham' },
|
||||||
|
numPages: 404,
|
||||||
|
avgRating: '4.04',
|
||||||
|
ratingsCount: 146_591,
|
||||||
|
imageUrl: 'https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1742957382i/229004506._SY75_.jpg',
|
||||||
|
description: {
|
||||||
|
html: 'Simon Latch is a lawyer in rural Virginia, making just enough to pay his bills while his marriage slowly falls apart. Then into his office walks Eleanor Barnett, an elderly wi\u2026',
|
||||||
|
truncated: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
'229004506',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.description).toBeUndefined();
|
||||||
|
expect(result).toMatchObject({
|
||||||
|
title: 'The Widow',
|
||||||
|
authors: ['John Grisham'],
|
||||||
|
pageCount: 404,
|
||||||
|
communityRating: 4.04,
|
||||||
|
communityRatingCount: 146_591,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps a description the autocomplete payload does not flag as truncated', () => {
|
||||||
|
const result = mapGoodreadsAutocompleteItem(
|
||||||
|
{ bookId: '1', bookTitleBare: 'Short Blurb', description: { html: 'A complete blurb.', truncated: false } },
|
||||||
|
'1',
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(result?.description).toBe('A complete blurb.');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('falls back to the richest titled book when the requested legacyId is an untitled stub', () => {
|
it('falls back to the richest titled book when the requested legacyId is an untitled stub', () => {
|
||||||
|
|||||||
@@ -104,7 +104,11 @@ function parseSeriesFromTitle(title: string | undefined): { seriesName?: string;
|
|||||||
return { seriesName: normalize(match[1]), seriesIndex: parseSeriesIndex(match[2]) };
|
return { seriesName: normalize(match[1]), seriesIndex: parseSeriesIndex(match[2]) };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Autocomplete caps the blurb near 180 characters and flags the cut with `truncated`. Keeping the
|
||||||
|
// snippet would let a WAF-blocked detail page overwrite a full description from a later provider
|
||||||
|
// with an ellipsised fragment, so drop it and leave the field for someone who has the whole thing.
|
||||||
function extractAutocompleteDescription(description: GoodreadsAutocompleteItem['description']): string | undefined {
|
function extractAutocompleteDescription(description: GoodreadsAutocompleteItem['description']): string | undefined {
|
||||||
|
if (typeof description === 'object' && description?.truncated) return undefined;
|
||||||
const html = typeof description === 'string' ? description : description?.html;
|
const html = typeof description === 'string' ? description : description?.html;
|
||||||
if (!html) return undefined;
|
if (!html) return undefined;
|
||||||
const text = htmlToPlainText(html, { preserveLineBreaks: true });
|
const text = htmlToPlainText(html, { preserveLineBreaks: true });
|
||||||
|
|||||||
@@ -330,7 +330,7 @@ describe('GoodreadsProvider', () => {
|
|||||||
expect(result.find((c) => c.providerId === '1')?.title).toBe('B1');
|
expect(result.find((c) => c.providerId === '1')?.title).toBe('B1');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('stops fetching detail pages once one is WAF-blocked and falls back to autocomplete', async () => {
|
it('reports a bot challenge as a throttle instead of quietly degrading the whole batch', async () => {
|
||||||
vi.useFakeTimers();
|
vi.useFakeTimers();
|
||||||
const autocomplete = [
|
const autocomplete = [
|
||||||
{ bookId: '1', bookUrl: '/book/show/1.B1', title: 'B1', bookTitleBare: 'B1' },
|
{ bookId: '1', bookUrl: '/book/show/1.B1', title: 'B1', bookTitleBare: 'B1' },
|
||||||
@@ -345,14 +345,137 @@ describe('GoodreadsProvider', () => {
|
|||||||
|
|
||||||
const searchPromise = provider.search({ title: 'B' });
|
const searchPromise = provider.search({ title: 'B' });
|
||||||
await vi.runAllTimersAsync();
|
await vi.runAllTimersAsync();
|
||||||
const result = await searchPromise;
|
|
||||||
|
|
||||||
|
await expect(searchPromise).rejects.toBeInstanceOf(ProviderThrottleError);
|
||||||
const detailUrls = vi
|
const detailUrls = vi
|
||||||
.mocked(global.fetch)
|
.mocked(global.fetch)
|
||||||
.mock.calls.map(([url]) => fetchUrl(url))
|
.mock.calls.map(([url]) => fetchUrl(url))
|
||||||
.filter((url) => url.includes('/book/show/'));
|
.filter((url) => url.includes('/book/show/'));
|
||||||
expect(detailUrls).toEqual(['https://www.goodreads.com/book/show/1']);
|
expect(detailUrls).toEqual(['https://www.goodreads.com/book/show/1']);
|
||||||
expect(result.map((c) => c.title)).toEqual(['B1', 'B2']);
|
});
|
||||||
|
|
||||||
|
it('reports a bot challenge on a direct lookup as a throttle', async () => {
|
||||||
|
global.fetch = vi
|
||||||
|
.fn()
|
||||||
|
.mockResolvedValue({ ok: true, status: 202, text: () => Promise.resolve('<div id="challenge-container"></div>') }) as never;
|
||||||
|
|
||||||
|
await expect(provider.lookupById('123')).rejects.toBeInstanceOf(ProviderThrottleError);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the description empty when an unavailable detail page forces the truncated autocomplete blurb', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const autocomplete = [
|
||||||
|
{
|
||||||
|
bookId: '229004506',
|
||||||
|
bookUrl: '/book/show/229004506-the-widow',
|
||||||
|
title: 'The Widow',
|
||||||
|
bookTitleBare: 'The Widow',
|
||||||
|
author: { name: 'John Grisham' },
|
||||||
|
numPages: 404,
|
||||||
|
description: {
|
||||||
|
html: 'Simon Latch is a lawyer in rural Virginia, making just enough to pay his bills while his marriage slowly falls apart. Then into his office walks Eleanor Barnett, an elderly wi\u2026',
|
||||||
|
truncated: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: Parameters<typeof fetch>[0]) => {
|
||||||
|
const url = fetchUrl(input);
|
||||||
|
if (url.includes('/auto_complete')) return Promise.resolve({ ok: true, json: () => Promise.resolve(autocomplete) });
|
||||||
|
return Promise.resolve({ ok: false, status: 503, text: () => Promise.resolve('') });
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
const searchPromise = provider.search({ title: 'The Widow', author: 'John Grisham' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result = await searchPromise;
|
||||||
|
|
||||||
|
expect(result).toHaveLength(1);
|
||||||
|
expect(result[0].title).toBe('The Widow');
|
||||||
|
expect(result[0].pageCount).toBe(404);
|
||||||
|
expect(result[0].description).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('retries a detail page Goodreads reports as temporarily unavailable', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const autocomplete = [{ bookId: '1', bookUrl: '/book/show/1.B1', title: 'B1', bookTitleBare: 'B1' }];
|
||||||
|
let detailAttempts = 0;
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: Parameters<typeof fetch>[0]) => {
|
||||||
|
const url = fetchUrl(input);
|
||||||
|
if (url.includes('/auto_complete')) return Promise.resolve({ ok: true, json: () => Promise.resolve(autocomplete) });
|
||||||
|
detailAttempts += 1;
|
||||||
|
if (detailAttempts === 1) return Promise.resolve({ ok: false, status: 503, text: () => Promise.resolve('') });
|
||||||
|
return Promise.resolve({ ok: true, text: () => Promise.resolve(goodreadsBookHtml('1', 'B1 From Detail')) });
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
const searchPromise = provider.search({ title: 'B' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result = await searchPromise;
|
||||||
|
|
||||||
|
expect(detailAttempts).toBe(2);
|
||||||
|
expect(result[0].title).toBe('B1 From Detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stops retrying a detail page once the attempt budget is spent', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const autocomplete = [{ bookId: '1', bookUrl: '/book/show/1.B1', title: 'B1', bookTitleBare: 'B1' }];
|
||||||
|
let detailAttempts = 0;
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: Parameters<typeof fetch>[0]) => {
|
||||||
|
const url = fetchUrl(input);
|
||||||
|
if (url.includes('/auto_complete')) return Promise.resolve({ ok: true, json: () => Promise.resolve(autocomplete) });
|
||||||
|
detailAttempts += 1;
|
||||||
|
return Promise.resolve({ ok: false, status: 503, text: () => Promise.resolve('') });
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
const searchPromise = provider.search({ title: 'B' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result = await searchPromise;
|
||||||
|
|
||||||
|
expect(detailAttempts).toBe(3);
|
||||||
|
expect(result[0].title).toBe('B1');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('keeps fetching detail pages for the rest of the batch after one is temporarily unavailable', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const autocomplete = [
|
||||||
|
{ bookId: '1', bookUrl: '/book/show/1.B1', title: 'B1', bookTitleBare: 'B1' },
|
||||||
|
{ bookId: '2', bookUrl: '/book/show/2.B2', title: 'B2', bookTitleBare: 'B2' },
|
||||||
|
];
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: Parameters<typeof fetch>[0]) => {
|
||||||
|
const url = fetchUrl(input);
|
||||||
|
if (url.includes('/auto_complete')) return Promise.resolve({ ok: true, json: () => Promise.resolve(autocomplete) });
|
||||||
|
if (url.includes('/book/show/1')) return Promise.resolve({ ok: false, status: 503, text: () => Promise.resolve('') });
|
||||||
|
return Promise.resolve({ ok: true, text: () => Promise.resolve(goodreadsBookHtml('2', 'B2 From Detail')) });
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
const searchPromise = provider.search({ title: 'B' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result = await searchPromise;
|
||||||
|
|
||||||
|
expect(result.find((c) => c.providerId === '1')?.title).toBe('B1');
|
||||||
|
expect(result.find((c) => c.providerId === '2')?.title).toBe('B2 From Detail');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not retry a detail page that is simply missing', async () => {
|
||||||
|
vi.useFakeTimers();
|
||||||
|
const autocomplete = [{ bookId: '1', bookUrl: '/book/show/1.B1', title: 'B1', bookTitleBare: 'B1' }];
|
||||||
|
let detailAttempts = 0;
|
||||||
|
|
||||||
|
global.fetch = vi.fn((input: Parameters<typeof fetch>[0]) => {
|
||||||
|
const url = fetchUrl(input);
|
||||||
|
if (url.includes('/auto_complete')) return Promise.resolve({ ok: true, json: () => Promise.resolve(autocomplete) });
|
||||||
|
detailAttempts += 1;
|
||||||
|
return Promise.resolve({ ok: false, status: 404, text: () => Promise.resolve('') });
|
||||||
|
}) as never;
|
||||||
|
|
||||||
|
const searchPromise = provider.search({ title: 'B' });
|
||||||
|
await vi.runAllTimersAsync();
|
||||||
|
const result = await searchPromise;
|
||||||
|
|
||||||
|
expect(detailAttempts).toBe(1);
|
||||||
|
expect(result[0].title).toBe('B1');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -6,9 +6,9 @@ import { sanitizeLogValue } from '../../../../common/utils/log-sanitize.utils';
|
|||||||
import { fetchWithThrottle } from '../../fetch-with-throttle';
|
import { fetchWithThrottle } from '../../fetch-with-throttle';
|
||||||
import { ProviderThrottleError } from '../../provider-throttle.error';
|
import { ProviderThrottleError } from '../../provider-throttle.error';
|
||||||
import { IdentifiableProvider } from '../metadata-provider';
|
import { IdentifiableProvider } from '../metadata-provider';
|
||||||
import { PROVIDER_DELAYS_MS, PROVIDER_LIMITS, PROVIDER_TIMEOUT_MS } from '../provider-constants';
|
import { PROVIDER_BUDGETS_MS, PROVIDER_DELAYS_MS, PROVIDER_LIMITS, PROVIDER_RETRY, PROVIDER_TIMEOUT_MS } from '../provider-constants';
|
||||||
import { MetadataSearchParams } from '../metadata-search-params';
|
import { MetadataSearchParams } from '../metadata-search-params';
|
||||||
import { buildRequestSignal, sleep } from '../provider-utils';
|
import { buildRequestSignal, createSearchDeadline, SearchDeadline, sleep } from '../provider-utils';
|
||||||
import { mapGoodreadsApolloState, mapGoodreadsAutocompleteItem } from './goodreads.mapper';
|
import { mapGoodreadsApolloState, mapGoodreadsAutocompleteItem } from './goodreads.mapper';
|
||||||
import { GoodreadsAutocompleteItem, GoodreadsNextData } from './goodreads.types';
|
import { GoodreadsAutocompleteItem, GoodreadsNextData } from './goodreads.types';
|
||||||
|
|
||||||
@@ -24,6 +24,21 @@ const JSON_HEADERS: HeadersInit = {
|
|||||||
};
|
};
|
||||||
type GoodreadsFetchOp = 'search' | 'search-autocomplete' | 'search-by-isbn' | 'lookup';
|
type GoodreadsFetchOp = 'search' | 'search-autocomplete' | 'search-by-isbn' | 'lookup';
|
||||||
|
|
||||||
|
/** `blocked` gates every page until the challenge clears; `unavailable` is specific to one request. */
|
||||||
|
type GoodreadsFetchOutcome = 'ok' | 'blocked' | 'unavailable';
|
||||||
|
|
||||||
|
interface GoodreadsHtmlFetch {
|
||||||
|
html: string | null;
|
||||||
|
outcome: GoodreadsFetchOutcome;
|
||||||
|
retryable?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface GoodreadsFetchContext {
|
||||||
|
deadline: SearchDeadline;
|
||||||
|
query?: string;
|
||||||
|
providerId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GoodreadsProvider implements IdentifiableProvider {
|
export class GoodreadsProvider implements IdentifiableProvider {
|
||||||
readonly key = MetadataProviderKey.GOODREADS;
|
readonly key = MetadataProviderKey.GOODREADS;
|
||||||
@@ -37,40 +52,55 @@ export class GoodreadsProvider implements IdentifiableProvider {
|
|||||||
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
|
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
|
||||||
const { enabled } = await this.providerConfig.getConfig().then((c) => c.goodreads);
|
const { enabled } = await this.providerConfig.getConfig().then((c) => c.goodreads);
|
||||||
if (!enabled) return [];
|
if (!enabled) return [];
|
||||||
const targets = params.isbn
|
|
||||||
? await this.findIdByIsbn(params.isbn, params.signal).then((id): GoodreadsSearchTarget[] => (id ? [{ id }] : []))
|
|
||||||
: await this.searchTargets(params, params.signal);
|
|
||||||
|
|
||||||
// Detail pages are frequently gated behind the AWS WAF challenge. Try the
|
// The budget is what makes retrying transient failures safe: it cancels whatever is still in
|
||||||
// detail scrape first (richest metadata), but once a detail page cannot be
|
// flight and leaves the caller holding the candidates already assembled, instead of letting a
|
||||||
// loaded at all, stop hitting it for the rest of the batch and build
|
// run of retries push the whole search past the hard provider timeout and lose everything.
|
||||||
// candidates from the autocomplete payload we already hold. A page that
|
const deadline = createSearchDeadline(PROVIDER_BUDGETS_MS.GOODREADS_SEARCH, params.signal);
|
||||||
// loads but fails to parse does not disable detail mode, so a single odd
|
try {
|
||||||
// book never downgrades the whole batch.
|
const targets = params.isbn
|
||||||
const results: MetadataCandidate[] = [];
|
? await this.findIdByIsbn(params.isbn, deadline).then((id): GoodreadsSearchTarget[] => (id ? [{ id }] : []))
|
||||||
let detailReachable = true;
|
: await this.searchTargets(params, deadline);
|
||||||
for (const target of targets.slice(0, PROVIDER_LIMITS.GOODREADS_MAX_RESULTS)) {
|
|
||||||
let candidate: MetadataCandidate | null = null;
|
// Try the detail scrape first, since it is the only source of a full description. A page that
|
||||||
if (detailReachable) {
|
// loads but fails to parse, or one that is briefly unavailable, only costs this one book.
|
||||||
if (results.length > 0) await sleep(PROVIDER_DELAYS_MS.GOODREADS_BETWEEN_REQUESTS, params.signal);
|
const results: MetadataCandidate[] = [];
|
||||||
const detail = await this.fetchBook(target.id, params.signal);
|
for (const target of targets.slice(0, PROVIDER_LIMITS.GOODREADS_MAX_RESULTS)) {
|
||||||
candidate = detail.candidate;
|
let candidate: MetadataCandidate | null = null;
|
||||||
if (!detail.reachable && target.item) detailReachable = false;
|
if (!deadline.expired()) {
|
||||||
|
if (results.length > 0) await sleep(PROVIDER_DELAYS_MS.GOODREADS_BETWEEN_REQUESTS, deadline.signal).catch(() => undefined);
|
||||||
|
// The pause between requests spends budget, so re-check before committing to a fetch.
|
||||||
|
if (!deadline.expired()) {
|
||||||
|
const detail = await this.fetchBook(target.id, deadline);
|
||||||
|
candidate = detail.candidate;
|
||||||
|
if (detail.outcome === 'blocked') throw blockedError();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!candidate && target.item) candidate = mapGoodreadsAutocompleteItem(target.item, target.id);
|
||||||
|
if (candidate) results.push(candidate);
|
||||||
}
|
}
|
||||||
if (!candidate && target.item) candidate = mapGoodreadsAutocompleteItem(target.item, target.id);
|
|
||||||
if (candidate) results.push(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
return results;
|
||||||
|
} finally {
|
||||||
|
deadline.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async lookupById(providerId: string, signal?: AbortSignal): Promise<MetadataCandidate | null> {
|
async lookupById(providerId: string, signal?: AbortSignal): Promise<MetadataCandidate | null> {
|
||||||
const { enabled } = await this.providerConfig.getConfig().then((c) => c.goodreads);
|
const { enabled } = await this.providerConfig.getConfig().then((c) => c.goodreads);
|
||||||
if (!enabled) return null;
|
if (!enabled) return null;
|
||||||
return (await this.fetchBook(providerId, signal)).candidate;
|
|
||||||
|
const deadline = createSearchDeadline(PROVIDER_BUDGETS_MS.GOODREADS_SEARCH, signal);
|
||||||
|
try {
|
||||||
|
const { candidate, outcome } = await this.fetchBook(providerId, deadline);
|
||||||
|
if (outcome === 'blocked') throw blockedError();
|
||||||
|
return candidate;
|
||||||
|
} finally {
|
||||||
|
deadline.dispose();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async searchTargets(params: MetadataSearchParams, signal?: AbortSignal): Promise<GoodreadsSearchTarget[]> {
|
private async searchTargets(params: MetadataSearchParams, deadline: SearchDeadline): Promise<GoodreadsSearchTarget[]> {
|
||||||
const query = [params.title, params.author].filter(Boolean).join(' ');
|
const query = [params.title, params.author].filter(Boolean).join(' ');
|
||||||
if (!query.trim()) return [];
|
if (!query.trim()) return [];
|
||||||
|
|
||||||
@@ -78,27 +108,29 @@ export class GoodreadsProvider implements IdentifiableProvider {
|
|||||||
// Prefer the JSON autocomplete endpoint, then fall back to HTML scraping.
|
// Prefer the JSON autocomplete endpoint, then fall back to HTML scraping.
|
||||||
const autocompleteItems: GoodreadsAutocompleteItem[] = [];
|
const autocompleteItems: GoodreadsAutocompleteItem[] = [];
|
||||||
for (const autocompleteQuery of buildAutocompleteQueries(params)) {
|
for (const autocompleteQuery of buildAutocompleteQueries(params)) {
|
||||||
|
if (deadline.expired()) break;
|
||||||
const autocompleteUrl = `https://www.goodreads.com/book/auto_complete?format=json&q=${encodeURIComponent(autocompleteQuery)}`;
|
const autocompleteUrl = `https://www.goodreads.com/book/auto_complete?format=json&q=${encodeURIComponent(autocompleteQuery)}`;
|
||||||
const autocomplete = await this.fetchJson<GoodreadsAutocompleteItem[]>(
|
const autocomplete = await this.fetchJson<GoodreadsAutocompleteItem[]>(
|
||||||
autocompleteUrl,
|
autocompleteUrl,
|
||||||
'search-autocomplete',
|
'search-autocomplete',
|
||||||
autocompleteQuery,
|
autocompleteQuery,
|
||||||
undefined,
|
undefined,
|
||||||
signal,
|
deadline.signal,
|
||||||
);
|
);
|
||||||
if (Array.isArray(autocomplete)) autocompleteItems.push(...autocomplete);
|
if (Array.isArray(autocomplete)) autocompleteItems.push(...autocomplete);
|
||||||
}
|
}
|
||||||
const autocompleteTargets = rankAutocompleteItems(autocompleteItems, params, PROVIDER_LIMITS.GOODREADS_MAX_RESULTS);
|
const autocompleteTargets = rankAutocompleteItems(autocompleteItems, params, PROVIDER_LIMITS.GOODREADS_MAX_RESULTS);
|
||||||
if (autocompleteTargets.length > 0) return autocompleteTargets;
|
if (autocompleteTargets.length > 0) return autocompleteTargets;
|
||||||
|
if (deadline.expired()) return [];
|
||||||
|
|
||||||
const searchUrl = `https://www.goodreads.com/search?q=${encodeURIComponent(query)}&search_type=books`;
|
const searchUrl = `https://www.goodreads.com/search?q=${encodeURIComponent(query)}&search_type=books`;
|
||||||
const html = await this.fetchHtml(searchUrl, 'search', query, undefined, signal);
|
const { html } = await this.fetchHtml(searchUrl, 'search', { query, deadline });
|
||||||
const ids = html ? extractBookIds(html, params.title, PROVIDER_LIMITS.GOODREADS_MAX_RESULTS) : [];
|
const ids = html ? extractBookIds(html, params.title, PROVIDER_LIMITS.GOODREADS_MAX_RESULTS) : [];
|
||||||
return ids.map((id) => ({ id }));
|
return ids.map((id) => ({ id }));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async findIdByIsbn(isbn: string, signal?: AbortSignal): Promise<string | null> {
|
private async findIdByIsbn(isbn: string, deadline: SearchDeadline): Promise<string | null> {
|
||||||
const html = await this.fetchHtml(`https://www.goodreads.com/book/isbn/${isbn}`, 'search-by-isbn', isbn, undefined, signal);
|
const { html } = await this.fetchHtml(`https://www.goodreads.com/book/isbn/${isbn}`, 'search-by-isbn', { query: isbn, deadline });
|
||||||
if (!html) return null;
|
if (!html) return null;
|
||||||
return (
|
return (
|
||||||
html.match(/property="og:url"\s+content="[^"]*\/book\/show\/(\d+)/)?.[1] ??
|
html.match(/property="og:url"\s+content="[^"]*\/book\/show\/(\d+)/)?.[1] ??
|
||||||
@@ -107,56 +139,80 @@ export class GoodreadsProvider implements IdentifiableProvider {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// `reachable` reports whether the detail page itself loaded, independent of
|
// `outcome` reports why the detail page did not yield a candidate, independent of whether it
|
||||||
// whether it parsed into a candidate. The caller uses it to tell a WAF block
|
// parsed. The caller uses it to tell a bot challenge, which gates every page until it is solved,
|
||||||
// apart from a book that simply did not map.
|
// apart from a page that was briefly unavailable or simply did not map.
|
||||||
private async fetchBook(bookId: string, signal?: AbortSignal): Promise<{ candidate: MetadataCandidate | null; reachable: boolean }> {
|
private async fetchBook(
|
||||||
|
bookId: string,
|
||||||
|
deadline: SearchDeadline,
|
||||||
|
): Promise<{ candidate: MetadataCandidate | null; outcome: GoodreadsFetchOutcome }> {
|
||||||
const url = `https://www.goodreads.com/book/show/${bookId}`;
|
const url = `https://www.goodreads.com/book/show/${bookId}`;
|
||||||
const html = await this.fetchHtml(url, 'lookup', undefined, bookId, signal);
|
const { html, outcome } = await this.fetchHtml(url, 'lookup', { providerId: bookId, deadline });
|
||||||
if (!html) return { candidate: null, reachable: false };
|
if (!html) return { candidate: null, outcome };
|
||||||
const nextData = extractNextData(html);
|
const nextData = extractNextData(html);
|
||||||
const state = nextData?.props?.pageProps?.apolloState;
|
const state = nextData?.props?.pageProps?.apolloState;
|
||||||
if (!state) return { candidate: null, reachable: true };
|
if (!state) return { candidate: null, outcome: 'ok' };
|
||||||
return { candidate: mapGoodreadsApolloState(state, bookId), reachable: true };
|
return { candidate: mapGoodreadsApolloState(state, bookId), outcome: 'ok' };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async fetchHtml(url: string, op: GoodreadsFetchOp, query?: string, providerId?: string, signal?: AbortSignal): Promise<string | null> {
|
/**
|
||||||
const safeQuery = query ? sanitizeLogValue(query) : undefined;
|
* Retries a page Goodreads reports as temporarily unavailable. Its rate limiter answers with a
|
||||||
const safeProviderId = providerId ? sanitizeLogValue(providerId) : undefined;
|
* bare 503 that clears within seconds, so the same URL usually succeeds on a second or third try;
|
||||||
|
* a bot challenge or a missing page is returned as-is, since neither improves by asking again.
|
||||||
|
*/
|
||||||
|
private async fetchHtml(url: string, op: GoodreadsFetchOp, context: GoodreadsFetchContext): Promise<GoodreadsHtmlFetch> {
|
||||||
|
const backoffs = PROVIDER_RETRY.GOODREADS_TRANSIENT_BACKOFF_MS;
|
||||||
|
let last: GoodreadsHtmlFetch = { html: null, outcome: 'unavailable' };
|
||||||
|
|
||||||
|
for (let attempt = 0; attempt < PROVIDER_RETRY.GOODREADS_TRANSIENT_ATTEMPTS; attempt++) {
|
||||||
|
if (attempt > 0) {
|
||||||
|
const backoff = backoffs[Math.min(attempt - 1, backoffs.length - 1)];
|
||||||
|
if (context.deadline.expired()) break;
|
||||||
|
await sleep(backoff, context.deadline.signal).catch(() => undefined);
|
||||||
|
if (context.deadline.expired()) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
last = await this.attemptHtml(url, op, context, attempt + 1);
|
||||||
|
if (last.outcome !== 'unavailable' || !last.retryable) return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
return last;
|
||||||
|
}
|
||||||
|
|
||||||
|
private async attemptHtml(url: string, op: GoodreadsFetchOp, context: GoodreadsFetchContext, attempt: number): Promise<GoodreadsHtmlFetch> {
|
||||||
|
const safeQuery = context.query ? sanitizeLogValue(context.query) : undefined;
|
||||||
|
const safeProviderId = context.providerId ? sanitizeLogValue(context.providerId) : undefined;
|
||||||
|
const suffix = `${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''}${attempt > 1 ? ` attempt=${attempt}` : ''}`;
|
||||||
const startedAt = Date.now();
|
const startedAt = Date.now();
|
||||||
this.logger.log(
|
this.logger.log(`[goodreads] [start] op=${op}${suffix}`);
|
||||||
`[goodreads] [start] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''}`,
|
|
||||||
);
|
|
||||||
try {
|
try {
|
||||||
const res = await fetchWithThrottle(url, { headers: HEADERS, signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.SCRAPE, signal) });
|
const res = await fetchWithThrottle(url, {
|
||||||
|
headers: HEADERS,
|
||||||
|
signal: buildRequestSignal(PROVIDER_TIMEOUT_MS.SCRAPE, context.deadline.signal),
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
const retryable = isTransientStatus(res.status);
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[goodreads] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt} message="non-ok response"`,
|
`[goodreads] [fail] op=${op}${suffix} status=${res.status} durationMs=${Date.now() - startedAt} message="${retryable ? 'temporarily unavailable' : 'non-ok response'}"`,
|
||||||
);
|
);
|
||||||
return null;
|
return { html: null, outcome: isDurableBlockStatus(res.status) ? 'blocked' : 'unavailable', retryable };
|
||||||
}
|
}
|
||||||
const html = await res.text();
|
const html = await res.text();
|
||||||
if (isWafChallenge(res.status, html)) {
|
if (isWafChallenge(res.status, html)) {
|
||||||
this.logger.warn(
|
this.logger.warn(`[goodreads] [fail] op=${op}${suffix} status=${res.status} durationMs=${Date.now() - startedAt} message="bot challenge"`);
|
||||||
`[goodreads] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt} message="bot challenge"`,
|
return { html: null, outcome: 'blocked' };
|
||||||
);
|
|
||||||
return null;
|
|
||||||
}
|
}
|
||||||
this.logger.log(
|
this.logger.log(`[goodreads] [end] op=${op}${suffix} status=${res.status} durationMs=${Date.now() - startedAt}`);
|
||||||
`[goodreads] [end] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt}`,
|
return { html, outcome: 'ok' };
|
||||||
);
|
|
||||||
return html;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
if (err instanceof ProviderThrottleError) {
|
if (err instanceof ProviderThrottleError) {
|
||||||
this.logger.warn(
|
this.logger.warn(`[goodreads] [fail] op=${op}${suffix} durationMs=${Date.now() - startedAt} message="throttled"`);
|
||||||
`[goodreads] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} durationMs=${Date.now() - startedAt} message="throttled"`,
|
|
||||||
);
|
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
this.logger.warn(
|
this.logger.warn(
|
||||||
`[goodreads] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
|
`[goodreads] [fail] op=${op}${suffix} durationMs=${Date.now() - startedAt} message="${err instanceof Error ? err.message : String(err)}"`,
|
||||||
);
|
);
|
||||||
return null;
|
return { html: null, outcome: 'unavailable' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -213,6 +269,25 @@ function isWafChallenge(status: number, html: string): boolean {
|
|||||||
return status === 202 || /awsWafCookieDomainList|AwsWafIntegration|id="challenge-container"|challenge\.js/.test(html);
|
return status === 202 || /awsWafCookieDomainList|AwsWafIntegration|id="challenge-container"|challenge\.js/.test(html);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Goodreads sheds load with a bare 503 and no Retry-After. 429 never reaches here: fetchWithThrottle
|
||||||
|
// turns it into a ProviderThrottleError so the shared cooldown owns it.
|
||||||
|
function isTransientStatus(status: number): boolean {
|
||||||
|
return status === 408 || status === 425 || status === 500 || status === 502 || status === 503 || status === 504;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isDurableBlockStatus(status: number): boolean {
|
||||||
|
return status === 401 || status === 403;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A bot challenge gates every page until it is solved, and a plain fetch cannot solve it. Carrying
|
||||||
|
* on would spend a whole bulk run hammering a wall, which is also what keeps the challenge up, so
|
||||||
|
* hand it to the shared cooldown and let the other providers answer until it lifts.
|
||||||
|
*/
|
||||||
|
function blockedError(): ProviderThrottleError {
|
||||||
|
return new ProviderThrottleError(undefined, 'bot challenge');
|
||||||
|
}
|
||||||
|
|
||||||
function buildAutocompleteQueries(params: MetadataSearchParams): string[] {
|
function buildAutocompleteQueries(params: MetadataSearchParams): string[] {
|
||||||
const title = params.title?.trim();
|
const title = params.title?.trim();
|
||||||
const author = params.author?.trim();
|
const author = params.author?.trim();
|
||||||
|
|||||||
@@ -22,6 +22,18 @@ export const PROVIDER_LIMITS = {
|
|||||||
*/
|
*/
|
||||||
export const PROVIDER_BUDGETS_MS = {
|
export const PROVIDER_BUDGETS_MS = {
|
||||||
COMICVINE_SEARCH: PROVIDER_TIMEOUT_MS.SCRAPE - 1_500,
|
COMICVINE_SEARCH: PROVIDER_TIMEOUT_MS.SCRAPE - 1_500,
|
||||||
|
GOODREADS_SEARCH: PROVIDER_TIMEOUT_MS.SCRAPE - 1_500,
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Goodreads rate-limits book pages with a bare 503 that carries no Retry-After and clears within a
|
||||||
|
* few seconds, so it is a per-request hiccup rather than a provider-wide cooldown. Retrying inside
|
||||||
|
* the search budget recovers most of them; a fixed longer delay would pay the same cost on every
|
||||||
|
* request instead of only the failing ones.
|
||||||
|
*/
|
||||||
|
export const PROVIDER_RETRY = {
|
||||||
|
GOODREADS_TRANSIENT_ATTEMPTS: 3,
|
||||||
|
GOODREADS_TRANSIENT_BACKOFF_MS: [1_200, 2_500],
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export const PROVIDER_DELAYS_MS = {
|
export const PROVIDER_DELAYS_MS = {
|
||||||
|
|||||||
Reference in New Issue
Block a user