with thumbnail!
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -21,3 +21,4 @@ Thumbs.db
|
|||||||
# Vite
|
# Vite
|
||||||
vite.config.js.timestamp-*
|
vite.config.js.timestamp-*
|
||||||
vite.config.ts.timestamp-*
|
vite.config.ts.timestamp-*
|
||||||
|
debug_page.txt
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ interface ExtractedRecipe {
|
|||||||
unit: string;
|
unit: string;
|
||||||
}> | null;
|
}> | null;
|
||||||
steps: string[] | null;
|
steps: string[] | null;
|
||||||
|
image?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -279,7 +280,7 @@ function buildTandoorRecipeDTO(recipe: ExtractedRecipe): TandoorRecipeDTO {
|
|||||||
*/
|
*/
|
||||||
export async function uploadRecipeWithIngredientsDTO(
|
export async function uploadRecipeWithIngredientsDTO(
|
||||||
recipe: ExtractedRecipe
|
recipe: ExtractedRecipe
|
||||||
): Promise<{ success: boolean; recipeId?: number; error?: string }> {
|
): Promise<{ success: boolean; recipeId?: number; imageUrl?: string; error?: string }> {
|
||||||
try {
|
try {
|
||||||
// Validate token
|
// Validate token
|
||||||
const token = tandoorConfig.token;
|
const token = tandoorConfig.token;
|
||||||
@@ -317,7 +318,8 @@ export async function uploadRecipeWithIngredientsDTO(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
success: true,
|
success: true,
|
||||||
recipeId: createdRecipe.id
|
recipeId: createdRecipe.id,
|
||||||
|
imageUrl: recipe.image || undefined
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -328,3 +330,51 @@ export async function uploadRecipeWithIngredientsDTO(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Uploads an image to a Tandoor recipe
|
||||||
|
*/
|
||||||
|
export async function uploadRecipeImage(
|
||||||
|
recipeId: number,
|
||||||
|
imageUrl: string
|
||||||
|
): Promise<{ success: boolean; error?: string }> {
|
||||||
|
try {
|
||||||
|
const token = tandoorConfig.token;
|
||||||
|
if (!token) {
|
||||||
|
return { success: false, error: 'TANDOOR_TOKEN not set' };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Uploading image for recipe ID:', recipeId, 'URL:', imageUrl.substring(0, 50));
|
||||||
|
|
||||||
|
// Convert base64 data URL to Blob for multipart upload
|
||||||
|
const response = await fetch(imageUrl);
|
||||||
|
const imageBlob = await response.blob();
|
||||||
|
|
||||||
|
// Use image field with multipart form data (Tandoor's binary upload support)
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('image', imageBlob, 'recipe-image.jpg');
|
||||||
|
|
||||||
|
// Upload to Tandoor
|
||||||
|
const uploadResponse = await fetch(
|
||||||
|
`${tandoorConfig.serverUrl}/api/recipe/${recipeId}/image/`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Authorization': `Bearer ${token}` },
|
||||||
|
body: formData
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!uploadResponse.ok) {
|
||||||
|
console.warn(`Image upload returned ${uploadResponse.status}`);
|
||||||
|
return { success: false, error: `Upload failed: ${uploadResponse.statusText}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Image uploaded successfully');
|
||||||
|
return { success: true };
|
||||||
|
} catch (error) {
|
||||||
|
const errorMsg = error instanceof Error ? error.message : 'Unknown error';
|
||||||
|
console.warn(`Image upload failed: ${errorMsg}`);
|
||||||
|
// Don't fail recipe creation if image fails
|
||||||
|
return { success: false, error: errorMsg };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ import { createLLM } from '$lib/server/llm';
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import fs, { writeFileSync } from 'fs';
|
import fs, { writeFileSync } from 'fs';
|
||||||
import { zodResponseFormat } from 'openai/helpers/zod';
|
import { zodResponseFormat } from 'openai/helpers/zod';
|
||||||
import { z } from 'zod';
|
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
const RecipeSchema = z.object({
|
const RecipeSchema = z.object({
|
||||||
name: z.string(),
|
name: z.string(),
|
||||||
@@ -15,7 +15,8 @@ const RecipeSchema = z.object({
|
|||||||
amount: z.string(),
|
amount: z.string(),
|
||||||
unit: z.string()
|
unit: z.string()
|
||||||
})).nullable(),
|
})).nullable(),
|
||||||
steps: z.array(z.string()).nullable()
|
steps: z.array(z.string()).nullable(),
|
||||||
|
image: z.string().nullable().optional()
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
||||||
@@ -34,18 +35,43 @@ export async function POST({ request }) {
|
|||||||
|
|
||||||
const context = await createBrowserContext(authPath);
|
const context = await createBrowserContext(authPath);
|
||||||
const page = await context.newPage();
|
const page = await context.newPage();
|
||||||
|
|
||||||
|
// Set a fixed viewport size (Instagram feed width)
|
||||||
|
await page.setViewportSize({ width: 1080, height: 1920 });
|
||||||
|
|
||||||
let bodyText = '';
|
let bodyText = '';
|
||||||
|
let thumbnail: string | null = null;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||||
// Extract HTML from the page
|
|
||||||
bodyText = (await page.evaluate(() => document.body.innerText)).replace(/^(?:.*\n){6}/, '').split('More posts from')[0].trim();
|
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, '');
|
bodyText = bodyText.replace(/@\w+/g, '').replace(/#\w+/g, '');
|
||||||
|
|
||||||
writeFileSync(path.resolve('debug_page.txt'), bodyText); // Save for debugging, overwriting if exists
|
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 (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) {
|
} catch (e) {
|
||||||
console.error('Scraping error:', e);
|
console.error('Scraping error:', e);
|
||||||
return json({ error: 'Failed to scrape URL' }, { status: 500 });
|
return json({ error: 'Failed to scrape URL' }, { status: 500 });
|
||||||
@@ -122,10 +148,15 @@ Extract ONLY what's explicitly in the text. Be accurate and literal.
|
|||||||
} else {
|
} else {
|
||||||
recipe.description = `Link: ${url}`;
|
recipe.description = `Link: ${url}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Add thumbnail to recipe
|
||||||
|
if (thumbnail) {
|
||||||
|
recipe.image = thumbnail;
|
||||||
|
}
|
||||||
|
|
||||||
return json({ recipe });
|
return json({ recipe, bodyText });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('LLM error:', e);
|
console.error('LLM error:', e);
|
||||||
return json({ error: 'Failed to parse recipe' }, { status: 500 });
|
return json({ error: 'Failed to parse recipe', bodyText }, { status: 500 });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import { json } from '@sveltejs/kit';
|
import { json } from '@sveltejs/kit';
|
||||||
import { uploadRecipeWithIngredientsDTO } from '$lib/server/tandoor';
|
import { uploadRecipeWithIngredientsDTO, uploadRecipeImage } from '$lib/server/tandoor';
|
||||||
|
|
||||||
export async function POST({ request }) {
|
export async function POST({ request }) {
|
||||||
const { recipe } = await request.json();
|
const { recipe } = await request.json();
|
||||||
@@ -15,10 +15,20 @@ export async function POST({ request }) {
|
|||||||
return json({ error: result.error || 'Failed to upload recipe' }, { status: 500 });
|
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({
|
return json({
|
||||||
success: true,
|
success: true,
|
||||||
message: 'Recipe successfully imported to Tandoor',
|
message: 'Recipe successfully imported to Tandoor',
|
||||||
recipeId: result.recipeId
|
recipeId: result.recipeId,
|
||||||
|
imageUpload: imageStatus?.success ? 'successful' : 'failed'
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Tandoor upload error:', error);
|
console.error('Tandoor upload error:', error);
|
||||||
|
|||||||
@@ -4,6 +4,7 @@
|
|||||||
let status = $state('idle');
|
let status = $state('idle');
|
||||||
let logs = $state<string[]>([]);
|
let logs = $state<string[]>([]);
|
||||||
let recipe = $state<any>(null);
|
let recipe = $state<any>(null);
|
||||||
|
let bodyText = $state<string>('');
|
||||||
let tandoorEnabled = $state(false);
|
let tandoorEnabled = $state(false);
|
||||||
let tandoorImporting = $state(false);
|
let tandoorImporting = $state(false);
|
||||||
let tandoorError = $state<string | null>(null);
|
let tandoorError = $state<string | null>(null);
|
||||||
@@ -51,10 +52,12 @@
|
|||||||
|
|
||||||
if (data.recipe) {
|
if (data.recipe) {
|
||||||
recipe = data.recipe;
|
recipe = data.recipe;
|
||||||
|
bodyText = data.bodyText || '';
|
||||||
status = 'done';
|
status = 'done';
|
||||||
logs = [...logs, 'Recipe extraction successful'];
|
logs = [...logs, 'Recipe extraction successful'];
|
||||||
} else {
|
} else {
|
||||||
logs = [...logs, 'Error: ' + JSON.stringify(data)];
|
bodyText = data.bodyText || '';
|
||||||
|
logs = [...logs, 'Error: ' + (data.error || JSON.stringify(data))];
|
||||||
status = 'error';
|
status = 'error';
|
||||||
}
|
}
|
||||||
} catch(e) {
|
} catch(e) {
|
||||||
@@ -63,6 +66,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function retry() {
|
||||||
|
recipe = null;
|
||||||
|
bodyText = '';
|
||||||
|
status = 'idle';
|
||||||
|
logs = [...logs, 'Retrying extraction...'];
|
||||||
|
await process();
|
||||||
|
}
|
||||||
|
|
||||||
async function importToTandoor() {
|
async function importToTandoor() {
|
||||||
if (!recipe) return;
|
if (!recipe) return;
|
||||||
|
|
||||||
@@ -116,11 +127,21 @@
|
|||||||
<div class="animate-pulse text-blue-600">Extracting data...</div>
|
<div class="animate-pulse text-blue-600">Extracting data...</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if bodyText}
|
||||||
|
<details class="border rounded p-2 bg-white text-sm">
|
||||||
|
<summary class="cursor-pointer font-semibold">📝 View Extracted Text</summary>
|
||||||
|
<div class="mt-2 pt-2 border-t whitespace-pre-wrap break-word max-h-48 overflow-y-auto text-xs">
|
||||||
|
{bodyText}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
{/if}
|
||||||
{#if recipe}
|
{#if recipe}
|
||||||
<div class="border rounded p-4 bg-green-50 space-y-2">
|
<div class="border rounded p-4 bg-green-50 space-y-2">
|
||||||
<h2 class="font-bold text-xl">{recipe.name}</h2>
|
<h2 class="font-bold text-xl">{recipe.name}</h2>
|
||||||
<p class="text-sm">{recipe.description}</p>
|
<p class="text-sm">{recipe.description}</p>
|
||||||
<p class="text-muted"><strong>Servings:</strong> {recipe.servings}</p>
|
<p class="text-muted"><strong>Servings:</strong> {recipe.servings}</p>
|
||||||
|
|
||||||
|
|
||||||
<h3 class="font-bold mt-2">Ingredients</h3>
|
<h3 class="font-bold mt-2">Ingredients</h3>
|
||||||
<ul class="list-disc pl-5 text-sm">
|
<ul class="list-disc pl-5 text-sm">
|
||||||
{#each recipe.ingredients as ing}
|
{#each recipe.ingredients as ing}
|
||||||
@@ -151,6 +172,31 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
<button
|
||||||
|
onclick={retry}
|
||||||
|
class="bg-blue-500 text-white px-4 py-2 rounded shadow hover:bg-blue-600 w-full mt-2"
|
||||||
|
>
|
||||||
|
🔄 Retry Extraction
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if status === 'error' && bodyText}
|
||||||
|
<div class="border rounded p-4 bg-yellow-50 space-y-2">
|
||||||
|
<h3 class="font-bold text-lg">Extraction Error - Raw Text Available</h3>
|
||||||
|
<details class="border rounded p-2 bg-white text-sm">
|
||||||
|
<summary class="cursor-pointer font-semibold">📝 View Extracted Text</summary>
|
||||||
|
<div class="mt-2 pt-2 border-t whitespace-pre-wrap break-word max-h-48 overflow-y-auto text-xs">
|
||||||
|
{bodyText}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
|
<button
|
||||||
|
onclick={retry}
|
||||||
|
class="bg-blue-500 text-white px-4 py-2 rounded shadow hover:bg-blue-600 w-full mt-2"
|
||||||
|
>
|
||||||
|
🔄 Retry Extraction
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user