mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 08:21:39 -06:00
51dd3d30f3
- add URI versioning: global prefix changed to api/v1, all frontend call sites updated - add AbortSignal.timeout to all outbound fetch calls (10s API, 15s scrapers) - add X-Request-Id response header and requestId field in error payloads - add response compression via @fastify/compress (gzip + brotli) - add security headers via @fastify/helmet with CSP for the Vite SPA - tune pg pool: max 20, idle/connection/statement timeouts on app and migration pools - add GET /api/v1/health endpoint via @nestjs/terminus with DB connectivity check
186 lines
6.5 KiB
TypeScript
186 lines
6.5 KiB
TypeScript
import { Injectable, Logger } from '@nestjs/common';
|
|
import { MetadataCandidate, MetadataProviderKey } from '@projectx/types';
|
|
|
|
import { ProviderConfigService } from '../../../metadata-preferences/provider-config.service';
|
|
import { IdentifiableProvider } from '../metadata-provider';
|
|
import { MetadataSearchParams } from '../metadata-search-params';
|
|
import { mapGoodreadsApolloState } from './goodreads.mapper';
|
|
import { GoodreadsNextData } from './goodreads.types';
|
|
|
|
const HEADERS: HeadersInit = {
|
|
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36',
|
|
accept: 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8',
|
|
'accept-language': 'en-US,en;q=0.9',
|
|
};
|
|
|
|
const MAX_RESULTS = 3;
|
|
const BETWEEN_REQUESTS_MS = 600;
|
|
|
|
@Injectable()
|
|
export class GoodreadsProvider implements IdentifiableProvider {
|
|
readonly key = MetadataProviderKey.GOODREADS;
|
|
readonly label = 'Goodreads';
|
|
readonly identifiable = true as const;
|
|
|
|
private readonly logger = new Logger(GoodreadsProvider.name);
|
|
|
|
constructor(private readonly providerConfig: ProviderConfigService) {}
|
|
|
|
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
|
|
const { enabled } = await this.providerConfig.getConfig().then((c) => c.goodreads);
|
|
if (!enabled) return [];
|
|
const ids = params.isbn ? await this.findIdByIsbn(params.isbn).then((id) => (id ? [id] : [])) : await this.searchIds(params);
|
|
|
|
const results: MetadataCandidate[] = [];
|
|
for (const id of ids.slice(0, MAX_RESULTS)) {
|
|
if (results.length > 0) await sleep(BETWEEN_REQUESTS_MS);
|
|
const candidate = await this.fetchBook(id);
|
|
if (candidate) results.push(candidate);
|
|
}
|
|
|
|
if (results.length > 1 && (params.title || params.author)) {
|
|
results.sort((a, b) => scoreRelevance(b, params) - scoreRelevance(a, params));
|
|
}
|
|
|
|
return results;
|
|
}
|
|
|
|
async lookupById(providerId: string): Promise<MetadataCandidate | null> {
|
|
const { enabled } = await this.providerConfig.getConfig().then((c) => c.goodreads);
|
|
if (!enabled) return null;
|
|
return this.fetchBook(providerId);
|
|
}
|
|
|
|
private async searchIds(params: MetadataSearchParams): Promise<string[]> {
|
|
const query = [params.title, params.author].filter(Boolean).join(' ');
|
|
const url = `https://www.goodreads.com/search?q=${encodeURIComponent(query)}&search_type=books`;
|
|
const html = await this.fetchHtml(url);
|
|
return html ? extractBookIds(html, params.title, MAX_RESULTS) : [];
|
|
}
|
|
|
|
private async findIdByIsbn(isbn: string): Promise<string | null> {
|
|
const html = await this.fetchHtml(`https://www.goodreads.com/book/isbn/${isbn}`);
|
|
if (!html) return null;
|
|
return (
|
|
html.match(/property="og:url"\s+content="[^"]*\/book\/show\/(\d+)/)?.[1] ??
|
|
html.match(/<link[^>]+rel="canonical"[^>]+href="[^"]*\/book\/show\/(\d+)/)?.[1] ??
|
|
null
|
|
);
|
|
}
|
|
|
|
private async fetchBook(bookId: string): Promise<MetadataCandidate | null> {
|
|
const html = await this.fetchHtml(`https://www.goodreads.com/book/show/${bookId}`);
|
|
if (!html) return null;
|
|
const nextData = extractNextData(html);
|
|
const state = nextData?.props?.pageProps?.apolloState;
|
|
if (!state) return null;
|
|
return mapGoodreadsApolloState(state, bookId);
|
|
}
|
|
|
|
private async fetchHtml(url: string): Promise<string | null> {
|
|
try {
|
|
const res = await fetch(url, { headers: HEADERS, signal: AbortSignal.timeout(15_000) });
|
|
if (!res.ok) {
|
|
this.logger.warn(`Goodreads returned ${res.status} for ${url}`);
|
|
return null;
|
|
}
|
|
return res.text();
|
|
} catch (err) {
|
|
this.logger.warn(`Goodreads fetch failed for ${url}: ${err}`);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function extractNextData(html: string): GoodreadsNextData | null {
|
|
const match = html.match(/<script id="__NEXT_DATA__"[^>]*>([\s\S]*?)<\/script>/);
|
|
if (!match?.[1]) return null;
|
|
try {
|
|
return JSON.parse(match[1]) as GoodreadsNextData;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function extractBookIds(html: string, titleHint: string | undefined, limit: number): string[] {
|
|
const seen = new Set<string>();
|
|
const entries: Array<{ id: string; slug: string }> = [];
|
|
|
|
// from_srp=true only appears on actual search result links, not nav/sidebar
|
|
const pattern = /href="(\/book\/show\/[^"]+)"/g;
|
|
let m: RegExpExecArray | null;
|
|
while ((m = pattern.exec(html)) !== null) {
|
|
const href = m[1];
|
|
if (!href.includes('from_srp=true')) continue;
|
|
const idMatch = /\/book\/show\/(\d+)([^?]*)/.exec(href);
|
|
if (!idMatch) continue;
|
|
const id = idMatch[1];
|
|
if (seen.has(id)) continue;
|
|
seen.add(id);
|
|
entries.push({ id, slug: idMatch[2] ?? '' });
|
|
}
|
|
|
|
if (!titleHint || entries.length <= limit) {
|
|
return entries.slice(0, limit).map((e) => e.id);
|
|
}
|
|
|
|
// Score entries by how many title words appear in the URL slug so that
|
|
// companion books and study guides (with unrelated slugs) rank below actual 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.slug.toLowerCase().includes(w)).length,
|
|
}));
|
|
scored.sort((a, b) => b.score - a.score);
|
|
return scored.slice(0, limit).map((s) => s.id);
|
|
}
|
|
|
|
function normalizeStr(s: string): string {
|
|
return s
|
|
.toLowerCase()
|
|
.replace(/[^a-z0-9 ]/g, ' ')
|
|
.replace(/\s+/g, ' ')
|
|
.trim();
|
|
}
|
|
|
|
function scoreRelevance(candidate: MetadataCandidate, params: MetadataSearchParams): number {
|
|
let score = 0;
|
|
|
|
if (params.title && candidate.title) {
|
|
const qt = normalizeStr(params.title);
|
|
const ct = normalizeStr(candidate.title);
|
|
if (ct === qt) score += 10;
|
|
else if (ct.startsWith(qt)) score += 6;
|
|
else {
|
|
const queryWords = qt.split(' ').filter((w) => w.length > 1);
|
|
const candidateWords = new Set(ct.split(' '));
|
|
score += queryWords.filter((w) => candidateWords.has(w)).length * 2;
|
|
}
|
|
}
|
|
|
|
if (params.author && candidate.authors?.length) {
|
|
const qa = normalizeStr(params.author);
|
|
const qaWords = new Set(qa.split(' ').filter((w) => w.length > 2));
|
|
for (const author of candidate.authors) {
|
|
const ca = normalizeStr(author);
|
|
if (ca.includes(qa) || qa.includes(ca)) {
|
|
score += 5;
|
|
break;
|
|
}
|
|
if (ca.split(' ').some((w) => qaWords.has(w))) {
|
|
score += 2;
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|