131 lines
3.8 KiB
TypeScript
131 lines
3.8 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 } 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 { Defuddle } from "defuddle/node";
|
|
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.enqueue("downloading article");
|
|
|
|
const doc = await webScrape(fetchUrl, streamResponse);
|
|
|
|
const result = await Defuddle(doc, fetchUrl, {
|
|
markdown: true,
|
|
});
|
|
|
|
streamResponse.enqueue("download success");
|
|
|
|
const jsonLds = Array.from(
|
|
doc?.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) {
|
|
recipe = parseJsonLdToRecipeSchema(jsonLd.textContent || "");
|
|
if (recipe) break;
|
|
}
|
|
}
|
|
|
|
if (!recipe) {
|
|
const res = await openai.extractRecipe(result.content);
|
|
if (!res || "errorMessages" in res) {
|
|
const errorMessage = res?.errorMessages?.[0] ||
|
|
"could not extract recipe";
|
|
streamResponse.enqueue(`failed to extract recipe: ${errorMessage}`);
|
|
return;
|
|
}
|
|
recipe = res;
|
|
}
|
|
|
|
const id = safeFileName(recipe?.name || "");
|
|
|
|
if (!recipe) {
|
|
streamResponse.enqueue("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(new URL(newRecipe.image).pathname);
|
|
const finalPath = `resources/recipes/images/${
|
|
safeFileName(id)
|
|
}_cover.${extension}`;
|
|
streamResponse.enqueue("downloading image");
|
|
try {
|
|
streamResponse.enqueue("downloading image");
|
|
const res = await fetch(newRecipe.image);
|
|
streamResponse.enqueue("saving image");
|
|
const buffer = await res.arrayBuffer();
|
|
await createResource(finalPath, buffer);
|
|
newRecipe.image = finalPath;
|
|
} catch (err) {
|
|
console.log("Failed to save image", err);
|
|
}
|
|
}
|
|
|
|
streamResponse.enqueue("finished processing, creating file");
|
|
|
|
await createResource(`recipes/${id}.md`, newRecipe);
|
|
|
|
streamResponse.enqueue("id: " + 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.enqueue(`error creating recipe: ${err}`);
|
|
log.error(err);
|
|
}).finally(() => {
|
|
streamResponse.cancel();
|
|
});
|
|
|
|
return streamResponse.response;
|
|
},
|
|
};
|