Initial microservice-importer setup with NestJS backend and Next.js frontend
This commit is contained in:
@@ -0,0 +1,27 @@
|
||||
# Byggas från projektets rot: docker build -f backend/Dockerfile -t recipe-importer-api:local .
|
||||
|
||||
# Stage 1: Bygg applikationen
|
||||
FROM node:22-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Kopiera backend-filer
|
||||
COPY backend/package.json ./
|
||||
COPY backend/src ./src
|
||||
COPY backend/tsconfig.json ./
|
||||
COPY backend/nest-cli.json ./
|
||||
|
||||
# Köra npm install
|
||||
RUN npm install
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Kör applikationen
|
||||
FROM node:22-alpine AS runner
|
||||
WORKDIR /app
|
||||
ENV NODE_ENV=production
|
||||
|
||||
COPY --from=builder /app/package.json ./package.json
|
||||
COPY --from=builder /app/node_modules ./node_modules
|
||||
COPY --from=builder /app/dist ./dist
|
||||
|
||||
EXPOSE 3001
|
||||
CMD ["node", "dist/main"]
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src"
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "recipe-importer-api",
|
||||
"version": "0.0.1",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "node dist/main",
|
||||
"start:dev": "nest start --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^10.3.0",
|
||||
"@nestjs/core": "^10.3.0",
|
||||
"@nestjs/platform-express": "^10.3.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.15.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.3.0",
|
||||
"@nestjs/schematics": "^10.1.1",
|
||||
"@types/express": "^4.17.21",
|
||||
"@types/node": "^22.15.29",
|
||||
"typescript": "^5.4.5"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { QuickImportModule } from './quick-import/quick-import.module';
|
||||
import { RecipesModule } from './recipes/recipes.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
QuickImportModule,
|
||||
RecipesModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,66 @@
|
||||
import {
|
||||
ExceptionFilter,
|
||||
Catch,
|
||||
ArgumentsHost,
|
||||
HttpException,
|
||||
HttpStatus,
|
||||
Logger,
|
||||
} from '@nestjs/common';
|
||||
import { Response } from 'express';
|
||||
|
||||
interface ErrorResponse {
|
||||
statusCode: number;
|
||||
message: string;
|
||||
timestamp: string;
|
||||
path?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Global exception filter som formaterar alla errors konsistent
|
||||
* Returnerar tydliga, svenska felmeddelanden utan att läcka känslig info
|
||||
*/
|
||||
@Catch()
|
||||
export class GlobalExceptionFilter implements ExceptionFilter {
|
||||
private readonly logger = new Logger(GlobalExceptionFilter.name);
|
||||
|
||||
catch(exception: any, host: ArgumentsHost) {
|
||||
const ctx = host.switchToHttp();
|
||||
const response = ctx.getResponse<Response>();
|
||||
const request = ctx.getRequest();
|
||||
const path = request.url;
|
||||
|
||||
let statusCode = HttpStatus.INTERNAL_SERVER_ERROR;
|
||||
let message = 'Ett internt serverfel inträffade.';
|
||||
|
||||
// Hantera HTTP-exceptions (t.ex. NotFoundException)
|
||||
if (exception instanceof HttpException) {
|
||||
statusCode = exception.getStatus();
|
||||
const exceptionResponse = exception.getResponse();
|
||||
|
||||
if (typeof exceptionResponse === 'object' && 'message' in exceptionResponse) {
|
||||
message = (exceptionResponse as any).message || message;
|
||||
} else if (typeof exceptionResponse === 'string') {
|
||||
message = exceptionResponse;
|
||||
}
|
||||
} else if (exception instanceof Error) {
|
||||
// Hantera vanliga Error-instanser
|
||||
message = exception.message || message;
|
||||
statusCode = HttpStatus.BAD_REQUEST;
|
||||
|
||||
// Log interna fel för debugging
|
||||
this.logger.error(`Error: ${exception.message}`, exception.stack);
|
||||
} else {
|
||||
// Okänd typ av exception
|
||||
this.logger.error('Unknown exception type', exception);
|
||||
}
|
||||
|
||||
const errorResponse: ErrorResponse = {
|
||||
statusCode,
|
||||
message,
|
||||
timestamp: new Date().toISOString(),
|
||||
path,
|
||||
};
|
||||
|
||||
response.status(statusCode).json(errorResponse);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export function normalizeName(value: string): string {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFD')
|
||||
.replace(/[\u0300-\u036f]/g, '')
|
||||
.replace(/[^a-z0-9\s]/g, '')
|
||||
.replace(/\s+/g, '');
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { AppModule } from './app.module';
|
||||
import { GlobalExceptionFilter } from './common/filters/global-exception.filter';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.setGlobalPrefix('api');
|
||||
|
||||
// Registrera global exception filter
|
||||
app.useGlobalFilters(new GlobalExceptionFilter());
|
||||
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
|
||||
const port = process.env.PORT || 3001;
|
||||
await app.listen(port);
|
||||
console.log(`🚀 Microservice importer running on port ${port}`);
|
||||
}
|
||||
bootstrap();
|
||||
@@ -0,0 +1,158 @@
|
||||
/**
|
||||
* Bas-parser för receptsidor
|
||||
* Alla site-specifika parsers bör extenda denna
|
||||
*/
|
||||
export interface ParsedRecipe {
|
||||
name: string;
|
||||
description?: string;
|
||||
ingredients: Array<{
|
||||
quantity: number;
|
||||
unit: string;
|
||||
name: string;
|
||||
note?: string;
|
||||
}>;
|
||||
instructions?: string;
|
||||
}
|
||||
|
||||
export abstract class RecipeParser {
|
||||
/**
|
||||
* Kontrollera om denna parser kan hantera denna URL
|
||||
*/
|
||||
abstract canHandle(url: string): boolean;
|
||||
|
||||
/**
|
||||
* Parsa HTML och extrahera receptdata
|
||||
*/
|
||||
abstract parse(html: string): ParsedRecipe;
|
||||
|
||||
/**
|
||||
* Hjälpfunktion: parsa ingrediens-rad
|
||||
* Hanterar format som:
|
||||
* - "3 ägg"
|
||||
* - "150 g lax"
|
||||
* - "1/2 citron"
|
||||
* - "1 msk senap"
|
||||
* - "salt och peppar"
|
||||
* - "1 förp handskalade räkor i lake (à 570 g)"
|
||||
*/
|
||||
protected parseIngredientLine(line: string): {
|
||||
quantity: number;
|
||||
unit: string;
|
||||
name: string;
|
||||
note?: string;
|
||||
} | null {
|
||||
let cleaned = line.replace(/<[^>]+>/g, '').trim();
|
||||
if (!cleaned) return null;
|
||||
|
||||
// Kända enheter
|
||||
const knownUnits = [
|
||||
'g', 'kg', 'hg', 'mg', 'ml', 'dl', 'l', 'tl',
|
||||
'st', 'tsk', 'msk', 'krm', 'matsked', 'tesked',
|
||||
'pris', 'portion', 'port', 'burk', 'förp', 'paket', 'efter smak', 'klyfta',
|
||||
];
|
||||
|
||||
// Extrahera parentetisk info
|
||||
let parentheticalText = '';
|
||||
const parentheteMatch = cleaned.match(/\s*\(([^)]*)\)/);
|
||||
if (parentheteMatch) {
|
||||
parentheticalText = parentheteMatch[1].trim();
|
||||
cleaned = cleaned.replace(/\s*\([^)]*\)/, '').trim();
|
||||
}
|
||||
|
||||
// Hantera bråkdelar: "1/2" eller "1 1/2" eller "1 1 / 2"
|
||||
// Regex: (optional whole)? numerator / denominator
|
||||
const fractionMatch = cleaned.match(/^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)/);
|
||||
let quantity = 0;
|
||||
let remainingText = cleaned;
|
||||
|
||||
if (fractionMatch) {
|
||||
if (fractionMatch[1]) {
|
||||
// Heltal + bråk: "1 1/2"
|
||||
const whole = parseFloat(fractionMatch[1]);
|
||||
const numerator = parseFloat(fractionMatch[2]);
|
||||
const denominator = parseFloat(fractionMatch[3]);
|
||||
quantity = whole + (numerator / denominator);
|
||||
} else {
|
||||
// Bara bråk: "1/2"
|
||||
const numerator = parseFloat(fractionMatch[2]);
|
||||
const denominator = parseFloat(fractionMatch[3]);
|
||||
quantity = numerator / denominator;
|
||||
}
|
||||
remainingText = cleaned.substring(fractionMatch[0].length).trim();
|
||||
} else {
|
||||
const numberMatch = remainingText.match(/^([\d.,]+)/);
|
||||
if (numberMatch) {
|
||||
quantity = parseFloat(numberMatch[1].replace(',', '.'));
|
||||
remainingText = remainingText.substring(numberMatch[0].length).trim();
|
||||
}
|
||||
}
|
||||
|
||||
// Extrahera potentiell enhet
|
||||
let potentialUnit = '';
|
||||
let productName = remainingText;
|
||||
|
||||
if (remainingText) {
|
||||
const unitMatch = remainingText.match(/^([a-zåäö]+)\b/i);
|
||||
if (unitMatch) {
|
||||
const candidateUnit = unitMatch[1].toLowerCase();
|
||||
if (knownUnits.includes(candidateUnit)) {
|
||||
potentialUnit = candidateUnit;
|
||||
productName = remainingText.substring(candidateUnit.length).trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Analysera parenthetical text för måttenhet
|
||||
let parenthHasUnit = false;
|
||||
if (parentheticalText) {
|
||||
for (const unit of knownUnits) {
|
||||
if (parentheticalText.toLowerCase().includes(unit)) {
|
||||
parenthHasUnit = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let note: string | undefined = undefined;
|
||||
|
||||
// Om vi hade quantity i huvuddelen och parenthetical innehåller unit
|
||||
// → spara parenthetical som note
|
||||
if (quantity > 0 && parenthHasUnit) {
|
||||
note = parentheticalText;
|
||||
}
|
||||
|
||||
// Om ingen mängd i huvuddelen men parenthetical hade både mängd och unit
|
||||
// → parse parenthetical som quantity + unit
|
||||
if (quantity === 0 && parentheticalText) {
|
||||
const parenthMatch = parentheticalText.match(/^[\D]*?([\d.,]+)?\s*([a-zåäö]*)?\s*(.*)$/i);
|
||||
if (parenthMatch) {
|
||||
let pQuantity = parenthMatch[1] ? parseFloat(parenthMatch[1].replace(',', '.')) : 0;
|
||||
let pUnit = parenthMatch[2]?.toLowerCase() || '';
|
||||
let pRest = parenthMatch[3]?.trim() || '';
|
||||
|
||||
if (knownUnits.includes(pUnit) && pQuantity > 0) {
|
||||
quantity = pQuantity;
|
||||
potentialUnit = pUnit;
|
||||
note = parentheticalText;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Om ingen mängd och enhet, bara returna produktnamnet
|
||||
if (quantity === 0) {
|
||||
return {
|
||||
quantity: 0,
|
||||
unit: '',
|
||||
name: cleaned,
|
||||
note: parentheticalText || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
quantity,
|
||||
unit: potentialUnit,
|
||||
name: productName,
|
||||
note: note,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { RecipeParser, ParsedRecipe } from './base.parser';
|
||||
|
||||
/**
|
||||
* Generisk parser för okända receptsidor
|
||||
* Försöker JSON-LD först, sedan vanlig HTML-parsing
|
||||
* Denna är mer permissiv än site-specifika parsers
|
||||
*/
|
||||
export class GenericRecipeParser extends RecipeParser {
|
||||
canHandle(url: string): boolean {
|
||||
// Denna parser hanterar alltid (är fallback)
|
||||
return true;
|
||||
}
|
||||
|
||||
parse(html: string): ParsedRecipe {
|
||||
console.log('[GenericParser] Parsing recipe from unknown site...');
|
||||
|
||||
// Försöka extrahera JSON-LD recipe data
|
||||
const jsonLdMatch = html.match(
|
||||
/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i
|
||||
);
|
||||
|
||||
if (jsonLdMatch) {
|
||||
try {
|
||||
const jsonData = JSON.parse(jsonLdMatch[1]);
|
||||
const recipe =
|
||||
jsonData['@type'] === 'Recipe'
|
||||
? jsonData
|
||||
: jsonData['@graph']?.find((item: any) => item['@type'] === 'Recipe');
|
||||
|
||||
if (recipe) {
|
||||
console.log('[GenericParser] ✓ JSON-LD data found');
|
||||
return this.extractFromJsonLd(recipe);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[GenericParser] JSON-LD parsing failed');
|
||||
}
|
||||
}
|
||||
|
||||
console.log('[GenericParser] No JSON-LD found, using HTML parsing');
|
||||
return this.parseFromHtml(html);
|
||||
}
|
||||
|
||||
private extractFromJsonLd(recipe: any): ParsedRecipe {
|
||||
const name = recipe.name || '';
|
||||
const description = recipe.description || '';
|
||||
|
||||
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
|
||||
if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) {
|
||||
for (const ing of recipe.recipeIngredient) {
|
||||
const parsed = this.parseIngredientLine(ing);
|
||||
if (parsed) {
|
||||
ingredients.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let instructions = '';
|
||||
if (recipe.recipeInstructions) {
|
||||
if (typeof recipe.recipeInstructions === 'string') {
|
||||
instructions = recipe.recipeInstructions;
|
||||
} else if (Array.isArray(recipe.recipeInstructions)) {
|
||||
instructions = recipe.recipeInstructions
|
||||
.map((step: any) => {
|
||||
if (typeof step === 'string') return step;
|
||||
if (step.text) return step.text;
|
||||
return '';
|
||||
})
|
||||
.filter((s: string) => s)
|
||||
.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
ingredients,
|
||||
instructions,
|
||||
};
|
||||
}
|
||||
|
||||
private parseFromHtml(html: string): ParsedRecipe {
|
||||
// Försöka hitta titel
|
||||
let name = '';
|
||||
|
||||
// Prova olika selector-mönster
|
||||
let titleMatch =
|
||||
html.match(/<h1[^>]*>([^<]+)<\/h1>/i) ||
|
||||
html.match(/<meta\s+property="og:title"\s+content="([^"]+)"/i) ||
|
||||
html.match(/<title>([^<]+)<\/title>/i);
|
||||
|
||||
if (titleMatch) {
|
||||
name = titleMatch[1].trim();
|
||||
}
|
||||
|
||||
// Försöka extrahera beskrivning från meta-taggar
|
||||
let description = '';
|
||||
const descMatch = html.match(
|
||||
/<meta\s+name="description"\s+content="([^"]+)"/i
|
||||
);
|
||||
if (descMatch) {
|
||||
description = descMatch[1].trim();
|
||||
}
|
||||
|
||||
// Försöka extrahera ingredienser från vanliga strukturer
|
||||
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
|
||||
|
||||
// Testa olika ingredient-selectors
|
||||
const ingredientPatterns = [
|
||||
/<li[^>]*>(.*?)<\/li>/gi,
|
||||
/<div[^>]*class="ingredient"[^>]*>(.*?)<\/div>/gi,
|
||||
/<p[^>]*class="ingredient"[^>]*>(.*?)<\/p>/gi,
|
||||
];
|
||||
|
||||
for (const pattern of ingredientPatterns) {
|
||||
let match;
|
||||
while ((match = pattern.exec(html)) !== null) {
|
||||
const parsed = this.parseIngredientLine(match[1]);
|
||||
if (parsed && parsed.name.length > 2) {
|
||||
// Undvik mycket korta ingredienser (troligen brus)
|
||||
ingredients.push(parsed);
|
||||
}
|
||||
}
|
||||
if (ingredients.length > 0) break; // Om vi hittat några, använd dessa
|
||||
}
|
||||
|
||||
// Försöka hitta instruktioner
|
||||
let instructions = '';
|
||||
const instructionsPatterns = [
|
||||
/<(?:div|section)[^>]*class="[^"]*(?:instruction|method|step)[^"]*"[^>]*>(.*?)<\/(?:div|section)>/is,
|
||||
/<ol[^>]*>(.*?)<\/ol>/i,
|
||||
];
|
||||
|
||||
for (const pattern of instructionsPatterns) {
|
||||
const match = html.match(pattern);
|
||||
if (match) {
|
||||
instructions = match[1].replace(/<[^>]+>/g, '').trim();
|
||||
if (instructions.length > 10) break;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
ingredients,
|
||||
instructions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { RecipeParser, ParsedRecipe } from './base.parser';
|
||||
|
||||
/**
|
||||
* Parser för ica.se receptsidor
|
||||
* Använder JSON-LD structured data som primär källa
|
||||
*/
|
||||
export class IcaRecipeParser extends RecipeParser {
|
||||
canHandle(url: string): boolean {
|
||||
return /ica\.se\/recept/i.test(url);
|
||||
}
|
||||
|
||||
parse(html: string): ParsedRecipe {
|
||||
console.log('[IcaParser] Parsing ICA recipe...');
|
||||
|
||||
// Försöka extrahera JSON-LD recipe data (ICA använder detta)
|
||||
const jsonLdMatch = html.match(
|
||||
/<script[^>]*type="application\/ld\+json"[^>]*>([\s\S]*?)<\/script>/i
|
||||
);
|
||||
|
||||
if (jsonLdMatch) {
|
||||
try {
|
||||
const jsonData = JSON.parse(jsonLdMatch[1]);
|
||||
|
||||
// Hitta recipe-objektet
|
||||
const recipe =
|
||||
jsonData['@type'] === 'Recipe'
|
||||
? jsonData
|
||||
: jsonData['@graph']?.find((item: any) => item['@type'] === 'Recipe');
|
||||
|
||||
if (recipe) {
|
||||
console.log('[IcaParser] ✓ JSON-LD recipe found');
|
||||
return this.extractFromJsonLd(recipe);
|
||||
}
|
||||
} catch (err) {
|
||||
console.log('[IcaParser] JSON-LD parsing failed:', err);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback: HTML parsing (sällan nödvändigt för ICA)
|
||||
console.log('[IcaParser] Falling back to HTML parsing');
|
||||
return this.parseFromHtml(html);
|
||||
}
|
||||
|
||||
private extractFromJsonLd(recipe: any): ParsedRecipe {
|
||||
// Extrahera titel
|
||||
const name = recipe.name || '';
|
||||
|
||||
// Extrahera beskrivning
|
||||
const description = recipe.description || '';
|
||||
|
||||
// Extrahera ingredienser
|
||||
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
|
||||
if (recipe.recipeIngredient && Array.isArray(recipe.recipeIngredient)) {
|
||||
for (const ing of recipe.recipeIngredient) {
|
||||
const parsed = this.parseIngredientLine(ing);
|
||||
if (parsed) {
|
||||
ingredients.push(parsed);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Extrahera instruktioner
|
||||
let instructions = '';
|
||||
if (recipe.recipeInstructions) {
|
||||
if (typeof recipe.recipeInstructions === 'string') {
|
||||
instructions = recipe.recipeInstructions;
|
||||
} else if (Array.isArray(recipe.recipeInstructions)) {
|
||||
instructions = recipe.recipeInstructions
|
||||
.map((step: any) => {
|
||||
if (typeof step === 'string') return step;
|
||||
if (step.text) return step.text;
|
||||
return '';
|
||||
})
|
||||
.filter((s: string) => s)
|
||||
.join('\n\n');
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
ingredients,
|
||||
instructions,
|
||||
};
|
||||
}
|
||||
|
||||
private parseFromHtml(html: string): ParsedRecipe {
|
||||
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 beskrivning från meta-taggar
|
||||
let description = '';
|
||||
const descMatch = html.match(
|
||||
/<meta\s+name="description"\s+content="([^"]+)"/i
|
||||
);
|
||||
if (descMatch) {
|
||||
description = descMatch[1].trim();
|
||||
}
|
||||
|
||||
const ingredients: Array<{ quantity: number; unit: string; name: string; note?: string }> = [];
|
||||
const ingredientRegex =
|
||||
/<li[^>]*class="[^"]*ingredient[^"]*"[^>]*>([^<]+)<\/li>/gi;
|
||||
let match;
|
||||
while ((match = ingredientRegex.exec(html)) !== null) {
|
||||
const parsed = this.parseIngredientLine(match[1]);
|
||||
if (parsed) {
|
||||
ingredients.push(parsed);
|
||||
}
|
||||
}
|
||||
|
||||
let instructions = '';
|
||||
const instructionsMatch = html.match(
|
||||
/<(?:div|section)[^>]*class="[^"]*(?:instruction|howto)[^"]*"[^>]*>([^<]*)<\/(?:div|section)>/is
|
||||
);
|
||||
if (instructionsMatch) {
|
||||
instructions = instructionsMatch[1].replace(/<[^>]+>/g, '').trim();
|
||||
}
|
||||
|
||||
return {
|
||||
name,
|
||||
description,
|
||||
ingredients,
|
||||
instructions,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Controller, Post, Body } from '@nestjs/common';
|
||||
import { QuickImportService, QuickImportResult } from './quick-import.service';
|
||||
|
||||
@Controller('quick-import')
|
||||
export class QuickImportController {
|
||||
constructor(private readonly quickImportService: QuickImportService) {}
|
||||
|
||||
@Post()
|
||||
async importFromInput(
|
||||
@Body() body: { input: string }
|
||||
): Promise<QuickImportResult> {
|
||||
return this.quickImportService.importFromInput(body.input);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { QuickImportController } from './quick-import.controller';
|
||||
import { QuickImportService } from './quick-import.service';
|
||||
|
||||
@Module({
|
||||
controllers: [QuickImportController],
|
||||
providers: [QuickImportService],
|
||||
})
|
||||
export class QuickImportModule {}
|
||||
@@ -0,0 +1,204 @@
|
||||
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;
|
||||
source: 'ica' | 'pdf' | 'other';
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class QuickImportService {
|
||||
/**
|
||||
* Detekterar typ av input (URL eller filsökväg) och importerar från lämplig källa
|
||||
*/
|
||||
async importFromInput(input: string): Promise<QuickImportResult> {
|
||||
input = input.trim();
|
||||
console.log('[QuickImport] Mottog input:', input);
|
||||
|
||||
if (!input) {
|
||||
throw new BadRequestException('Du måste ange en URL eller filsökväg');
|
||||
}
|
||||
|
||||
// Detektera typ
|
||||
const isUrl = this.isUrl(input);
|
||||
const isPdf = this.isPdfPath(input);
|
||||
|
||||
console.log('[QuickImport] isUrl:', isUrl, 'isPdf:', isPdf);
|
||||
|
||||
if (isUrl) {
|
||||
console.log('[QuickImport] Detekterade URL, försöker scrapa...');
|
||||
return this.scrapeRecipeFromUrl(input);
|
||||
} else if (isPdf) {
|
||||
console.log('[QuickImport] Detekterade PDF-fil');
|
||||
throw new BadRequestException(
|
||||
'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');
|
||||
throw new BadRequestException(
|
||||
'Ogültig input. Ange en gyltig URL (t.ex. ica.se/recept/...) eller filsökväg'
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontrollerar om input är en URL
|
||||
*/
|
||||
private isUrl(input: string): boolean {
|
||||
try {
|
||||
new URL(input);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Kontrollerar om input är en PDF-filsökväg
|
||||
*/
|
||||
private isPdfPath(input: string): boolean {
|
||||
const normalized = input.toLowerCase();
|
||||
return normalized.endsWith('.pdf');
|
||||
}
|
||||
|
||||
/**
|
||||
* Skrapar recept från en URL
|
||||
*
|
||||
* Använder site-specifika parsers om tillgängliga,
|
||||
* annars fallback till generisk parser.
|
||||
*
|
||||
* @param url URL till receptsidan
|
||||
* @returns Markdown-format
|
||||
*/
|
||||
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: {
|
||||
'User-Agent':
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36',
|
||||
},
|
||||
});
|
||||
|
||||
console.log('[QuickImport] HTTP status:', response.status);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
console.log('[QuickImport] HTML längd:', html.length, 'tecken');
|
||||
|
||||
// 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.');
|
||||
}
|
||||
|
||||
// Konvertera till Markdown-format
|
||||
const markdown = this.recipeToMarkdown(recipe, url);
|
||||
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,
|
||||
};
|
||||
} 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: ${message}. Kontrollera att länken är korrekt och försök igen.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Konvertera receptobjekt till Markdown-format
|
||||
*/
|
||||
private recipeToMarkdown(
|
||||
recipe: {
|
||||
name: string;
|
||||
description?: string;
|
||||
ingredients: Array<{
|
||||
quantity: number;
|
||||
unit: string;
|
||||
name: string;
|
||||
note?: string;
|
||||
}>;
|
||||
instructions?: string;
|
||||
},
|
||||
sourceUrl?: string,
|
||||
): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Titel
|
||||
lines.push(`# ${recipe.name}`);
|
||||
lines.push('');
|
||||
|
||||
// Beskrivning
|
||||
if (recipe.description) {
|
||||
lines.push(recipe.description);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Ingredienser
|
||||
if (recipe.ingredients.length > 0) {
|
||||
lines.push('## Ingredienser');
|
||||
for (const ing of recipe.ingredients) {
|
||||
const quantity = ing.quantity > 0 ? `${ing.quantity} ` : '';
|
||||
const unit = ing.unit ? `${ing.unit} ` : '';
|
||||
const note = ing.note ? ` (${ing.note})` : '';
|
||||
lines.push(`- ${quantity}${unit}${ing.name}${note}`);
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Instruktioner
|
||||
if (recipe.instructions) {
|
||||
lines.push('## Tillvägagångssätt');
|
||||
lines.push(recipe.instructions);
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Källa
|
||||
if (sourceUrl) {
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
lines.push(`Källa: [${sourceUrl}](${sourceUrl})`);
|
||||
}
|
||||
|
||||
return lines.join('\n');
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { IsString, MinLength } from 'class-validator';
|
||||
|
||||
export class ParseMarkdownDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
markdown!: string;
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { RecipesService } from './recipes.service';
|
||||
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
|
||||
|
||||
@Controller('recipes')
|
||||
export class RecipesController {
|
||||
constructor(private readonly recipesService: RecipesService) {}
|
||||
|
||||
@Post('parse-markdown')
|
||||
parseMarkdown(@Body() dto: ParseMarkdownDto) {
|
||||
return this.recipesService.parseMarkdown(dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { RecipesController } from './recipes.controller';
|
||||
import { RecipesService } from './recipes.service';
|
||||
|
||||
@Module({
|
||||
controllers: [RecipesController],
|
||||
providers: [RecipesService],
|
||||
})
|
||||
export class RecipesModule {}
|
||||
@@ -0,0 +1,220 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { ParseMarkdownDto } from './dto/parse-markdown.dto';
|
||||
|
||||
// ============================================================================
|
||||
// Local Type Definitions (previously from recipe-document-converter)
|
||||
// ============================================================================
|
||||
|
||||
interface ParsedIngredient {
|
||||
rawName: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
note: string | null;
|
||||
}
|
||||
|
||||
interface ParsedRecipe {
|
||||
name: string;
|
||||
description: string;
|
||||
instructions: string;
|
||||
ingredients: ParsedIngredient[];
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Parser Functions
|
||||
// ============================================================================
|
||||
|
||||
/**
|
||||
* Parsar ett recept i Markdown-format och extraherar namn, beskrivning,
|
||||
* instruktioner och ingredienser.
|
||||
*
|
||||
* Förväntat format:
|
||||
* # Receptnamn
|
||||
* Beskrivning (valfritt stycke efter titeln)
|
||||
*
|
||||
* ## Ingredienser
|
||||
* - 400 g kycklingfilé
|
||||
* - 2 dl grädde (eller crème fraiche)
|
||||
*
|
||||
* ## Instruktioner
|
||||
* 1. Stek kycklingen …
|
||||
*/
|
||||
function parseRecipeMarkdown(markdown: string): ParsedRecipe {
|
||||
const lines = markdown.split('\n');
|
||||
|
||||
let name = '';
|
||||
let description = '';
|
||||
let instructions = '';
|
||||
const ingredients: ParsedIngredient[] = [];
|
||||
|
||||
let currentSection: 'none' | 'description' | 'ingredients' | 'instructions' = 'none';
|
||||
const descriptionLines: string[] = [];
|
||||
const instructionLines: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const trimmed = line.trim();
|
||||
|
||||
// H1 — receptnamn
|
||||
if (/^#\s+/.test(trimmed) && !trimmed.startsWith('##')) {
|
||||
name = trimmed.replace(/^#\s+/, '').trim();
|
||||
currentSection = 'description';
|
||||
continue;
|
||||
}
|
||||
|
||||
// H2 — sektionsrubriker
|
||||
if (/^##\s+/.test(trimmed)) {
|
||||
const heading = trimmed.replace(/^##\s+/, '').trim().toLowerCase();
|
||||
if (/ingrediens/.test(heading)) {
|
||||
currentSection = 'ingredients';
|
||||
} else if (/instruktion|tillagning|gör så här|steg|tillväg|metod/.test(heading)) {
|
||||
currentSection = 'instructions';
|
||||
} else {
|
||||
currentSection = 'none';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// Samla rader beroende på sektion
|
||||
switch (currentSection) {
|
||||
case 'description':
|
||||
if (trimmed.length > 0) {
|
||||
descriptionLines.push(trimmed);
|
||||
}
|
||||
break;
|
||||
|
||||
case 'ingredients':
|
||||
if (/^[-*]\s+/.test(trimmed)) {
|
||||
const ingredientText = trimmed.replace(/^[-*]\s+/, '');
|
||||
ingredients.push(parseIngredientLine(ingredientText));
|
||||
}
|
||||
break;
|
||||
|
||||
case 'instructions':
|
||||
if (trimmed.length > 0) {
|
||||
instructionLines.push(trimmed);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
description = descriptionLines.join('\n');
|
||||
instructions = instructionLines.join('\n');
|
||||
|
||||
return { name, description, instructions, ingredients };
|
||||
}
|
||||
|
||||
/**
|
||||
* Parsar en ingrediensrad, t.ex.:
|
||||
* "400 g kycklingfilé"
|
||||
* "2 dl grädde (eller crème fraiche)"
|
||||
* "1 1/2 dl crème fraiche"
|
||||
* "1 polka- eller gulbeta"
|
||||
* "1 kruka basilika"
|
||||
* "salt"
|
||||
*/
|
||||
function parseIngredientLine(text: string): ParsedIngredient {
|
||||
const trimmed = text.trim();
|
||||
|
||||
// Kända enheter
|
||||
const knownUnits = [
|
||||
'g', 'kg', 'hg', 'mg', 'ml', 'dl', 'l', 'tl',
|
||||
'st', 'tsk', 'msk', 'krm', 'matsled', 'tesled',
|
||||
'pris', 'portion', 'port', 'burk', 'förp', 'paket', 'efter smak', 'klyfta',
|
||||
];
|
||||
|
||||
// Extrahera eventuell parentes-not i slutet
|
||||
let note: string | null = null;
|
||||
let main = trimmed;
|
||||
const parenMatch = trimmed.match(/\(([^)]+)\)\s*$/);
|
||||
if (parenMatch) {
|
||||
note = parenMatch[1].trim();
|
||||
main = trimmed.slice(0, parenMatch.index).trim();
|
||||
}
|
||||
|
||||
// Försök matcha bråk först: "1 1/2 dl crème fraiche" eller "1/2 dl"
|
||||
const fractionMatch = main.match(/^(\d+)?\s*(\d+)\s*\/\s*([\d.]+)\s+(\S+)\s+(.*)$/);
|
||||
if (fractionMatch) {
|
||||
let quantity = 0;
|
||||
if (fractionMatch[1]) {
|
||||
quantity = parseFloat(fractionMatch[1]) + parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]);
|
||||
} else {
|
||||
quantity = parseFloat(fractionMatch[2]) / parseFloat(fractionMatch[3]);
|
||||
}
|
||||
const candidateUnit = fractionMatch[4].toLowerCase();
|
||||
if (knownUnits.includes(candidateUnit)) {
|
||||
return {
|
||||
quantity,
|
||||
unit: candidateUnit,
|
||||
rawName: fractionMatch[5].trim(),
|
||||
note,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Försök matcha "kvantitet enhet namn" — t.ex. "400 g kycklingfilé" eller "2.5 dl grädde"
|
||||
const fullMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(\S+)\s+(.+)$/);
|
||||
if (fullMatch) {
|
||||
const candidateUnit = fullMatch[2].toLowerCase();
|
||||
// Validera att det andra ordet är en känd enhet
|
||||
if (knownUnits.includes(candidateUnit)) {
|
||||
return {
|
||||
quantity: parseNumber(fullMatch[1]),
|
||||
unit: candidateUnit,
|
||||
rawName: fullMatch[3].trim(),
|
||||
note,
|
||||
};
|
||||
}
|
||||
// Om inte känd enhet, behandla som "kvantitet namn" utan enhet
|
||||
return {
|
||||
quantity: parseNumber(fullMatch[1]),
|
||||
unit: 'st',
|
||||
rawName: fullMatch[2] + ' ' + fullMatch[3],
|
||||
note,
|
||||
};
|
||||
}
|
||||
|
||||
// Försök matcha "kvantitet namn" utan enhet — t.ex. "3 ägg"
|
||||
const noUnitMatch = main.match(/^(\d+(?:[.,]\d+)?)\s+(.+)$/);
|
||||
if (noUnitMatch) {
|
||||
return {
|
||||
quantity: parseNumber(noUnitMatch[1]),
|
||||
unit: 'st',
|
||||
rawName: noUnitMatch[2].trim(),
|
||||
note,
|
||||
};
|
||||
}
|
||||
|
||||
// Bara ett namn, ingen kvantitet — t.ex. "salt"
|
||||
return {
|
||||
quantity: 0,
|
||||
unit: '',
|
||||
rawName: main,
|
||||
note,
|
||||
};
|
||||
}
|
||||
|
||||
function parseNumber(s: string): number {
|
||||
return parseFloat(s.replace(',', '.'));
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Service
|
||||
// ============================================================================
|
||||
|
||||
@Injectable()
|
||||
export class RecipesService {
|
||||
parseMarkdown(dto: ParseMarkdownDto) {
|
||||
const parsed = parseRecipeMarkdown(dto.markdown);
|
||||
|
||||
return {
|
||||
name: parsed.name,
|
||||
description: parsed.description,
|
||||
instructions: parsed.instructions,
|
||||
ingredients: parsed.ingredients.map((ingredient: ParsedIngredient) => ({
|
||||
rawName: ingredient.rawName,
|
||||
quantity: ingredient.quantity,
|
||||
unit: ingredient.unit,
|
||||
note: ingredient.note,
|
||||
})),
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"rootDir": "./src",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": false,
|
||||
"forceConsistentCasingInFileNames": false,
|
||||
"noFallthroughCasesInSwitch": false
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user