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 { 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, ) { streamResponse.info("fetching from Hardcover"); const bookDetails = await getBookDetails(hardcoverSlug); if (!bookDetails) { throw new NotFoundError("Book not found on Hardcover"); } const book = await fetchResource(`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, ) { 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(`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, ) { const book = await fetchResource(`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, };