feat: integrate Hardcover API for books

- Add lib/hardcover.ts with GraphQL client for Hardcover API
- Add routes/api/books/[name].ts for creating books via Hardcover ID
- Add routes/api/books/enhance/[name].ts for enhancing books
- Add routes/api/hardcover/query.ts for searching books
- Add routes/books/[name].tsx and index.tsx for book pages
This commit is contained in:
2026-02-10 18:19:10 +01:00
parent c232794cc0
commit e0bfbdd719
8 changed files with 923 additions and 0 deletions

View File

@@ -0,0 +1,244 @@
import { FreshContext, Handlers } from "$fresh/server.ts";
import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts";
import { formatDate, safeFileName, toUrlSafeString } from "@lib/string.ts";
import { createStreamResponse } from "@lib/helpers.ts";
import {
AccessDeniedError,
BadRequestError,
NotFoundError,
} from "@lib/errors.ts";
import { createResource, fetchResource } from "@lib/marka/index.ts";
import { BookResource } from "@lib/marka/schema.ts";
import { webScrape } from "@lib/webScraper.ts";
import * as openai from "@lib/openai.ts";
import { getBookDetails } from "@lib/hardcover.ts";
import { createLogger } from "@lib/log/index.ts";
function ext(str: string) {
try {
const u = new URL(str);
if (u.searchParams.has("fm")) {
return u.searchParams.get("fm")!;
}
return fileExtension(u.pathname);
} catch (_e) {
return fileExtension(str);
}
}
const log = createLogger("api/book/enhance");
async function fetchAndStoreCover(
imageUrl: string | undefined,
title: string,
): Promise<string | undefined> {
if (!imageUrl) return;
const imagePath = `books/images/${safeFileName(title)}_cover.${
ext(imageUrl)
}`;
try {
const res = await fetch(imageUrl);
if (!res.ok) {
log.error(`Failed to download remote image: ${imageUrl}`, {
status: res.status,
});
return;
}
const buffer = await res.arrayBuffer();
await createResource(imagePath, buffer);
return `resources/${imagePath}`;
} catch (err) {
log.error(`Failed to save image: ${imageUrl}`, err);
return undefined;
}
}
async function processEnhanceFromHardcover(
name: string,
hardcoverSlug: string,
streamResponse: ReturnType<typeof createStreamResponse>,
) {
streamResponse.info("fetching from Hardcover");
const bookDetails = await getBookDetails(hardcoverSlug);
if (!bookDetails) {
throw new NotFoundError("Book not found on Hardcover");
}
const book = await fetchResource<BookResource>(`books/${name}`);
if (!book) {
throw new NotFoundError();
}
const title = bookDetails.title || book.content?.headline || "";
const authorName = bookDetails.author_names?.[0] || "";
streamResponse.info("updating cover image");
if (bookDetails.image && !book.content?.image) {
const coverPath = await fetchAndStoreCover(bookDetails.image, title);
if (coverPath) {
book.content.image = coverPath;
}
}
if (!book.content?.headline || book.content.headline !== bookDetails.title) {
book.content.headline = bookDetails.title;
}
if (!book.content?.subtitle && bookDetails.subtitle) {
book.content.subtitle = bookDetails.subtitle;
}
if (!book.content?.isbn && (bookDetails.isbn13 || bookDetails.isbn)) {
book.content.isbn = bookDetails.isbn13 || bookDetails.isbn;
}
if (bookDetails.rating && !book.content?.reviewRating) {
book.content.reviewRating = {
ratingValue: bookDetails.rating,
bestRating: 5,
worstRating: 1,
};
}
if (!book.content?.datePublished && bookDetails.release_year) {
book.content.datePublished = formatDate(`${bookDetails.release_year}-01-01`);
}
const newKeywords = [
...(bookDetails.genres?.map((g: { name: string }) => g.name) || []),
...(bookDetails.tags?.map((t: { name: string }) => t.name) || []),
].filter(Boolean);
if (newKeywords.length > 0) {
book.content.keywords = [
...(book.content.keywords || []),
...newKeywords,
];
}
streamResponse.info("writing to disk");
await createResource(`books/${name}`, book.content);
streamResponse.send({ type: "finished", url: name.replace(/$\.md/, "") });
}
async function processEnhanceFromUrl(
name: string,
fetchUrl: string,
streamResponse: ReturnType<typeof createStreamResponse>,
) {
log.info("enhancing book from url", { url: fetchUrl });
streamResponse.info("scraping url");
const result = await webScrape(fetchUrl, streamResponse);
streamResponse.info("parsing content");
log.debug("downloaded and parsed", result);
streamResponse.info("extracting metadata with openai");
const aiMeta = await openai.extractArticleMetadata(result.markdown);
const title = result?.title || aiMeta?.headline ||
name;
const book = await fetchResource<BookResource>(`books/${name}`);
if (!book) {
throw new NotFoundError();
}
book.content ??= {
_type: "Book",
headline: title,
url: fetchUrl,
};
if (!book.content.bookBody && result.markdown) {
book.content.bookBody = result.markdown;
}
book.content.datePublished ??= formatDate(
result?.published || aiMeta?.datePublished || undefined,
);
if (!book.content.author?.name || book.content.author.name === "") {
book.content.author = {
_type: "Person",
name: (result.schemaOrgData?.author?.name || aiMeta?.author || "")
.replace("@", "twitter:"),
};
}
if (!book.content.image && result?.image?.length) {
const coverPath = await fetchAndStoreCover(result.image, title);
if (coverPath) {
book.content.image = coverPath;
}
}
log.debug("writing to disk", {
name: name,
book: {
...book,
content: {
...book.content,
bookBody: book.content.bookBody?.slice(0, 200),
},
},
});
streamResponse.info("writing to disk");
await createResource(`books/${name}`, book.content);
streamResponse.send({ type: "finished", url: name.replace(/$\.md/, "") });
}
async function processEnhanceBook(
name: string,
streamResponse: ReturnType<typeof createStreamResponse>,
) {
const book = await fetchResource<BookResource>(`books/${name}`);
if (!book) {
throw new NotFoundError();
}
const hardcoverUrl = book.content?.url;
if (hardcoverUrl?.includes("hardcover.app")) {
const match = hardcoverUrl.match(/books\/(.+)/);
if (match && match[1]) {
return processEnhanceFromHardcover(name, match[1], streamResponse);
}
}
if (hardcoverUrl) {
return processEnhanceFromUrl(name, hardcoverUrl, streamResponse);
}
throw new BadRequestError("Book has no URL to enhance from.");
}
const POST = (
_req: Request,
ctx: FreshContext,
): Response => {
const session = ctx.state.session;
if (!session) {
throw new AccessDeniedError();
}
const streamResponse = createStreamResponse();
processEnhanceBook(ctx.params.name, streamResponse)
.catch((err) => {
log.error(err);
streamResponse.error(err.message);
})
.finally(() => {
streamResponse.cancel();
});
return streamResponse.response;
};
export const handler: Handlers = {
POST,
};