/** * 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 = { 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; 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 }; } }