Files
bookorbit/server/src/modules/metadata-fetch/providers/goodreads/goodreads.mapper.ts
T
neon f6394a9b0c security: remediate dependency and code scanning alerts
Upgrade vulnerable transitive dependencies and harden filesystem, URL, HTML, cryptographic, and input-processing boundaries identified by security scans.
2026-07-15 18:42:07 +00:00

211 lines
8.4 KiB
TypeScript

import { MetadataCandidate, MetadataProviderKey } from '@bookorbit/types';
import {
GoodreadsApolloBook,
GoodreadsApolloContributor,
GoodreadsApolloSeries,
GoodreadsApolloWork,
GoodreadsAutocompleteItem,
} from './goodreads.types';
import { parsePublishedDateFromEpochMillis, publishedYearFromDateKey } from '../../../../common/utils/published-date.utils';
import { htmlToPlainText } from '../../../../common/utils/html-to-text.utils';
export function mapGoodreadsApolloState(state: Record<string, unknown>, bookId: string): MetadataCandidate | null {
const book = findBook(state, bookId);
if (!book?.title) return null;
const firstSeries = book.bookSeries?.[0];
const seriesRef = firstSeries?.series?.__ref;
const series = findSeries(state, seriesRef) ?? firstSeries?.series;
const primaryContributorRef = book.primaryContributorEdge?.node?.__ref;
const contributor = findContributor(state, primaryContributorRef);
const authorName = contributor?.name;
const details = book.details;
const work = findWork(state, book.work?.__ref);
const genres = (book.bookGenres ?? []).map((g) => g.genre?.name).filter((n): n is string => !!n);
const { title, subtitle } = splitTitle(book.title);
const publishedDate = parsePublishedDateFromEpochMillis(details?.publicationTime);
const publishedYear = publishedDate ? publishedYearFromDateKey(publishedDate) : undefined;
const pageCount = parsePositiveInt(details?.numPages);
const seriesIndex = parseSeriesIndex(firstSeries?.userPosition);
const communityRating = normalizeCommunityRating(work?.stats?.averageRating);
const communityRatingCount = normalizeCommunityRatingCount(work?.stats?.ratingsCount);
return {
provider: MetadataProviderKey.GOODREADS,
providerId: bookId,
title,
subtitle,
authors: authorName ? [authorName] : undefined,
description: normalize(book.description),
publisher: normalize(details?.publisher),
publishedDate,
publishedYear,
language: normalize(details?.language?.name),
pageCount,
isbn10: normalize(details?.isbn),
isbn13: normalize(details?.isbn13),
genres: genres.length ? genres : undefined,
coverUrl: book.imageUrl,
sourceUrl: `https://www.goodreads.com/book/show/${bookId}`,
seriesName: normalize(series?.title),
seriesIndex,
...(communityRating !== undefined ? { communityRating } : {}),
...(communityRatingCount !== undefined ? { communityRatingCount } : {}),
};
}
export function mapGoodreadsAutocompleteItem(item: GoodreadsAutocompleteItem, bookId: string): MetadataCandidate | null {
const rawTitle = normalize(item.bookTitleBare) ?? normalize(stripSeriesSuffix(item.title));
if (!rawTitle) return null;
const { title, subtitle } = splitTitle(rawTitle);
const authorName = normalize(typeof item.author === 'string' ? item.author : item.author?.name);
const { seriesName, seriesIndex } = parseSeriesFromTitle(item.title);
const communityRating = normalizeCommunityRating(item.avgRating);
const communityRatingCount = normalizeCommunityRatingCount(item.ratingsCount);
return {
provider: MetadataProviderKey.GOODREADS,
providerId: bookId,
title,
subtitle,
authors: authorName ? [authorName] : undefined,
description: extractAutocompleteDescription(item.description),
pageCount: parsePositiveInt(item.numPages),
coverUrl: upgradeCoverUrl(item.imageUrl),
sourceUrl: `https://www.goodreads.com/book/show/${bookId}`,
seriesName,
seriesIndex,
...(communityRating !== undefined ? { communityRating } : {}),
...(communityRatingCount !== undefined ? { communityRatingCount } : {}),
};
}
// Autocomplete cover URLs carry a thumbnail size token (e.g. `._SY75_`).
// Stripping it yields the full-resolution image.
function upgradeCoverUrl(url: string | undefined): string | undefined {
const normalized = normalize(url);
return normalized ? normalized.replace(/\._S[XY]\d+_\./, '.') : undefined;
}
function stripSeriesSuffix(title: string | undefined): string | undefined {
return title?.replace(/\s*\([^,(]+,\s*#[\d.]+\)\s*$/, '').trim();
}
function parseSeriesFromTitle(title: string | undefined): { seriesName?: string; seriesIndex?: number } {
const match = title?.match(/\(([^,(]+),\s*#([\d.]+)\)\s*$/);
if (!match) return {};
return { seriesName: normalize(match[1]), seriesIndex: parseSeriesIndex(match[2]) };
}
function extractAutocompleteDescription(description: GoodreadsAutocompleteItem['description']): string | undefined {
const html = typeof description === 'string' ? description : description?.html;
if (!html) return undefined;
const text = htmlToPlainText(html, { preserveLineBreaks: true });
return text || undefined;
}
function findByKeyPrefix<T>(state: Record<string, unknown>, prefix: string): T | undefined {
const key = Object.keys(state).find((k) => k.startsWith(prefix));
return key ? (state[key] as T) : undefined;
}
function findBook(state: Record<string, unknown>, bookId: string): GoodreadsApolloBook | undefined {
const exact = state[`Book:kca:${bookId}`] as GoodreadsApolloBook | undefined;
if (isTitledBook(exact)) return exact;
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 {
if (ref) {
return state[ref] as GoodreadsApolloContributor | undefined;
}
const key = Object.keys(state).find((k) => k.startsWith('Contributor:kca') && !!(state[k] as GoodreadsApolloContributor)?.name);
return key ? (state[key] as GoodreadsApolloContributor) : undefined;
}
function findSeries(state: Record<string, unknown>, ref: string | undefined): GoodreadsApolloSeries | undefined {
if (ref) {
return state[ref] as GoodreadsApolloSeries | undefined;
}
return findByKeyPrefix<GoodreadsApolloSeries>(state, 'Series:kca');
}
function findWork(state: Record<string, unknown>, ref: string | undefined): GoodreadsApolloWork | undefined {
if (!ref) return undefined;
return state[ref] as GoodreadsApolloWork | undefined;
}
function splitTitle(fullTitle: string): { title: string; subtitle?: string } {
const colon = fullTitle.indexOf(':');
if (colon > 0) {
return {
title: fullTitle.substring(0, colon).trim(),
subtitle: fullTitle.substring(colon + 1).trim(),
};
}
return { title: fullTitle };
}
function normalize(value: string | undefined | null): string | undefined {
if (!value || value === 'null') return undefined;
return value.trim() || undefined;
}
function parsePositiveInt(value: string | number | undefined): number | undefined {
if (value == null) return undefined;
const n = typeof value === 'string' ? parseInt(value, 10) : Math.round(value);
return n > 0 && !Number.isNaN(n) ? n : undefined;
}
function normalizeCommunityRating(value: string | number | undefined): number | undefined {
if (value == null) return undefined;
const n = typeof value === 'string' ? parseFloat(value) : value;
return Number.isFinite(n) && n >= 0 && n <= 5 ? n : undefined;
}
function normalizeCommunityRatingCount(value: string | number | undefined): number | undefined {
if (value == null) return undefined;
if (typeof value === 'string' && value.trim() === '') return undefined;
const normalized = typeof value === 'string' ? value.replace(/,/g, '') : value;
const n = typeof normalized === 'string' ? Number(normalized) : normalized;
return Number.isInteger(n) && n >= 0 ? n : undefined;
}
function parseSeriesIndex(value: string | undefined): number | undefined {
if (!value) return undefined;
const n = parseFloat(value);
return Number.isNaN(n) ? undefined : n;
}