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:
75
routes/api/books/[name].ts
Normal file
75
routes/api/books/[name].ts
Normal file
@@ -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 });
|
||||
},
|
||||
};
|
||||
242
routes/api/books/create/index.ts
Normal file
242
routes/api/books/create/index.ts
Normal file
@@ -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<typeof createStreamResponse>,
|
||||
): Promise<string | undefined> {
|
||||
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<typeof createStreamResponse>,
|
||||
): Promise<string | undefined> {
|
||||
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<typeof createStreamResponse>;
|
||||
},
|
||||
) {
|
||||
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<typeof createStreamResponse>;
|
||||
},
|
||||
) {
|
||||
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;
|
||||
},
|
||||
};
|
||||
244
routes/api/books/enhance/[name].ts
Normal file
244
routes/api/books/enhance/[name].ts
Normal 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,
|
||||
};
|
||||
10
routes/api/books/index.ts
Normal file
10
routes/api/books/index.ts
Normal file
@@ -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);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user