diff --git a/README.md b/README.md index 078119a..540d71a 100644 --- a/README.md +++ b/README.md @@ -1,37 +1,38 @@ # Recipe Importer -Docker container for importing recipes from Hungarian websites into [Mealie](https://mealie.io/) (Tandoor support planned). +Docker container for importing recipes from Hungarian websites into [Mealie](https://mealie.io/) and [Tandoor Recipes](https://tandoor.dev/). -**Problem**: Mealie's built-in URL import cannot parse ingredients and instructions from Hungarian recipe sites like mindmegette.hu — it imports the title and image but shows "Could not detect ingredients / instructions". +**Problem**: Mealie's and Tandoor's built-in URL import cannot parse ingredients and instructions from Hungarian recipe sites like mindmegette.hu. -**Solution**: This container provides a web UI that scrapes Hungarian recipe pages with site-specific parsers, lets you review and edit the extracted data, then pushes it to Mealie via its REST API. +**Solution**: This container provides a web UI that scrapes Hungarian recipe pages with site-specific parsers, lets you review and edit the extracted data, then pushes it to Mealie and/or Tandoor via their REST APIs. ## Architecture ``` -┌─────────────────────────────────────────────────┐ -│ recipe-importer container (:8000) │ -│ │ -│ Flask + Gunicorn │ -│ ├── /settings → Configure Mealie connection │ -│ ├── /import → Paste URL, scrape, review │ -│ ├── /scrape → AJAX: parse recipe HTML │ -│ ├── /send → AJAX: push to Mealie API │ -│ └── /health → Health check │ -│ │ -│ Modules: │ -│ ├── app/config.py → JSON config persistence │ -│ ├── app/scraper.py → Site-specific parsers │ -│ └── app/mealie.py → Mealie REST API client │ -└───────────────────┬─────────────────────────────┘ - │ HTTP - ▼ - ┌──────────────────┐ - │ Mealie instance │ - │ POST /api/recipes│ - │ PATCH /api/... │ - │ PUT /api/.../img │ - └──────────────────┘ +┌──────────────────────────────────────────────────────┐ +│ recipe-importer container (:8000) │ +│ │ +│ Flask + Gunicorn │ +│ ├── /settings → Configure Mealie & Tandoor │ +│ ├── /import → Paste URL, scrape, review │ +│ ├── /scrape → AJAX: parse recipe HTML │ +│ ├── /send → AJAX: push to Mealie API │ +│ ├── /send-tandoor → AJAX: push to Tandoor API │ +│ └── /health → Health check │ +│ │ +│ Modules: │ +│ ├── app/config.py → JSON config persistence │ +│ ├── app/scraper.py → Site-specific parsers │ +│ ├── app/mealie.py → Mealie REST API client │ +│ └── app/tandoor.py → Tandoor REST API client │ +└───────────────────┬──────────────┬───────────────────┘ + │ HTTP │ HTTP + ▼ ▼ + ┌──────────────┐ ┌───────────────┐ + │ Mealie │ │ Tandoor │ + │ POST /api/.. │ │ POST /api/.. │ + │ PUT /api/.. │ │ PUT /api/.. │ + └──────────────┘ └───────────────┘ ``` ## Supported Sites @@ -76,6 +77,19 @@ The importer uses the Mealie REST API: Authentication uses a long-lived API token (Bearer header), created in Mealie at *Profile → API Tokens*. +## Tandoor API Integration + +The importer uses the Tandoor REST API: + +1. **POST** `/api/recipe/` — create the full recipe in one call (name, description, source_url, steps with nested ingredients) +2. **PUT** `/api/recipe/{id}/image/` — upload the recipe image + +**Step-based ingredients**: Tandoor nests ingredients inside steps. All ingredients are attached to the first step. Units and foods are auto-created by name (no separate resolution needed). Ingredient groups use `is_header: true` on a header entry. + +**Duplicate detection**: Before import, searches Tandoor by title and checks the `source_url` field to detect already-imported recipes. + +Authentication uses an API token (Bearer header), created in Tandoor at *Settings → API Browser → Auth Token*. + ## Configuration All settings are persisted to `/data/config.json` (mounted as a Docker volume). @@ -84,6 +98,8 @@ All settings are persisted to `/data/config.json` (mounted as a Docker volume). |---------|-------------| | `mealie_url` | Full URL to Mealie instance (e.g. `https://mealie.example.com`) | | `mealie_api_key` | Mealie API token | +| `tandoor_url` | Full URL to Tandoor instance (e.g. `https://recipes.example.com`) | +| `tandoor_api_key` | Tandoor API token | ## Deployment @@ -92,7 +108,7 @@ All settings are persisted to `/data/config.json` (mounted as a Docker volume). ```yaml services: recipe-importer: - image: gitea.dooplex.hu/admin/recipe-importer:0.1.7 + image: gitea.dooplex.hu/admin/recipe-importer:0.1.9 container_name: recipe-importer restart: unless-stopped ports: @@ -101,6 +117,8 @@ services: - recipe-data:/data environment: - SECRET_KEY=change-me-in-production + - MEALIE_INTERNAL_URL=http://mealie:9000 + - TANDOOR_INTERNAL_URL=http://tandoor:8080 volumes: recipe-data: @@ -114,6 +132,7 @@ volumes: | `DATA_DIR` | `/data` | Persistent storage path | | `VERSION` | `dev` | Shown in the UI navbar | | `MEALIE_INTERNAL_URL` | *(empty)* | Docker-internal Mealie URL (e.g. `http://mealie:9000`) to avoid Cloudflare hairpin | +| `TANDOOR_INTERNAL_URL` | *(empty)* | Docker-internal Tandoor URL (e.g. `http://tandoor:8080`) to avoid Cloudflare hairpin | ## Building @@ -128,10 +147,10 @@ cd ~/build/recipe-importer The UI is in Hungarian and uses a dark theme. The workflow is: -1. **Settings** (`/settings`) — Enter Mealie URL and API key, test connection +1. **Settings** (`/settings`) — Configure Mealie and/or Tandoor connection (URL + API key), test each connection 2. **Import** (`/import`) — Paste a recipe URL, click "Beolvasás" (Scrape) 3. **Review** — Edit structured ingredients (4-column: quantity, unit, food, note), add/remove ingredient groups, edit instructions -4. **Send** — Click "Importálás Mealie-be" to push to Mealie +4. **Send** — Click "Importálás Mealie-be" and/or "Importálás Tandoor-ba" to push to your configured services ## Tech Stack diff --git a/app/config.py b/app/config.py index 4eec05b..b12034a 100644 --- a/app/config.py +++ b/app/config.py @@ -1,4 +1,4 @@ -"""Configuration management — persists Mealie connection settings to a JSON file.""" +"""Configuration management — persists Mealie and Tandoor connection settings to a JSON file.""" import json import os @@ -7,13 +7,16 @@ from pathlib import Path DATA_DIR = Path(os.environ.get("DATA_DIR", "/data")) CONFIG_FILE = DATA_DIR / "config.json" -# Optional internal URL for Docker-to-Docker API calls (avoids Cloudflare hairpin). -# The user-facing mealie_url is still used for browser links. +# Optional internal URLs for Docker-to-Docker API calls (avoids Cloudflare hairpin). +# The user-facing URLs are still used for browser links. MEALIE_INTERNAL_URL = os.environ.get("MEALIE_INTERNAL_URL", "").strip().rstrip("/") +TANDOOR_INTERNAL_URL = os.environ.get("TANDOOR_INTERNAL_URL", "").strip().rstrip("/") _DEFAULTS = { "mealie_url": "", "mealie_api_key": "", + "tandoor_url": "", + "tandoor_api_key": "", } diff --git a/app/main.py b/app/main.py index 09f8e60..9327bc0 100644 --- a/app/main.py +++ b/app/main.py @@ -8,6 +8,7 @@ from flask import Flask, render_template, request, redirect, url_for, flash, jso from app import config from app.scraper import scrape from app.mealie import MealieClient +from app.tandoor import TandoorClient app = Flask( __name__, @@ -28,7 +29,9 @@ VERSION = os.environ.get("VERSION", "dev") def index(): """Redirect to the import page (or settings if not configured).""" cfg = config.load() - if not cfg.get("mealie_url") or not cfg.get("mealie_api_key"): + has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key")) + has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key")) + if not has_mealie and not has_tandoor: return redirect(url_for("settings")) return redirect(url_for("import_page")) @@ -41,6 +44,8 @@ def settings(): if request.method == "POST": cfg["mealie_url"] = request.form.get("mealie_url", "").strip().rstrip("/") cfg["mealie_api_key"] = request.form.get("mealie_api_key", "").strip() + cfg["tandoor_url"] = request.form.get("tandoor_url", "").strip().rstrip("/") + cfg["tandoor_api_key"] = request.form.get("tandoor_api_key", "").strip() config.save(cfg) flash("Beállítások mentve.", "success") return redirect(url_for("settings")) @@ -63,14 +68,32 @@ def settings_test(): return jsonify({"ok": False, "error": str(exc)}) +@app.route("/settings/test-tandoor", methods=["POST"]) +def settings_test_tandoor(): + """AJAX endpoint — test Tandoor connection using form values.""" + url = (request.form.get("tandoor_url") or "").strip().rstrip("/") + key = (request.form.get("tandoor_api_key") or "").strip() + if not url or not key: + return jsonify({"ok": False, "error": "Nincs megadva Tandoor URL vagy API kulcs."}) + try: + client = TandoorClient(url, key, api_url=config.TANDOOR_INTERNAL_URL) + info = client.test_connection() + return jsonify({"ok": True, "data": info}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc)}) + + @app.route("/import", methods=["GET"]) def import_page(): """Show the import form.""" cfg = config.load() - if not cfg.get("mealie_url") or not cfg.get("mealie_api_key"): - flash("Először állítsd be a Mealie kapcsolatot.", "warning") + has_mealie = bool(cfg.get("mealie_url") and cfg.get("mealie_api_key")) + has_tandoor = bool(cfg.get("tandoor_url") and cfg.get("tandoor_api_key")) + if not has_mealie and not has_tandoor: + flash("Először állíts be legalább egy szolgáltatást (Mealie vagy Tandoor).", "warning") return redirect(url_for("settings")) - return render_template("import.html", cfg=cfg, version=VERSION) + return render_template("import.html", cfg=cfg, version=VERSION, + has_mealie=has_mealie, has_tandoor=has_tandoor) @app.route("/scrape", methods=["POST"]) @@ -82,8 +105,9 @@ def scrape_url(): try: data = scrape(url) - # Check for duplicate in Mealie + # Check for duplicates in Mealie and Tandoor duplicate = None + tandoor_duplicate = None cfg = config.load() if cfg.get("mealie_url") and cfg.get("mealie_api_key"): try: @@ -92,8 +116,16 @@ def scrape_url(): duplicate = client.find_duplicate(url, data.get("title", "")) except Exception: pass # non-fatal + if cfg.get("tandoor_url") and cfg.get("tandoor_api_key"): + try: + client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], + api_url=config.TANDOOR_INTERNAL_URL) + tandoor_duplicate = client.find_duplicate(url, data.get("title", "")) + except Exception: + pass # non-fatal - return jsonify({"ok": True, "data": data, "duplicate": duplicate}) + return jsonify({"ok": True, "data": data, "duplicate": duplicate, + "tandoor_duplicate": tandoor_duplicate}) except Exception as exc: return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}) @@ -119,6 +151,26 @@ def send_to_mealie(): return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}) +@app.route("/send-tandoor", methods=["POST"]) +def send_to_tandoor(): + """AJAX — send edited recipe data to Tandoor.""" + cfg = config.load() + if not cfg.get("tandoor_url") or not cfg.get("tandoor_api_key"): + return jsonify({"ok": False, "error": "Tandoor nincs beállítva."}) + + payload = request.get_json(silent=True) + if not payload: + return jsonify({"ok": False, "error": "Érvénytelen kérés."}) + + try: + client = TandoorClient(cfg["tandoor_url"], cfg["tandoor_api_key"], + api_url=config.TANDOOR_INTERNAL_URL) + result = client.create_recipe(payload) + return jsonify({"ok": True, "id": result["id"], "url": result["url"]}) + except Exception as exc: + return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}) + + # --------------------------------------------------------------------------- # Health # --------------------------------------------------------------------------- diff --git a/app/tandoor.py b/app/tandoor.py new file mode 100644 index 0000000..c2685cd --- /dev/null +++ b/app/tandoor.py @@ -0,0 +1,229 @@ +"""Tandoor API client — creates recipes and uploads images.""" + +import io +import re +import requests + + +class TandoorClient: + """Thin wrapper around the Tandoor REST API.""" + + def __init__(self, base_url: str, api_key: str, api_url: str = ""): + self.base_url = base_url.rstrip("/") + self.api_url = api_url.rstrip("/") if api_url else self.base_url + self.session = requests.Session() + self.session.headers.update({ + "Authorization": f"Bearer {api_key}", + "Accept": "application/json", + }) + + # ------------------------------------------------------------------ + # Public + # ------------------------------------------------------------------ + + def test_connection(self) -> dict: + """Return Tandoor version info or raise on failure.""" + # Try the recipe list endpoint as a connection test + r = self.session.get( + f"{self.api_url}/api/recipe/", + params={"limit": 1}, + timeout=10, + ) + r.raise_for_status() + + # Try to get version from openapi endpoint + version = "unknown" + try: + r2 = self.session.get(f"{self.api_url}/openapi/", timeout=10) + if r2.ok: + # Parse version from YAML: "version: 1.5.26" + m = re.search(r"version:\s*['\"]?([0-9][0-9a-z._-]*)", r2.text) + if m: + version = m.group(1) + except Exception: + pass + + return {"version": version} + + def find_duplicate(self, url: str, title: str = "") -> dict | None: + """Check if a recipe with this source URL already exists.""" + if not url and not title: + return None + search = title or url.split("/")[-1].replace("-", " ") + r = self.session.get( + f"{self.api_url}/api/recipe/", + params={"query": search, "limit": 20}, + timeout=10, + ) + if not r.ok: + return None + for item in r.json().get("results", []): + source = item.get("source_url") or "" + desc = item.get("description") or "" + if source == url or url in desc: + return { + "id": item["id"], + "name": item["name"], + "url": f"{self.base_url}/view/recipe/{item['id']}", + } + return None + + def create_recipe(self, recipe: dict) -> dict: + """Create a recipe in Tandoor from a scraper result dict. + + Returns {"id": int, "url": str}. + """ + payload = self._build_payload(recipe) + r = self.session.post( + f"{self.api_url}/api/recipe/", + json=payload, + timeout=15, + ) + r.raise_for_status() + data = r.json() + recipe_id = data["id"] + + # Upload image if available + image_url = recipe.get("image_url") + if image_url: + try: + self._upload_image(recipe_id, image_url) + except Exception: + pass # non-fatal + + return { + "id": recipe_id, + "url": f"{self.base_url}/view/recipe/{recipe_id}", + } + + # ------------------------------------------------------------------ + # Internal + # ------------------------------------------------------------------ + + def _build_payload(self, recipe: dict) -> dict: + description = recipe.get("description", "") + original_url = recipe.get("original_url", "") + if original_url and original_url not in description: + description = f"{description}\n\n{original_url}".strip() + + # Build ingredients list + ingredients = [] + order = 0 + pending_group = "" + for item in recipe.get("ingredients", []): + if isinstance(item, dict): + if "group" in item and "food" not in item: + # Group header + ingredients.append({ + "food": None, + "unit": None, + "amount": 0, + "note": item["group"], + "is_header": True, + "no_amount": True, + "order": order, + }) + order += 1 + else: + ingredients.append(self._build_ingredient(item, order)) + order += 1 + + # Build steps — all ingredients on step 0 + instructions = recipe.get("instructions", []) + steps = [] + for i, text in enumerate(instructions): + steps.append({ + "instruction": text, + "ingredients": ingredients if i == 0 else [], + "order": i, + }) + + if not instructions and ingredients: + steps.append({ + "instruction": "", + "ingredients": ingredients, + "order": 0, + }) + + return { + "name": recipe["title"], + "description": description, + "source_url": original_url, + "steps": steps, + "servings": 1, + } + + def _build_ingredient(self, item: dict, order: int) -> dict: + """Build a Tandoor ingredient from a structured dict.""" + qty_str = str(item.get("quantity", "")).strip() + unit_str = item.get("unit", "").strip() + food_str = item.get("food", "").strip() + extra = item.get("extra", "").strip() + + has_structured = bool(qty_str or unit_str) + + if has_structured and food_str: + qty = 0 + try: + qty = float(qty_str.replace(",", ".")) + except (ValueError, TypeError): + pass + + return { + "food": {"name": food_str}, + "unit": {"name": unit_str} if unit_str else None, + "amount": qty, + "note": extra, + "is_header": False, + "no_amount": False, + "order": order, + } + else: + # No quantity/unit — food-only or note + note = food_str + if extra: + note = f"{note} ({extra})" if note else extra + if food_str: + return { + "food": {"name": food_str}, + "unit": None, + "amount": 0, + "note": extra, + "is_header": False, + "no_amount": True, + "order": order, + } + else: + return { + "food": None, + "unit": None, + "amount": 0, + "note": note, + "is_header": False, + "no_amount": True, + "order": order, + } + + def _upload_image(self, recipe_id: int, image_url: str): + """Download image from *image_url* and upload it to the recipe.""" + img_resp = requests.get(image_url, timeout=30, headers={ + "User-Agent": "RecipeImporter/1.0", + }) + img_resp.raise_for_status() + + content_type = img_resp.headers.get("Content-Type", "image/jpeg") + ext = "jpg" + if "png" in content_type: + ext = "png" + elif "webp" in content_type: + ext = "webp" + + files = { + "image": (f"recipe.{ext}", io.BytesIO(img_resp.content), content_type), + } + r = self.session.put( + f"{self.api_url}/api/recipe/{recipe_id}/image/", + files=files, + timeout=30, + ) + r.raise_for_status() diff --git a/app/templates/import.html b/app/templates/import.html index fd44881..6bed04f 100644 --- a/app/templates/import.html +++ b/app/templates/import.html @@ -180,9 +180,16 @@
+ Az API kulcsot a Tandoor-ban itt hozhatod létre: + Settings → API Browser → Auth Token +
+ +