feat: Implement PDF recipe parser and quick import service for file and URL inputs

This commit is contained in:
Nils-Johan Gynther
2026-04-14 22:24:28 +02:00
parent e90fd2d670
commit 1ce1318bf5
10 changed files with 758 additions and 194 deletions
@@ -0,0 +1,116 @@
/**
* Parser för PDF-filer
* Använder pdf-parse för att extrahera text från PDF-dokument
*/
import { RecipeParser, ParsedRecipe } from './base.parser';
import * as pdf from 'pdf-parse';
export class PdfRecipeParser extends RecipeParser {
canHandle(url: string): boolean {
// Denna parser hanterar PDF-filer
const normalized = url.toLowerCase();
return normalized.endsWith('.pdf');
}
async parse(fileBuffer: Buffer): Promise<ParsedRecipe> {
console.log('[PdfParser] Parsing PDF file...');
try {
// Extrahera text från PDF
const data = await pdf(fileBuffer);
const text = data.text;
console.log('[PdfParser] Extraherad text längd:', text.length);
// Parsa texten till receptstruktur
return this.parseRecipeText(text);
} catch (err) {
console.error('[PdfParser] Fel vid PDF-parsing:', err);
throw new Error('Kunde inte tolka PDF-filen. Kontrollera att det är ett giltigt recept.');
}
}
/**
* Parsar råtext från PDF till strukturerat recept
* Försöker identifiera receptnamn, ingredienser och instruktioner
*/
private parseRecipeText(text: string): ParsedRecipe {
const lines = text.split('\n').map(line => line.trim()).filter(line => line.length > 0);
let name = 'Okänt recept';
let description = '';
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
let instructions = '';
let currentSection: 'name' | 'description' | 'ingredients' | 'instructions' | null = null;
// Försök hitta receptnamn (stor text i början)
const titleMatch = text.match(/^[A-ZÅÄÖ\s]+/i);
if (titleMatch) {
name = titleMatch[0].trim();
}
// Analysera texten rad för rad
for (const line of lines) {
// Hoppa över tomma rader
if (!line || line.length === 0) continue;
// Detektera sektioner
if (line.toLowerCase().includes('ingredienser')) {
currentSection = 'ingredients';
continue;
}
if (line.toLowerCase().includes('tillvägagångssätt') ||
line.toLowerCase().includes('instruktioner') ||
line.toLowerCase().includes('gör så här')) {
currentSection = 'instructions';
continue;
}
// Samla in innehåll baserat på aktuell sektion
switch (currentSection) {
case 'ingredients':
if (line.toLowerCase().includes('tillvägagångssätt') ||
line.toLowerCase().includes('instruktioner')) {
currentSection = 'instructions';
break;
}
// Parsa ingrediensrad
const ingredient = this.parseIngredientLine(line);
if (ingredient) {
ingredients.push(ingredient);
}
break;
case 'instructions':
if (instructions.length > 0) {
instructions += '\n';
}
instructions += line;
break;
default:
// Om vi inte har hittat ingredienser än, kan detta vara beskrivning
if (ingredients.length === 0 && !description.includes(line)) {
if (description.length > 0) {
description += ' ';
}
description += line;
}
break;
}
}
// Om vi inte hittade något receptnamn, försök använda första meningsfulla raden
if (name === 'Okänt recept' && lines.length > 0) {
name = lines[0].length > 50 ? lines[0].substring(0, 50) + '...' : lines[0];
}
return {
name,
description: description || undefined,
ingredients,
instructions: instructions || undefined,
};
}
}