Files
ReadMeABook/src/lib/services/hardcover-api.service.ts
T
Tom Bernens 0561459782 bulljobs don't respect common headers
added the common truth (user-agent.ts) to all bulljob services
2026-05-15 20:03:40 -07:00

330 lines
9.7 KiB
TypeScript

/**
* Component: Hardcover API Service
* Documentation: documentation/backend/services/hardcover-sync.md
*
* GraphQL queries and API communication with the Hardcover platform.
* Exports fetchHardcoverList for use by the sync orchestration layer.
*/
import axios from 'axios';
import { RMAB_USER_AGENT } from '@/lib/utils/user-agent';
import { RMABLogger } from '@/lib/utils/logger';
const logger = RMABLogger.create('HardcoverAPI');
const HARDCOVER_API_URL = 'https://api.hardcover.app/v1/graphql';
export interface HardcoverApiBook {
bookId: string;
title: string;
author: string;
coverUrl?: string;
}
/** Shape of a book node returned inside user_books or list_books from the Hardcover GraphQL API */
interface HardcoverBookNode {
id?: number;
title?: string;
cached_image?: string | { url?: string };
image?: { url?: string };
contributions?: Array<{ author?: { name?: string } }>;
}
/** Shape of a list object returned from the Hardcover GraphQL API */
interface HardcoverListData {
name?: string;
list_books?: Array<{ book?: HardcoverBookNode }>;
}
const PAGE_SIZE = 100;
const MAX_PAGES = 50;
/** Extract HardcoverApiBook[] from an array of book-containing items */
function extractBooks(items: Array<{ book?: HardcoverBookNode }>): HardcoverApiBook[] {
const books: HardcoverApiBook[] = [];
for (const item of items) {
const book = item.book;
if (!book || !book.id) continue;
const authorName =
book.contributions?.[0]?.author?.name || 'Unknown Author';
const cachedImg = book.cached_image;
const coverUrl =
(typeof cachedImg === 'string' ? cachedImg : cachedImg?.url) ||
book.image?.url ||
undefined;
books.push({
bookId: book.id.toString(),
title: book.title || 'Unknown Title',
author: authorName,
coverUrl,
});
}
return books;
}
/**
* Fetch a Hardcover List using their GraphQL API.
* This handles both 'status_id' user_books or 'list_id' list_books queries.
* For simplicity, we assume `listId` provided by the user is an Int corresponding to a list_id or status_id.
*/
export async function fetchHardcoverList(
apiToken: string,
listIdStr: string,
): Promise<{ listName: string; books: HardcoverApiBook[] }> {
// Check if it's a status list
const isStatus = listIdStr.startsWith('status-');
if (isStatus) {
const statusId = parseInt(listIdStr.replace('status-', ''), 10);
const query = `
query GetStatusBooks($statusId: Int!, $limit: Int!, $offset: Int!) {
me {
user_books(where: {status_id: {_eq: $statusId}}, limit: $limit, offset: $offset, order_by: {id: desc}) {
book {
id
title
contributions {
author {
name
}
}
cached_image
image {
url
}
}
}
}
}
`;
// Map status numbers to names
const statusNames: Record<number, string> = {
1: 'Want to Read',
2: 'Currently Reading',
3: 'Read',
4: 'Did Not Finish',
};
const listName = statusNames[statusId] || `Status ${statusId}`;
const allBooks: HardcoverApiBook[] = [];
let offset = 0;
let page = 0;
// Paginate until fewer results than PAGE_SIZE are returned
while (++page <= MAX_PAGES) {
const response = await axios.post(
HARDCOVER_API_URL,
{ query, variables: { statusId, limit: PAGE_SIZE, offset } },
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
'User-Agent': RMAB_USER_AGENT,
},
timeout: 30000,
},
);
if (response.data?.errors) {
throw new Error(
`Hardcover API Error: ${response.data.errors[0]?.message}`,
);
}
const userBooks: Array<{ book?: HardcoverBookNode }> =
response.data?.data?.me?.[0]?.user_books || [];
const pageBooks = extractBooks(userBooks);
allBooks.push(...pageBooks);
if (userBooks.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
return { listName, books: allBooks };
} else {
// Custom list query
// - URL with @username → query that user's lists by slug
// - Bare slug (no username) → query authenticated user's lists via `me`
// - Numeric ID → query globally (IDs are unique)
const isIntId = /^\d+$/.test(listIdStr);
let extractedSlug = listIdStr;
let extractedUsername: string | null = null;
if (!isIntId) {
try {
if (listIdStr.includes('hardcover.app')) {
const url = new URL(
listIdStr.startsWith('http') ? listIdStr : `https://${listIdStr}`,
);
const parts = url.pathname.split('/').filter(Boolean);
// URL format: /@username/lists/slug
if (parts.length > 0) {
extractedSlug = parts[parts.length - 1];
}
const userPart = parts.find((p) => p.startsWith('@'));
if (userPart) {
extractedUsername = userPart.slice(1);
}
}
} catch (e) {
// use extractedSlug as-is
}
}
const listBookFields = `
name
list_books(limit: $limit, offset: $offset, order_by: {id: desc}) {
book {
id title cached_image image { url }
contributions { author { name } }
}
}
`;
// Numeric ID: globally unique, query the lists table directly
const queryById = `
query GetListBooks($listId: Int!, $limit: Int!, $offset: Int!) {
lists(where: {id: {_eq: $listId}}, limit: 1) {
${listBookFields}
}
}
`;
// Slug with username: query through the users table to scope to that user
const queryByUserSlug = `
query GetUserListBySlug($username: citext!, $slug: String!, $limit: Int!, $offset: Int!) {
users(where: {username: {_eq: $username}}, limit: 1) {
lists(where: {slug: {_eq: $slug}}, limit: 1) {
${listBookFields}
}
}
}
`;
// Bare slug (no username): scope to the authenticated user via `me`
const queryByMySlug = `
query GetMyListBySlug($slug: String!, $limit: Int!, $offset: Int!) {
me {
lists(where: {slug: {_eq: $slug}}, limit: 1) {
${listBookFields}
}
}
}
`;
let activeQuery: string;
let baseVariables: Record<string, unknown>;
if (isIntId) {
activeQuery = queryById;
baseVariables = { listId: parseInt(listIdStr, 10) };
} else if (extractedUsername) {
activeQuery = queryByUserSlug;
baseVariables = { username: extractedUsername, slug: extractedSlug };
} else {
activeQuery = queryByMySlug;
baseVariables = { slug: extractedSlug };
}
// First request to discover list metadata and first page of books
const firstResponse = await axios.post(
HARDCOVER_API_URL,
{
query: activeQuery,
variables: { ...baseVariables, limit: PAGE_SIZE, offset: 0 },
},
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
timeout: 30000,
},
);
if (firstResponse.data?.errors) {
throw new Error(
`Hardcover API Error: ${firstResponse.data.errors[0]?.message}`,
);
}
// Extract lists array from the response based on which query was used
let listsData: HardcoverListData[];
if (isIntId) {
listsData = firstResponse.data?.data?.lists || [];
} else if (extractedUsername) {
const users = firstResponse.data?.data?.users || [];
listsData = users[0]?.lists || [];
} else {
listsData = firstResponse.data?.data?.me?.[0]?.lists || [];
}
if (listsData.length === 0) {
let identifier: string;
if (isIntId) {
identifier = `ID "${listIdStr}"`;
} else if (extractedUsername) {
identifier = `slug "${extractedSlug}" for user @${extractedUsername}`;
} else {
identifier = `slug "${extractedSlug}" in your Hardcover account`;
}
throw new Error(`Could not find a list with ${identifier}`);
}
const listName = listsData[0].name || 'Hardcover List';
const firstPageItems = listsData[0].list_books || [];
const allBooks = extractBooks(firstPageItems);
// Paginate if first page was full
if (firstPageItems.length >= PAGE_SIZE) {
let offset = PAGE_SIZE;
let page = 1; // first page already fetched
while (++page <= MAX_PAGES) {
const pageResponse = await axios.post(
HARDCOVER_API_URL,
{
query: activeQuery,
variables: { ...baseVariables, limit: PAGE_SIZE, offset },
},
{
headers: {
Authorization: `Bearer ${apiToken}`,
'Content-Type': 'application/json',
},
timeout: 30000,
},
);
if (pageResponse.data?.errors) {
logger.warn('Hardcover pagination interrupted by API error', {
errors: pageResponse.data.errors,
offset,
});
break;
}
let pageListsData: HardcoverListData[];
if (isIntId) {
pageListsData = pageResponse.data?.data?.lists || [];
} else if (extractedUsername) {
const users = pageResponse.data?.data?.users || [];
pageListsData = users[0]?.lists || [];
} else {
pageListsData = pageResponse.data?.data?.me?.[0]?.lists || [];
}
const pageItems = pageListsData[0]?.list_books || [];
allBooks.push(...extractBooks(pageItems));
if (pageItems.length < PAGE_SIZE) break;
offset += PAGE_SIZE;
}
}
return { listName, books: allBooks };
}
}