feat: Implement site-specific recipe parsers for ICA and generic fallback
This commit is contained in:
@@ -1,4 +1,7 @@
|
||||
import { Injectable, BadRequestException } from '@nestjs/common';
|
||||
import { IcaRecipeParser } from './parsers/ica.parser';
|
||||
import { GenericRecipeParser } from './parsers/generic.parser';
|
||||
import { RecipeParser } from './parsers/base.parser';
|
||||
|
||||
export interface QuickImportResult {
|
||||
markdown: string;
|
||||
@@ -25,20 +28,12 @@ export class QuickImportService {
|
||||
console.log('[QuickImport] isUrl:', isUrl, 'isPdf:', isPdf);
|
||||
|
||||
if (isUrl) {
|
||||
// Försök detektera webbplats
|
||||
if (input.includes('ica.se')) {
|
||||
console.log('[QuickImport] Detekterade ICA-länk, startar skrapning...');
|
||||
return this.scrapeIcaRecipe(input);
|
||||
} else {
|
||||
console.log('[QuickImport] URL är inte från ICA.se');
|
||||
throw new BadRequestException(
|
||||
'Endast ICA-recept stöds för närvarande. Försök med en ICA-länk (ica.se)'
|
||||
);
|
||||
}
|
||||
console.log('[QuickImport] Detekterade URL, försöker scrapa...');
|
||||
return this.scrapeRecipeFromUrl(input);
|
||||
} else if (isPdf) {
|
||||
console.log('[QuickImport] PDF-fil identifierad');
|
||||
console.log('[QuickImport] Detekterade PDF-fil');
|
||||
throw new BadRequestException(
|
||||
'PDF-import är under utveckling. Använd snabbimport för ICA-recept eller skriv in receptet manuellt.'
|
||||
'PDF-import under utveckling. Försök med en URL från ICA.se eller annat receptsida.'
|
||||
);
|
||||
} else {
|
||||
console.log('[QuickImport] Input är inte URL eller PDF');
|
||||
@@ -69,20 +64,18 @@ export class QuickImportService {
|
||||
}
|
||||
|
||||
/**
|
||||
* Skrapar recept från ICA.se
|
||||
* Skrapar recept från en URL
|
||||
*
|
||||
* Försöker hämta:
|
||||
* - Recepttitel (från h1 eller meta title)
|
||||
* - Ingredienser (från ingrediens-lista)
|
||||
* - Instruktioner (från steg-lista eller beskrivning)
|
||||
* Använder site-specifika parsers om tillgängliga,
|
||||
* annars fallback till generisk parser.
|
||||
*
|
||||
* @param url ICA-receptlänk
|
||||
* @param url URL till receptsidan
|
||||
* @returns Markdown-format
|
||||
*/
|
||||
private async scrapeIcaRecipe(url: string): Promise<QuickImportResult> {
|
||||
private async scrapeRecipeFromUrl(url: string): Promise<QuickImportResult> {
|
||||
try {
|
||||
console.log('[QuickImport] Hämtar HTML från:', url);
|
||||
|
||||
|
||||
// Hämta HTML från URL
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
@@ -100,9 +93,29 @@ export class QuickImportService {
|
||||
const html = await response.text();
|
||||
console.log('[QuickImport] HTML längd:', html.length, 'tecken');
|
||||
|
||||
// Extrahera receptinformation från HTML
|
||||
const recipe = this.parseIcaHtml(html);
|
||||
console.log('[QuickImport] Parsad recept:', { name: recipe.name, ingredienser: recipe.ingredients.length });
|
||||
// Välj lämplig parser
|
||||
const parsers: RecipeParser[] = [
|
||||
new IcaRecipeParser(),
|
||||
new GenericRecipeParser(),
|
||||
];
|
||||
|
||||
let recipe = null;
|
||||
for (const parser of parsers) {
|
||||
if (parser.canHandle(url)) {
|
||||
console.log('[QuickImport] Använder parser:', parser.constructor.name);
|
||||
recipe = parser.parse(html);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!recipe) {
|
||||
throw new Error('Ingen parserutrustning tillgänglig');
|
||||
}
|
||||
|
||||
console.log('[QuickImport] Parsad recept:', {
|
||||
name: recipe.name,
|
||||
ingredienser: recipe.ingredients.length,
|
||||
});
|
||||
|
||||
if (!recipe.name) {
|
||||
throw new Error('Kunde inte hitta receptnamn på sidan. Försök med en annan länk.');
|
||||
@@ -112,80 +125,25 @@ export class QuickImportService {
|
||||
const markdown = this.recipeToMarkdown(recipe);
|
||||
console.log('[QuickImport] Markdown genererad, längd:', markdown.length);
|
||||
|
||||
// Detektera källa från URL
|
||||
let source: 'ica' | 'pdf' | 'other' = 'other';
|
||||
if (/ica\.se/i.test(url)) {
|
||||
source = 'ica';
|
||||
}
|
||||
|
||||
return {
|
||||
markdown,
|
||||
source: 'ica',
|
||||
source,
|
||||
};
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Okänt fel vid scraping';
|
||||
console.error('[QuickImport] ERROR:', message);
|
||||
throw new BadRequestException(
|
||||
`Kunde inte hämta recept från ICA: ${message}. Kontrollera att länken är korrekt och försök igen.`
|
||||
`Kunde inte hämta recept: ${message}. Kontrollera att länken är korrekt och försök igen.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsa ICA-receptsida (HTML)
|
||||
*
|
||||
* Denna är en simplified version. För full produrktion behöver du:
|
||||
* - Headless browser (Puppeteer/Playwright)
|
||||
* - API-integration eller scraping-bibliotek
|
||||
* - Proper error handling för sidstruktur-ändringar
|
||||
*/
|
||||
private parseIcaHtml(html: string): {
|
||||
name: string;
|
||||
description?: string;
|
||||
ingredients: Array<{
|
||||
quantity: number;
|
||||
unit: string;
|
||||
name: string;
|
||||
}>;
|
||||
instructions?: string;
|
||||
} {
|
||||
// Extrahera titel
|
||||
let name = '';
|
||||
const titleMatch = html.match(/<h1[^>]*>([^<]+)<\/h1>/i);
|
||||
if (titleMatch) {
|
||||
name = titleMatch[1].trim();
|
||||
}
|
||||
|
||||
if (!name) {
|
||||
const ogTitleMatch = html.match(/<meta\s+property="og:title"\s+content="([^"]+)"/i);
|
||||
if (ogTitleMatch) {
|
||||
name = ogTitleMatch[1].trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Extrahera ingredienser (en enkel regex - kan behöva anpassas)
|
||||
const ingredients: Array<{ quantity: number; unit: string; name: string }> = [];
|
||||
const ingredientRegex = /(?:ingredients?|<li[^>]*>)([^<]*?(\d+(?:[.,]\d+)?)\s*([a-zåäö]*)\s*([^<]+))/gi;
|
||||
let match;
|
||||
while ((match = ingredientRegex.exec(html)) !== null) {
|
||||
const quantity = parseFloat(match[2].replace(',', '.'));
|
||||
const unit = match[3].toLowerCase().trim() || 'st';
|
||||
const name = match[4].trim();
|
||||
if (name) {
|
||||
ingredients.push({ quantity, unit, name });
|
||||
}
|
||||
}
|
||||
|
||||
// Extrahera instruktioner (första paragraf eller instruktions-sektion)
|
||||
let instructions = '';
|
||||
const instructionsMatch = html.match(
|
||||
/<(?:div|section)[^>]*class="[^"]*instruction[^"]*"[^>]*>([^<]*)<\/(?:div|section)>/is
|
||||
);
|
||||
if (instructionsMatch) {
|
||||
instructions = instructionsMatch[1].replace(/<[^>]+>/g, '').trim();
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
ingredients: ingredients.length > 0 ? ingredients : [],
|
||||
instructions,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertera receptobjekt till Markdown-format
|
||||
*/
|
||||
|
||||
Reference in New Issue
Block a user