diff --git a/lib/hardcover.ts b/lib/hardcover.ts new file mode 100644 index 0000000..2d9787c --- /dev/null +++ b/lib/hardcover.ts @@ -0,0 +1,140 @@ +import { createCache } from "@lib/cache.ts"; + +const HARDCOVER_API_URL = "https://api.hardcover.app/v1/graphql"; + +const CACHE_INTERVAL = 1000 * 60 * 60 * 24 * 7; +const cache = createCache("hardcover", { expires: CACHE_INTERVAL }); + +export interface HardcoverBookResult { + id: string; + slug: string; + title: string; + subtitle?: string; + author_names: string[]; + cover_color?: string; + description?: string; + isbn?: string; + isbn13?: string; + release_year?: string; + series_names?: string[]; + rating?: number; + ratings_count?: number; + pages?: number; + image?: string; +} + +async function hardcoverFetch(query: string, variables: Record = {}) { + const response = await fetch(HARDCOVER_API_URL, { + method: "POST", + headers: { + "Content-Type": "application/json", + "Authorization": Deno.env.get("HARDCOVER_API_TOKEN") || "", + }, + body: JSON.stringify({ query, variables }), + }); + + if (!response.ok) { + throw new Error(`Hardcover API error: ${response.statusText}`); + } + + const json = await response.json(); + + if (json.errors) { + console.error("Hardcover GraphQL errors:", JSON.stringify(json.errors, null, 2)); + throw new Error(`Hardcover GraphQL error: ${json.errors[0]?.message || "Unknown error"}`); + } + + return json; +} + +export const searchBook = async (query: string) => { + const id = `query:booksearch:${query}`; + if (cache.has(id)) return cache.get(id) as HardcoverBookResult[]; + + const graphqlQuery = ` + query SearchBooks($query: String!, $perPage: Int, $page: Int) { + search( + query: $query + query_type: "Book" + per_page: $perPage + page: $page + ) { + results + } + } + `; + + const result = await hardcoverFetch(graphqlQuery, { + query, + perPage: 10, + page: 1, + }); + + const typesenseResponse = result.data?.search?.results; + const hits = typesenseResponse?.hits; + const books = (hits || []).map((hit: { document: HardcoverBookResult }) => hit.document); + + cache.set(id, books); + return books as HardcoverBookResult[]; +}; + +export const getBookDetails = async (id: string) => { + const cacheId = `query:bookdetails:${id}`; + if (cache.has(cacheId)) return cache.get(cacheId); + + const graphqlQuery = ` + query GetBook($id: Int!) { + books(where: { id: { _eq: $id } }) { + id + slug + title + subtitle + description + release_date + release_year + rating + ratings_count + pages + cached_image + cached_contributors + featured_book_series { + series { + id + name + slug + } + } + editions(limit: 1) { + isbn_10 + isbn_13 + } + } + } + `; + + const result = await hardcoverFetch(graphqlQuery, { id: parseInt(id) }); + + const bookData = result.data?.books?.[0]; + + if (bookData) { + const isbn13 = bookData.editions?.[0]?.isbn_13; + const isbn10 = bookData.editions?.[0]?.isbn_10; + + const cachedContributors = bookData.cached_contributors as Array<{ author: { name: string }; contribution: string }> | undefined; + const authorName = cachedContributors?.[0]?.author?.name; + + const book = { + ...bookData, + isbn: isbn13 || isbn10 || "", + isbn13, + isbn10, + author_names: authorName ? [authorName] : [], + image: bookData.cached_image?.url, + }; + + cache.set(cacheId, book); + return book; + } + + return null; +}; diff --git a/routes/api/books/[name].ts b/routes/api/books/[name].ts new file mode 100644 index 0000000..4d98c0f --- /dev/null +++ b/routes/api/books/[name].ts @@ -0,0 +1,75 @@ +import { Handlers } from "$fresh/server.ts"; +import { json } from "@lib/helpers.ts"; +import { getBookDetails } from "@lib/hardcover.ts"; +import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts"; +import { formatDate, safeFileName, toUrlSafeString } from "@lib/string.ts"; +import { AccessDeniedError } from "@lib/errors.ts"; +import { createResource, fetchResource } from "@lib/marka/index.ts"; +import { ReviewResource } from "@lib/marka/schema.ts"; + +export const handler: Handlers = { + async GET(_, ctx) { + const book = await fetchResource(`books/${ctx.params.name}`); + return json(book?.content); + }, + async POST(_, ctx) { + const session = ctx.state.session; + if (!session) throw new AccessDeniedError(); + + const hardcoverId = ctx.params.name; + if (!hardcoverId) throw new AccessDeniedError(); + + const bookDetails = await getBookDetails(hardcoverId); + + if (!bookDetails) { + throw new Error("Book not found on Hardcover"); + } + + const title = bookDetails.title || hardcoverId; + const authorName = bookDetails.author_names?.[0] || ""; + const isbn = bookDetails.isbn13 || bookDetails.isbn || ""; + const releaseDate = bookDetails.release_year + ? `${bookDetails.release_year}-01-01` + : undefined; + + let finalPath = ""; + if (bookDetails.image) { + try { + const response = await fetch(bookDetails.image); + if (response.ok) { + const buffer = await response.arrayBuffer(); + const extension = fileExtension(bookDetails.image); + finalPath = `books/images/${safeFileName(title)}_cover.${extension}`; + await createResource(finalPath, buffer); + } + } catch { + console.log("Failed to download book cover"); + } + } + + const book: ReviewResource["content"] = { + _type: "Review", + headline: title, + subtitle: bookDetails.subtitle, + bookBody: bookDetails.description || "", + link: `https://hardcover.app/books/${bookDetails.slug}`, + image: finalPath ? `resources/${finalPath}` : undefined, + datePublished: formatDate(releaseDate), + author: authorName ? { + _type: "Person", + name: authorName, + } : undefined, + itemReviewed: { + name: title, + }, + }; + + console.log("Creating book resource:", JSON.stringify(book, null, 2)); + + const fileName = toUrlSafeString(title); + + await createResource(`books/${fileName}.md`, book); + + return json({ name: fileName }); + }, +}; diff --git a/routes/api/books/create/index.ts b/routes/api/books/create/index.ts new file mode 100644 index 0000000..07595bb --- /dev/null +++ b/routes/api/books/create/index.ts @@ -0,0 +1,242 @@ +import { Handlers } from "$fresh/server.ts"; +import { Defuddle } from "defuddle/node"; +import { AccessDeniedError, BadRequestError } from "@lib/errors.ts"; +import { createStreamResponse, isValidUrl } from "@lib/helpers.ts"; +import * as openai from "@lib/openai.ts"; +import * as unsplash from "@lib/unsplash.ts"; +import { getYoutubeVideoDetails } from "@lib/youtube.ts"; +import { + extractYoutubeId, + formatDate, + isYoutubeLink, + safeFileName, + toUrlSafeString, +} from "@lib/string.ts"; +import { createLogger } from "@lib/log/index.ts"; +import { createResource } from "@lib/marka/index.ts"; +import { webScrape } from "@lib/webScraper.ts"; +import { BookResource } from "@lib/marka/schema.ts"; +import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts"; + +const log = createLogger("api/book"); + +async function getUnsplashCoverImage( + content: string, + streamResponse: ReturnType, +): Promise { + try { + streamResponse.info("creating unsplash search term"); + const searchTerm = await openai.createUnsplashSearchTerm(content); + if (!searchTerm) return; + streamResponse.info(`searching for ${searchTerm}`); + const unsplashUrl = await unsplash.getImageBySearchTerm(searchTerm); + return unsplashUrl; + } catch (e) { + log.error("Failed to get unsplash cover image", e); + return undefined; + } +} + +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); + } +} + +async function fetchAndStoreCover( + imageUrl: string | undefined, + title: string, + streamResponse?: ReturnType, +): Promise { + if (!imageUrl) return; + const imagePath = `books/images/${safeFileName(title)}_cover.${ + ext(imageUrl) + }`; + try { + streamResponse?.info("downloading image"); + const res = await fetch(imageUrl); + streamResponse?.info("saving image"); + if (!res.ok) { + console.log(`Failed to download remote image: ${imageUrl}`, res.status); + return; + } + const buffer = await res.arrayBuffer(); + await createResource(imagePath, buffer); + return `resources/${imagePath}`; + } catch (err) { + console.log(`Failed to save image: ${imageUrl}`, err); + return undefined; + } +} + +async function processCreateBook( + { fetchUrl, streamResponse }: { + fetchUrl: string; + streamResponse: ReturnType; + }, +) { + log.info("create book from url", { url: fetchUrl }); + + streamResponse.info("downloading book"); + + const result = await webScrape(fetchUrl, streamResponse); + + log.debug("downloaded and parse parsed", result); + + streamResponse.info("parsed book, creating tags with openai"); + + const aiMeta = await openai.extractArticleMetadata(result.markdown); + + streamResponse.info("postprocessing book"); + + const title = result?.title || aiMeta?.headline || ""; + + let coverImagePath: string | undefined = undefined; + if (result?.image?.length) { + log.debug("using local image for cover image", { image: result.image }); + coverImagePath = await fetchAndStoreCover( + result.image, + title, + streamResponse, + ); + } else { + const urlPath = await getUnsplashCoverImage( + result.markdown, + streamResponse, + ); + coverImagePath = await fetchAndStoreCover(urlPath, title, streamResponse); + log.debug("using unsplash for cover image", { image: coverImagePath }); + } + + const url = toUrlSafeString(title); + + const newBook: BookResource["content"] = { + _type: "Book", + headline: title, + bookBody: result.markdown, + url: fetchUrl, + datePublished: formatDate( + result?.published || aiMeta?.datePublished || undefined, + ), + image: coverImagePath, + author: { + _type: "Person", + name: (result.schemaOrgData?.author?.name || aiMeta?.author || "") + .replace( + "@", + "twitter:", + ), + }, + } as const; + + streamResponse.info("writing to disk"); + + log.debug("writing to disk", { + ...newBook, + bookBody: newBook.bookBody?.slice(0, 200), + }); + + await createResource(`books/${url}.md`, newBook); + + streamResponse.send({ type: "finished", url }); +} + +async function processCreateYoutubeVideo( + { fetchUrl, streamResponse }: { + fetchUrl: string; + streamResponse: ReturnType; + }, +) { + log.info("create youtube book from url", { + url: fetchUrl, + }); + + streamResponse.info("getting video infos from youtube api"); + + const youtubeId = extractYoutubeId(fetchUrl); + + const video = await getYoutubeVideoDetails(youtubeId); + + streamResponse.info("shortening title with openai"); + const videoTitle = await openai.shortenTitle(video.snippet.title) || + video.snippet.title; + + const thumbnail = video?.snippet?.thumbnails?.maxres; + const coverImagePath = await fetchAndStoreCover( + thumbnail.url, + videoTitle || video.snippet.title, + streamResponse, + ); + + const newBook: BookResource["content"] = { + _type: "Book", + headline: video.snippet.title, + bookBody: video.snippet.description, + image: coverImagePath, + url: fetchUrl, + datePublished: formatDate(video.snippet.publishedAt), + author: { + _type: "Person", + name: video.snippet.channelTitle, + }, + }; + + streamResponse.info("creating book"); + + const filename = toUrlSafeString(videoTitle); + + await createResource( + `books/${filename}.md`, + newBook, + ); + + streamResponse.info("finished"); + + streamResponse.send({ type: "finished", url: filename }); +} + +export const handler: Handlers = { + GET(req, ctx) { + const session = ctx.state.session; + if (!session) { + throw new AccessDeniedError(); + } + + const url = new URL(req.url); + const fetchUrl = url.searchParams.get("url"); + + if (!fetchUrl || !isValidUrl(fetchUrl)) { + throw new BadRequestError(); + } + + const streamResponse = createStreamResponse(); + + if (isYoutubeLink(fetchUrl)) { + processCreateYoutubeVideo({ fetchUrl, streamResponse }).then( + (book) => { + log.debug("created book from youtube", { book }); + }, + ).catch((err) => { + log.error(err); + }).finally(() => { + streamResponse.cancel(); + }); + } else { + processCreateBook({ fetchUrl, streamResponse }).then((book) => { + log.debug("created book from link", { book }); + }).catch((err) => { + log.error(err); + }).finally(() => { + streamResponse.cancel(); + }); + } + + return streamResponse.response; + }, +}; diff --git a/routes/api/books/enhance/[name].ts b/routes/api/books/enhance/[name].ts new file mode 100644 index 0000000..173efdd --- /dev/null +++ b/routes/api/books/enhance/[name].ts @@ -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 { + 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, +}; diff --git a/routes/api/books/index.ts b/routes/api/books/index.ts new file mode 100644 index 0000000..a11ad7b --- /dev/null +++ b/routes/api/books/index.ts @@ -0,0 +1,10 @@ +import { Handlers } from "$fresh/server.ts"; +import { json } from "@lib/helpers.ts"; +import { fetchResource } from "@lib/marka/index.ts"; + +export const handler: Handlers = { + async GET() { + const books = await fetchResource("books"); + return json(books?.content); + }, +}; diff --git a/routes/api/hardcover/query.ts b/routes/api/hardcover/query.ts new file mode 100644 index 0000000..abaf73b --- /dev/null +++ b/routes/api/hardcover/query.ts @@ -0,0 +1,28 @@ +import { FreshContext, Handlers } from "$fresh/server.ts"; +import { searchBook } from "@lib/hardcover.ts"; +import { AccessDeniedError, BadRequestError } from "@lib/errors.ts"; + +const GET = async ( + req: Request, + ctx: FreshContext, +) => { + const session = ctx.state.session; + if (!session) { + throw new AccessDeniedError(); + } + + const u = new URL(req.url); + const query = u.searchParams.get("q"); + + if (!query) { + throw new BadRequestError(); + } + + const books = await searchBook(query); + console.log("Hardcover search results:", JSON.stringify(books).slice(0, 500)); + return new Response(JSON.stringify(books)); +}; + +export const handler: Handlers = { + GET, +}; diff --git a/routes/books/[name].tsx b/routes/books/[name].tsx new file mode 100644 index 0000000..f39656a --- /dev/null +++ b/routes/books/[name].tsx @@ -0,0 +1,122 @@ +import { Handlers, PageProps } from "$fresh/server.ts"; +import { MainLayout } from "@components/layouts/main.tsx"; +import { KMenu } from "@islands/KMenu.tsx"; +import { YoutubePlayer } from "@components/Youtube.tsx"; +import { HashTags } from "@components/HashTags.tsx"; +import { isYoutubeLink } from "@lib/string.ts"; +import { removeImage, renderMarkdown } from "@lib/markdown.ts"; +import { RedirectSearchHandler } from "@islands/Search.tsx"; +import PageHero from "@components/PageHero.tsx"; +import { Star } from "@components/Stars.tsx"; +import { MetaTags } from "@components/MetaTags.tsx"; +import { fetchResource } from "@lib/marka/index.ts"; +import { BookResource } from "@lib/marka/schema.ts"; +import { parseRating } from "@lib/helpers.ts"; + +export const handler: Handlers<{ book: BookResource; session: unknown }> = + { + async GET(_, ctx) { + const book = await fetchResource( + `books/${ctx.params.name}.md`, + ); + if (!book) { + return ctx.renderNotFound(); + } + return ctx.render({ book, session: ctx.state.session }); + }, + }; + +export default function Greet( + props: PageProps< + { book: BookResource; session: Record } + >, +) { + const { book, session } = props.data; + + const { author, datePublished, reviewRating, bookBody = "", reviewBody } = + book?.content || {}; + + const bookContent = renderMarkdown( + removeImage(bookBody, book.image?.url), + ); + + const reviewContent = reviewBody + ? renderMarkdown(removeImage(reviewBody, book.image?.url)) + : undefined; + + const rating = reviewRating?.ratingValue && + parseRating(reviewRating.ratingValue); + + return ( + ${book.content.headline}`} + context={book} + > + + + + + + + + {session && ( + + )} + + + + {book.content.headline} + + + {rating && } + + + + {book.content?.keywords?.length && ( + <> +
+ + + )} + +
+ {(book.content.url && isYoutubeLink(book.content.url)) && ( + + )} +
+        {reviewContent && (
+          <>
+            

Review

+
+          
+        )}
+      
+
+ ); +} diff --git a/routes/books/index.tsx b/routes/books/index.tsx new file mode 100644 index 0000000..a4e76a8 --- /dev/null +++ b/routes/books/index.tsx @@ -0,0 +1,62 @@ +import { Handlers, PageProps } from "$fresh/server.ts"; +import { MainLayout } from "@components/layouts/main.tsx"; +import { type BookResource, GenericResource } from "@lib/marka/schema.ts"; +import { KMenu } from "@islands/KMenu.tsx"; +import { Grid } from "@components/Grid.tsx"; +import { IconArrowLeft } from "@components/icons.tsx"; +import { RedirectSearchHandler } from "@islands/Search.tsx"; +import { parseResourceUrl, searchResource } from "@lib/search.ts"; +import { ResourceCard } from "@components/Card.tsx"; +import { Link } from "@islands/Link.tsx"; +import { listResources } from "@lib/marka/index.ts"; + +export const handler: Handlers< + { books: BookResource[] | null; searchResults?: GenericResource[] } +> = { + async GET(req, ctx) { + const books = await listResources("books"); + const searchParams = parseResourceUrl(req.url); + const searchResults = searchParams && + await searchResource({ ...searchParams, types: ["books"] }); + return ctx.render({ books, searchResults }); + }, +}; + +export default function Greet( + props: PageProps< + { books: BookResource[] | null; searchResults: GenericResource[] } + >, +) { + const { books, searchResults } = props.data; + return ( + +
+ + + Back + + +

📚 Books

+
+ + + + {books?.map((doc, i) => ( + + ))} + +
+ ); +}