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:
neon
2026-08-26 19:00:35 +00:00
parent 75ccb8dd1a
commit 87a3217c5e
7 changed files with 353 additions and 67 deletions
@@ -1365,6 +1365,34 @@ describe('MetadataFetchPipeline', () => {
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 () => {
const prefs = createPreferences((fields) => {
fields.seriesName = {
@@ -1,6 +1,9 @@
export class ProviderThrottleError extends Error {
constructor(readonly retryAfterSeconds?: number) {
super('Provider throttled (HTTP 429)');
constructor(
readonly retryAfterSeconds?: number,
reason = 'HTTP 429',
) {
super(`Provider throttled (${reason})`);
this.name = 'ProviderThrottleError';
}
}
@@ -255,7 +255,7 @@ describe('GoodreadsMapper', () => {
avgRating: '4.42',
ratingsCount: 987_654,
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 &amp; no flowers bloomed.<br/><br/>Once, a hero rose…', truncated: true },
description: { html: 'For a thousand years the ash fell &amp; no flowers bloomed.<br/><br/>Once, a hero rose.' },
},
'68428',
);
@@ -266,7 +266,7 @@ describe('GoodreadsMapper', () => {
title: 'Mistborn',
subtitle: 'The Final Empire',
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,
coverUrl: 'https://i.gr-assets.com/images/S/compressed.photo.goodreads.com/books/1617768316i/68428.jpg',
sourceUrl: 'https://www.goodreads.com/book/show/68428',
@@ -332,6 +332,47 @@ describe('GoodreadsMapper', () => {
expect(result?.pageCount).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', () => {
@@ -104,7 +104,11 @@ function parseSeriesFromTitle(title: string | undefined): { seriesName?: string;
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 {
if (typeof description === 'object' && description?.truncated) return undefined;
const html = typeof description === 'string' ? description : description?.html;
if (!html) return undefined;
const text = htmlToPlainText(html, { preserveLineBreaks: true });
@@ -330,7 +330,7 @@ describe('GoodreadsProvider', () => {
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();
const autocomplete = [
{ bookId: '1', bookUrl: '/book/show/1.B1', title: 'B1', bookTitleBare: 'B1' },
@@ -345,14 +345,137 @@ describe('GoodreadsProvider', () => {
const searchPromise = provider.search({ title: 'B' });
await vi.runAllTimersAsync();
const result = await searchPromise;
await expect(searchPromise).rejects.toBeInstanceOf(ProviderThrottleError);
const detailUrls = vi
.mocked(global.fetch)
.mock.calls.map(([url]) => fetchUrl(url))
.filter((url) => url.includes('/book/show/'));
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 { ProviderThrottleError } from '../../provider-throttle.error';
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 { buildRequestSignal, sleep } from '../provider-utils';
import { buildRequestSignal, createSearchDeadline, SearchDeadline, sleep } from '../provider-utils';
import { mapGoodreadsApolloState, mapGoodreadsAutocompleteItem } from './goodreads.mapper';
import { GoodreadsAutocompleteItem, GoodreadsNextData } from './goodreads.types';
@@ -24,6 +24,21 @@ const JSON_HEADERS: HeadersInit = {
};
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()
export class GoodreadsProvider implements IdentifiableProvider {
readonly key = MetadataProviderKey.GOODREADS;
@@ -37,40 +52,55 @@ export class GoodreadsProvider implements IdentifiableProvider {
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
const { enabled } = await this.providerConfig.getConfig().then((c) => c.goodreads);
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
// detail scrape first (richest metadata), but once a detail page cannot be
// loaded at all, stop hitting it for the rest of the batch and build
// candidates from the autocomplete payload we already hold. A page that
// loads but fails to parse does not disable detail mode, so a single odd
// book never downgrades the whole batch.
const results: MetadataCandidate[] = [];
let detailReachable = true;
for (const target of targets.slice(0, PROVIDER_LIMITS.GOODREADS_MAX_RESULTS)) {
let candidate: MetadataCandidate | null = null;
if (detailReachable) {
if (results.length > 0) await sleep(PROVIDER_DELAYS_MS.GOODREADS_BETWEEN_REQUESTS, params.signal);
const detail = await this.fetchBook(target.id, params.signal);
candidate = detail.candidate;
if (!detail.reachable && target.item) detailReachable = false;
// The budget is what makes retrying transient failures safe: it cancels whatever is still in
// flight and leaves the caller holding the candidates already assembled, instead of letting a
// run of retries push the whole search past the hard provider timeout and lose everything.
const deadline = createSearchDeadline(PROVIDER_BUDGETS_MS.GOODREADS_SEARCH, params.signal);
try {
const targets = params.isbn
? await this.findIdByIsbn(params.isbn, deadline).then((id): GoodreadsSearchTarget[] => (id ? [{ id }] : []))
: await this.searchTargets(params, deadline);
// Try the detail scrape first, since it is the only source of a full description. A page that
// loads but fails to parse, or one that is briefly unavailable, only costs this one book.
const results: MetadataCandidate[] = [];
for (const target of targets.slice(0, PROVIDER_LIMITS.GOODREADS_MAX_RESULTS)) {
let candidate: MetadataCandidate | null = null;
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> {
const { enabled } = await this.providerConfig.getConfig().then((c) => c.goodreads);
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(' ');
if (!query.trim()) return [];
@@ -78,27 +108,29 @@ export class GoodreadsProvider implements IdentifiableProvider {
// Prefer the JSON autocomplete endpoint, then fall back to HTML scraping.
const autocompleteItems: GoodreadsAutocompleteItem[] = [];
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 autocomplete = await this.fetchJson<GoodreadsAutocompleteItem[]>(
autocompleteUrl,
'search-autocomplete',
autocompleteQuery,
undefined,
signal,
deadline.signal,
);
if (Array.isArray(autocomplete)) autocompleteItems.push(...autocomplete);
}
const autocompleteTargets = rankAutocompleteItems(autocompleteItems, params, PROVIDER_LIMITS.GOODREADS_MAX_RESULTS);
if (autocompleteTargets.length > 0) return autocompleteTargets;
if (deadline.expired()) return [];
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) : [];
return ids.map((id) => ({ id }));
}
private async findIdByIsbn(isbn: string, signal?: AbortSignal): Promise<string | null> {
const html = await this.fetchHtml(`https://www.goodreads.com/book/isbn/${isbn}`, 'search-by-isbn', isbn, undefined, signal);
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', { query: isbn, deadline });
if (!html) return null;
return (
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
// whether it parsed into a candidate. The caller uses it to tell a WAF block
// apart from a book that simply did not map.
private async fetchBook(bookId: string, signal?: AbortSignal): Promise<{ candidate: MetadataCandidate | null; reachable: boolean }> {
// `outcome` reports why the detail page did not yield a candidate, independent of whether it
// parsed. The caller uses it to tell a bot challenge, which gates every page until it is solved,
// apart from a page that was briefly unavailable or simply did not map.
private async fetchBook(
bookId: string,
deadline: SearchDeadline,
): Promise<{ candidate: MetadataCandidate | null; outcome: GoodreadsFetchOutcome }> {
const url = `https://www.goodreads.com/book/show/${bookId}`;
const html = await this.fetchHtml(url, 'lookup', undefined, bookId, signal);
if (!html) return { candidate: null, reachable: false };
const { html, outcome } = await this.fetchHtml(url, 'lookup', { providerId: bookId, deadline });
if (!html) return { candidate: null, outcome };
const nextData = extractNextData(html);
const state = nextData?.props?.pageProps?.apolloState;
if (!state) return { candidate: null, reachable: true };
return { candidate: mapGoodreadsApolloState(state, bookId), reachable: true };
if (!state) return { candidate: null, outcome: 'ok' };
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;
const safeProviderId = providerId ? sanitizeLogValue(providerId) : undefined;
/**
* Retries a page Goodreads reports as temporarily unavailable. Its rate limiter answers with a
* 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();
this.logger.log(
`[goodreads] [start] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''}`,
);
this.logger.log(`[goodreads] [start] op=${op}${suffix}`);
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) {
const retryable = isTransientStatus(res.status);
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();
if (isWafChallenge(res.status, html)) {
this.logger.warn(
`[goodreads] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt} message="bot challenge"`,
);
return null;
this.logger.warn(`[goodreads] [fail] op=${op}${suffix} status=${res.status} durationMs=${Date.now() - startedAt} message="bot challenge"`);
return { html: null, outcome: 'blocked' };
}
this.logger.log(
`[goodreads] [end] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} status=${res.status} durationMs=${Date.now() - startedAt}`,
);
return html;
this.logger.log(`[goodreads] [end] op=${op}${suffix} status=${res.status} durationMs=${Date.now() - startedAt}`);
return { html, outcome: 'ok' };
} catch (err) {
if (err instanceof ProviderThrottleError) {
this.logger.warn(
`[goodreads] [fail] op=${op}${safeQuery ? ` query="${safeQuery}"` : ''}${safeProviderId ? ` providerId="${safeProviderId}"` : ''} durationMs=${Date.now() - startedAt} message="throttled"`,
);
this.logger.warn(`[goodreads] [fail] op=${op}${suffix} durationMs=${Date.now() - startedAt} message="throttled"`);
throw err;
}
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);
}
// 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[] {
const title = params.title?.trim();
const author = params.author?.trim();
@@ -22,6 +22,18 @@ export const PROVIDER_LIMITS = {
*/
export const PROVIDER_BUDGETS_MS = {
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;
export const PROVIDER_DELAYS_MS = {