mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -06:00
fix(metadata): improve goodreads search relevance and ranking
Search results from Goodreads frequently surfaced study guides, summaries, and companion books instead of the actual primary work because they often shared similar titles. To ensure the correct book is selected during metadata fetch, the autocomplete ranking now heavily penalizes derivative works and boosts items with high rating counts and strong author name overlap.
This commit is contained in:
@@ -34,6 +34,19 @@ describe('candidate-relevance', () => {
|
||||
expect(result[0].title).toBe('The Great Gatsby');
|
||||
});
|
||||
|
||||
it('filters out derivative guides when the queried title appears first', () => {
|
||||
const candidates = [
|
||||
candidate('To Kill a Mockingbird'),
|
||||
candidate('To Kill a Mockingbird by Harper Lee | Summary & Analysis'),
|
||||
candidate('The Great Gatsby by F. Scott Fitzgerald (Book Analysis)'),
|
||||
];
|
||||
|
||||
const result = filterAndRank(candidates, { title: 'To Kill a Mockingbird' });
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].title).toBe('To Kill a Mockingbird');
|
||||
});
|
||||
|
||||
it('always includes exact ISBN matches regardless of title', () => {
|
||||
const candidates = [candidate('Wrong Title', 'Wrong Author', '1234567890')];
|
||||
const params: MetadataSearchParams = { title: 'Right Title', isbn: '123-456-7890' }; // hyphens in query
|
||||
|
||||
@@ -5,7 +5,10 @@ import { MetadataSearchParams } from './providers/metadata-search-params';
|
||||
|
||||
const DEFAULT_LIMIT = 5;
|
||||
|
||||
const SKIP_TITLE_PREFIXES = /^(summary|study guide|analysis|guide to|workbook|review of|summary of|chapter summary|chapter-by-chapter)\b/i;
|
||||
const SKIP_TITLE_PATTERNS = [
|
||||
/^(summary|study guide|analysis|guide to|workbook|review of|summary of|chapter summary|chapter-by-chapter)\b/i,
|
||||
/\b(summary|study guide|book analysis|reading guide|literature guide|workbook|digest|breakdown and analysis)\b/i,
|
||||
];
|
||||
|
||||
/**
|
||||
* Scores, filters, and ranks candidates by relevance to the search params.
|
||||
@@ -21,7 +24,7 @@ export function filterAndRank(candidates: MetadataCandidate[], params: MetadataS
|
||||
}
|
||||
|
||||
return candidates
|
||||
.filter((c) => !c.title || !SKIP_TITLE_PREFIXES.test(c.title))
|
||||
.filter((c) => !c.title || !SKIP_TITLE_PATTERNS.some((pattern) => pattern.test(c.title)))
|
||||
.map((c) => ({ candidate: c, score: scoreCandidate(c, params) }))
|
||||
.filter(({ score }) => score > 0)
|
||||
.sort((a, b) => b.score - a.score)
|
||||
|
||||
@@ -119,4 +119,73 @@ describe('GoodreadsMapper', () => {
|
||||
|
||||
expect(result?.title).toBe('Right Book');
|
||||
});
|
||||
|
||||
it('selects the live Goodreads legacyId match when a stub book appears first', () => {
|
||||
const mockState = {
|
||||
'Book:kca://book/amzn1.gr.book.v3.stub': {
|
||||
legacyId: 56916837,
|
||||
webUrl: 'https://www.goodreads.com/book/show/56916837-to-kill-a-mockingbird',
|
||||
},
|
||||
'Book:kca://book/amzn1.gr.book.v1.full': {
|
||||
legacyId: 2657,
|
||||
title: 'To Kill a Mockingbird',
|
||||
description: 'A novel about Scout Finch.',
|
||||
imageUrl: 'https://images.gr-assets.com/books/2657.jpg',
|
||||
primaryContributorEdge: {
|
||||
node: { __ref: 'Contributor:kca://author/harper-lee' },
|
||||
},
|
||||
bookGenres: [{ genre: { name: 'Classics' } }],
|
||||
details: {
|
||||
publicationTime: 1148367600000,
|
||||
numPages: 323,
|
||||
publisher: 'Harper Perennial Modern Classics',
|
||||
language: { name: 'English' },
|
||||
isbn: '0060935464',
|
||||
isbn13: '9780060935467',
|
||||
},
|
||||
},
|
||||
'Contributor:kca://author/harper-lee': {
|
||||
name: 'Harper Lee',
|
||||
},
|
||||
};
|
||||
|
||||
const result = mapGoodreadsApolloState(mockState, '2657');
|
||||
|
||||
expect(result).toMatchObject({
|
||||
providerId: '2657',
|
||||
title: 'To Kill a Mockingbird',
|
||||
authors: ['Harper Lee'],
|
||||
description: 'A novel about Scout Finch.',
|
||||
publisher: 'Harper Perennial Modern Classics',
|
||||
pageCount: 323,
|
||||
isbn13: '9780060935467',
|
||||
genres: ['Classics'],
|
||||
coverUrl: 'https://images.gr-assets.com/books/2657.jpg',
|
||||
});
|
||||
});
|
||||
|
||||
it('falls back to the richest titled book when the requested legacyId is an untitled stub', () => {
|
||||
const mockState = {
|
||||
'Book:kca://book/amzn1.gr.book.v3.stub': {
|
||||
legacyId: 56916837,
|
||||
webUrl: 'https://www.goodreads.com/book/show/56916837-to-kill-a-mockingbird',
|
||||
},
|
||||
'Book:kca://book/amzn1.gr.book.v1.full': {
|
||||
legacyId: 2657,
|
||||
title: 'To Kill a Mockingbird',
|
||||
description: 'A novel about Scout Finch.',
|
||||
imageUrl: 'https://images.gr-assets.com/books/2657.jpg',
|
||||
details: {
|
||||
numPages: 323,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const result = mapGoodreadsApolloState(mockState, '56916837');
|
||||
|
||||
expect(result?.providerId).toBe('56916837');
|
||||
expect(result?.title).toBe('To Kill a Mockingbird');
|
||||
expect(result?.description).toBe('A novel about Scout Finch.');
|
||||
expect(result?.pageCount).toBe(323);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -8,7 +8,7 @@ export function mapGoodreadsApolloState(state: Record<string, unknown>, bookId:
|
||||
|
||||
const firstSeries = book.bookSeries?.[0];
|
||||
const seriesRef = firstSeries?.series?.__ref;
|
||||
const series = findSeries(state, seriesRef);
|
||||
const series = findSeries(state, seriesRef) ?? firstSeries?.series;
|
||||
|
||||
const primaryContributorRef = book.primaryContributorEdge?.node?.__ref;
|
||||
const contributor = findContributor(state, primaryContributorRef);
|
||||
@@ -52,9 +52,33 @@ function findByKeyPrefix<T>(state: Record<string, unknown>, prefix: string): T |
|
||||
|
||||
function findBook(state: Record<string, unknown>, bookId: string): GoodreadsApolloBook | undefined {
|
||||
const exact = state[`Book:kca:${bookId}`] as GoodreadsApolloBook | undefined;
|
||||
if (exact?.title) return exact;
|
||||
if (isTitledBook(exact)) return exact;
|
||||
|
||||
return findByKeyPrefix<GoodreadsApolloBook>(state, 'Book:kca:');
|
||||
const books = Object.keys(state)
|
||||
.filter((key) => key.startsWith('Book:kca:'))
|
||||
.map((key) => state[key] as GoodreadsApolloBook | undefined)
|
||||
.filter((book): book is GoodreadsApolloBook => !!book);
|
||||
|
||||
const legacyMatch = books.find((book) => isTitledBook(book) && String(book.legacyId) === bookId);
|
||||
if (legacyMatch) return legacyMatch;
|
||||
|
||||
return books.filter(isTitledBook).sort((a, b) => scoreBookShape(b) - scoreBookShape(a))[0];
|
||||
}
|
||||
|
||||
function isTitledBook(book: GoodreadsApolloBook | undefined): book is GoodreadsApolloBook & { title: string } {
|
||||
return typeof book?.title === 'string' && book.title.trim().length > 0;
|
||||
}
|
||||
|
||||
function scoreBookShape(book: GoodreadsApolloBook): number {
|
||||
let score = 0;
|
||||
if (book.title) score += 8;
|
||||
if (book.description) score += 4;
|
||||
if (book.details) score += 4;
|
||||
if (book.primaryContributorEdge) score += 2;
|
||||
if (book.bookGenres?.length) score += 2;
|
||||
if (book.imageUrl) score += 2;
|
||||
if (book.bookSeries?.length) score += 1;
|
||||
return score;
|
||||
}
|
||||
|
||||
function findContributor(state: Record<string, unknown>, ref: string | undefined): GoodreadsApolloContributor | undefined {
|
||||
|
||||
@@ -21,6 +21,20 @@ describe('GoodreadsProvider', () => {
|
||||
ranobedb: { enabled: false },
|
||||
};
|
||||
|
||||
function goodreadsBookHtml(bookId: string, title: string): string {
|
||||
const mockState = {
|
||||
[`Book:kca:${bookId}`]: { title },
|
||||
};
|
||||
return `<script id="__NEXT_DATA__">{"props":{"pageProps":{"apolloState":${JSON.stringify(mockState)}}}}</script>`;
|
||||
}
|
||||
|
||||
function fetchUrl(input: unknown): string {
|
||||
if (typeof input === 'string') return input;
|
||||
if (input instanceof URL) return input.toString();
|
||||
if (typeof input === 'object' && input !== null && 'url' in input && typeof input.url === 'string') return input.url;
|
||||
return '';
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
providerConfig = {
|
||||
getConfig: vi.fn().mockResolvedValue(mockConfig),
|
||||
@@ -181,6 +195,111 @@ describe('GoodreadsProvider', () => {
|
||||
await searchPromise;
|
||||
expect(global.fetch).toHaveBeenCalledTimes(4); // 1 autocomplete + 3 book lookups
|
||||
});
|
||||
|
||||
it('prefers title-only autocomplete matches over author-query summary results', async () => {
|
||||
vi.useFakeTimers();
|
||||
const titleOnly = [
|
||||
{
|
||||
bookId: '56916837',
|
||||
bookUrl: '/book/show/56916837-to-kill-a-mockingbird',
|
||||
title: 'To Kill a Mockingbird',
|
||||
bookTitleBare: 'To Kill a Mockingbird',
|
||||
author: 'Harper Lee',
|
||||
ratingsCount: 7_000_000,
|
||||
},
|
||||
];
|
||||
const titleWithAuthor = [
|
||||
{
|
||||
bookId: '26189532',
|
||||
bookUrl: '/book/show/26189532-to-kill-a-mockingbird-by-harper-lee-summary-analysis',
|
||||
title: 'To Kill a Mockingbird by Harper Lee | Summary & Analysis',
|
||||
bookTitleBare: 'To Kill a Mockingbird by Harper Lee | Summary & Analysis',
|
||||
author: 'aBookaDay',
|
||||
ratingsCount: 100,
|
||||
},
|
||||
];
|
||||
|
||||
global.fetch = vi.fn((input: Parameters<typeof fetch>[0]) => {
|
||||
const url = fetchUrl(input);
|
||||
if (url.includes('q=To%20Kill%20a%20Mockingbird%20Harper%20Lee')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(titleWithAuthor) });
|
||||
}
|
||||
if (url.includes('q=To%20Kill%20a%20Mockingbird')) {
|
||||
return Promise.resolve({ ok: true, json: () => Promise.resolve(titleOnly) });
|
||||
}
|
||||
if (url.includes('/book/show/56916837')) {
|
||||
return Promise.resolve({ ok: true, text: () => Promise.resolve(goodreadsBookHtml('56916837', 'To Kill a Mockingbird')) });
|
||||
}
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
text: () => Promise.resolve(goodreadsBookHtml('26189532', 'To Kill a Mockingbird by Harper Lee | Summary & Analysis')),
|
||||
});
|
||||
}) as never;
|
||||
|
||||
const searchPromise = provider.search({ title: 'To Kill a Mockingbird', author: 'Harper Lee' });
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await searchPromise;
|
||||
|
||||
const bookFetchUrls = vi
|
||||
.mocked(global.fetch)
|
||||
.mock.calls.map(([url]) => fetchUrl(url))
|
||||
.filter((url) => url.includes('/book/show/'));
|
||||
expect(bookFetchUrls[0]).toBe('https://www.goodreads.com/book/show/56916837');
|
||||
expect(result[0].title).toBe('To Kill a Mockingbird');
|
||||
});
|
||||
|
||||
it('ranks exact title and author autocomplete matches above companion books', async () => {
|
||||
vi.useFakeTimers();
|
||||
const titleOnly = [
|
||||
{
|
||||
bookId: '44767458',
|
||||
bookUrl: '/book/show/44767458-dune',
|
||||
title: 'Dune (Dune, #1)',
|
||||
bookTitleBare: 'Dune',
|
||||
author: 'Frank Patrick Herbert',
|
||||
ratingsCount: 1_600_000,
|
||||
},
|
||||
{
|
||||
bookId: '110',
|
||||
bookUrl: '/book/show/110.The_Road_to_Dune',
|
||||
title: 'The Road to Dune',
|
||||
bookTitleBare: 'The Road to Dune',
|
||||
author: 'Frank Herbert',
|
||||
ratingsCount: 20_000,
|
||||
},
|
||||
];
|
||||
const titleWithAuthor = [
|
||||
{
|
||||
bookId: '110',
|
||||
bookUrl: '/book/show/110.The_Road_to_Dune',
|
||||
title: 'The Road to Dune',
|
||||
bookTitleBare: 'The Road to Dune',
|
||||
author: 'Frank Herbert',
|
||||
ratingsCount: 20_000,
|
||||
},
|
||||
];
|
||||
|
||||
global.fetch = vi.fn((input: Parameters<typeof fetch>[0]) => {
|
||||
const url = fetchUrl(input);
|
||||
if (url.includes('q=Dune%20Frank%20Herbert')) return Promise.resolve({ ok: true, json: () => Promise.resolve(titleWithAuthor) });
|
||||
if (url.includes('q=Dune')) return Promise.resolve({ ok: true, json: () => Promise.resolve(titleOnly) });
|
||||
if (url.includes('/book/show/44767458')) {
|
||||
return Promise.resolve({ ok: true, text: () => Promise.resolve(goodreadsBookHtml('44767458', 'Dune')) });
|
||||
}
|
||||
return Promise.resolve({ ok: true, text: () => Promise.resolve(goodreadsBookHtml('110', 'The Road to Dune')) });
|
||||
}) as never;
|
||||
|
||||
const searchPromise = provider.search({ title: 'Dune', author: 'Frank Herbert' });
|
||||
await vi.runAllTimersAsync();
|
||||
const result = await searchPromise;
|
||||
|
||||
const bookFetchUrls = vi
|
||||
.mocked(global.fetch)
|
||||
.mock.calls.map(([url]) => fetchUrl(url))
|
||||
.filter((url) => url.includes('/book/show/'));
|
||||
expect(bookFetchUrls[0]).toBe('https://www.goodreads.com/book/show/44767458');
|
||||
expect(result[0].title).toBe('Dune');
|
||||
});
|
||||
});
|
||||
|
||||
describe('lookupById', () => {
|
||||
|
||||
@@ -63,9 +63,19 @@ export class GoodreadsProvider implements IdentifiableProvider {
|
||||
|
||||
// Goodreads search pages are frequently gated behind WAF challenges.
|
||||
// Prefer the JSON autocomplete endpoint, then fall back to HTML scraping.
|
||||
const autocompleteUrl = `https://www.goodreads.com/book/auto_complete?format=json&q=${encodeURIComponent(query)}`;
|
||||
const autocomplete = await this.fetchJson<GoodreadsAutocompleteItem[]>(autocompleteUrl, 'search-autocomplete', query, undefined, signal);
|
||||
const autocompleteIds = extractBookIdsFromAutocomplete(autocomplete, params.title, PROVIDER_LIMITS.GOODREADS_MAX_RESULTS);
|
||||
const autocompleteItems: GoodreadsAutocompleteItem[] = [];
|
||||
for (const autocompleteQuery of buildAutocompleteQueries(params)) {
|
||||
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,
|
||||
);
|
||||
if (Array.isArray(autocomplete)) autocompleteItems.push(...autocomplete);
|
||||
}
|
||||
const autocompleteIds = extractBookIdsFromAutocomplete(autocompleteItems, params, PROVIDER_LIMITS.GOODREADS_MAX_RESULTS);
|
||||
if (autocompleteIds.length > 0) return autocompleteIds;
|
||||
|
||||
const searchUrl = `https://www.goodreads.com/search?q=${encodeURIComponent(query)}&search_type=books`;
|
||||
@@ -177,6 +187,15 @@ interface GoodreadsAutocompleteItem {
|
||||
bookUrl?: string;
|
||||
title?: string;
|
||||
bookTitleBare?: string;
|
||||
author?: string | { name?: string };
|
||||
ratingsCount?: string | number;
|
||||
}
|
||||
|
||||
function buildAutocompleteQueries(params: MetadataSearchParams): string[] {
|
||||
const title = params.title?.trim();
|
||||
const author = params.author?.trim();
|
||||
const queries = title ? [title, [title, author].filter(Boolean).join(' ')] : [author ?? ''];
|
||||
return [...new Set(queries.filter((q) => q.trim().length > 0))];
|
||||
}
|
||||
|
||||
function extractBookIds(html: string, titleHint: string | undefined, limit: number): string[] {
|
||||
@@ -200,21 +219,27 @@ function extractBookIds(html: string, titleHint: string | undefined, limit: numb
|
||||
return rankBookIds(entries, titleHint, limit);
|
||||
}
|
||||
|
||||
function extractBookIdsFromAutocomplete(payload: GoodreadsAutocompleteItem[] | null, titleHint: string | undefined, limit: number): string[] {
|
||||
function extractBookIdsFromAutocomplete(payload: GoodreadsAutocompleteItem[] | null, params: MetadataSearchParams, limit: number): string[] {
|
||||
if (!Array.isArray(payload) || payload.length === 0) return [];
|
||||
|
||||
const seen = new Set<string>();
|
||||
const entries: Array<{ id: string; text: string }> = [];
|
||||
const entries: Array<{ id: string; text: string; title?: string; author?: string; ratingsCount?: number }> = [];
|
||||
for (const item of payload) {
|
||||
const id = getAutocompleteBookId(item);
|
||||
if (!id || seen.has(id)) continue;
|
||||
seen.add(id);
|
||||
|
||||
const text = [item.bookUrl ?? '', item.title ?? '', item.bookTitleBare ?? ''].join(' ');
|
||||
entries.push({ id, text });
|
||||
entries.push({
|
||||
id,
|
||||
text,
|
||||
title: item.bookTitleBare ?? item.title,
|
||||
author: getAutocompleteAuthor(item),
|
||||
ratingsCount: parseRatingsCount(item.ratingsCount),
|
||||
});
|
||||
}
|
||||
|
||||
return rankBookIds(entries, titleHint, limit);
|
||||
return rankBookIds(entries, params.title, limit, params.author);
|
||||
}
|
||||
|
||||
function getAutocompleteBookId(item: GoodreadsAutocompleteItem): string | null {
|
||||
@@ -225,21 +250,95 @@ function getAutocompleteBookId(item: GoodreadsAutocompleteItem): string | null {
|
||||
return fromUrl ?? null;
|
||||
}
|
||||
|
||||
function rankBookIds(entries: Array<{ id: string; text: string }>, titleHint: string | undefined, limit: number): string[] {
|
||||
function getAutocompleteAuthor(item: GoodreadsAutocompleteItem): string | undefined {
|
||||
if (typeof item.author === 'string') return item.author;
|
||||
return item.author?.name;
|
||||
}
|
||||
|
||||
function parseRatingsCount(value: string | number | undefined): number | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = typeof value === 'string' ? value.replace(/,/g, '') : value;
|
||||
const parsed = typeof normalized === 'string' ? parseInt(normalized, 10) : Math.round(normalized);
|
||||
return Number.isNaN(parsed) || parsed < 0 ? undefined : parsed;
|
||||
}
|
||||
|
||||
function rankBookIds(
|
||||
entries: Array<{ id: string; text: string; title?: string; author?: string; ratingsCount?: number }>,
|
||||
titleHint: string | undefined,
|
||||
limit: number,
|
||||
authorHint?: string,
|
||||
): string[] {
|
||||
if (!titleHint || entries.length <= limit) {
|
||||
return entries.slice(0, limit).map((e) => e.id);
|
||||
return entries
|
||||
.map((entry, index) => ({ entry, index, score: scoreRankedEntry(entry, titleHint, authorHint) }))
|
||||
.sort((a, b) => b.score - a.score || a.index - b.index)
|
||||
.slice(0, limit)
|
||||
.map(({ entry }) => entry.id);
|
||||
}
|
||||
|
||||
// Score entries by how many title words appear in result text (slug/title)
|
||||
// so companion books and study guides rank below likely direct matches.
|
||||
const titleWords = titleHint
|
||||
.toLowerCase()
|
||||
.split(/\W+/)
|
||||
.filter((w) => w.length > 2);
|
||||
const scored = entries.map((e) => ({
|
||||
id: e.id,
|
||||
score: titleWords.filter((w) => e.text.toLowerCase().includes(w)).length,
|
||||
}));
|
||||
scored.sort((a, b) => b.score - a.score);
|
||||
return scored.slice(0, limit).map((s) => s.id);
|
||||
const scored = entries.map((entry, index) => ({ entry, index, score: scoreRankedEntry(entry, titleHint, authorHint) }));
|
||||
scored.sort((a, b) => b.score - a.score || a.index - b.index);
|
||||
return scored.slice(0, limit).map(({ entry }) => entry.id);
|
||||
}
|
||||
|
||||
function scoreRankedEntry(
|
||||
entry: { text: string; title?: string; author?: string; ratingsCount?: number },
|
||||
titleHint: string | undefined,
|
||||
authorHint?: string,
|
||||
): number {
|
||||
let score = 0;
|
||||
const title = normalizeSearchText(entry.title ?? entry.text);
|
||||
const queryTitle = titleHint ? normalizeSearchText(titleHint) : '';
|
||||
|
||||
if (queryTitle) {
|
||||
if (title === queryTitle) score += 100;
|
||||
else if (title.startsWith(queryTitle)) score += 70;
|
||||
else if (queryTitle.startsWith(title)) score += 55;
|
||||
else if (title.includes(queryTitle)) score += 45;
|
||||
else score += titleOverlapScore(queryTitle, title);
|
||||
}
|
||||
|
||||
const queryAuthor = authorHint ? normalizeSearchText(authorHint) : '';
|
||||
const author = entry.author ? normalizeSearchText(entry.author) : '';
|
||||
if (queryAuthor && author) {
|
||||
if (author === queryAuthor || author.includes(queryAuthor) || queryAuthor.includes(author)) score += 30;
|
||||
else score += authorOverlapScore(queryAuthor, author);
|
||||
}
|
||||
|
||||
if (entry.ratingsCount) score += Math.min(Math.log10(entry.ratingsCount + 1) * 3, 20);
|
||||
if (isDerivativeResult(entry.title ?? entry.text)) score -= 80;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
function titleOverlapScore(queryTitle: string, title: string): number {
|
||||
const queryTokens = tokenizeSearchText(queryTitle);
|
||||
if (!queryTokens.length) return 0;
|
||||
const titleTokens = new Set(tokenizeSearchText(title));
|
||||
return (queryTokens.filter((token) => titleTokens.has(token)).length / queryTokens.length) * 30;
|
||||
}
|
||||
|
||||
function authorOverlapScore(queryAuthor: string, author: string): number {
|
||||
const queryTokens = tokenizeSearchText(queryAuthor).filter((token) => token.length > 1);
|
||||
if (!queryTokens.length) return 0;
|
||||
const authorTokens = new Set(tokenizeSearchText(author));
|
||||
return (queryTokens.filter((token) => authorTokens.has(token)).length / queryTokens.length) * 15;
|
||||
}
|
||||
|
||||
function isDerivativeResult(value: string): boolean {
|
||||
return /\b(summary|study guide|analysis|book analysis|reading guide|literature guide|workbook|digest|breakdown|companion)\b/i.test(value);
|
||||
}
|
||||
|
||||
function normalizeSearchText(value: string): string {
|
||||
return value
|
||||
.toLowerCase()
|
||||
.replace(/[\u2018\u2019]/g, "'")
|
||||
.replace(/&/g, ' and ')
|
||||
.replace(/[^a-z0-9 ]/g, ' ')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
}
|
||||
|
||||
function tokenizeSearchText(value: string): string[] {
|
||||
return value.split(' ').filter((token) => token.length > 1);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,7 @@ export interface GoodreadsNextData {
|
||||
|
||||
// Inline nested objects — NOT __ref pointers
|
||||
export interface GoodreadsApolloBook {
|
||||
legacyId?: string | number;
|
||||
title?: string;
|
||||
description?: string;
|
||||
imageUrl?: string;
|
||||
|
||||
Reference in New Issue
Block a user