feat: Implement PDF document import functionality with Markdown conversion
- Added DocumentImportModule, DocumentImportController, and DocumentImportService for handling PDF uploads. - Integrated pdf-parse for extracting text from PDF files. - Created PdfParser for parsing PDF documents and converting them to Markdown format. - Updated frontend to support file uploads via drag-and-drop and file input for PDF documents. - Modified API routes to handle document import requests. - Enhanced error handling for unsupported file types and file size limits. - Updated README to reflect new features and usage instructions.
This commit is contained in:
@@ -1,31 +1,30 @@
|
||||
# Microservice Importer
|
||||
|
||||
Standalone-tjänst för snabbimport av recept från webben. Extraherar receptdata (namn, beskrivning, ingredienser, instruktioner) från URL:er och konverterar till standardiserad Markdown-format.
|
||||
Standalone-tjänst för import och konvertering av dokument till Markdown-format. Primärt fokus på PDF-filer — textbaserad extraction samt OCR för skannade dokument. Webb-skrapning finns som sekundär funktion för referens och framtida integration.
|
||||
|
||||
**Kopplat till:** [`recipe-app`](../recipe-app/) — men kan användas helt oberoende.
|
||||
Kan användas helt oberoende som fristående microservice.
|
||||
|
||||
---
|
||||
|
||||
## Features
|
||||
|
||||
### Quick-Import via URL
|
||||
### Dokument-Import via PDF
|
||||
- **Textbaserad extraction** — Snabb och precis för digitalt skapade PDFs
|
||||
- **OCR-detektering** — Identifierar skannade bild-PDFs och rapporterar tydligt (OCR-stöd under utveckling)
|
||||
- **Automatisk konvertering:**
|
||||
- Titeln extraheras från filnamnet
|
||||
- Texten struktureras till Markdown-stycken
|
||||
- Metadata sparas (antal sidor, teckenantal, producent, skapelsedatum)
|
||||
- **Filstorlek:** Max 50 MB per fil
|
||||
- **Format:** Multipart/form-data uppladdning
|
||||
|
||||
### Webb-skrapning (sekundär funktion)
|
||||
- **Skrapa från ICA.se** — JSON-LD structured data + HTML-parsing
|
||||
- **Generisk parser** — Fallback för andra webbplatser
|
||||
- **Automatisk extraction:**
|
||||
- Receptnamn
|
||||
- Beskrivning (från meta-taggar eller JSON-LD)
|
||||
- Ingredienser med kvantitet, enhet och noter
|
||||
- Instruktioner/tillvägagångssätt
|
||||
- Källlänk (läggs till i footer)
|
||||
|
||||
### Stödd format
|
||||
- **Bråkmängder:** `1 1/2 dl`, `1/2 tsk`
|
||||
- **Enheter:** g, kg, ml, dl, msk, tsk, st, port, efter smak, förp, klyfta, m.fl.
|
||||
- **Parentetiska noter:** `2 dl grädde (vispgrädde)` → note sparas separat
|
||||
- **Markdown-output:** Strukturerat receptformat för vidare bearbetning
|
||||
- **Automatisk extraction:** Namn, beskrivning, ingredienser, instruktioner
|
||||
|
||||
### Parse-Markdown endpoint
|
||||
Tolka Markdown-format recepter utan databaskomplikationer. Användbar för API-integration utan lokal DB.
|
||||
Tolka Markdown-format utan databaskomplikationer. Användbar för API-integration utan lokal DB.
|
||||
|
||||
---
|
||||
|
||||
@@ -36,47 +35,53 @@ Tolka Markdown-format recepter utan databaskomplikationer. Användbar för API-i
|
||||
|
||||
```
|
||||
src/
|
||||
├── app.module.ts # Root module (Quick-import + Recipes)
|
||||
├── main.ts # Startpunkt
|
||||
├── app.module.ts # Root module
|
||||
├── main.ts # Startpunkt
|
||||
├── common/
|
||||
│ ├── filters/
|
||||
│ │ └── global-exception.filter.ts # Centraliserad felhantering (svenska meddelanden)
|
||||
│ │ └── global-exception.filter.ts # Centraliserad felhantering (svenska meddelanden)
|
||||
│ └── utils/
|
||||
│ └── normalize-name.ts # Namnormalisering (åäö-handling)
|
||||
├── quick-import/ # URL-scraping & parsing
|
||||
│ ├── quick-import.controller.ts # POST /api/quick-import
|
||||
│ ├── quick-import.service.ts # Scraping-logik, parser-selection
|
||||
│ └── normalize-name.ts # Namnormalisering (åäö-handling)
|
||||
├── document-import/ # PDF-import & konvertering (primär funktion)
|
||||
│ ├── document-import.controller.ts # POST /api/document-import
|
||||
│ ├── document-import.service.ts # Filvalidering, parser-routing
|
||||
│ ├── document-import.module.ts
|
||||
│ └── parsers/
|
||||
│ ├── document.parser.ts # Abstrakt bas-klass
|
||||
│ └── pdf.parser.ts # PDF-extraction via pdf-parse
|
||||
├── quick-import/ # Webb-skrapning (sekundär funktion)
|
||||
│ ├── quick-import.controller.ts # POST /api/quick-import
|
||||
│ ├── quick-import.service.ts # Scraping-logik, parser-selection
|
||||
│ ├── quick-import.module.ts
|
||||
│ └── parsers/
|
||||
│ ├── base.parser.ts # Abstrakt bas-klass
|
||||
│ ├── ica.parser.ts # ICA.se-specifik (JSON-LD prioritet)
|
||||
│ └── generic.parser.ts # Fallback för alla webbplatser
|
||||
└── recipes/ # Markdown-tolkning (SOM DB!)
|
||||
├── recipes.controller.ts # POST /api/recipes/parse-markdown
|
||||
├── recipes.service.ts # Enkel markdown-parsing
|
||||
│ ├── base.parser.ts # Abstrakt bas-klass
|
||||
│ ├── ica.parser.ts # ICA.se-specifik (JSON-LD prioritet)
|
||||
│ └── generic.parser.ts # Fallback för alla webbplatser
|
||||
└── recipes/ # Markdown-tolkning (utan DB)
|
||||
├── recipes.controller.ts # POST /api/recipes/parse-markdown
|
||||
├── recipes.service.ts
|
||||
├── recipes.module.ts
|
||||
└── dto/
|
||||
└── parse-markdown.dto.ts # Validering
|
||||
└── parse-markdown.dto.ts
|
||||
```
|
||||
|
||||
**Viktigt:** Backend har _INGEN_ databaskonfiguration (@prisma/client inte installerat).
|
||||
**Viktigt:** Backend har _INGEN_ databaskonfiguration — stateless service.
|
||||
|
||||
### Frontend (Next.js 16.2, React 19.2, TypeScript 5.4.5)
|
||||
**Port:** 3000
|
||||
|
||||
```
|
||||
app/
|
||||
├── layout.tsx # Root layout
|
||||
├── page.tsx # Home page
|
||||
├── Navigation.tsx # Minimal nav (Home + Import)
|
||||
├── import/page.tsx # PRIMARY FEATURE — Import UI
|
||||
│ └── Inmatningsfält för URL/filsökväg
|
||||
│ └── Visa resultat i realtid
|
||||
├── layout.tsx # Root layout
|
||||
├── page.tsx # Home page
|
||||
├── Navigation.tsx # Minimal nav (Home + Import)
|
||||
├── import/page.tsx # PRIMARY FEATURE — Drag-and-drop filuppladdning
|
||||
├── api/
|
||||
│ └── parse-markdown-proxy/route.ts # API proxy till backend
|
||||
│ ├── document-import-proxy/route.ts # Proxy: multipart/form-data → backend
|
||||
│ └── parse-markdown-proxy/route.ts # Proxy: Markdown-tolkning → backend
|
||||
└── lib/
|
||||
├── api.ts # Centraliserad API-access (fetchJson)
|
||||
└── error-handler.ts # parseErrorResponse (svenska meddelanden)
|
||||
├── api.ts # Centraliserad API-access (fetchJson)
|
||||
└── error-handler.ts # parseErrorResponse (svenska meddelanden)
|
||||
```
|
||||
|
||||
---
|
||||
@@ -124,9 +129,53 @@ docker compose down
|
||||
|
||||
## API-dokumentation
|
||||
|
||||
### POST /api/document-import
|
||||
|
||||
**Syfte:** Ladda upp en PDF och returnera Markdown-text
|
||||
|
||||
**Request:** `multipart/form-data` med fältet `file` (PDF, max 50 MB)
|
||||
|
||||
```bash
|
||||
curl -X POST http://localhost:3001/api/document-import \
|
||||
-F "file=@dokument.pdf"
|
||||
```
|
||||
|
||||
**Response (Success 200):**
|
||||
```json
|
||||
{
|
||||
"markdown": "# Dokument\n\nInnehåll från PDFen...",
|
||||
"title": "Dokument",
|
||||
"documentType": "pdf",
|
||||
"metadata": {
|
||||
"pageCount": 5,
|
||||
"characterCount": 12400,
|
||||
"producer": "Adobe PDF Library",
|
||||
"creationDate": "D:20260101120000"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Error 400):**
|
||||
```json
|
||||
{
|
||||
"statusCode": 400,
|
||||
"message": "Kunde inte läsa dokumentet: PDFen verkar vara en skannad bild utan textlager.",
|
||||
"timestamp": "2026-04-12T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Error-scenarier:**
|
||||
- `400` — Ingen fil bifogad
|
||||
- `400` — Fel filtyp (ej PDF)
|
||||
- `400` — Filen överstiger 50 MB
|
||||
- `400` — Lösenordsskyddad PDF
|
||||
- `400` — Skannad bild-PDF utan textlager (OCR ej implementerat ännu)
|
||||
|
||||
---
|
||||
|
||||
### POST /api/quick-import
|
||||
|
||||
**Syfte:** Skrapa webbsida och returnera Markdown-recept
|
||||
**Syfte:** Skrapa webbsida och returnera Markdown (sekundär funktion)
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
@@ -138,51 +187,37 @@ docker compose down
|
||||
**Response (Success 200):**
|
||||
```json
|
||||
{
|
||||
"markdown": "# Köttfärssås\n\nEn klassisk...\n\n## Ingredienser\n- 500 g köttfärs\n...",
|
||||
"markdown": "# Köttfärssås\n\nEn klassisk...",
|
||||
"source": "ica"
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Error 400/503):**
|
||||
```json
|
||||
{
|
||||
"statusCode": 400,
|
||||
"message": "Kunde inte hämta recept: HTTP 404. Kontrollera att länken är korrekt och försök igen.",
|
||||
"timestamp": "2026-04-12T10:30:00.000Z"
|
||||
}
|
||||
```
|
||||
|
||||
**Error-scenarier:**
|
||||
- `400` — Tomt input, inte en URL, inte en filsökväg
|
||||
- `400` — HTML-parsing misslyckades (receptnamn/ingredienser inte hittade)
|
||||
- `503` — Network-fel (t.ex. webbsidan nåbar, men HTTP 500 från server)
|
||||
- `400` — Tomt input, inte en URL
|
||||
- `400` — HTML-parsing misslyckades
|
||||
- `503` — Network-fel
|
||||
|
||||
---
|
||||
|
||||
### POST /api/recipes/parse-markdown
|
||||
|
||||
**Syfte:** Tolka Markdown-receptformat utan database
|
||||
**Syfte:** Tolka Markdown-format utan databas
|
||||
|
||||
**Request:**
|
||||
```json
|
||||
{
|
||||
"markdown": "# Receptnamn\n\n## Ingredienser\n- 500 g köttfärs\n\n## Tillvägagångssätt\nStek löken..."
|
||||
"markdown": "# Titel\n\n## Ingredienser\n- 500 g mjöl\n\n## Tillvägagångssätt\nBlanda..."
|
||||
}
|
||||
```
|
||||
|
||||
**Response (Success 200):**
|
||||
```json
|
||||
{
|
||||
"name": "Receptnamn",
|
||||
"name": "Titel",
|
||||
"description": "",
|
||||
"instructions": "Stek löken...",
|
||||
"instructions": "Blanda...",
|
||||
"ingredients": [
|
||||
{
|
||||
"rawName": "köttfärs",
|
||||
"quantity": 500,
|
||||
"unit": "g",
|
||||
"note": null
|
||||
}
|
||||
{ "rawName": "mjöl", "quantity": 500, "unit": "g", "note": null }
|
||||
]
|
||||
}
|
||||
```
|
||||
@@ -191,116 +226,40 @@ docker compose down
|
||||
|
||||
## Parser-arkitektur
|
||||
|
||||
### Bas-parser (`base.parser.ts`)
|
||||
### Dokument-parsers (`document-import/parsers/`)
|
||||
|
||||
Abstrakt klass som alla parsers ärver från. Innehåller gemensam parsing-logik:
|
||||
Abstrakt bas `DocumentParser` som alla dokumenttyp-specifika parsers ärver från:
|
||||
|
||||
```typescript
|
||||
abstract class RecipeParser {
|
||||
abstract canHandle(url: string): boolean;
|
||||
abstract parse(html: string): ParsedRecipe;
|
||||
|
||||
protected parseIngredientLine(line: string): ParsedIngredient | null {
|
||||
// Shared logic för ingrediensparsning
|
||||
abstract class DocumentParser {
|
||||
abstract parse(buffer: Buffer, filename: string): Promise<ParsedDocument>;
|
||||
|
||||
protected textToMarkdown(text: string, title: string): string {
|
||||
// Slår ihop sammanhängande textrader, bevarar stycken
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**parseIngredientLine-funktionen hanterar:**
|
||||
- `500 g köttfärs` → `{quantity: 500, unit: "g", name: "köttfärs"}`
|
||||
- `1 1/2 dl grädde (vispgrädde)` → `{quantity: 1.5, unit: "dl", name: "grädde", note: "vispgrädde"}`
|
||||
- `3 ägg` → `{quantity: 3, unit: "st", name: "ägg"}`
|
||||
- `salt` → `{quantity: 0, unit: "", name: "salt"}`
|
||||
### PDF Parser (`pdf.parser.ts`)
|
||||
|
||||
**Kända enheter:**
|
||||
```
|
||||
Vikt: g, kg, hg, mg
|
||||
Volym: ml, dl, l, tl
|
||||
Portioner: tsk, msk, krm
|
||||
Övriga: st, port, burk, förp, paket, pris, portion, matsked, tesked, efter smak, klyfta
|
||||
```
|
||||
|
||||
### ICA Parser (`ica.parser.ts`)
|
||||
|
||||
Optimerad för ICA.se receptsidor.
|
||||
Hanterar textbaserade PDFs via `pdf-parse`.
|
||||
|
||||
**Strategi:**
|
||||
1. Försök extrahera JSON-LD structured data (prioritet)
|
||||
2. Fallback: HTML-regex parsing
|
||||
1. Extrahera text med `pdf-parse`
|
||||
2. Kontrollera att text hittades (annars: skannad PDF-varning)
|
||||
3. Konvertera text → Markdown via `textToMarkdown()`
|
||||
4. Returnera titel (från filnamn), innehåll och metadata
|
||||
|
||||
**JSON-LD mål:**
|
||||
- Receptnamn från `@type === "Recipe"` → `.name`
|
||||
- Beskrivning från `.description`
|
||||
- Ingredienser från `.recipeIngredient[]`
|
||||
- Instruktioner från `.recipeInstructions[]`
|
||||
**Felhantering:**
|
||||
- Lösenordsskyddade PDFs → tydligt felmeddelande
|
||||
- Skannade bild-PDFs → informativt felmeddelande om OCR
|
||||
|
||||
**HTML-fallback:**
|
||||
- Titel: `<h1>` eller `<meta property="og:title">`
|
||||
- Beskrivning: `<meta name="description">`
|
||||
- Ingredienser: `<li class="...ingredient...">` regex
|
||||
- Instruktioner: `<div class="...instruction...">` regex
|
||||
### Webb-parsers (`quick-import/parsers/`)
|
||||
|
||||
### Generic Parser (`generic.parser.ts`)
|
||||
Abstrakt bas `RecipeParser` med `canHandle(url)` + `parse(html)`. Implementationer:
|
||||
|
||||
Fallback-parser för alla okända webbplatser.
|
||||
|
||||
**Strategi:**
|
||||
1. Försök JSON-LD structured data (alla webbplatser kan ha detta)
|
||||
2. Fallback: Permissiv HTML-parsing
|
||||
|
||||
**HTML-parsing försöker flera selectors:**
|
||||
- Ingredienser: `<li>`, `<div class="ingredient">`, `<p class="ingredient">`
|
||||
- Instruktioner: `<div class="instruction">`, `<ol>` listor
|
||||
- Titel: `<h1>`, `<meta property="og:title">`, `<title>`
|
||||
|
||||
---
|
||||
|
||||
## Markdown-format och parsing
|
||||
|
||||
### Input-format
|
||||
|
||||
```markdown
|
||||
# Receptnamn
|
||||
|
||||
Valfri beskrivning av receptet (1+ stycken).
|
||||
|
||||
## Ingredienser
|
||||
- 500 g köttfärs
|
||||
- 1 st lök
|
||||
- 2.5 msk tomatpuré
|
||||
- 1 dl grädde (vispgrädde)
|
||||
- salt
|
||||
|
||||
## Tillvägagångssätt
|
||||
Steg 1: Stek löken i smör.
|
||||
Steg 2: Tillsätt köttfärsen och stek tills den är genomstekt.
|
||||
```
|
||||
|
||||
### Parsningsregler
|
||||
|
||||
| Element | Tolkning |
|
||||
|---------|----------|
|
||||
| `# Rubrik` | Receptnamn (första H1) |
|
||||
| Text mellan H1 och `## Ingredienser` | Beskrivning (flera rader OK, valfritt) |
|
||||
| `## Ingredienser` | Ingredient-markerare (case-insensitive) |
|
||||
| `- ANTAL ENHET NAMN` | Ingrediens med alla delar |
|
||||
| `- ANTAL NAMN` | Ingrediens utan enhet (unit → "st") |
|
||||
| `- NAMN` | Ingrediens utan kvantitet (quantity → 0) |
|
||||
| `(text i parentes)` | Ingrediensnot (sparas separat) |
|
||||
| `## Tillvägagångssätt` / `## Instruktioner` / `## Tillagning` | Instruktions-markerare |
|
||||
| Text under instruktioner | Tillagningssteg (flera rader OK) |
|
||||
|
||||
**Exempel:**
|
||||
```
|
||||
Input: "- 1,5 dl grädde (vispgrädde)"
|
||||
Output: {quantity: 1.5, unit: "dl", rawName: "grädde", note: "vispgrädde"}
|
||||
|
||||
Input: "- 3 ägg"
|
||||
Output: {quantity: 3, unit: "st", rawName: "ägg", note: null}
|
||||
|
||||
Input: "- salt"
|
||||
Output: {quantity: 0, unit: "", rawName: "salt", note: null}
|
||||
```
|
||||
- **`ica.parser.ts`** — ICA.se, prioriterar JSON-LD structured data
|
||||
- **`generic.parser.ts`** — Fallback för alla webbplatser, försöker JSON-LD sedan HTML
|
||||
|
||||
---
|
||||
|
||||
@@ -357,47 +316,36 @@ NEXT_PUBLIC_API_URL_INTERNAL=http://importer-api:3001
|
||||
|
||||
---
|
||||
|
||||
## Integration med Recipe App
|
||||
## Integration med andra tjänster
|
||||
|
||||
Recipe App kan anropa denna microservice som extern API:
|
||||
Microservicen kan anropas som extern API från andra applikationer:
|
||||
|
||||
```typescript
|
||||
const IMPORTER_URL = process.env.MICROSERVICE_IMPORTER_URL || 'http://localhost:3001';
|
||||
|
||||
const response = await fetch(`${IMPORTER_URL}/api/quick-import`, {
|
||||
// PDF-konvertering
|
||||
const formData = new FormData();
|
||||
formData.append('file', pdfFile);
|
||||
|
||||
const response = await fetch(`${IMPORTER_URL}/api/document-import`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ input: url })
|
||||
body: formData,
|
||||
});
|
||||
|
||||
const { markdown, source } = await response.json();
|
||||
|
||||
// Sedan kan recipe-app:s parse-markdown-endpoint
|
||||
// använda Markdown för DB-matchning mot produkter
|
||||
const { markdown, title, metadata } = await response.json();
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Fil-struktur & Koddelning
|
||||
|
||||
Följande filer är **identiska** mellan recipe-app och microservice-importer:
|
||||
|
||||
- `backend/src/quick-import/parsers/base.parser.ts`
|
||||
- `backend/src/quick-import/parsers/ica.parser.ts`
|
||||
- `backend/src/quick-import/parsers/generic.parser.ts`
|
||||
- `backend/src/common/filters/global-exception.filter.ts`
|
||||
- `backend/src/common/utils/normalize-name.ts`
|
||||
|
||||
**Framtida improvement:** Dessa kan förpackas som separat npm-paket för bättre koddelning och versionering.
|
||||
|
||||
---
|
||||
|
||||
## Tekniska detaljer
|
||||
|
||||
### Backend Stack
|
||||
- **NestJS** 10.3 — REST API & modular architecture
|
||||
- **TypeScript** 5.4.5 — Type safety
|
||||
- **Node.js** 22.x — Runtime
|
||||
- **pdf-parse** 1.1.x — PDF text extraction
|
||||
- **tesseract.js** 5.x — OCR (förberett, ej aktivt ännu)
|
||||
- **multer** — Multipart file upload handling
|
||||
- **class-validator** — DTO validation (svenska felmeddelanden)
|
||||
- **Ingen databas** — Stateless service
|
||||
|
||||
@@ -405,34 +353,34 @@ Följande filer är **identiska** mellan recipe-app och microservice-importer:
|
||||
- **Next.js** 16.2 — React framework (App Router)
|
||||
- **React** 19.2 — UI components
|
||||
- **TypeScript** 5.4.5
|
||||
- **Inline CSS** — Minimal styling, no framework dependencies
|
||||
- **Inline CSS** — Minimal styling, inga framework-beroenden
|
||||
|
||||
### Error Handling
|
||||
- Centraliserad `GlobalExceptionFilter` (svenska meddelanden)
|
||||
- Konsistent JSON-responsformat
|
||||
- Konsistent JSON-responsformat: `{ statusCode, message, timestamp, path }`
|
||||
- HTTP status codes: 200, 400, 503
|
||||
|
||||
---
|
||||
|
||||
## Framtida utbyggnader
|
||||
|
||||
- [ ] PDF-import (stubben redan på plats)
|
||||
- [ ] Stöd för fler webbplatser (mat.se, kokaihop.se, etc.)
|
||||
- [ ] Caching av parsed recept
|
||||
- [ ] Rate limiting för scraping
|
||||
- [ ] WebSocket support för real-time parsing
|
||||
- [ ] GraphQL endpoint
|
||||
- [x] PDF-import — textbaserad extraction
|
||||
- [ ] OCR för skannade bild-PDFs (Tesseract.js förberett)
|
||||
- [ ] Word (.docx) import
|
||||
- [ ] Batch-processing (flera filer samtidigt)
|
||||
- [ ] Strukturerad data-extraction (tabeller, listor)
|
||||
- [ ] Stöd för fler webbplatser i webb-skraparen (mat.se, kokaihop.se, etc.)
|
||||
- [ ] Caching av konverterade dokument
|
||||
- [ ] Rate limiting
|
||||
|
||||
---
|
||||
|
||||
## Licens
|
||||
|
||||
Samma som Recipe App — se [main repo](../recipe-app/)
|
||||
MIT
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
Relaterade projekt:
|
||||
- **Recipe App** — [`recipe-app`](../recipe-app/) (Full platform med databas + quick-import integrerad)
|
||||
- **Git Repo** — Gitea på `192.168.50.2:2222/nilsjohan/microservice-importer`
|
||||
|
||||
Reference in New Issue
Block a user