This commit is contained in:
Giancarmine Salucci
2025-12-21 02:03:05 +01:00
parent 167cd1f4bb
commit 9357bd483a
36 changed files with 6251 additions and 1547 deletions

View File

@@ -1,162 +1,42 @@
import { createBrowserContext } from '$lib/server/browser';
import { createLLM } from '$lib/server/llm';
import { extractTextAndThumbnail } from '$lib/server/extraction';
import { extractRecipe } from '$lib/server/parser';
import { json } from '@sveltejs/kit';
import fs, { writeFileSync } from 'fs';
import { zodResponseFormat } from 'openai/helpers/zod';
import path from 'path';
import { z } from 'zod';
const RecipeSchema = z.object({
name: z.string(),
servings: z.number().nullable(),
description: z.string().nullable(),
ingredients: z.array(z.object({
item: z.string(),
amount: z.string(),
unit: z.string()
})).nullable(),
steps: z.array(z.string()).nullable(),
image: z.string().nullable().optional()
});
export async function POST({ request }) {
const { url } = await request.json();
// 1. Browser Connection - now managed by SvelteKit
console.log('Creating browser context for URL:', url);
const { url } = await request.json();
// Try to find auth storage
const authPathDocker = '/app/secrets/auth.json';
const authPathLocal = './secrets/auth.json';
const authPath = fs.existsSync(authPathDocker) ? authPathDocker :
fs.existsSync(authPathLocal) ? authPathLocal :
undefined;
console.log('Processing URL:', url);
const context = await createBrowserContext(authPath);
const page = await context.newPage();
// Set a fixed viewport size (Instagram feed width)
await page.setViewportSize({ width: 1080, height: 1920 });
let bodyText = '';
let thumbnail: string | null = null;
try {
// Step 1: Extract text and thumbnail from page
const { bodyText, thumbnail } = await extractTextAndThumbnail(url);
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
bodyText = (await page.evaluate(() => document.body.innerText)).replace(/^(?:.*\n){6}/, '').split('More posts from')[0].trim();
bodyText = bodyText.replace(/@\w+/g, '').replace(/#\w+/g, '');
// Step 2: Parse recipe from extracted text
const recipe = await extractRecipe(bodyText);
writeFileSync(path.resolve('debug_page.txt'), bodyText); // Save for debugging, overwriting if exists
const videoBounds = await page.evaluate(() => {
const video = document.querySelector('video');
if (!video) return null;
const rect = video.getBoundingClientRect();
return {
x: Math.max(0, rect.left),
y: Math.max(0, rect.top),
width: Math.min(rect.width, window.innerWidth),
height: Math.min(rect.height, window.innerHeight)
};
});
if (!recipe) {
return json({ error: 'No recipe found in provided text' }, { status: 400 });
}
if (videoBounds && videoBounds.width > 0 && videoBounds.height > 0) {
const screenshotBuffer = await page.screenshot({
type: 'jpeg',
quality: 85,
clip: videoBounds
});
thumbnail = `data:image/jpeg;base64,${screenshotBuffer.toString('base64')}`;
} else {
console.warn('Video element not found or has no size, taking full page screenshot');
const screenshotBuffer = await page.screenshot({ type: 'jpeg', quality: 85 });
thumbnail = `data:image/jpeg;base64,${screenshotBuffer.toString('base64')}`;
}
} catch (e) {
console.error('Scraping error:', e);
return json({ error: 'Failed to scrape URL' }, { status: 500 });
} finally {
await page.close();
await context.close();
}
// Step 3: Enrich recipe with metadata
if (recipe.description) {
recipe.description += `\n\nLink: ${url}`;
} else {
recipe.description = `Link: ${url}`;
}
// 2. LLM Processing - Two-step validation
try {
const { client, model } = createLLM();
// STEP 1: Binary recipe detection (yes/no only)
const detectionResponse = await client.chat.completions.create({
model,
messages: [
{ role: "system", content: "You are a recipe detector. Answer with ONLY 'yes' or 'no' - nothing else. A recipe MUST have: (1) name/title, (2) ingredients with quantities, (3) numbered cooking steps. If ANY are missing, answer 'no'." },
{ role: "user", content: `Does this text contain a recipe?\n\n${bodyText}` }
],
max_tokens: 10,
});
if (thumbnail) {
recipe.image = thumbnail;
}
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
const hasRecipe = detectionResult.includes("yes");
return json({ recipe, bodyText });
} catch (error) {
const errorMessage = error instanceof Error ? error.message : 'Unknown error';
console.error('Recipe extraction pipeline error:', errorMessage);
if (!hasRecipe) {
return json({ error: "No recipe found in provided text" }, { status: 400 });
}
// STEP 2: Extract recipe (only proceeds if recipe detected)
const completion = await client.beta.chat.completions.parse({
model,
messages: [
{ role: "system", content: `You are a RECIPE EXTRACTOR. Extract the recipe from the provided text.
✅ REQUIREMENTS:
1. Extract the exact recipe name from the text
2. List all ingredients with their quantities and units
3. List all cooking steps in order
4. Translate everything to Italian
5. Convert measurements to SI units (g, mL, °C)
📋 CONVERSION TABLE:
- 1 cup = 240 mL, 1 tbsp = 15 mL, 1 tsp = 5 mL
- 1 oz = 28.35 g, 1 lb = 453.59 g
- 1 stick butter = 113 g
- °F→°C: (°F32)×5/9
🔄 OUTPUT FORMAT:
{
"name": "recipe name in Italian",
"servings": number or null,
"description": "description in Italian or null",
"ingredients": [{"item": "ingredient name", "amount": "quantity", "unit": "SI unit"}],
"steps": ["1. First step", "2. Second step", ...]
}
Extract ONLY what's explicitly in the text. Be accurate and literal.
` },
{ role: "user", content: `Extract the recipe from this text:\n\n${bodyText}` }
],
response_format: zodResponseFormat(RecipeSchema, "recipe")
});
console.log('LLM extraction successful:', completion.choices[0].message);
const recipe = completion.choices[0].message.parsed;
if (!recipe || !recipe.name) {
return json({ error: "Failed to extract recipe" }, { status: 400 });
}
// Append original Instagram link to description
if (recipe.description) {
recipe.description += `\n\nLink: ${url}`;
} else {
recipe.description = `Link: ${url}`;
}
// Add thumbnail to recipe
if (thumbnail) {
recipe.image = thumbnail;
}
return json({ recipe, bodyText });
} catch (e) {
console.error('LLM error:', e);
return json({ error: 'Failed to parse recipe', bodyText }, { status: 500 });
}
return json(
{ error: errorMessage || 'Failed to process URL' },
{ status: error instanceof Error && error.message.includes('scrape') ? 500 : 400 }
);
}
}

View File

@@ -1,5 +1,5 @@
import { json } from '@sveltejs/kit';
import {tandoorConfig} from '$lib/server/tandoor-config';
export async function GET() {
return json({...tandoorConfig, token: ''});
}
import { json } from '@sveltejs/kit';
import {tandoorConfig} from '$lib/server/tandoor-config';
export async function GET() {
return json({...tandoorConfig, token: ''});
}

View File

@@ -1,42 +1,42 @@
import { json } from '@sveltejs/kit';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
export async function POST({ request }) {
const { recipe } = await request.json();
if (!recipe) {
return json({ error: 'No recipe provided' }, { status: 400 });
}
try {
const result = await uploadRecipeWithIngredientsDTO(recipe);
if (!result.success) {
return json({ error: result.error || 'Failed to upload recipe' }, { status: 500 });
}
// Upload image if available
let imageStatus = null;
if (result.recipeId && result.imageUrl) {
imageStatus = await uploadRecipeImage(result.recipeId, result.imageUrl);
if (!imageStatus.success) {
console.warn('Image upload failed, but recipe created:', imageStatus.error);
}
}
return json({
success: true,
message: 'Recipe successfully imported to Tandoor',
recipeId: result.recipeId,
imageUpload: imageStatus?.success ? 'successful' : 'failed'
});
} catch (error) {
console.error('Tandoor upload error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
{ status: 500 }
);
}
}
import { json } from '@sveltejs/kit';
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
export async function POST({ request }) {
const { recipe } = await request.json();
if (!recipe) {
return json({ error: 'No recipe provided' }, { status: 400 });
}
try {
const result = await uploadRecipeWithIngredientsDTO(recipe);
if (!result.success) {
return json({ error: result.error || 'Failed to upload recipe' }, { status: 500 });
}
// Upload image if available
let imageStatus = null;
if (result.recipeId && result.imageUrl) {
imageStatus = await uploadRecipeImage(result.recipeId, result.imageUrl);
if (!imageStatus.success) {
console.warn('Image upload failed, but recipe created:', imageStatus.error);
}
}
return json({
success: true,
message: 'Recipe successfully imported to Tandoor',
recipeId: result.recipeId,
imageUpload: imageStatus?.success ? 'successful' : 'failed'
});
} catch (error) {
console.error('Tandoor upload error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
{ status: 500 }
);
}
}