Files
memorium/routes/api/recipes/create/index.ts
2025-11-12 15:41:30 +01:00

124 lines
3.6 KiB
TypeScript

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<typeof createStreamResponse>;
},
) {
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<typeof recipeSchema> | 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;
},
};