mirror of
https://github.com/bookorbit/bookorbit.git
synced 2026-11-03 00:11:37 -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
107 lines
4.1 KiB
TypeScript
107 lines
4.1 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 { extractAsins, parseBookPage } from './amazon.scraper';
|
|
|
|
const HEADERS: HeadersInit = {
|
|
'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/137.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',
|
|
'sec-ch-ua': '"Google Chrome";v="137", "Chromium";v="137", "Not_A Brand";v="24"',
|
|
'sec-ch-ua-mobile': '?0',
|
|
'sec-ch-ua-platform': '"macOS"',
|
|
'sec-fetch-dest': 'document',
|
|
'sec-fetch-mode': 'navigate',
|
|
'sec-fetch-site': 'none',
|
|
};
|
|
|
|
const MAX_RESULTS = 3;
|
|
const BETWEEN_REQUESTS_MS = 800;
|
|
|
|
@Injectable()
|
|
export class AmazonProvider implements IdentifiableProvider {
|
|
readonly key = MetadataProviderKey.AMAZON;
|
|
readonly label = 'Amazon';
|
|
readonly identifiable = true as const;
|
|
|
|
private readonly logger = new Logger(AmazonProvider.name);
|
|
|
|
constructor(private readonly providerConfig: ProviderConfigService) {}
|
|
|
|
async search(params: MetadataSearchParams): Promise<MetadataCandidate[]> {
|
|
const { enabled, domain, cookie } = await this.providerConfig.getConfig().then((c) => c.amazon);
|
|
if (!enabled) return [];
|
|
|
|
const asins = params.isbn ? [params.isbn] : await this.searchAsins(params, domain, cookie);
|
|
|
|
const results: MetadataCandidate[] = [];
|
|
for (const asin of asins.slice(0, MAX_RESULTS)) {
|
|
if (results.length > 0) await sleep(BETWEEN_REQUESTS_MS);
|
|
const candidate = await this.fetchByAsin(asin, domain, cookie);
|
|
if (candidate) results.push(candidate);
|
|
}
|
|
return results;
|
|
}
|
|
|
|
async lookupById(providerId: string): Promise<MetadataCandidate | null> {
|
|
const { enabled, domain, cookie } = await this.providerConfig.getConfig().then((c) => c.amazon);
|
|
if (!enabled) return null;
|
|
return this.fetchByAsin(providerId, domain, cookie);
|
|
}
|
|
|
|
private async searchAsins(params: MetadataSearchParams, domain: string, cookie: string): Promise<string[]> {
|
|
const query = [params.title, params.author].filter(Boolean).join(' ');
|
|
const url = `https://www.${domain}/s?k=${encodeURIComponent(query)}&i=stripbooks`;
|
|
const html = await this.fetchHtml(url, cookie);
|
|
return html ? extractAsins(html, MAX_RESULTS) : [];
|
|
}
|
|
|
|
private async fetchByAsin(asin: string, domain: string, cookie: string): Promise<MetadataCandidate | null> {
|
|
const html = await this.fetchHtml(`https://www.${domain}/dp/${asin}`, cookie);
|
|
if (!html) return null;
|
|
const data = parseBookPage(html);
|
|
if (!data.title) return null;
|
|
return {
|
|
provider: MetadataProviderKey.AMAZON,
|
|
providerId: asin,
|
|
title: data.title,
|
|
subtitle: data.subtitle,
|
|
authors: data.authors?.length ? data.authors : undefined,
|
|
description: data.description,
|
|
isbn13: data.isbn13,
|
|
isbn10: data.isbn10,
|
|
publisher: data.publisher,
|
|
publishedYear: data.publishedYear,
|
|
language: data.language,
|
|
pageCount: data.pageCount,
|
|
seriesName: data.seriesName,
|
|
seriesIndex: data.seriesIndex,
|
|
coverUrl: data.coverUrl,
|
|
genres: data.tags?.length ? data.tags : undefined,
|
|
sourceUrl: `https://www.${domain}/dp/${asin}`,
|
|
};
|
|
}
|
|
|
|
private async fetchHtml(url: string, cookie = ''): Promise<string | null> {
|
|
const headers: HeadersInit = cookie ? { ...HEADERS, cookie } : HEADERS;
|
|
try {
|
|
const res = await fetch(url, { headers, signal: AbortSignal.timeout(15_000) });
|
|
if (!res.ok) {
|
|
this.logger.warn(`Amazon returned ${res.status} for ${url}`);
|
|
return null;
|
|
}
|
|
return res.text();
|
|
} catch (err) {
|
|
this.logger.warn(`Amazon fetch failed for ${url}: ${err}`);
|
|
return null;
|
|
}
|
|
}
|
|
}
|
|
|
|
function sleep(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|