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

27
src/app.server.ts Normal file
View File

@@ -0,0 +1,27 @@
import { initializeBrowser, closeBrowser } from '$lib/server/browser';
// Initialize browser when server starts
export async function init() {
try {
await initializeBrowser();
} catch (error) {
console.error('Failed to initialize browser:', error);
process.exit(1);
}
// Graceful shutdown
process.on('SIGTERM', async () => {
console.log('SIGTERM received, shutting down gracefully...');
await closeBrowser();
process.exit(0);
});
process.on('SIGINT', async () => {
console.log('SIGINT received, shutting down gracefully...');
await closeBrowser();
process.exit(0);
});
}
// Run initialization immediately
init().catch(console.error);

52
src/lib/server/browser.ts Normal file
View File

@@ -0,0 +1,52 @@
import { chromium, type Browser, type BrowserContext } from 'playwright';
import fs from 'fs';
let browser: Browser | null = null;
export async function initializeBrowser(): Promise<Browser> {
if (browser) {
return browser;
}
console.log('Initializing Playwright browser...');
browser = await chromium.launch({
headless: true,
args: ['--disable-gpu', '--no-sandbox', '--disable-dev-shm-usage']
});
console.log('Browser initialized successfully');
return browser;
}
export async function getBrowser(): Promise<Browser> {
if (!browser) {
return initializeBrowser();
}
return browser;
}
export async function createBrowserContext(
authStoragePath?: string
): Promise<BrowserContext> {
const browserInstance = await getBrowser();
// Load auth if available
let context: BrowserContext;
if (authStoragePath && fs.existsSync(authStoragePath)) {
console.log('Loading authentication from:', authStoragePath);
context = await browserInstance.newContext({ storageState: authStoragePath });
} else {
console.warn('No auth storage found. Running as guest.');
context = await browserInstance.newContext();
}
return context;
}
export async function closeBrowser(): Promise<void> {
if (browser) {
console.log('Closing Playwright browser...');
await browser.close();
browser = null;
}
}

View File

@@ -0,0 +1,12 @@
import { env } from '$env/dynamic/private';
/**
* Server-side environment configuration for Tandoor integration
* These variables should be set in your .env file or as environment variables
*/
export const tandoorConfig = {
enabled: env.TANDOOR_ENABLED === 'true',
serverUrl: (env.TANDOOR_SERVER_URL || '').replace(/\/$/, ''),
space: env.TANDOOR_SPACE ? parseInt(env.TANDOOR_SPACE, 10) : 1,
token: env.TANDOOR_TOKEN || null
};

330
src/lib/server/tandoor.ts Normal file
View File

@@ -0,0 +1,330 @@
import { tandoorConfig } from '$lib/server/tandoor-config';
import { z } from 'zod';
/**
* Tandoor Recipe Export Format
* Based on the Default/JSON-LD Tandoor export format
*/
export const TandoorRecipeSchema = z.object({
name: z.string(),
author: z.string().optional().nullable(),
description: z.string().optional().nullable(),
servings: z.number().optional().nullable(),
servings_text: z.string().optional().nullable(),
keywords: z.array(z.string()).optional(),
prep_time: z.string().optional(),
cook_time: z.string().optional(),
waiting_time: z.string().optional(),
steps: z.array(
z.object({
step: z.number(),
instruction: z.string(),
ingredients: z.array(
z.object({
food: z.object({
id: z.number(),
name: z.string()
}),
unit: z.object({
id: z.number(),
name: z.string()
}).nullable(),
amount: z.number(),
note: z.string().optional()
})
).optional()
})
).optional(),
ingredients: z.array(
z.object({
food: z.object({
name: z.string()
}),
unit: z.object({
name: z.string()
}).nullable(),
amount: z.number(),
note: z.string().optional()
})
).optional()
});
export type TandoorRecipe = z.infer<typeof TandoorRecipeSchema>;
interface ExtractedRecipe {
name: string;
servings: number | null;
description: string | null;
ingredients: Array<{
item: string;
amount: string;
unit: string;
}> | null;
steps: string[] | null;
}
/**
* DTO for Tandoor Recipe API (POST /api/recipe/)
* Matches the Tandoor endpoint schema for recipe creation
*/
interface TandoorRecipeDTO {
name: string;
description?: string;
keywords: Array<{
name: string;
description?: string;
}>;
steps: Array<{
name?: string;
instruction: string;
ingredients: Array<{
food: {
name: string;
};
unit: {
name: string;
} | null;
amount: string;
note?: string;
}>;
order?: number;
show_as_header?: boolean;
}>;
working_time?: number;
waiting_time?: number;
servings?: number;
servings_text?: string;
private?: boolean;
show_ingredient_overview?: boolean;
}
/**
* Helper function to make authenticated API calls
*/
async function fetchFromTandoor<T>(
url: string,
options: Partial<RequestInit> = { method: 'GET' },
): Promise<{ ok: boolean; data?: T; error?: string }> {
const headers = new Headers({
'Content-Type': 'application/json',
'Accept': 'application/json',
Authorization: `Bearer ${tandoorConfig.token}`
});
// Merge any additional headers from options
if (options.headers) {
const optHeaders = new Headers(options.headers);
optHeaders.forEach((value, key) => {
headers.set(key, value);
});
}
console.debug(`Fetching from Tandoor: ${url}`, {
method: options.method,
headers: Object.fromEntries(headers),
body: options.body
});
try {
const response = await fetch(`${tandoorConfig.serverUrl}${url}`, {
...options,
headers
});
if (!response.ok) {
const errorBody = await response.json().catch(() => ({}));
console.error(`API Error ${response.status}: ${response.statusText}`, errorBody);
return {
ok: false,
error: `API Error: ${response.statusText} - ${JSON.stringify(errorBody)}`
};
}
const data = (await response.json()) as T;
console.debug(`Tandoor response OK:`, data);
return { ok: true, data };
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.error(`Fetch error: ${errorMsg}`);
return {
ok: false,
error: `Fetch error: ${errorMsg}`
};
}
}
/**
* Partitions ingredients across steps by distributing them evenly
* When step association is unknown, this spreads ingredients proportionally
*/
function partitionIngredientsAcrossSteps(
ingredients: Array<{
item: string;
amount: string;
unit: string;
}>,
stepCount: number
): Array<Array<{ item: string; amount: string; unit: string }>> {
if (stepCount === 0 || !ingredients || ingredients.length === 0) {
return [];
}
const partitions: Array<Array<{ item: string; amount: string; unit: string }>> = Array.from(
{ length: stepCount },
() => []
);
// Distribute ingredients round-robin across steps
ingredients.forEach((ingredient, index) => {
partitions[index % stepCount].push(ingredient);
});
console.debug(`Partitioned ${ingredients.length} ingredients across ${stepCount} steps:`, partitions);
return partitions;
}
/**
* Parses amount string to a number, handling special cases
* Returns null if amount cannot be parsed to a valid number
*/
function parseAmount(amountStr: string): number | null {
if (!amountStr || typeof amountStr !== 'string') {
return null;
}
const trimmed = amountStr.trim().toLowerCase();
// Skip special cases that can't be converted to numbers
if (!trimmed || trimmed === 'q.b.' || trimmed === 'qb' || trimmed === 'to taste') {
return null;
}
// Try to extract the first number from the string
const numberMatch = trimmed.match(/^[\d.,]+/);
if (!numberMatch) {
return null;
}
const numStr = numberMatch[0].replace(',', '.');
const parsed = parseFloat(numStr);
// Return null for zero or invalid numbers
if (isNaN(parsed) || parsed === 0) {
return null;
}
return parsed;
}
/**
* Builds a complete Tandoor Recipe DTO from extracted recipe data
* Includes ingredients partitioned across steps
*/
function buildTandoorRecipeDTO(recipe: ExtractedRecipe): TandoorRecipeDTO {
const stepCount = recipe.steps?.length || 1;
const ingredientPartitions = partitionIngredientsAcrossSteps(
recipe.ingredients || [],
stepCount
);
const steps: TandoorRecipeDTO['steps'] = (recipe.steps || []).map((instruction, index) => {
// Map ingredients, converting unparseable amounts to 1 q.b.
const mappedIngredients = (ingredientPartitions[index] || []).map((ing) => {
const amount = parseAmount(ing.amount);
if (amount === null) {
console.debug(`Converting ingredient with unparseable amount to 1 q.b.: ${ing.item} (${ing.amount})`);
return {
food: {
name: ing.item
},
unit: { name: 'q.b.' },
amount: '1',
note: ''
};
}
return {
food: {
name: ing.item
},
unit: ing.unit && ing.unit.trim() ? { name: ing.unit } : null,
amount: amount.toString(),
note: ''
};
});
return {
instruction,
order: index,
ingredients: mappedIngredients
};
});
return {
name: recipe.name,
description: recipe.description || undefined,
keywords: [],
steps,
servings: recipe.servings || undefined,
servings_text: recipe.servings ? `${recipe.servings} servings` : undefined,
private: false,
show_ingredient_overview: true
};
}
/**
* Uploads a recipe to Tandoor server using the DTO-based approach
* Creates recipe with ingredients partitioned across steps in a single request
*/
export async function uploadRecipeWithIngredientsDTO(
recipe: ExtractedRecipe
): Promise<{ success: boolean; recipeId?: number; error?: string }> {
try {
// Validate token
const token = tandoorConfig.token;
if (!token) {
return {
success: false,
error: 'TANDOOR_TOKEN environment variable not set'
};
}
// Build the complete DTO
const recipeDTO = buildTandoorRecipeDTO(recipe);
console.debug('Uploading recipe with ingredients DTO:', recipeDTO);
// Call the API with the DTO
const recipeResult = await fetchFromTandoor<{ id: number }>(
`/api/recipe/`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(recipeDTO)
}
);
if (!recipeResult.ok || !recipeResult.data) {
console.error('Recipe creation failed:', recipeResult.error);
return {
success: false,
error: `Failed to create recipe: ${recipeResult.error}`
};
}
const createdRecipe = recipeResult.data;
console.debug('Successfully created recipe with ID:', createdRecipe.id);
return {
success: true,
recipeId: createdRecipe.id
};
} catch (error) {
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
console.error(`Error uploading recipe to Tandoor: ${errorMsg}`);
return {
success: false,
error: `Error uploading to Tandoor: ${errorMsg}`
};
}
}

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}