import { Handlers } from "$fresh/server.ts"; import { AccessDeniedError, BadRequestError } from "@lib/errors.ts"; import { createStreamResponse, isValidUrl } from "@lib/helpers.ts"; import * as openai from "@lib/openai.ts"; import { createLogger } from "@lib/log/index.ts"; import recipeSchema from "@lib/recipeSchema.ts"; import { fileExtension } from "https://deno.land/x/file_extension@v2.1.0/mod.ts"; import { safeFileName, toUrlSafeString } from "@lib/string.ts"; import { parseJsonLdToRecipeSchema } from "./parseJsonLd.ts"; import z from "zod"; import { createResource } from "@lib/marka/index.ts"; import { webScrape } from "@lib/webScraper.ts"; import { RecipeResource } from "@lib/marka/schema.ts"; const log = createLogger("api/article"); async function processCreateRecipeFromUrl( { fetchUrl, streamResponse }: { fetchUrl: string; streamResponse: ReturnType; }, ) { log.info("create article from url", { url: fetchUrl }); streamResponse.info("downloading article"); const result = await webScrape(fetchUrl, streamResponse); streamResponse.info("download success"); const jsonLds = Array.from( result.dom?.querySelectorAll( "script[type='application/ld+json']", ), ) as unknown as HTMLScriptElement[]; let recipe: z.infer | undefined = undefined; if (jsonLds.length > 0) { for (const jsonLd of jsonLds) { if (jsonLd.textContent) { recipe = parseJsonLdToRecipeSchema(jsonLd.textContent); if (recipe) break; } } } if (!recipe) { const res = await openai.extractRecipe(result.markdown); if (!res || res === "none") { streamResponse.error(`failed to extract recipe: ${res}`); return; } recipe = res; } const id = toUrlSafeString(recipe?.name || ""); if (!recipe) { streamResponse.error("failed to parse recipe"); streamResponse.cancel(); return; } const newRecipe: RecipeResource["content"] = { ...recipe, _type: "Recipe", totalTime: recipe?.totalTime ? `${recipe?.totalTime?.toString()} minutes` : undefined, url: fetchUrl, }; if (newRecipe?.image && newRecipe.image.length > 5) { const extension = fileExtension(newRecipe.image); const finalPath = `recipes/images/${safeFileName(id)}_cover.${extension}`; streamResponse.info("downloading image"); try { streamResponse.info("downloading image"); const res = await fetch(newRecipe.image); streamResponse.info("saving image"); const buffer = await res.arrayBuffer(); await createResource(finalPath, buffer); newRecipe.image = `resources/${finalPath}`; } catch (err) { console.log("Failed to save image", err); } } streamResponse.info("finished processing, creating file"); await createResource(`recipes/${id}.md`, newRecipe); streamResponse.send({ type: "finished", url: id }); } 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(); processCreateRecipeFromUrl({ fetchUrl, streamResponse }).then((article) => { log.debug("created article from link", { article }); }).catch((err) => { streamResponse.error(`creating recipe: ${err}`); log.error(err); }).finally(() => { streamResponse.cancel(); }); return streamResponse.response; }, };