full tour

This commit is contained in:
Giancarmine Salucci
2025-11-30 09:06:44 +01:00
parent 0477964009
commit 23583f54c6
18 changed files with 1679 additions and 89 deletions

View File

@@ -1,74 +1,129 @@
import { json } from '@sveltejs/kit';
import { createBrowserContext } from '$lib/server/browser';
import { createLLM } from '$lib/server/llm';
import { z } from 'zod';
import { json } from '@sveltejs/kit';
import fs, { writeFileSync } from 'fs';
import { zodResponseFormat } from 'openai/helpers/zod';
import { chromium } from 'playwright';
import fs from 'fs';
import { env } from '$env/dynamic/private';
import { z } from 'zod';
import path from 'path';
const RecipeSchema = z.object({
name: z.string(),
description: z.string(),
steps: z.array(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()
});
export async function POST({ request }) {
const { url } = await request.json();
// 1. Browser Connection
// Fallback to localhost if env var not set (e.g. running outside docker)
const wsEndpoint = env.PLAYWRIGHT_WS_ENDPOINT || 'ws://127.0.0.1:3000';
console.log('Connecting to browser at:', wsEndpoint);
// 1. Browser Connection - now managed by SvelteKit
console.log('Creating browser context for URL:', url);
const browser = await chromium.connect(wsEndpoint);
// 2. Load Auth if available
const authPath = '/app/secrets/auth.json';
let context;
// We check absolute path (Docker) or relative (Local)
if (fs.existsSync(authPath)) {
context = await browser.newContext({ storageState: authPath });
} else if (fs.existsSync('./secrets/auth.json')) {
context = await browser.newContext({ storageState: './secrets/auth.json' });
} else {
console.warn('No auth.json found. Running as guest.');
context = await browser.newContext();
}
// 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;
const context = await createBrowserContext(authPath);
const page = await context.newPage();
let bodyText = '';
try {
await page.goto(url, { waitUntil: 'domcontentloaded' });
// Naive scraper attempt
bodyText = await page.evaluate(() => document.body.innerText);
// Extract HTML from the page
bodyText = (await page.evaluate(() => document.body.innerText)).replace(/^(?:.*\n){6}/, '').split('More posts from')[0].trim();
// Cleaning steps
// 1. Remove @tags and #hashtags
bodyText = bodyText.replace(/@\w+/g, '').replace(/#\w+/g, '');
writeFileSync(path.resolve('debug_page.txt'), bodyText); // Save for debugging, overwriting if exists
} catch (e) {
console.error('Scraping error:', e);
return json({ error: 'Failed to scrape URL' }, { status: 500 });
} finally {
await page.close();
await context.close();
await browser.close();
}
// 3. LLM Processing
// 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,
});
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
const hasRecipe = detectionResult.includes("yes");
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: "Extract a recipe structure from this text. If it is not a recipe, return empty arrays." },
{ role: "user", content: bodyText.substring(0, 8000) } // Limit context window
{ 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")
});
return json({ recipe: completion.choices[0].message.parsed });
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}`;
}
return json({ recipe });
} catch (e) {
console.error('LLM error:', e);
return json({ error: 'Failed to parse recipe' }, { status: 500 });

View File

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

View File

@@ -0,0 +1,32 @@
import { json } from '@sveltejs/kit';
import { uploadRecipeWithIngredientsDTO } 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 });
}
return json({
success: true,
message: 'Recipe successfully imported to Tandoor',
recipeId: result.recipeId
});
} catch (error) {
console.error('Tandoor upload error:', error);
return json(
{
error: error instanceof Error ? error.message : 'Unknown error occurred'
},
{ status: 500 }
);
}
}

View File

@@ -4,6 +4,9 @@
let status = $state('idle');
let logs = $state<string[]>([]);
let recipe = $state<any>(null);
let tandoorEnabled = $state(false);
let tandoorImporting = $state(false);
let tandoorError = $state<string | null>(null);
// URL param parsing for Share Target
// Instagram typically shares text that contains the URL, so we might need to parse it out
@@ -17,6 +20,22 @@
let targetUrl = $derived(sharedUrl || extractUrl(sharedText));
$effect.pre(() => {
loadTandoorConfig();
});
// Load Tandoor config on mount
async function loadTandoorConfig() {
try {
const res = await fetch('/api/tandoor-config');
const config = await res.json();
tandoorEnabled = config.enabled;
logs = [...logs, `Tandoor integration ${config.enabled ? 'enabled' : 'disabled'}`];
} catch(e) {
logs = [...logs, 'Failed to load Tandoor config'];
}
}
async function process() {
if(!targetUrl) return;
status = 'extracting';
@@ -33,6 +52,7 @@
if (data.recipe) {
recipe = data.recipe;
status = 'done';
logs = [...logs, 'Recipe extraction successful'];
} else {
logs = [...logs, 'Error: ' + JSON.stringify(data)];
status = 'error';
@@ -42,6 +62,38 @@
status = 'error';
}
}
async function importToTandoor() {
if (!recipe) return;
tandoorImporting = true;
tandoorError = null;
logs = [...logs, 'Importing recipe to Tandoor...'];
try {
const res = await fetch('/api/tandoor', {
method: 'POST',
body: JSON.stringify({ recipe }),
headers: { 'Content-Type': 'application/json' }
});
const data = await res.json();
if (data.success) {
logs = [...logs, `✓ Recipe imported successfully (ID: ${data.recipeId})`];
tandoorError = null;
} else {
logs = [...logs, `✗ Import failed: ${data.error}`];
tandoorError = data.error;
}
} catch(e) {
const errorMsg = e instanceof Error ? e.message : 'Unknown error';
logs = [...logs, `✗ Network error: ${errorMsg}`];
tandoorError = errorMsg;
} finally {
tandoorImporting = false;
}
}
</script>
<div class="p-8 max-w-lg mx-auto space-y-4">
@@ -68,12 +120,37 @@
<div class="border rounded p-4 bg-green-50 space-y-2">
<h2 class="font-bold text-xl">{recipe.name}</h2>
<p class="text-sm">{recipe.description}</p>
<p class="text-muted"><strong>Servings:</strong> {recipe.servings}</p>
<h3 class="font-bold mt-2">Ingredients</h3>
<ul class="list-disc pl-5 text-sm">
{#each recipe.ingredients as ing}
<li>{ing.amount} {ing.unit} {ing.item}</li>
{/each}
</ul>
<h3 class="font-bold mt-2">Steps</h3>
<ol class="list-decimal pl-5 text-sm">
{#each recipe.steps as step}
<li>{step}</li>
{/each}
</ol>
{#if tandoorEnabled}
<div class="mt-4 pt-4 border-t space-y-2">
<h3 class="font-bold">Tandoor Integration</h3>
{#if tandoorError}
<div class="bg-red-100 text-red-800 p-2 rounded text-sm">
Error: {tandoorError}
</div>
{/if}
<button
onclick={importToTandoor}
disabled={tandoorImporting}
class="bg-orange-600 text-white px-4 py-2 rounded shadow hover:bg-orange-700 w-full disabled:bg-gray-400 disabled:cursor-not-allowed"
>
{tandoorImporting ? 'Importing...' : 'Import to Tandoor'}
</button>
</div>
{/if}
</div>
{/if}