fix
This commit is contained in:
@@ -1,52 +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;
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
115
src/lib/server/extraction.ts
Normal file
115
src/lib/server/extraction.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { createBrowserContext } from './browser';
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import type { Page } from 'playwright';
|
||||
|
||||
export interface ExtractedContent {
|
||||
bodyText: string;
|
||||
thumbnail: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve authentication storage path
|
||||
* Checks Docker path first, then local path
|
||||
*/
|
||||
function resolveAuthPath(): string | undefined {
|
||||
const authPathDocker = '/app/secrets/auth.json';
|
||||
const authPathLocal = './secrets/auth.json';
|
||||
|
||||
if (fs.existsSync(authPathDocker)) {
|
||||
return authPathDocker;
|
||||
}
|
||||
|
||||
if (fs.existsSync(authPathLocal)) {
|
||||
return authPathLocal;
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract text content and thumbnail from a URL using Playwright browser
|
||||
* @param url - The URL to extract from
|
||||
* @returns Extracted text and thumbnail
|
||||
*/
|
||||
export async function extractTextAndThumbnail(
|
||||
url: string
|
||||
): Promise<ExtractedContent> {
|
||||
const authPath = resolveAuthPath();
|
||||
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 {
|
||||
await page.goto(url, { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Extract and clean text content
|
||||
bodyText = await extractCleanText(page);
|
||||
|
||||
// Save debug content
|
||||
fs.writeFileSync(path.resolve('debug_page.txt'), bodyText);
|
||||
|
||||
// Extract thumbnail from video element
|
||||
thumbnail = await extractThumbnail(page);
|
||||
} catch (e) {
|
||||
console.error('Scraping error:', e);
|
||||
throw new Error('Failed to scrape URL');
|
||||
} finally {
|
||||
await page.close();
|
||||
await context.close();
|
||||
}
|
||||
|
||||
return { bodyText, thumbnail };
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract and clean text from page body
|
||||
*/
|
||||
async function extractCleanText(page: Page): Promise<string> {
|
||||
let text = (await page.evaluate(() => document.body.innerText))
|
||||
.replace(/^(?:.*\n){6}/, '') // Remove first 6 lines
|
||||
.split('More posts from')[0] // Cut at "More posts from"
|
||||
.trim();
|
||||
|
||||
// Remove mentions and hashtags
|
||||
text = text.replace(/@\w+/g, '').replace(/#\w+/g, '');
|
||||
|
||||
return text;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract thumbnail from video element or take full page screenshot
|
||||
*/
|
||||
async function extractThumbnail(page: Page): Promise<string | null> {
|
||||
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)
|
||||
};
|
||||
});
|
||||
|
||||
let screenshotBuffer: Buffer;
|
||||
|
||||
if (videoBounds && videoBounds.width > 0 && videoBounds.height > 0) {
|
||||
screenshotBuffer = await page.screenshot({
|
||||
type: 'jpeg',
|
||||
quality: 85,
|
||||
clip: videoBounds
|
||||
});
|
||||
} else {
|
||||
console.warn('Video element not found or has no size, taking full page screenshot');
|
||||
screenshotBuffer = await page.screenshot({ type: 'jpeg', quality: 85 });
|
||||
}
|
||||
|
||||
return `data:image/jpeg;base64,${screenshotBuffer.toString('base64')}`;
|
||||
}
|
||||
130
src/lib/server/parser.ts
Normal file
130
src/lib/server/parser.ts
Normal file
@@ -0,0 +1,130 @@
|
||||
import { createLLM } from './llm';
|
||||
import { zodResponseFormat } from 'openai/helpers/zod';
|
||||
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 type Recipe = z.infer<typeof RecipeSchema>;
|
||||
|
||||
/**
|
||||
* Detect if the text contains a recipe using binary classification
|
||||
* @param text - The text to analyze
|
||||
* @returns True if a recipe is detected, false otherwise
|
||||
*/
|
||||
export async function detectRecipe(text: string): Promise<boolean> {
|
||||
try {
|
||||
const { client, model } = createLLM();
|
||||
|
||||
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${text}`
|
||||
}
|
||||
],
|
||||
max_tokens: 10
|
||||
});
|
||||
|
||||
const detectionResult = detectionResponse.choices[0].message.content?.toLowerCase() ?? '';
|
||||
return detectionResult.includes('yes');
|
||||
} catch (e) {
|
||||
console.error('Recipe detection error:', e);
|
||||
throw new Error('Failed to detect recipe');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract recipe data from text using LLM structured output
|
||||
* @param text - The text containing the recipe
|
||||
* @returns Parsed recipe object
|
||||
*/
|
||||
export async function parseRecipe(text: string): Promise<Recipe> {
|
||||
try {
|
||||
const { client, model } = createLLM();
|
||||
|
||||
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: (°F–32)×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${text}`
|
||||
}
|
||||
],
|
||||
response_format: zodResponseFormat(RecipeSchema, 'recipe')
|
||||
});
|
||||
|
||||
const recipe = completion.choices[0].message.parsed;
|
||||
|
||||
if (!recipe || !recipe.name) {
|
||||
throw new Error('Failed to extract recipe - missing name');
|
||||
}
|
||||
|
||||
return recipe;
|
||||
} catch (e) {
|
||||
console.error('Recipe parsing error:', e);
|
||||
throw new Error('Failed to parse recipe');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Complete workflow: detect recipe and parse if found
|
||||
* @param text - The text to analyze
|
||||
* @returns Parsed recipe object if detected, null otherwise
|
||||
*/
|
||||
export async function extractRecipe(text: string): Promise<Recipe | null> {
|
||||
const isRecipe = await detectRecipe(text);
|
||||
|
||||
if (!isRecipe) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return parseRecipe(text);
|
||||
}
|
||||
182
src/lib/server/scheduler.ts
Normal file
182
src/lib/server/scheduler.ts
Normal file
@@ -0,0 +1,182 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { getBrowser } from './browser';
|
||||
|
||||
export interface SchedulerConfig {
|
||||
enabled: boolean;
|
||||
intervalHours: number;
|
||||
}
|
||||
|
||||
interface SchedulerState {
|
||||
intervalId: NodeJS.Timer | null;
|
||||
lastRenewalTime: number | null;
|
||||
isRenewing: boolean;
|
||||
}
|
||||
|
||||
const state: SchedulerState = {
|
||||
intervalId: null,
|
||||
lastRenewalTime: null,
|
||||
isRenewing: false
|
||||
};
|
||||
|
||||
/**
|
||||
* Get scheduler configuration from environment variables
|
||||
*/
|
||||
function getConfig(): SchedulerConfig {
|
||||
const enabled = process.env.AUTH_SCHEDULER_ENABLED === 'true';
|
||||
const intervalHours = parseInt(process.env.AUTH_SCHEDULER_INTERVAL_HOURS || '12', 10);
|
||||
|
||||
return {
|
||||
enabled,
|
||||
intervalHours
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve authentication storage path
|
||||
*/
|
||||
function resolveAuthPath(): string {
|
||||
const authPathDocker = '/app/secrets/auth.json';
|
||||
const authPathLocal = './secrets/auth.json';
|
||||
|
||||
if (fs.existsSync(authPathDocker)) {
|
||||
return authPathDocker;
|
||||
}
|
||||
|
||||
if (fs.existsSync(authPathLocal)) {
|
||||
return authPathLocal;
|
||||
}
|
||||
|
||||
// Default to local path if neither exists yet
|
||||
return authPathLocal;
|
||||
}
|
||||
|
||||
/**
|
||||
* Renew Instagram authentication by loading existing auth and refreshing the session
|
||||
* Inspired by gen-auth.js - reuses existing stored credentials without manual input
|
||||
*/
|
||||
async function renewInstagramAuth(): Promise<boolean> {
|
||||
if (state.isRenewing) {
|
||||
console.log('[Scheduler] Auth renewal already in progress, skipping');
|
||||
return false;
|
||||
}
|
||||
|
||||
const authPath = resolveAuthPath();
|
||||
|
||||
if (!fs.existsSync(authPath)) {
|
||||
console.warn('[Scheduler] No existing auth.json found. Run gen-auth.js first to set up initial authentication.');
|
||||
return false;
|
||||
}
|
||||
|
||||
state.isRenewing = true;
|
||||
|
||||
try {
|
||||
console.log('[Scheduler] Starting Instagram authentication renewal...');
|
||||
console.log(`[Scheduler] Loading existing auth from: ${authPath}`);
|
||||
|
||||
const browser = await getBrowser();
|
||||
// Load existing authentication state
|
||||
const context = await browser.newContext({ storageState: authPath });
|
||||
const page = await context.newPage();
|
||||
|
||||
// Navigate to Instagram homepage - the existing auth will be used automatically
|
||||
await page.goto('https://www.instagram.com/', { waitUntil: 'domcontentloaded' });
|
||||
|
||||
// Wait for the "Home" icon to appear (indicates successful login)
|
||||
try {
|
||||
await page.waitForSelector('svg[aria-label="Home"]', { timeout: 30000 });
|
||||
console.log('[Scheduler] Successfully authenticated with Instagram');
|
||||
} catch (e) {
|
||||
console.warn('[Scheduler] Home icon not found - session may be expired or invalid');
|
||||
await page.close();
|
||||
await context.close();
|
||||
state.isRenewing = false;
|
||||
return false;
|
||||
}
|
||||
|
||||
// Save the refreshed authentication state
|
||||
const authDir = path.dirname(authPath);
|
||||
|
||||
// Ensure directory exists
|
||||
if (!fs.existsSync(authDir)) {
|
||||
fs.mkdirSync(authDir, { recursive: true });
|
||||
}
|
||||
|
||||
// Update auth.json with refreshed session
|
||||
await context.storageState({ path: authPath });
|
||||
|
||||
await page.close();
|
||||
await context.close();
|
||||
|
||||
state.lastRenewalTime = Date.now();
|
||||
console.log(`[Scheduler] Instagram authentication renewed successfully at ${new Date().toISOString()}`);
|
||||
console.log(`[Scheduler] Auth state updated at: ${authPath}`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error('[Scheduler] Instagram authentication renewal failed:', error);
|
||||
return false;
|
||||
} finally {
|
||||
state.isRenewing = false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the authentication renewal scheduler
|
||||
*/
|
||||
export async function startScheduler(): Promise<void> {
|
||||
const config = getConfig();
|
||||
|
||||
if (!config.enabled) {
|
||||
console.log('[Scheduler] Authentication scheduler is disabled (set AUTH_SCHEDULER_ENABLED=true to enable)');
|
||||
return;
|
||||
}
|
||||
|
||||
if (state.intervalId !== null) {
|
||||
console.warn('[Scheduler] Scheduler is already running');
|
||||
return;
|
||||
}
|
||||
|
||||
const intervalMs = config.intervalHours * 60 * 60 * 1000;
|
||||
|
||||
console.log(`[Scheduler] Starting authentication scheduler with ${config.intervalHours}h interval`);
|
||||
|
||||
// Schedule periodic renewals
|
||||
state.intervalId = setInterval(async () => {
|
||||
await renewInstagramAuth();
|
||||
}, intervalMs);
|
||||
|
||||
// Ensure interval is not blocking (set it as unreferenceable so it doesn't keep the process alive)
|
||||
if (state.intervalId.unref) {
|
||||
state.intervalId.unref();
|
||||
}
|
||||
|
||||
// Optional: Perform initial renewal on startup (uncomment to enable)
|
||||
// await renewInstagramAuth();
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the authentication renewal scheduler
|
||||
*/
|
||||
export async function stopScheduler(): Promise<void> {
|
||||
if (state.intervalId === null) {
|
||||
console.log('[Scheduler] Scheduler is not running');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[Scheduler] Stopping authentication scheduler...');
|
||||
clearInterval(state.intervalId);
|
||||
state.intervalId = null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get scheduler status information
|
||||
*/
|
||||
export function getSchedulerStatus() {
|
||||
return {
|
||||
running: state.intervalId !== null,
|
||||
lastRenewalTime: state.lastRenewalTime ? new Date(state.lastRenewalTime).toISOString() : null,
|
||||
isRenewing: state.isRenewing,
|
||||
config: getConfig()
|
||||
};
|
||||
}
|
||||
@@ -1,12 +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
|
||||
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
|
||||
};
|
||||
@@ -1,380 +1,380 @@
|
||||
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;
|
||||
image?: 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; imageUrl?: string; 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,
|
||||
imageUrl: recipe.image || undefined
|
||||
};
|
||||
} 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}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
}
|
||||
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;
|
||||
image?: 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; imageUrl?: string; 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,
|
||||
imageUrl: recipe.image || undefined
|
||||
};
|
||||
} 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}`
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user