This commit is contained in:
Andre
2026-03-29 20:52:20 +02:00
commit e832c4e7ce
29 changed files with 3730 additions and 0 deletions
+7
View File
@@ -0,0 +1,7 @@
OPENAI_API_KEY=sk-...
USDA_API_KEY=DEMO_KEY
TANDOOR_BASE_URL=http://localhost:8080
TANDOOR_API_TOKEN=
APP_HOST=0.0.0.0
APP_PORT=8000
TMP_DIR=/app/tmp
+20
View File
@@ -0,0 +1,20 @@
.env
.venv/
__pycache__/
*.pyc
*.pyo
*.pyd
*.egg-info/
dist/
build/
.eggs/
tmp/
*.mp3
*.wav
*.m4a
*.webm
.DS_Store
*.log
CLAUDE.md
.vscode/
.claude/
+30
View File
@@ -0,0 +1,30 @@
FROM python:3.12-slim-bookworm
# Install system dependencies (ffmpeg required by yt-dlp for audio extraction)
RUN apt-get update && apt-get install -y --no-install-recommends \
ffmpeg \
curl \
&& rm -rf /var/lib/apt/lists/*
# Install yt-dlp (standalone binary, always up-to-date)
RUN curl -L https://github.com/yt-dlp/yt-dlp/releases/latest/download/yt-dlp \
-o /usr/local/bin/yt-dlp && \
chmod +x /usr/local/bin/yt-dlp
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
RUN mkdir -p /app/tmp
ENV APP_HOST=0.0.0.0
ENV APP_PORT=8000
ENV TMP_DIR=/app/tmp
EXPOSE 8000
# Single worker to stay within Pi 3's memory budget
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000", "--workers", "1"]
View File
View File
+85
View File
@@ -0,0 +1,85 @@
"""GET /api/extract/stream – SSE endpoint."""
from __future__ import annotations
import json
import logging
from typing import AsyncGenerator
from fastapi import APIRouter
from fastapi.responses import StreamingResponse
from app.extractors.base import is_video_url
from app.extractors.website import extract_website
from app.extractors.video import extract_video
from app.models import Recipe, StatusEvent, ErrorEvent, ExtractResponse
from app.services.nutrition import enrich_nutrition
from app.services.tandoor_service import recipe_to_tandoor
from app.services.history_service import save_recipe as save_to_history
log = logging.getLogger(__name__)
router = APIRouter()
def _sse(event: str, data: str) -> str:
return f"event: {event}\ndata: {data}\n\n"
async def _stream(url: str) -> AsyncGenerator[str, None]:
try:
recipe: Recipe | None = None
pipeline = extract_video(url) if is_video_url(url) else extract_website(url)
async for item in pipeline:
if isinstance(item, StatusEvent):
yield _sse("status", item.model_dump_json())
elif isinstance(item, Recipe):
recipe = item
if recipe is None:
raise RuntimeError("Kein Rezept extrahiert.")
# Nutrition enrichment (may yield status events in future; here inline)
yield _sse("status", StatusEvent(
stage="nutrition", message="Berechne Nährwerte...", progress=90
).model_dump_json())
recipe = await enrich_nutrition(recipe)
tandoor_json = recipe_to_tandoor(recipe)
base = ExtractResponse(recipe=recipe, tandoor_json=tandoor_json)
history_id = None
try:
history_id = save_to_history(base.model_dump())
except Exception as db_err:
log.warning("History save failed: %s", db_err)
response = ExtractResponse(recipe=recipe, tandoor_json=tandoor_json, history_id=history_id)
yield _sse("result", response.model_dump_json())
except Exception as e:
log.exception("Extraction error for %s", url)
yield _sse("error", ErrorEvent(message=str(e)).model_dump_json())
@router.get("/extract/stream")
async def extract_stream(url: str):
if not url or not url.startswith(("http://", "https://")):
async def bad():
yield _sse("error", ErrorEvent(message="Ungültige URL.").model_dump_json())
return StreamingResponse(
bad(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
return StreamingResponse(
_stream(url),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"X-Accel-Buffering": "no",
"Connection": "keep-alive",
},
)
+22
View File
@@ -0,0 +1,22 @@
"""GET /api/history, DELETE /api/history/{id}"""
from fastapi import APIRouter, HTTPException
from app.services.history_service import get_all, delete_recipe
router = APIRouter()
@router.get("/history")
async def get_history():
try:
return get_all()
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/history/{history_id}")
async def delete_history_entry(history_id: int):
try:
delete_recipe(history_id)
return {"status": "ok"}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
+21
View File
@@ -0,0 +1,21 @@
"""POST /api/import – import recipe to Tandoor."""
from fastapi import APIRouter, HTTPException
from app.models import ExtractResponse
from app.services.tandoor_service import import_to_tandoor
from app.services.history_service import mark_imported
router = APIRouter()
@router.post("/import")
async def import_recipe(data: ExtractResponse):
try:
result = await import_to_tandoor(data.recipe)
tandoor_recipe_id = result.get("id")
if data.history_id:
mark_imported(data.history_id, tandoor_recipe_id)
return {"status": "ok", "recipe_id": tandoor_recipe_id}
except RuntimeError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
raise HTTPException(status_code=500, detail=f"Import fehlgeschlagen: {e}")
+20
View File
@@ -0,0 +1,20 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
from functools import lru_cache
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8")
openai_api_key: str = ""
usda_api_key: str = "DEMO_KEY"
tandoor_base_url: str = "http://localhost:8080"
tandoor_api_token: str = ""
app_host: str = "0.0.0.0"
app_port: int = 8000
tmp_dir: str = "/app/tmp"
ffmpeg_location: str = "" # Optional: full path to ffmpeg binary dir (for Windows local dev)
@lru_cache
def get_settings() -> Settings:
return Settings()
View File
+22
View File
@@ -0,0 +1,22 @@
"""Platform detection: decides whether a URL is a video or a website."""
import re
VIDEO_PATTERNS = [
r"youtube\.com/watch",
r"youtu\.be/",
r"tiktok\.com/",
r"instagram\.com/reel",
r"instagram\.com/p/",
r"vimeo\.com/",
r"twitch\.tv/",
r"twitter\.com/.+/status",
r"x\.com/.+/status",
r"facebook\.com/.*video",
r"reddit\.com/r/.*/comments", # reddit video posts
]
_VIDEO_RE = re.compile("|".join(VIDEO_PATTERNS), re.IGNORECASE)
def is_video_url(url: str) -> bool:
return bool(_VIDEO_RE.search(url))
+206
View File
@@ -0,0 +1,206 @@
"""Video recipe extraction: yt-dlp → caption check → Whisper → GPT-4."""
from __future__ import annotations
import asyncio
import json
import logging
import os
import uuid
from pathlib import Path
from typing import AsyncGenerator
from app.config import get_settings
from app.models import Recipe, StatusEvent
from app.services.openai_service import (
check_caption_has_recipe,
extract_recipe_from_text,
transcribe_audio,
)
log = logging.getLogger(__name__)
MAX_DURATION_WARN = 20 * 60 # 20 minutes in seconds
MAX_FILESIZE = "50m"
WHISPER_MAX_BYTES = 24 * 1024 * 1024 # 25MB limit, buffer to 24MB
async def extract_video(url: str) -> AsyncGenerator[StatusEvent | Recipe, None]:
yield StatusEvent(stage="detecting", message="Erkenne Plattform...", progress=5)
# Step 1: Get metadata without downloading
yield StatusEvent(stage="metadata", message="Lade Video-Metadaten...", progress=10)
metadata = await _get_metadata(url)
title = metadata.get("title", "")
description = metadata.get("description", "") or ""
tags = metadata.get("tags") or []
duration = metadata.get("duration") or 0
# Step 2: Check if caption/description contains a complete recipe
yield StatusEvent(stage="caption", message="Prüfe Video-Beschreibung...", progress=20)
caption_text = f"{title}\n\n{description}"
has_recipe_in_caption = await check_caption_has_recipe(caption_text)
if has_recipe_in_caption:
yield StatusEvent(stage="extracting", message="Extrahiere Rezept aus Beschreibung...", progress=50)
enriched_text = f"Titel: {title}\n\nBeschreibung:\n{description}"
recipe = await extract_recipe_from_text(enriched_text, url)
if recipe.image_url is None:
recipe.image_url = _get_thumbnail(metadata)
if not recipe.tags and tags:
recipe.tags = [str(t) for t in tags[:5]]
yield StatusEvent(stage="done", message="Rezept aus Beschreibung extrahiert.", progress=95)
yield recipe
return
# Step 3: Audio download + transcription fallback
if duration > MAX_DURATION_WARN:
yield StatusEvent(
stage="warning",
message=f"Video ist {duration // 60} Min. lang – Transkription kann dauern.",
progress=25,
)
yield StatusEvent(stage="downloading", message="Lade Audio herunter...", progress=30)
settings = get_settings()
audio_path = Path(settings.tmp_dir) / f"{uuid.uuid4()}.mp3"
try:
await _download_audio(url, audio_path)
# Check file size for Whisper limit
if audio_path.exists():
size = audio_path.stat().st_size
if size > WHISPER_MAX_BYTES:
raise RuntimeError(
f"Audio-Datei zu groß ({size // 1024 // 1024} MB). "
"Whisper unterstützt max. 25MB. Bitte kürzeres Video verwenden."
)
else:
raise RuntimeError("Audio-Download fehlgeschlagen.")
yield StatusEvent(stage="transcribing", message="Transkribiere Audio (Whisper)...", progress=55)
transcript = await transcribe_audio(audio_path)
if not transcript.strip():
raise RuntimeError("Transkription leer. Kein gesprochener Text erkannt.")
yield StatusEvent(stage="extracting", message="Extrahiere Rezept aus Transkription...", progress=75)
enriched_text = (
f"Video-Titel: {title}\n"
f"Beschreibung: {description[:500]}\n\n"
f"Transkription:\n{transcript}"
)
recipe = await extract_recipe_from_text(enriched_text, url)
if recipe.image_url is None:
recipe.image_url = _get_thumbnail(metadata)
if not recipe.tags and tags:
recipe.tags = [str(t) for t in tags[:5]]
yield StatusEvent(stage="done", message="Rezept extrahiert.", progress=95)
yield recipe
except FileNotFoundError:
raise RuntimeError(
"yt-dlp nicht gefunden. Bitte sicherstellen, dass yt-dlp installiert ist."
)
except Exception:
raise
finally:
if audio_path.exists():
try:
audio_path.unlink()
except Exception as e:
log.warning("Could not delete tmp file %s: %s", audio_path, e)
def _ffmpeg_args() -> list[str]:
"""Return --ffmpeg-location args if configured, else empty list."""
loc = get_settings().ffmpeg_location
return ["--ffmpeg-location", loc] if loc else []
async def _get_metadata(url: str) -> dict:
"""Run yt-dlp --dump-json to get metadata without downloading."""
proc = await asyncio.create_subprocess_exec(
"yt-dlp",
"--dump-json",
"--no-playlist",
*_ffmpeg_args(),
url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=60)
if proc.returncode != 0:
err = stderr.decode(errors="replace")
_check_tiktok_error(err, url)
raise RuntimeError(f"yt-dlp Metadaten-Fehler: {err[:500]}")
try:
return json.loads(stdout.decode())
except json.JSONDecodeError:
return {}
async def _download_audio(url: str, output_path: Path) -> None:
"""Download audio only via yt-dlp subprocess (non-blocking)."""
output_path.parent.mkdir(parents=True, exist_ok=True)
proc = await asyncio.create_subprocess_exec(
"yt-dlp",
"--no-playlist",
"--extract-audio",
"--audio-format", "mp3",
"--audio-quality", "5",
"--max-filesize", MAX_FILESIZE,
*_ffmpeg_args(),
"--output", str(output_path).replace(".mp3", ".%(ext)s"),
url,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
try:
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=300)
except asyncio.TimeoutError:
proc.kill()
raise RuntimeError("Audio-Download Timeout (5 Min.) überschritten.")
if proc.returncode != 0:
err = stderr.decode(errors="replace")
_check_tiktok_error(err, url)
raise RuntimeError(f"Audio-Download fehlgeschlagen: {err[:500]}")
# yt-dlp may output with different extension before conversion
# Try to find the actual file
if not output_path.exists():
parent = output_path.parent
stem = output_path.stem
for f in parent.glob(f"{stem}*"):
if f.suffix in (".mp3", ".m4a", ".webm", ".opus"):
f.rename(output_path)
break
def _get_thumbnail(metadata: dict) -> str | None:
thumbnail = metadata.get("thumbnail")
if thumbnail:
return thumbnail
thumbnails = metadata.get("thumbnails") or []
if thumbnails:
# prefer largest
best = max(thumbnails, key=lambda t: (t.get("width", 0) or 0), default=None)
if best:
return best.get("url")
return None
def _check_tiktok_error(stderr: str, url: str) -> None:
"""Raise a clear error for TikTok login/cookie issues."""
if "tiktok" in url.lower():
if any(phrase in stderr.lower() for phrase in ["login", "cookie", "captcha", "403"]):
raise RuntimeError(
"TikTok erfordert Cookies für dieses Video. "
"Bitte exportiere deine Browser-Cookies und konfiguriere yt-dlp entsprechend."
)
+301
View File
@@ -0,0 +1,301 @@
"""Website recipe extraction: recipe-scrapers → LD+JSON → GPT-4 fallback."""
from __future__ import annotations
import json
import logging
import re
from typing import AsyncGenerator
import httpx
from bs4 import BeautifulSoup
from app.models import Ingredient, Recipe, StatusEvent
log = logging.getLogger(__name__)
MAX_GPT_CHARS = 12_000
async def extract_website(
url: str,
) -> AsyncGenerator[StatusEvent | Recipe, None]:
"""Yields StatusEvents then a final Recipe."""
yield StatusEvent(stage="detecting", message="Erkenne Rezeptseite...", progress=10)
yield StatusEvent(stage="fetching", message="Lade Seite...", progress=20)
# Fetch HTML once — reused by all tiers
try:
async with httpx.AsyncClient(timeout=30, follow_redirects=True) as client:
resp = await client.get(
url,
headers={"User-Agent": "Mozilla/5.0 (compatible; Rezept0r/1.0)"},
)
resp.raise_for_status()
html = resp.text
except Exception as e:
raise RuntimeError(f"Seite konnte nicht geladen werden: {e}") from e
soup = BeautifulSoup(html, "lxml")
# Tier 1: recipe-scrapers (uses already-fetched HTML)
try:
from recipe_scrapers import scrape_html, NoSchemaFoundInWildMode
scraper = scrape_html(html, org_url=url, wild_mode=True)
recipe = _scraper_to_recipe(scraper, url)
if recipe.ingredients and recipe.steps:
yield StatusEvent(stage="parsing", message="Rezept geparst.", progress=70)
yield recipe
return
except Exception as e:
log.info("recipe-scrapers failed (%s), trying LD+JSON fallback", e)
yield StatusEvent(stage="parsing", message="Suche nach Schema.org-Daten...", progress=40)
# Tier 2: LD+JSON in page (including Next.js RSC payloads)
recipe = _try_ldjson(soup, url)
if recipe and (recipe.ingredients or recipe.steps):
yield StatusEvent(stage="parsing", message="Schema.org-Daten gefunden.", progress=70)
yield recipe
return
yield StatusEvent(stage="gpt", message="Extrahiere mit KI...", progress=55)
# Tier 3: GPT-4 fallback
from app.services.openai_service import extract_recipe_from_text
article_text = _extract_article_text(soup)
if not article_text.strip():
raise RuntimeError("Kein verwertbarer Text auf der Seite gefunden.")
truncated = article_text[:MAX_GPT_CHARS]
recipe = await extract_recipe_from_text(truncated, url)
yield StatusEvent(stage="done", message="Rezept extrahiert.", progress=100)
yield recipe
def _scraper_to_recipe(scraper, url: str) -> Recipe:
def safe(fn):
try:
return fn()
except Exception:
return None
raw_ingredients = safe(scraper.ingredients) or []
ingredients = [Ingredient(name=i) for i in raw_ingredients if i]
raw_instructions = safe(scraper.instructions_list) or []
if not raw_instructions:
instr = safe(scraper.instructions) or ""
raw_instructions = [s.strip() for s in instr.split("\n") if s.strip()] if instr else []
nutrition_data = safe(scraper.nutrients) or {}
nutrition = None
if nutrition_data:
from app.models import NutritionInfo
kcal_str = nutrition_data.get("calories", "")
kcal = _parse_float(kcal_str)
if kcal:
nutrition = NutritionInfo(kcal_per_serving=kcal, source="extracted")
servings = None
yields_raw = safe(scraper.yields)
if yields_raw:
servings = _parse_int(yields_raw)
return Recipe(
title=safe(scraper.title) or "Unbekanntes Rezept",
description=safe(scraper.description),
image_url=safe(scraper.image),
source_url=url,
servings=servings,
prep_time_minutes=safe(scraper.prep_time),
cook_time_minutes=safe(scraper.cook_time),
ingredients=ingredients,
steps=raw_instructions,
nutrition=nutrition,
tags=list(safe(scraper.category) or []) if isinstance(safe(scraper.category), list)
else ([safe(scraper.category)] if safe(scraper.category) else []),
)
def _is_recipe_type(data: dict) -> bool:
t = data.get("@type")
if isinstance(t, list):
return "Recipe" in t
return t == "Recipe"
def _parse_ldjson_candidates(data) -> list[dict]:
"""Return all Recipe-type dicts from a parsed JSON-LD structure."""
candidates: list[dict] = []
if isinstance(data, dict):
if "@graph" in data:
for item in data["@graph"]:
if isinstance(item, dict) and _is_recipe_type(item):
candidates.append(item)
elif _is_recipe_type(data):
candidates.append(data)
elif isinstance(data, list):
for item in data:
if isinstance(item, dict) and _is_recipe_type(item):
candidates.append(item)
return candidates
def _try_ldjson(soup: BeautifulSoup, url: str) -> Recipe | None:
# Standard <script type="application/ld+json"> tags
for script in soup.find_all("script", type="application/ld+json"):
try:
data = json.loads(script.string or "")
except (json.JSONDecodeError, TypeError):
continue
for item in _parse_ldjson_candidates(data):
return _ldjson_to_recipe(item, url)
# Next.js RSC streaming payloads: self.__next_f.push([1,"<json-encoded-string>"])
for script in soup.find_all("script"):
text = script.string or ""
if "recipeInstructions" not in text and "@type" not in text:
continue
m = re.search(r'self\.__next_f\.push\(\[1,"(.+)"\]\)\s*$', text, re.DOTALL)
if not m:
continue
try:
decoded = json.loads('"' + m.group(1) + '"')
data = json.loads(decoded)
except (json.JSONDecodeError, ValueError):
continue
for item in _parse_ldjson_candidates(data):
return _ldjson_to_recipe(item, url)
return None
def _ldjson_to_recipe(data: dict, url: str) -> Recipe:
from app.models import NutritionInfo
ingredients_raw = data.get("recipeIngredient", [])
ingredients = [Ingredient(name=i) for i in ingredients_raw if isinstance(i, str)]
instructions_raw = data.get("recipeInstructions", [])
steps: list[str] = []
if isinstance(instructions_raw, str):
steps = [s.strip() for s in instructions_raw.split("\n") if s.strip()]
elif isinstance(instructions_raw, list):
for item in instructions_raw:
if isinstance(item, str):
steps.append(item)
elif isinstance(item, dict):
item_type = item.get("@type", "")
# HowToSection: recurse into itemListElement
if item_type == "HowToSection":
for sub in item.get("itemListElement", []):
if isinstance(sub, dict):
text = sub.get("text", "") or sub.get("name", "")
if text:
steps.append(text)
else:
text = item.get("text", "") or item.get("name", "")
if text:
steps.append(text)
nutrition = None
nutr_data = data.get("nutrition", {})
if isinstance(nutr_data, dict):
kcal = _parse_float(nutr_data.get("calories", ""))
if kcal:
nutrition = NutritionInfo(kcal_per_serving=kcal, source="extracted")
yields_raw = data.get("recipeYield", data.get("yield", ""))
servings = None
if isinstance(yields_raw, list) and yields_raw:
servings = _parse_int(str(yields_raw[0]))
elif isinstance(yields_raw, (str, int)):
servings = _parse_int(str(yields_raw))
keywords = data.get("keywords", "")
if isinstance(keywords, str):
tags = [k.strip() for k in keywords.split(",") if k.strip()]
elif isinstance(keywords, list):
tags = [str(k).strip() for k in keywords if k]
else:
tags = []
categories = data.get("recipeCategory", [])
if isinstance(categories, str):
tags += [categories]
elif isinstance(categories, list):
tags += categories
return Recipe(
title=data.get("name", "Unbekanntes Rezept"),
description=data.get("description"),
image_url=_extract_image_url(data.get("image")),
source_url=url,
servings=servings,
prep_time_minutes=_parse_duration(data.get("prepTime")),
cook_time_minutes=_parse_duration(data.get("cookTime")),
ingredients=ingredients,
steps=steps,
nutrition=nutrition,
tags=list(set(tags))[:10],
)
def _extract_article_text(soup: BeautifulSoup) -> str:
for tag in soup.find_all(["script", "style", "nav", "footer", "header", "aside"]):
tag.decompose()
for selector in ["article", "main", '[class*="recipe"]', '[id*="recipe"]']:
el = soup.select_one(selector)
if el:
return el.get_text(separator="\n", strip=True)
return soup.get_text(separator="\n", strip=True)
def _extract_image_url(image) -> str | None:
if not image:
return None
if isinstance(image, str):
return image
if isinstance(image, list) and image:
first = image[0]
return first.get("url") if isinstance(first, dict) else str(first)
if isinstance(image, dict):
return image.get("url") or image.get("contentUrl")
return None
def _parse_float(s: str | None) -> float | None:
if not s:
return None
import re
m = re.search(r"[\d.]+", str(s))
try:
return float(m.group()) if m else None
except ValueError:
return None
def _parse_int(s: str | None) -> int | None:
if not s:
return None
import re
m = re.search(r"\d+", str(s))
try:
return int(m.group()) if m else None
except ValueError:
return None
def _parse_duration(iso: str | None) -> int | None:
"""Parse ISO 8601 duration (PT1H30M) → minutes."""
if not iso:
return None
import re
hours = re.search(r"(\d+)H", iso)
minutes = re.search(r"(\d+)M", iso)
total = 0
if hours:
total += int(hours.group(1)) * 60
if minutes:
total += int(minutes.group(1))
return total or None
+74
View File
@@ -0,0 +1,74 @@
from __future__ import annotations
from typing import Optional
from pydantic import BaseModel
class Ingredient(BaseModel):
name: str
amount: Optional[str] = None
unit: Optional[str] = None
note: Optional[str] = None
class NutritionInfo(BaseModel):
kcal_per_serving: Optional[float] = None
kcal_total: Optional[float] = None
servings: Optional[int] = None
source: str = "extracted" # "extracted" | "usda" | "gpt4_estimate"
class Recipe(BaseModel):
title: str
description: Optional[str] = None
image_url: Optional[str] = None
source_url: str
servings: Optional[int] = None
prep_time_minutes: Optional[int] = None
cook_time_minutes: Optional[int] = None
ingredients: list[Ingredient] = []
steps: list[str] = []
nutrition: Optional[NutritionInfo] = None
tags: list[str] = []
class TandoorStep(BaseModel):
instruction: str
ingredients: list[TandoorIngredient] = []
time: int = 0
class TandoorIngredient(BaseModel):
food: dict # {"name": str}
amount: float = 0
unit: Optional[dict] = None # {"name": str} or None
note: str = ""
class TandoorRecipe(BaseModel):
name: str
description: str = ""
servings: int = 1
working_time: int = 0
source_url: str = ""
image: Optional[str] = None
steps: list[TandoorStep] = []
keywords: list[dict] = []
internal: bool = True
show_ingredient_overview: bool = False
servings_text: str = "Portionen"
class ExtractResponse(BaseModel):
recipe: Recipe
tandoor_json: TandoorRecipe
history_id: Optional[int] = None
class StatusEvent(BaseModel):
stage: str
message: str
progress: int
class ErrorEvent(BaseModel):
message: str
View File
+83
View File
@@ -0,0 +1,83 @@
"""SQLite-based recipe history."""
from __future__ import annotations
import json
import sqlite3
from datetime import datetime, timezone
from pathlib import Path
from typing import Optional
from app.config import get_settings
def _db_path() -> Path:
settings = get_settings()
return Path(settings.tmp_dir).parent / "history.db"
def init_db() -> None:
db = _db_path()
db.parent.mkdir(parents=True, exist_ok=True)
with sqlite3.connect(db) as conn:
conn.execute("""
CREATE TABLE IF NOT EXISTS recipe_history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
extracted_at TEXT NOT NULL,
title TEXT,
source_url TEXT,
image_url TEXT,
recipe_json TEXT NOT NULL,
imported_at TEXT,
tandoor_recipe_id INTEGER
)
""")
conn.commit()
def save_recipe(extract_data: dict) -> int:
recipe = extract_data.get("recipe", {})
now = datetime.now(timezone.utc).isoformat()
with sqlite3.connect(_db_path()) as conn:
cur = conn.execute(
"""INSERT INTO recipe_history
(extracted_at, title, source_url, image_url, recipe_json)
VALUES (?, ?, ?, ?, ?)""",
(
now,
recipe.get("title"),
recipe.get("source_url"),
recipe.get("image_url"),
json.dumps(extract_data),
),
)
conn.commit()
return cur.lastrowid
def get_all() -> list[dict]:
with sqlite3.connect(_db_path()) as conn:
conn.row_factory = sqlite3.Row
rows = conn.execute(
"SELECT * FROM recipe_history ORDER BY extracted_at DESC"
).fetchall()
result = []
for r in rows:
entry = dict(r)
entry["recipe_data"] = json.loads(entry.pop("recipe_json"))
result.append(entry)
return result
def delete_recipe(history_id: int) -> None:
with sqlite3.connect(_db_path()) as conn:
conn.execute("DELETE FROM recipe_history WHERE id = ?", (history_id,))
conn.commit()
def mark_imported(history_id: int, tandoor_recipe_id: Optional[int]) -> None:
now = datetime.now(timezone.utc).isoformat()
with sqlite3.connect(_db_path()) as conn:
conn.execute(
"UPDATE recipe_history SET imported_at = ?, tandoor_recipe_id = ? WHERE id = ?",
(now, tandoor_recipe_id, history_id),
)
conn.commit()
+186
View File
@@ -0,0 +1,186 @@
"""3-Tier nutrition strategy: extracted → USDA FoodData → GPT-4 estimate."""
from __future__ import annotations
import logging
import re
from typing import Optional
import httpx
from app.config import get_settings
from app.models import Ingredient, NutritionInfo, Recipe
log = logging.getLogger(__name__)
USDA_API_URL = "https://api.nal.usda.gov/fdc/v1"
MIN_USDA_COVERAGE = 0.5 # require ≥50% ingredients resolved
async def enrich_nutrition(recipe: Recipe) -> Recipe:
"""Enrich recipe with nutrition info using 3-tier fallback."""
# Tier 1: Already extracted from source
if recipe.nutrition and recipe.nutrition.kcal_per_serving:
return recipe
if not recipe.ingredients:
return recipe
servings = recipe.servings or 1
# Tier 2: USDA FoodData
try:
nutrition = await _usda_lookup(recipe.ingredients, servings)
if nutrition and nutrition.kcal_per_serving:
recipe = recipe.model_copy(update={"nutrition": nutrition})
return recipe
except Exception as e:
log.warning("USDA lookup failed: %s", e)
# Tier 3: GPT-4 estimate (only if OpenAI key configured)
settings = get_settings()
if settings.openai_api_key:
try:
from app.services.openai_service import estimate_nutrition_gpt
nutrition = await estimate_nutrition_gpt(recipe.ingredients, servings)
recipe = recipe.model_copy(update={"nutrition": nutrition})
except Exception as e:
log.warning("GPT-4 nutrition estimate failed: %s", e)
return recipe
async def _usda_lookup(ingredients: list[Ingredient], servings: int) -> Optional[NutritionInfo]:
"""Look up each ingredient in USDA FoodData API and sum kcal."""
settings = get_settings()
api_key = settings.usda_api_key or "DEMO_KEY"
total_kcal = 0.0
resolved = 0
async with httpx.AsyncClient(timeout=10) as client:
for ing in ingredients:
try:
kcal = await _usda_ingredient_kcal(client, ing, api_key)
if kcal is not None:
total_kcal += kcal
resolved += 1
except Exception as e:
log.debug("USDA lookup for '%s' failed: %s", ing.name, e)
coverage = resolved / len(ingredients) if ingredients else 0
if coverage < MIN_USDA_COVERAGE:
log.info("USDA coverage %.0f%% < 50%%, skipping", coverage * 100)
return None
kcal_per_serving = total_kcal / servings if servings else total_kcal
return NutritionInfo(
kcal_per_serving=round(kcal_per_serving, 1),
kcal_total=round(total_kcal, 1),
servings=servings,
source="usda",
)
async def _usda_ingredient_kcal(
client: httpx.AsyncClient,
ing: Ingredient,
api_key: str,
) -> Optional[float]:
"""Search USDA for one ingredient and return estimated kcal."""
resp = await client.get(
f"{USDA_API_URL}/foods/search",
params={
"query": ing.name,
"dataType": "Foundation,SR Legacy",
"pageSize": 1,
"api_key": api_key,
},
)
resp.raise_for_status()
data = resp.json()
foods = data.get("foods", [])
if not foods:
return None
food = foods[0]
# Find Energy (kcal) nutrient
nutrients = food.get("foodNutrients", [])
kcal_per_100g = None
for n in nutrients:
name = (n.get("nutrientName") or "").lower()
unit = (n.get("unitName") or "").upper()
if "energy" in name and unit == "KCAL":
kcal_per_100g = n.get("value")
break
if kcal_per_100g is None:
return None
grams = _estimate_grams(ing)
if grams is None:
return None
return kcal_per_100g * grams / 100
def _estimate_grams(ing: Ingredient) -> Optional[float]:
"""Convert ingredient amount + unit to grams (approximate)."""
if not ing.amount:
return None
amount_val = _parse_float(ing.amount)
if amount_val is None:
return None
unit = (ing.unit or "").lower().strip()
# Direct weight
if unit in ("g", "gr", "gramm", "gram"):
return amount_val
if unit in ("kg", "kilogramm"):
return amount_val * 1000
if unit in ("mg", "milligramm"):
return amount_val / 1000
# Volume → grams (approximate, assuming water density)
if unit in ("ml", "milliliter", "millilitre"):
return amount_val
if unit in ("l", "liter", "litre"):
return amount_val * 1000
if unit in ("el", "esslöffel", "tbsp", "tablespoon"):
return amount_val * 15
if unit in ("tl", "teelöffel", "tsp", "teaspoon"):
return amount_val * 5
if unit in ("cup", "cups", "tasse"):
return amount_val * 240
# Count → fallback average
if unit in ("stück", "stk", "piece", "pieces", "", "pcs"):
return amount_val * 100 # rough average
# Unknown unit → assume grams
return amount_val
def _parse_float(s: str) -> Optional[float]:
if not s:
return None
# Handle fractions like "1/2"
frac = re.match(r"^\s*(\d+)\s*/\s*(\d+)\s*$", s)
if frac:
return int(frac.group(1)) / int(frac.group(2))
# Handle mixed like "1 1/2"
mixed = re.match(r"^\s*(\d+)\s+(\d+)\s*/\s*(\d+)\s*$", s)
if mixed:
return int(mixed.group(1)) + int(mixed.group(2)) / int(mixed.group(3))
# Range like "2-3" → take first
range_m = re.match(r"^\s*([\d.]+)\s*[-–]\s*([\d.]+)\s*$", s)
if range_m:
return float(range_m.group(1))
# Plain float
plain = re.search(r"[\d.]+", s)
if plain:
try:
return float(plain.group())
except ValueError:
return None
return None
+183
View File
@@ -0,0 +1,183 @@
"""OpenAI integration: Whisper transcription + GPT-4o-mini recipe extraction."""
from __future__ import annotations
import json
import logging
from pathlib import Path
from openai import AsyncOpenAI, AuthenticationError, RateLimitError, APIConnectionError
from app.config import get_settings
from app.models import Ingredient, NutritionInfo, Recipe
log = logging.getLogger(__name__)
_RECIPE_SYSTEM_PROMPT = """Du bist ein Rezept-Extraktor. Extrahiere das Rezept aus dem Text und antworte NUR mit einem JSON-Objekt (kein Markdown).
JSON-Schema:
{
"title": "string",
"description": "string oder null",
"servings": integer oder null,
"prep_time_minutes": integer oder null,
"cook_time_minutes": integer oder null,
"ingredients": [
{"name": "string", "amount": "string oder null", "unit": "string oder null", "note": "string oder null"}
],
"steps": ["string"],
"kcal_per_serving": number oder null,
"tags": ["string"]
}
Regeln:
- amount ist immer ein String (z.B. "200", "1/2", "2-3")
- unit ist die Maßeinheit (g, ml, EL, TL, Stück, etc.)
- Wenn keine Kalorien erkennbar sind, setze kcal_per_serving auf null
- steps sind vollständige Zubereitungsschritte
- tags: maximal 5 relevante Schlagwörter
"""
_CAPTION_CHECK_PROMPT = """Enthält der folgende Text ein vollständiges Rezept mit Zutaten UND Zubereitungsschritten?
Antworte NUR mit einem JSON-Objekt: {"has_recipe": true} oder {"has_recipe": false}
Text:
"""
def _get_client() -> AsyncOpenAI:
return AsyncOpenAI(api_key=get_settings().openai_api_key)
def _openai_error(e: Exception) -> RuntimeError:
if isinstance(e, AuthenticationError):
return RuntimeError("OpenAI API-Schlüssel ungültig oder abgelaufen.")
if isinstance(e, RateLimitError):
return RuntimeError("OpenAI Rate-Limit erreicht. Bitte kurz warten und erneut versuchen.")
if isinstance(e, APIConnectionError):
return RuntimeError("OpenAI nicht erreichbar. Internetverbindung prüfen.")
return RuntimeError(f"OpenAI-Fehler: {e}")
async def check_caption_has_recipe(text: str) -> bool:
"""Check if a video description/caption contains a complete recipe."""
client = _get_client()
try:
resp = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "user", "content": _CAPTION_CHECK_PROMPT + text[:3000]}
],
max_tokens=20,
response_format={"type": "json_object"},
)
data = json.loads(resp.choices[0].message.content or "{}")
return bool(data.get("has_recipe", False))
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
raise _openai_error(e) from e
except Exception as e:
log.warning("Caption check failed: %s", e)
return False
async def transcribe_audio(audio_path: Path) -> str:
"""Transcribe audio file with Whisper API."""
client = _get_client()
try:
with open(audio_path, "rb") as f:
transcript = await client.audio.transcriptions.create(
model="whisper-1",
file=f,
language="de",
response_format="text",
)
return str(transcript)
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
raise _openai_error(e) from e
async def extract_recipe_from_text(text: str, source_url: str) -> Recipe:
"""Use GPT-4o-mini to extract a structured recipe from text."""
client = _get_client()
try:
resp = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[
{"role": "system", "content": _RECIPE_SYSTEM_PROMPT},
{"role": "user", "content": text[:12000]},
],
response_format={"type": "json_object"},
temperature=0.1,
)
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
raise _openai_error(e) from e
raw = resp.choices[0].message.content or "{}"
data = json.loads(raw)
return _gpt_json_to_recipe(data, source_url)
async def estimate_nutrition_gpt(ingredients: list[Ingredient], servings: int) -> NutritionInfo:
"""Estimate kcal using GPT-4o-mini as last resort."""
client = _get_client()
ing_text = "\n".join(
f"- {i.amount or ''} {i.unit or ''} {i.name} {('(' + i.note + ')') if i.note else ''}".strip()
for i in ingredients
)
prompt = (
f"Schätze die Gesamtkalorienzahl und Kalorien pro Portion für folgende Zutaten "
f"({servings} Portionen):\n{ing_text}\n\n"
'Antworte NUR mit JSON: {"kcal_total": number, "kcal_per_serving": number}'
)
try:
resp = await client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": prompt}],
response_format={"type": "json_object"},
temperature=0,
)
data = json.loads(resp.choices[0].message.content or "{}")
return NutritionInfo(
kcal_per_serving=data.get("kcal_per_serving"),
kcal_total=data.get("kcal_total"),
servings=servings,
source="gpt4_estimate",
)
except (AuthenticationError, RateLimitError, APIConnectionError) as e:
log.warning("GPT nutrition estimate failed (API): %s", _openai_error(e))
return NutritionInfo(source="gpt4_estimate")
except Exception as e:
log.warning("GPT nutrition estimate failed: %s", e)
return NutritionInfo(source="gpt4_estimate")
def _gpt_json_to_recipe(data: dict, source_url: str) -> Recipe:
ingredients = [
Ingredient(
name=i.get("name", ""),
amount=i.get("amount"),
unit=i.get("unit"),
note=i.get("note"),
)
for i in data.get("ingredients", [])
if i.get("name")
]
nutrition = None
kcal = data.get("kcal_per_serving")
if kcal:
try:
nutrition = NutritionInfo(kcal_per_serving=float(kcal), source="extracted")
except (ValueError, TypeError):
pass
return Recipe(
title=data.get("title", "Unbekanntes Rezept"),
description=data.get("description"),
image_url=None,
source_url=source_url,
servings=data.get("servings"),
prep_time_minutes=data.get("prep_time_minutes"),
cook_time_minutes=data.get("cook_time_minutes"),
ingredients=ingredients,
steps=data.get("steps", []),
nutrition=nutrition,
tags=data.get("tags", [])[:10],
)
+146
View File
@@ -0,0 +1,146 @@
"""Tandoor REST API client."""
from __future__ import annotations
import logging
from typing import Optional
import httpx
from app.config import get_settings
from app.models import (
Ingredient,
Recipe,
TandoorIngredient,
TandoorRecipe,
TandoorStep,
)
log = logging.getLogger(__name__)
def recipe_to_tandoor(recipe: Recipe) -> TandoorRecipe:
"""Convert internal Recipe to Tandoor-compatible import format."""
working_time = (recipe.prep_time_minutes or 0) + (recipe.cook_time_minutes or 0)
# All ingredients go into step[0] (Tandoor convention)
tandoor_ingredients = [_to_tandoor_ingredient(ing) for ing in recipe.ingredients]
# Build steps: first step carries all ingredients, subsequent steps are plain text
steps: list[TandoorStep] = []
for i, step_text in enumerate(recipe.steps):
step = TandoorStep(
instruction=step_text,
ingredients=tandoor_ingredients if i == 0 else [],
time=0,
)
steps.append(step)
if not steps:
# If no steps, create a placeholder with all ingredients
steps = [TandoorStep(instruction="Zubereitung", ingredients=tandoor_ingredients)]
keywords = [{"label": tag, "name": tag} for tag in recipe.tags]
servings_text = "Portionen"
if recipe.nutrition and recipe.nutrition.kcal_per_serving:
kcal = round(recipe.nutrition.kcal_per_serving)
servings_text = f"Portionen (ca. {kcal} kcal)"
return TandoorRecipe(
name=recipe.title,
description=recipe.description or "",
servings=recipe.servings or 1,
working_time=working_time,
source_url=recipe.source_url,
image=recipe.image_url,
steps=steps,
keywords=keywords,
servings_text=servings_text,
)
def _to_tandoor_ingredient(ing: Ingredient) -> TandoorIngredient:
amount = 0.0
if ing.amount:
try:
from app.services.nutrition import _parse_float
val = _parse_float(ing.amount)
amount = val if val is not None else 0.0
except Exception:
amount = 0.0
unit = {"name": ing.unit} if ing.unit else None
return TandoorIngredient(
food={"name": ing.name},
amount=amount,
unit=unit,
note=ing.note or "",
)
async def import_to_tandoor(recipe: Recipe) -> dict:
"""Import a recipe to Tandoor via REST API. Returns created recipe data."""
settings = get_settings()
if not settings.tandoor_api_token:
raise RuntimeError("TANDOOR_API_TOKEN nicht konfiguriert.")
if not settings.tandoor_base_url:
raise RuntimeError("TANDOOR_BASE_URL nicht konfiguriert.")
base_url = settings.tandoor_base_url.rstrip("/")
headers = {
"Authorization": f"Bearer {settings.tandoor_api_token}",
"Content-Type": "application/json",
}
tandoor_recipe = recipe_to_tandoor(recipe)
payload = tandoor_recipe.model_dump(exclude={"image"})
async with httpx.AsyncClient(timeout=30) as client:
# POST recipe
resp = await client.post(
f"{base_url}/api/recipe/",
json=payload,
headers=headers,
)
if resp.status_code not in (200, 201):
raise RuntimeError(
f"Tandoor API Fehler {resp.status_code}: {resp.text[:300]}"
)
created = resp.json()
recipe_id = created.get("id")
# Upload image if available
if recipe.image_url and recipe_id:
await _upload_image(client, base_url, recipe_id, recipe.image_url, settings.tandoor_api_token)
return created
async def _upload_image(
client: httpx.AsyncClient,
base_url: str,
recipe_id: int,
image_url: str,
token: str,
) -> None:
"""Download image from URL and upload to Tandoor."""
try:
img_resp = await client.get(image_url, follow_redirects=True, timeout=15)
img_resp.raise_for_status()
image_data = img_resp.content
# Determine content type
content_type = img_resp.headers.get("content-type", "image/jpeg").split(";")[0]
ext = {"image/jpeg": "jpg", "image/png": "png", "image/webp": "webp"}.get(content_type, "jpg")
files = {"image": (f"recipe.{ext}", image_data, content_type)}
patch_resp = await client.patch(
f"{base_url}/api/recipe/{recipe_id}/",
files=files,
headers={"Authorization": f"Bearer {token}"}, # NO Content-Type here
)
if patch_resp.status_code not in (200, 201):
log.warning("Image upload failed: %s", patch_resp.text[:200])
except Exception as e:
log.warning("Could not upload image: %s", e)
+523
View File
@@ -0,0 +1,523 @@
/* Rezept0r – Frontend */
let currentRecipeData = null;
let tandoorConfigured = false;
async function checkHealth() {
try {
const res = await fetch('/api/health');
const data = await res.json();
tandoorConfigured = data.tandoor_configured;
} catch (_) {}
}
function renderMarkdownBold(text) {
const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
return escaped.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
}
function showSection(id) {
['progress-section', 'result-section', 'error-section'].forEach(s => {
document.getElementById(s).classList.add('hidden');
});
document.getElementById(id).classList.remove('hidden');
}
function setProgress(pct, message) {
document.getElementById('progress-bar').style.width = pct + '%';
document.getElementById('progress-message').textContent = message;
}
function showError(message) {
showSection('error-section');
document.getElementById('error-message').textContent = message;
}
function renderRecipe(data) {
const { recipe, tandoor_json } = data;
currentRecipeData = data;
// Image
const img = document.getElementById('recipe-image');
if (recipe.image_url) {
img.src = recipe.image_url;
img.classList.remove('hidden');
img.onerror = () => img.classList.add('hidden');
} else {
img.classList.add('hidden');
}
document.getElementById('recipe-title').textContent = recipe.title || '';
const desc = document.getElementById('recipe-description');
if (recipe.description) {
desc.textContent = recipe.description;
desc.classList.remove('hidden');
} else {
desc.classList.add('hidden');
}
// Meta tags
if (recipe.servings) {
document.getElementById('servings-value').textContent = recipe.servings + ' Portionen';
document.getElementById('recipe-servings').classList.remove('hidden');
}
if (recipe.prep_time_minutes) {
document.getElementById('prep-value').textContent = recipe.prep_time_minutes;
document.getElementById('recipe-prep').classList.remove('hidden');
}
if (recipe.cook_time_minutes) {
document.getElementById('cook-value').textContent = recipe.cook_time_minutes;
document.getElementById('recipe-cook').classList.remove('hidden');
}
if (recipe.nutrition?.kcal_per_serving) {
const sourceLabel = {
'extracted': '',
'usda': '(USDA)',
'gpt4_estimate': '(Schätzung)'
}[recipe.nutrition.source] || '';
document.getElementById('kcal-value').textContent = Math.round(recipe.nutrition.kcal_per_serving);
document.getElementById('kcal-source').textContent = sourceLabel;
document.getElementById('recipe-kcal').classList.remove('hidden');
}
// Tags
const tagList = document.getElementById('recipe-tags');
tagList.innerHTML = '';
(recipe.tags || []).forEach(tag => {
const span = document.createElement('span');
span.className = 'tag';
span.textContent = tag;
tagList.appendChild(span);
});
// Ingredients
const ul = document.getElementById('ingredients-list');
ul.innerHTML = '';
(recipe.ingredients || []).forEach(ing => {
const li = document.createElement('li');
if (ing.amount || ing.unit) {
const amountSpan = document.createElement('span');
amountSpan.className = 'ingredient-amount';
amountSpan.textContent = [ing.amount, ing.unit].filter(Boolean).join(' ');
li.appendChild(amountSpan);
}
const nameSpan = document.createElement('span');
nameSpan.textContent = ing.name;
li.appendChild(nameSpan);
if (ing.note) {
const noteSpan = document.createElement('span');
noteSpan.className = 'ingredient-note';
noteSpan.textContent = '(' + ing.note + ')';
li.appendChild(noteSpan);
}
ul.appendChild(li);
});
// Steps
const ol = document.getElementById('steps-list');
ol.innerHTML = '';
(recipe.steps || []).forEach(step => {
const li = document.createElement('li');
li.innerHTML = renderMarkdownBold(step);
ol.appendChild(li);
});
// Source link
const sourceLink = document.getElementById('source-link');
sourceLink.href = recipe.source_url || '#';
// Tandoor fallback JSON
document.getElementById('tandoor-json').value = JSON.stringify(tandoor_json, null, 2);
document.getElementById('import-status').textContent = '';
document.getElementById('tandoor-fallback').classList.add('hidden');
showSection('result-section');
}
function startExtraction() {
const url = document.getElementById('url-input').value.trim();
if (!url) return;
const btn = document.getElementById('extract-btn');
btn.disabled = true;
showSection('progress-section');
setProgress(0, 'Starte Extraktion...');
const sse = new EventSource('/api/extract/stream?url=' + encodeURIComponent(url));
sse.addEventListener('status', e => {
const data = JSON.parse(e.data);
setProgress(data.progress, data.message);
});
sse.addEventListener('result', e => {
sse.close();
btn.disabled = false;
const data = JSON.parse(e.data);
renderRecipe(data);
loadHistory();
});
sse.addEventListener('error', e => {
sse.close();
btn.disabled = false;
if (e.data) {
try {
const data = JSON.parse(e.data);
showError(data.message || 'Unbekannter Fehler');
} catch (_) {
showError('Verbindungsfehler. Bitte erneut versuchen.');
}
} else {
showError('Verbindung zum Server unterbrochen.');
}
});
}
async function importToTandoor() {
if (!currentRecipeData) return;
const btn = document.getElementById('import-btn');
const status = document.getElementById('import-status');
if (!tandoorConfigured) {
status.textContent = 'Tandoor nicht konfiguriert – JSON unten kopieren.';
status.className = 'import-status error';
showTandoorFallback();
return;
}
btn.disabled = true;
status.textContent = 'Importiere...';
status.className = 'import-status';
try {
const res = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(currentRecipeData)
});
const data = await res.json();
if (res.ok) {
status.textContent = 'Erfolgreich importiert!';
status.className = 'import-status success';
loadHistory();
} else {
status.textContent = 'Fehler: ' + (data.detail || 'Import fehlgeschlagen');
status.className = 'import-status error';
showTandoorFallback();
}
} catch (_) {
status.textContent = 'Netzwerkfehler beim Import';
status.className = 'import-status error';
showTandoorFallback();
} finally {
btn.disabled = false;
}
}
function showTandoorFallback() {
document.getElementById('tandoor-fallback').classList.remove('hidden');
}
function copyJson() {
const ta = document.getElementById('tandoor-json');
ta.select();
navigator.clipboard.writeText(ta.value).catch(() => document.execCommand('copy'));
const btn = document.getElementById('copy-json-btn');
btn.textContent = 'Kopiert!';
setTimeout(() => btn.textContent = 'JSON kopieren', 2000);
}
// ── History ──────────────────────────────────────────────────────────────────
function formatDate(isoStr) {
if (!isoStr) return '';
const d = new Date(isoStr);
return d.toLocaleDateString('de-DE', {
day: '2-digit', month: '2-digit', year: 'numeric',
hour: '2-digit', minute: '2-digit'
});
}
async function loadHistory() {
try {
const res = await fetch('/api/history');
const entries = await res.json();
renderHistory(entries);
} catch (_) {}
}
function renderHistory(entries) {
const section = document.getElementById('history-section');
const container = document.getElementById('history-list');
if (!section || !container) return;
if (!Array.isArray(entries) || entries.length === 0) {
section.classList.add('hidden');
return;
}
section.classList.remove('hidden');
container.innerHTML = '';
entries.forEach(entry => {
try {
container.appendChild(buildHistoryCard(entry));
} catch (e) {
console.error('History card render error:', e, entry);
}
});
}
function buildHistoryCard(entry) {
const recipe = entry.recipe_data?.recipe || {};
const card = document.createElement('div');
card.className = 'history-card';
// ── Header (always visible) ──
const header = document.createElement('div');
header.className = 'history-card-header';
if (recipe.image_url) {
const img = document.createElement('img');
img.className = 'history-thumb';
img.src = recipe.image_url;
img.alt = '';
img.onerror = () => img.remove();
header.appendChild(img);
}
const info = document.createElement('div');
info.className = 'history-info';
const title = document.createElement('h3');
title.className = 'history-title';
title.textContent = entry.title || 'Unbekanntes Rezept';
info.appendChild(title);
if (recipe.description) {
const desc = document.createElement('p');
desc.className = 'history-desc';
desc.textContent = recipe.description;
info.appendChild(desc);
}
// Meta-Tags (Portionen, Zeit, kcal)
const metaTags = document.createElement('div');
metaTags.className = 'meta-tags';
const addMetaTag = (icon, text) => {
const span = document.createElement('span');
span.className = 'meta-tag';
span.innerHTML = `<span class="icon">${icon}</span> ${text}`;
metaTags.appendChild(span);
};
if (recipe.servings) addMetaTag('&#9201;', recipe.servings + ' Portionen');
if (recipe.prep_time_minutes) addMetaTag('&#9201;', 'Vorbereitung: ' + recipe.prep_time_minutes + ' Min.');
if (recipe.cook_time_minutes) addMetaTag('&#128293;', 'Kochen: ' + recipe.cook_time_minutes + ' Min.');
if (recipe.nutrition?.kcal_per_serving) {
const sourceLabel = { usda: ' (USDA)', gpt4_estimate: ' (Schätzung)' }[recipe.nutrition.source] || '';
addMetaTag('&#128293;', Math.round(recipe.nutrition.kcal_per_serving) + ' kcal' + sourceLabel);
}
if (metaTags.children.length) info.appendChild(metaTags);
if (recipe.tags?.length) {
const tagList = document.createElement('div');
tagList.className = 'tag-list history-tags';
recipe.tags.forEach(tag => {
const span = document.createElement('span');
span.className = 'tag';
span.textContent = tag;
tagList.appendChild(span);
});
info.appendChild(tagList);
}
const meta = document.createElement('div');
meta.className = 'history-meta';
const dateSpan = document.createElement('span');
dateSpan.className = 'history-date';
dateSpan.textContent = formatDate(entry.extracted_at);
meta.appendChild(dateSpan);
if (entry.imported_at) {
const badge = document.createElement('span');
badge.className = 'history-imported-badge';
badge.textContent = 'Importiert ' + formatDate(entry.imported_at);
meta.appendChild(badge);
}
info.appendChild(meta);
header.appendChild(info);
// Controls
const controls = document.createElement('div');
controls.className = 'history-controls';
const toggleBtn = document.createElement('button');
toggleBtn.className = 'history-toggle-btn';
toggleBtn.title = 'Auf-/Zuklappen';
toggleBtn.innerHTML = '&#9660;';
controls.appendChild(toggleBtn);
const deleteBtn = document.createElement('button');
deleteBtn.className = 'history-delete-btn';
deleteBtn.title = 'Löschen';
deleteBtn.innerHTML = '&#10005;';
controls.appendChild(deleteBtn);
header.appendChild(controls);
card.appendChild(header);
// ── Body (collapsed by default) ──
const body = document.createElement('div');
body.className = 'history-card-body hidden';
const bodyInner = document.createElement('div');
bodyInner.className = 'recipe-body';
if (recipe.ingredients?.length) {
const ingSection = document.createElement('div');
ingSection.className = 'ingredients-section';
const ingTitle = document.createElement('h3');
ingTitle.textContent = 'Zutaten';
ingSection.appendChild(ingTitle);
const ul = document.createElement('ul');
ul.className = 'ingredients-list';
recipe.ingredients.forEach(ing => {
const li = document.createElement('li');
if (ing.amount || ing.unit) {
const amountSpan = document.createElement('span');
amountSpan.className = 'ingredient-amount';
amountSpan.textContent = [ing.amount, ing.unit].filter(Boolean).join(' ');
li.appendChild(amountSpan);
}
const nameSpan = document.createElement('span');
nameSpan.textContent = ing.name;
li.appendChild(nameSpan);
if (ing.note) {
const noteSpan = document.createElement('span');
noteSpan.className = 'ingredient-note';
noteSpan.textContent = '(' + ing.note + ')';
li.appendChild(noteSpan);
}
ul.appendChild(li);
});
ingSection.appendChild(ul);
bodyInner.appendChild(ingSection);
}
if (recipe.steps?.length) {
const stepsSection = document.createElement('div');
stepsSection.className = 'steps-section';
const stepsTitle = document.createElement('h3');
stepsTitle.textContent = 'Zubereitung';
stepsSection.appendChild(stepsTitle);
const ol = document.createElement('ol');
ol.className = 'steps-list';
recipe.steps.forEach(step => {
const li = document.createElement('li');
li.innerHTML = renderMarkdownBold(step);
ol.appendChild(li);
});
stepsSection.appendChild(ol);
bodyInner.appendChild(stepsSection);
}
body.appendChild(bodyInner);
// Actions
const actions = document.createElement('div');
actions.className = 'recipe-actions';
if (recipe.source_url) {
const sourceLink = document.createElement('a');
sourceLink.href = recipe.source_url;
sourceLink.target = '_blank';
sourceLink.className = 'btn btn-secondary';
sourceLink.textContent = 'Originalrezept';
actions.appendChild(sourceLink);
}
if (!entry.imported_at) {
const importBtn = document.createElement('button');
importBtn.className = 'btn btn-primary';
importBtn.textContent = 'In Tandoor importieren';
const importStatus = document.createElement('span');
importStatus.className = 'import-status';
importBtn.addEventListener('click', async () => {
if (!tandoorConfigured) {
importStatus.textContent = 'Tandoor nicht konfiguriert.';
importStatus.className = 'import-status error';
return;
}
importBtn.disabled = true;
importStatus.textContent = 'Importiere...';
importStatus.className = 'import-status';
try {
const payload = { ...entry.recipe_data, history_id: entry.id };
const res = await fetch('/api/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
const data = await res.json();
if (res.ok) {
await loadHistory();
} else {
importStatus.textContent = 'Fehler: ' + (data.detail || 'Import fehlgeschlagen');
importStatus.className = 'import-status error';
importBtn.disabled = false;
}
} catch (_) {
importStatus.textContent = 'Netzwerkfehler';
importStatus.className = 'import-status error';
importBtn.disabled = false;
}
});
actions.appendChild(importBtn);
actions.appendChild(importStatus);
}
body.appendChild(actions);
card.appendChild(body);
// ── Toggle ──
toggleBtn.addEventListener('click', () => {
const expanded = !body.classList.contains('hidden');
body.classList.toggle('hidden', expanded);
toggleBtn.innerHTML = expanded ? '&#9660;' : '&#9650;';
card.classList.toggle('history-card--expanded', !expanded);
});
// ── Delete ──
deleteBtn.addEventListener('click', async () => {
if (!confirm('Rezept aus dem Verlauf löschen?')) return;
try {
await fetch('/api/history/' + entry.id, { method: 'DELETE' });
card.remove();
if (!document.querySelector('#history-list .history-card')) {
document.getElementById('history-section').classList.add('hidden');
}
} catch (_) {}
});
return card;
}
// ── Event listeners ──────────────────────────────────────────────────────────
document.getElementById('extract-btn').addEventListener('click', startExtraction);
document.getElementById('url-input').addEventListener('keydown', e => {
if (e.key === 'Enter') startExtraction();
});
document.getElementById('import-btn').addEventListener('click', importToTandoor);
document.getElementById('copy-json-btn').addEventListener('click', copyJson);
// Init
checkHealth();
loadHistory();
+141
View File
@@ -0,0 +1,141 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rezept0r</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Instrument+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/style.css?v=1">
</head>
<body>
<nav class="nav">
<div class="nav-inner">
<span class="nav-logo">Rezept0r</span>
<span class="nav-badge">v2</span>
</div>
</nav>
<main class="page-shell">
<section class="hero">
<h1 class="hero-headline">Rezepte aus Social,<br>Video und Web.</h1>
<p class="hero-sub">URL einfügen — analysieren, extrahieren, direkt nach Tandoor importieren.</p>
<div class="url-form">
<input
type="url"
id="url-input"
placeholder="Rezept-URL oder YouTube / TikTok / Instagram…"
autocomplete="off"
/>
<button id="extract-btn" type="button">
<span class="btn-label">Extrahieren</span>
<span class="btn-arrow" aria-hidden="true">→</span>
</button>
</div>
<div class="platform-strip" aria-label="Unterstützte Plattformen">
<span class="platform-label">Unterstützt:</span>
<div class="platform-pill">
<svg class="platform-icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="#FF0000" d="M23.5 6.19a3.02 3.02 0 0 0-2.12-2.14C19.5 3.55 12 3.55 12 3.55s-7.51 0-9.38.5A3.02 3.02 0 0 0 .5 6.19C0 8.07 0 12 0 12s0 3.93.5 5.81a3.02 3.02 0 0 0 2.12 2.14c1.87.5 9.38.5 9.38.5s7.51 0 9.38-.5a3.02 3.02 0 0 0 2.12-2.14C24 15.93 24 12 24 12s0-3.93-.5-5.81zM9.54 15.57V8.43L15.82 12l-6.28 3.57z"/></svg>
YouTube
</div>
<div class="platform-pill">
<svg class="platform-icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="#010101" d="M12.53.02C13.84 0 15.14.01 16.44 0c.08 1.53.63 3.09 1.75 4.17 1.12 1.11 2.7 1.62 4.24 1.79v4.03c-1.44-.05-2.89-.35-4.2-.97-.57-.26-1.1-.59-1.62-.93-.01 2.92.01 5.84-.02 8.75-.08 1.4-.54 2.79-1.35 3.94-1.31 1.92-3.58 3.17-5.91 3.21-1.43.08-2.86-.31-4.08-1.03-2.02-1.19-3.44-3.37-3.65-5.71-.02-.5-.03-1-.01-1.49.18-1.9 1.12-3.72 2.58-4.96 1.66-1.44 3.98-2.13 6.15-1.72.02 1.48-.04 2.96-.04 4.44-.99-.32-2.15-.23-3.02.37-.63.41-1.11 1.04-1.36 1.75-.21.51-.15 1.07-.14 1.61.24 1.64 1.82 3.02 3.5 2.87 1.12-.01 2.19-.66 2.77-1.61.19-.33.4-.67.41-1.06.1-1.79.06-3.57.07-5.36.01-4.03-.01-8.05.02-12.07z"/></svg>
TikTok
</div>
<div class="platform-pill">
<svg class="platform-icon" viewBox="0 0 24 24" aria-hidden="true"><defs><radialGradient id="ig-grad" cx="30%" cy="107%" r="150%"><stop offset="0%" stop-color="#fdf497"/><stop offset="5%" stop-color="#fdf497"/><stop offset="45%" stop-color="#fd5949"/><stop offset="60%" stop-color="#d6249f"/><stop offset="90%" stop-color="#285AEB"/></radialGradient></defs><path fill="url(#ig-grad)" d="M12 2.163c3.204 0 3.584.012 4.85.07 3.252.148 4.771 1.691 4.919 4.919.058 1.265.069 1.645.069 4.849 0 3.205-.012 3.584-.069 4.849-.149 3.225-1.664 4.771-4.919 4.919-1.266.058-1.644.07-4.85.07-3.204 0-3.584-.012-4.849-.07-3.26-.149-4.771-1.699-4.919-4.92-.058-1.265-.07-1.644-.07-4.849 0-3.204.013-3.583.07-4.849.149-3.227 1.664-4.771 4.919-4.919 1.266-.057 1.645-.069 4.849-.069zM12 0C8.741 0 8.333.014 7.053.072 2.695.272.273 2.69.073 7.052.014 8.333 0 8.741 0 12c0 3.259.014 3.668.072 4.948.2 4.358 2.618 6.78 6.98 6.98C8.333 23.986 8.741 24 12 24c3.259 0 3.668-.014 4.948-.072 4.354-.2 6.782-2.618 6.979-6.98.059-1.28.073-1.689.073-4.948 0-3.259-.014-3.667-.072-4.947-.196-4.354-2.617-6.78-6.979-6.98C15.668.014 15.259 0 12 0zm0 5.838a6.162 6.162 0 1 0 0 12.324 6.162 6.162 0 0 0 0-12.324zM12 16a4 4 0 1 1 0-8 4 4 0 0 1 0 8zm6.406-11.845a1.44 1.44 0 1 0 0 2.881 1.44 1.44 0 0 0 0-2.881z"/></svg>
Instagram
</div>
<div class="platform-pill">
<svg class="platform-icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="#E60023" d="M12 0C5.373 0 0 5.373 0 12c0 5.084 3.163 9.426 7.627 11.174-.105-.949-.2-2.405.042-3.441.218-.937 1.407-5.965 1.407-5.965s-.359-.719-.359-1.782c0-1.668.967-2.914 2.171-2.914 1.023 0 1.518.769 1.518 1.69 0 1.029-.655 2.568-.994 3.995-.283 1.194.599 2.169 1.777 2.169 2.133 0 3.772-2.249 3.772-5.495 0-2.873-2.064-4.882-5.012-4.882-3.414 0-5.418 2.561-5.418 5.207 0 1.031.397 2.138.893 2.738a.36.36 0 0 1 .083.345l-.333 1.36c-.053.22-.174.267-.402.161-1.499-.698-2.436-2.889-2.436-4.649 0-3.785 2.75-7.262 7.929-7.262 4.163 0 7.398 2.967 7.398 6.931 0 4.136-2.607 7.464-6.227 7.464-1.216 0-2.359-.632-2.75-1.378l-.748 2.853c-.271 1.043-1.002 2.35-1.492 3.146C9.57 23.812 10.763 24 12 24c6.627 0 12-5.373 12-12S18.627 0 12 0z"/></svg>
Pinterest
</div>
<div class="platform-pill">
<img class="platform-icon" src="/static/picnic-logo.svg" alt="Picnic" aria-hidden="true">
Picnic
</div>
<div class="platform-pill">
<svg class="platform-icon" viewBox="0 0 24 24" aria-hidden="true"><path fill="#0071e3" d="M12 2a10 10 0 1 0 0 20A10 10 0 0 0 12 2zm0 1.5c1.07 0 2.1.6 2.97 1.67-.37.13-.75.25-1.14.35A9.7 9.7 0 0 0 12 5.1a9.7 9.7 0 0 0-1.83.42c-.39-.1-.77-.22-1.14-.35C9.9 4.1 10.93 3.5 12 3.5zm-3.7 1.04c.46.2.93.37 1.41.51a9.5 9.5 0 0 0-.52 1.7 12.7 12.7 0 0 1-2.25-.96 8.5 8.5 0 0 1 1.36-1.25zm7.4 0c.5.37.96.79 1.36 1.25a12.7 12.7 0 0 1-2.25.96 9.5 9.5 0 0 0-.52-1.7c.48-.14.95-.31 1.41-.51zM12 6.6c.43 0 .86.03 1.27.09.2.67.32 1.38.35 2.1H10.38c.03-.72.15-1.43.35-2.1.41-.06.84-.09 1.27-.09zm-3.14.27a10.9 10.9 0 0 0-.29 1.92H5.64a8.5 8.5 0 0 1 3.22-1.92zm6.28 0a8.5 8.5 0 0 1 3.22 1.92h-2.93a10.9 10.9 0 0 0-.29-1.92zM5.17 10.3h3.2c-.04.55-.07 1.1-.07 1.65 0 .57.03 1.12.08 1.65H5.17a8.5 8.5 0 0 1 0-3.3zm4.7 0h4.26c.05.54.07 1.09.07 1.65 0 .56-.02 1.11-.07 1.65H9.87a19.3 19.3 0 0 1-.07-1.65c0-.56.02-1.11.07-1.65zm5.76 0h3.2a8.5 8.5 0 0 1 0 3.3h-3.21c.05-.53.08-1.08.08-1.65 0-.55-.03-1.1-.07-1.65zm-9.99 4.8h2.93c.08.68.18 1.33.29 1.93a8.5 8.5 0 0 1-3.22-1.92zm4.38 0h4.04a9.5 9.5 0 0 1-.35 2.1A9.5 9.5 0 0 0 12 17.4a9.5 9.5 0 0 0-1.27-.09 9.5 9.5 0 0 1-.35-2.1h-.36zm5.54 0h2.93a8.5 8.5 0 0 1-3.22 1.92c.11-.6.21-1.25.29-1.93zM8.7 16.37c.37.13.75.25 1.14.35.26.76.54 1.41.83 1.91-.94-.47-1.77-1.17-1.97-2.26zm6.6 0c-.2 1.09-1.03 1.79-1.97 2.26.29-.5.57-1.15.83-1.91.39-.1.77-.22 1.14-.35z"/></svg>
Webseiten
</div>
</div>
</section>
<section id="progress-section" class="hidden">
<div class="progress-wrap">
<div class="progress-bar-container">
<div class="progress-bar" id="progress-bar"></div>
</div>
<p id="progress-message" class="progress-message"></p>
</div>
</section>
<section id="result-section" class="hidden">
<div id="recipe-card" class="recipe-card">
<img id="recipe-image" class="recipe-image hidden" src="" alt="Rezeptbild" />
<div class="recipe-content">
<div class="recipe-meta-top">
<h2 id="recipe-title"></h2>
<p id="recipe-description" class="recipe-description"></p>
</div>
<div class="meta-tags">
<span id="recipe-servings" class="meta-tag hidden"><span id="servings-value"></span></span>
<span id="recipe-prep" class="meta-tag hidden">⏱ <span id="prep-value"></span> Min. Vorbereitung</span>
<span id="recipe-cook" class="meta-tag hidden">🔥 <span id="cook-value"></span> Min. Kochen</span>
<span id="recipe-kcal" class="meta-tag hidden">⚡ <span id="kcal-value"></span> kcal<span id="kcal-source" class="kcal-source"></span></span>
</div>
<div id="recipe-tags" class="tag-list"></div>
<div class="recipe-body">
<div class="ingredients-section">
<h3>Zutaten</h3>
<ul id="ingredients-list" class="ingredients-list"></ul>
</div>
<div class="steps-section">
<h3>Zubereitung</h3>
<ol id="steps-list" class="steps-list"></ol>
</div>
</div>
<div class="recipe-actions">
<a id="source-link" href="#" target="_blank" class="btn btn-secondary">Originalrezept</a>
<button id="import-btn" class="btn btn-primary">In Tandoor importieren</button>
<span id="import-status" class="import-status"></span>
</div>
</div>
</div>
<div id="tandoor-fallback" class="hidden fallback-card">
<h3>Tandoor JSON</h3>
<p class="fallback-note">Kopiere dieses JSON in Tandoor unter Einstellungen → Import.</p>
<textarea id="tandoor-json" class="json-textarea" readonly></textarea>
<button id="copy-json-btn" class="btn btn-secondary">JSON kopieren</button>
</div>
</section>
<section id="error-section" class="hidden">
<div class="error-box">
<div class="error-icon" aria-hidden="true">!</div>
<p id="error-message"></p>
</div>
</section>
<section id="history-section" class="hidden">
<h2 class="section-heading">Verlauf</h2>
<div id="history-list"></div>
</section>
</main>
<script src="/static/app.js?v=4"></script>
</body>
</html>
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 133 KiB

+730
View File
@@ -0,0 +1,730 @@
:root {
--bg: #f5f5f7;
--surface: #ffffff;
--text: #1d1d1f;
--text-secondary: #6e6e73;
--text-tertiary: #86868b;
--accent: #0071e3;
--line: rgba(0, 0, 0, 0.08);
--line-strong: rgba(0, 0, 0, 0.12);
--ok: #34c759;
--ok-text: #1a7a3c;
--err: #ff3b30;
--shadow-card: 0 2px 16px rgba(0, 0, 0, 0.06), 0 0 0 0.5px rgba(0, 0, 0, 0.04);
--shadow-hover: 0 8px 40px rgba(0, 0, 0, 0.12), 0 0 0 0.5px rgba(0, 0, 0, 0.06);
--radius-xl: 24px;
--radius-lg: 18px;
--radius-md: 12px;
--radius-sm: 8px;
--radius-pill: 999px;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
body {
min-height: 100vh;
font-family: 'Instrument Sans', -apple-system, BlinkMacSystemFont, sans-serif;
font-size: 15px;
color: var(--text);
background: var(--bg);
overflow-x: hidden;
}
/* ── Nav ──────────────────────────────────────────────────────────────── */
.nav {
position: sticky;
top: 0;
z-index: 100;
backdrop-filter: saturate(180%) blur(20px);
-webkit-backdrop-filter: saturate(180%) blur(20px);
background: rgba(245, 245, 247, 0.8);
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
}
.nav-inner {
max-width: 960px;
margin: 0 auto;
padding: 0 24px;
height: 48px;
display: flex;
align-items: center;
gap: 10px;
}
.nav-logo {
font-size: 1rem;
font-weight: 700;
color: var(--text);
letter-spacing: -0.015em;
}
.nav-badge {
font-size: 0.68rem;
font-weight: 600;
color: var(--text-tertiary);
background: rgba(0, 0, 0, 0.06);
padding: 2px 8px;
border-radius: var(--radius-pill);
letter-spacing: 0.03em;
}
/* ── Page Shell ───────────────────────────────────────────────────────── */
.page-shell {
max-width: 960px;
margin: 0 auto;
padding: 0 24px 80px;
}
/* ── Hero ─────────────────────────────────────────────────────────────── */
.hero {
padding: 80px 0 56px;
text-align: center;
animation: appear 600ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
@keyframes appear {
from { opacity: 0; transform: translateY(24px); }
to { opacity: 1; transform: translateY(0); }
}
.hero-headline {
font-size: clamp(2.8rem, 6vw, 4.6rem);
font-weight: 700;
letter-spacing: -0.03em;
line-height: 1.04;
margin-bottom: 18px;
/* Most of headline is Apple near-black; last word bleeds into warm orange */
background: linear-gradient(
130deg,
#1d1d1f 0%,
#1d1d1f 50%,
#e8612a 75%,
#f5a623 100%
);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
.hero-sub {
font-size: clamp(1rem, 2.2vw, 1.15rem);
color: var(--text-secondary);
font-weight: 400;
margin-bottom: 36px;
letter-spacing: -0.005em;
line-height: 1.5;
}
/* ── URL Form ─────────────────────────────────────────────────────────── */
.url-form {
display: flex;
gap: 8px;
max-width: 700px;
margin: 0 auto 24px;
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 8px;
}
.url-form input[type="url"] {
flex: 1;
border: 0;
background: transparent;
color: var(--text);
font-family: inherit;
font-size: 0.95rem;
padding: 10px 12px;
outline: none;
min-width: 0;
}
.url-form input[type="url"]::placeholder {
color: var(--text-tertiary);
}
#extract-btn {
border: 0;
border-radius: var(--radius-md);
background: var(--text);
color: #fff;
font-family: inherit;
font-weight: 600;
font-size: 0.88rem;
padding: 10px 20px;
cursor: pointer;
transition: background 150ms ease, transform 150ms ease, opacity 150ms ease;
display: inline-flex;
align-items: center;
gap: 8px;
white-space: nowrap;
flex-shrink: 0;
}
#extract-btn:hover {
background: #333;
transform: scale(1.02);
}
#extract-btn:disabled {
opacity: 0.38;
cursor: not-allowed;
transform: none;
}
.btn-arrow {
display: inline-block;
transition: transform 200ms ease;
}
#extract-btn:hover .btn-arrow {
transform: translateX(4px);
}
/* ── Platform Strip ───────────────────────────────────────────────────── */
.platform-strip {
display: flex;
align-items: center;
justify-content: center;
gap: 8px;
flex-wrap: wrap;
animation: appear 600ms 80ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.platform-label {
font-size: 0.78rem;
color: var(--text-tertiary);
font-weight: 500;
margin-right: 2px;
}
.platform-pill {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--surface);
border-radius: var(--radius-pill);
padding: 5px 13px;
font-size: 0.78rem;
font-weight: 500;
color: var(--text-secondary);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
}
.platform-icon {
width: 15px;
height: 15px;
flex-shrink: 0;
}
/* ── Progress ─────────────────────────────────────────────────────────── */
#progress-section,
#result-section,
#error-section,
#history-section {
animation: appear 400ms cubic-bezier(0.22, 1, 0.36, 1) both;
}
.progress-wrap {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 24px 28px;
margin-bottom: 20px;
}
.progress-bar-container {
background: rgba(0, 0, 0, 0.06);
border-radius: var(--radius-pill);
height: 4px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-bar {
height: 100%;
width: 0;
background: var(--text);
border-radius: var(--radius-pill);
transition: width 350ms ease;
}
.progress-message {
font-size: 0.85rem;
color: var(--text-secondary);
}
/* ── Recipe Card ──────────────────────────────────────────────────────── */
.recipe-card {
background: var(--surface);
border-radius: var(--radius-xl);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 16px;
}
.recipe-image {
width: 100%;
height: 320px;
object-fit: cover;
display: block;
}
.recipe-content {
padding: 32px;
}
.recipe-meta-top {
margin-bottom: 20px;
}
.recipe-meta-top h2 {
font-size: clamp(1.6rem, 3.5vw, 2.2rem);
font-weight: 700;
letter-spacing: -0.025em;
line-height: 1.15;
margin-bottom: 10px;
color: var(--text);
}
.recipe-description {
font-size: 0.95rem;
color: var(--text-secondary);
line-height: 1.6;
}
/* Meta chips (both .meta-tag from JS-built history cards and static) */
.meta-tags {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 16px;
}
.meta-tag {
display: inline-flex;
align-items: center;
gap: 5px;
background: var(--bg);
border-radius: var(--radius-pill);
padding: 6px 14px;
font-size: 0.82rem;
font-weight: 500;
color: var(--text-secondary);
}
.kcal-source {
font-size: 0.72rem;
opacity: 0.6;
margin-left: 2px;
font-weight: 400;
}
/* Tags */
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 7px;
margin-bottom: 28px;
}
.tag {
background: rgba(0, 113, 227, 0.08);
color: var(--accent);
border-radius: var(--radius-pill);
font-size: 0.78rem;
font-weight: 600;
padding: 4px 12px;
}
/* ── Recipe Body ──────────────────────────────────────────────────────── */
.recipe-body {
display: grid;
grid-template-columns: 1fr 1.7fr;
border: 1px solid var(--line);
border-radius: var(--radius-lg);
overflow: hidden;
margin-bottom: 24px;
}
.ingredients-section,
.steps-section {
padding: 24px;
}
.ingredients-section {
background: var(--bg);
border-right: 1px solid var(--line);
}
.ingredients-section h3,
.steps-section h3 {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 16px;
}
.ingredients-list {
list-style: none;
}
.ingredients-list li {
padding: 8px 0;
border-bottom: 1px solid var(--line);
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 0.9rem;
align-items: baseline;
line-height: 1.5;
}
.ingredients-list li:last-child {
border-bottom: 0;
padding-bottom: 0;
}
.ingredient-amount {
font-weight: 700;
font-size: 0.86rem;
color: var(--text);
flex-shrink: 0;
}
.ingredient-note {
color: var(--text-tertiary);
font-size: 0.82rem;
}
.steps-list {
padding-left: 20px;
}
.steps-list li {
margin-bottom: 14px;
font-size: 0.9rem;
line-height: 1.72;
color: var(--text);
}
/* ── Actions ──────────────────────────────────────────────────────────── */
.recipe-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.btn {
border: 1px solid transparent;
border-radius: var(--radius-pill);
text-decoration: none;
padding: 10px 20px;
font-family: inherit;
font-weight: 600;
font-size: 0.88rem;
cursor: pointer;
transition: background 150ms ease, transform 150ms ease, border-color 150ms ease;
display: inline-flex;
align-items: center;
line-height: 1;
}
.btn-primary {
background: var(--text);
color: #fff;
border-color: var(--text);
}
.btn-primary:hover {
background: #333;
border-color: #333;
transform: scale(1.02);
}
.btn-primary:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: var(--bg);
color: var(--text);
border-color: var(--line-strong);
}
.btn-secondary:hover {
background: #e8e8ed;
}
.import-status {
font-size: 0.84rem;
font-weight: 500;
}
.import-status.success { color: var(--ok-text); }
.import-status.error { color: var(--err); }
/* ── Fallback Card ────────────────────────────────────────────────────── */
.fallback-card {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 24px;
margin-bottom: 16px;
}
.fallback-card h3 {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
margin-bottom: 8px;
}
.fallback-note {
font-size: 0.86rem;
color: var(--text-secondary);
margin-bottom: 14px;
line-height: 1.5;
}
.json-textarea {
width: 100%;
min-height: 180px;
resize: vertical;
background: var(--bg);
border: 1px solid var(--line);
border-radius: var(--radius-md);
color: var(--text-secondary);
padding: 14px;
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
font-size: 0.8rem;
margin-bottom: 12px;
line-height: 1.5;
}
/* ── Error ────────────────────────────────────────────────────────────── */
.error-box {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
padding: 20px 24px;
display: flex;
align-items: center;
gap: 14px;
color: var(--err);
font-size: 0.9rem;
font-weight: 500;
}
.error-icon {
width: 32px;
height: 32px;
border-radius: 50%;
background: rgba(255, 59, 48, 0.1);
color: var(--err);
display: flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 1rem;
flex-shrink: 0;
}
/* ── History ──────────────────────────────────────────────────────────── */
.section-heading {
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
color: var(--text-tertiary);
margin: 36px 0 14px;
}
.history-card {
background: var(--surface);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-card);
overflow: hidden;
margin-bottom: 10px;
transition: box-shadow 200ms ease, transform 200ms ease;
}
.history-card:hover {
box-shadow: var(--shadow-hover);
transform: translateY(-1px);
}
.history-card-header {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 14px;
}
.history-thumb {
width: 68px;
height: 60px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.history-info {
min-width: 0;
flex: 1;
}
.history-title {
font-size: 0.9rem;
font-weight: 600;
margin-bottom: 4px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-desc {
color: var(--text-tertiary);
font-size: 0.8rem;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.45;
}
.history-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
align-items: center;
}
.history-date {
color: var(--text-tertiary);
font-size: 0.72rem;
}
.history-imported-badge {
background: rgba(52, 199, 89, 0.1);
color: var(--ok-text);
border-radius: var(--radius-pill);
padding: 2px 8px;
font-size: 0.68rem;
font-weight: 600;
}
.history-tags {
margin-bottom: 0;
}
.history-controls {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.history-toggle-btn,
.history-delete-btn {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: var(--bg);
color: var(--text-secondary);
cursor: pointer;
transition: all 120ms ease;
font-size: 0.72rem;
display: flex;
align-items: center;
justify-content: center;
}
.history-toggle-btn:hover {
background: #e8e8ed;
color: var(--text);
}
.history-delete-btn:hover {
background: rgba(255, 59, 48, 0.07);
color: var(--err);
border-color: rgba(255, 59, 48, 0.2);
}
.history-card-body {
border-top: 1px solid var(--line);
}
/* ── Utility ──────────────────────────────────────────────────────────── */
.hidden { display: none !important; }
/* ── Responsive ───────────────────────────────────────────────────────── */
@media (max-width: 720px) {
.hero {
padding: 52px 0 40px;
}
.url-form {
flex-direction: column;
gap: 6px;
}
#extract-btn {
width: 100%;
justify-content: center;
padding: 14px;
}
.recipe-content {
padding: 20px;
}
.recipe-body {
grid-template-columns: 1fr;
}
.ingredients-section {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.recipe-image {
height: 220px;
}
}
+698
View File
@@ -0,0 +1,698 @@
:root {
--bg: #0d0c0b;
--bg-card: #131110;
--bg-elevated: #1a1714;
--surface: #1e1b17;
--text: #f0e8db;
--muted: #6e6153;
--muted-light: #8a7a6b;
--accent: #c97a3a;
--accent-warm: #e8a065;
--line: #252118;
--line-soft: #1c1a16;
--ok: #4d9468;
--err: #b85454;
--shadow: 0 8px 32px rgba(0, 0, 0, 0.6);
--shadow-lg: 0 20px 60px rgba(0, 0, 0, 0.8);
--radius-xl: 20px;
--radius-lg: 14px;
--radius-md: 8px;
--radius-sm: 4px;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* Grain texture overlay */
body::after {
content: '';
position: fixed;
inset: 0;
z-index: 9999;
pointer-events: none;
opacity: 0.035;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 512 512' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.75' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
background-size: 256px 256px;
}
body {
min-height: 100vh;
font-family: 'Syne', 'Segoe UI', sans-serif;
font-size: 15px;
color: var(--text);
background-color: var(--bg);
padding: 32px 16px 80px;
overflow-x: hidden;
}
.bg-orb {
position: fixed;
z-index: 0;
border-radius: 50%;
filter: blur(100px);
pointer-events: none;
}
.orb-a {
width: 500px;
height: 500px;
background: radial-gradient(circle, rgba(160, 75, 20, 0.18), transparent 70%);
top: -150px;
left: -150px;
}
.orb-b {
width: 600px;
height: 600px;
background: radial-gradient(circle, rgba(120, 55, 15, 0.12), transparent 70%);
right: -200px;
bottom: -100px;
}
.page-shell {
max-width: 1000px;
margin: 0 auto;
position: relative;
z-index: 1;
}
/* ── Hero ───────────────────────────────────────────────────────────── */
.hero {
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius-xl);
padding: clamp(28px, 5vw, 52px);
margin-bottom: 20px;
animation: fade-up 500ms cubic-bezier(0.16, 1, 0.3, 1) both;
position: relative;
overflow: hidden;
}
/* Amber gradient top edge */
.hero::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 1px;
background: linear-gradient(90deg, transparent 0%, rgba(201, 122, 58, 0.5) 40%, rgba(232, 160, 101, 0.5) 60%, transparent 100%);
}
@keyframes fade-up {
from { opacity: 0; transform: translateY(18px); }
to { opacity: 1; transform: translateY(0); }
}
.eyebrow {
display: inline-flex;
align-items: center;
gap: 10px;
font-size: 0.65rem;
font-weight: 700;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 18px;
}
.eyebrow::before {
content: '';
display: block;
width: 22px;
height: 1px;
background: var(--accent);
flex-shrink: 0;
}
.hero h1 {
font-family: 'Cormorant Garamond', Georgia, serif;
font-size: clamp(2.4rem, 5.5vw, 4rem);
font-weight: 600;
font-style: italic;
line-height: 1.05;
color: var(--text);
margin-bottom: 14px;
}
.hero-copy {
color: var(--muted-light);
font-size: 0.86rem;
letter-spacing: 0.01em;
line-height: 1.6;
margin-bottom: 28px;
max-width: 480px;
}
/* ── URL Form ───────────────────────────────────────────────────────── */
.url-form {
display: grid;
grid-template-columns: 1fr auto;
gap: 8px;
}
.url-form input[type="url"] {
width: 100%;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
color: var(--text);
font-family: 'Syne', sans-serif;
font-size: 0.88rem;
padding: 0.85rem 1.1rem;
transition: border-color 150ms ease, box-shadow 150ms ease;
}
.url-form input[type="url"]::placeholder {
color: var(--muted);
}
.url-form input[type="url"]:focus {
outline: none;
border-color: var(--accent);
box-shadow: 0 0 0 3px rgba(201, 122, 58, 0.12);
}
#extract-btn {
border: 0;
border-radius: var(--radius-md);
background: var(--accent);
color: #0d0c0b;
font-family: 'Syne', sans-serif;
font-weight: 700;
font-size: 0.78rem;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 0 1.4rem;
cursor: pointer;
transition: background 120ms ease, transform 120ms ease;
white-space: nowrap;
}
#extract-btn:hover {
background: var(--accent-warm);
transform: translateY(-1px);
}
#extract-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
transform: none;
}
/* ── Platform Pills ─────────────────────────────────────────────────── */
.platform-strip {
margin-top: 16px;
display: flex;
align-items: center;
gap: 7px;
flex-wrap: wrap;
}
.platform-pill {
display: inline-flex;
align-items: center;
gap: 6px;
background: transparent;
border: 1px solid var(--line);
border-radius: var(--radius-sm);
padding: 5px 10px;
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.08em;
color: var(--muted);
text-transform: uppercase;
transition: border-color 120ms ease, color 120ms ease;
}
.platform-pill:hover {
border-color: var(--muted);
color: var(--muted-light);
}
.platform-icon {
color: var(--accent);
font-size: 0.7rem;
}
/* ── Progress ───────────────────────────────────────────────────────── */
#progress-section,
#result-section,
#error-section,
#history-section {
animation: fade-up 380ms cubic-bezier(0.16, 1, 0.3, 1) both;
}
.progress-bar-container {
background: var(--surface);
border-radius: 999px;
height: 2px;
overflow: hidden;
margin-bottom: 12px;
}
.progress-bar {
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--accent), var(--accent-warm), var(--accent));
background-size: 200% 100%;
animation: shimmer 1.8s linear infinite;
transition: width 350ms ease;
}
@keyframes shimmer {
0% { background-position: 0% 0%; }
100% { background-position: 200% 0%; }
}
.progress-message {
color: var(--muted-light);
font-size: 0.8rem;
letter-spacing: 0.05em;
}
/* ── Cards ──────────────────────────────────────────────────────────── */
.recipe-card,
#tandoor-fallback,
.history-card {
background: var(--bg-card);
border: 1px solid var(--line);
border-radius: var(--radius-lg);
overflow: hidden;
}
/* ── Recipe Header ──────────────────────────────────────────────────── */
.recipe-header {
display: flex;
flex-wrap: wrap;
gap: 0;
border-bottom: 1px solid var(--line);
}
.recipe-image {
width: 260px;
height: 200px;
object-fit: cover;
flex-shrink: 0;
}
.recipe-meta {
flex: 1;
min-width: 240px;
padding: 24px;
}
.recipe-meta h2 {
font-family: 'Cormorant Garamond', Georgia, serif;
font-size: 1.9rem;
font-weight: 600;
line-height: 1.15;
margin-bottom: 8px;
color: var(--text);
}
.recipe-description {
color: var(--muted-light);
font-size: 0.86rem;
line-height: 1.65;
margin-bottom: 14px;
}
.meta-tags,
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 10px;
}
.meta-tag {
background: var(--surface);
border: 1px solid var(--line);
color: var(--muted-light);
border-radius: var(--radius-sm);
padding: 4px 9px;
font-size: 0.72rem;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
}
.kcal-source {
font-size: 0.68rem;
opacity: 0.65;
font-weight: 400;
text-transform: none;
letter-spacing: 0;
}
.tag {
background: rgba(201, 122, 58, 0.12);
color: var(--accent-warm);
border: 1px solid rgba(201, 122, 58, 0.2);
border-radius: var(--radius-sm);
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
padding: 3px 8px;
}
/* ── Recipe Body ────────────────────────────────────────────────────── */
.recipe-body {
display: grid;
grid-template-columns: 1fr 1.7fr;
}
.ingredients-section,
.steps-section {
padding: 24px;
}
.ingredients-section {
border-right: 1px solid var(--line);
}
.ingredients-section h3,
.steps-section h3,
.section-heading {
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--accent);
margin-bottom: 16px;
}
.ingredients-list {
list-style: none;
}
.ingredients-list li {
padding: 7px 0;
border-bottom: 1px solid var(--line-soft);
display: flex;
flex-wrap: wrap;
gap: 6px;
font-size: 0.89rem;
align-items: baseline;
line-height: 1.5;
}
.ingredients-list li:last-child {
border-bottom: 0;
}
.ingredient-amount {
color: var(--accent-warm);
font-weight: 700;
font-size: 0.84rem;
flex-shrink: 0;
}
.ingredient-note {
color: var(--muted);
font-size: 0.8rem;
}
.steps-list {
padding-left: 20px;
}
.steps-list li {
margin-bottom: 12px;
font-size: 0.89rem;
line-height: 1.7;
color: #cec4b8;
}
/* ── Actions ────────────────────────────────────────────────────────── */
.recipe-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
border-top: 1px solid var(--line);
padding: 14px 24px;
}
.btn {
border: 1px solid transparent;
border-radius: var(--radius-md);
text-decoration: none;
padding: 8px 14px;
font-family: 'Syne', sans-serif;
font-weight: 700;
font-size: 0.74rem;
letter-spacing: 0.1em;
text-transform: uppercase;
cursor: pointer;
transition: background 120ms ease, border-color 120ms ease, color 120ms ease, transform 120ms ease;
}
.btn-primary {
background: var(--accent);
color: #0d0c0b;
border-color: var(--accent);
}
.btn-primary:hover {
background: var(--accent-warm);
border-color: var(--accent-warm);
transform: translateY(-1px);
}
.btn-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
transform: none;
}
.btn-secondary {
background: transparent;
color: var(--muted-light);
border-color: var(--line);
}
.btn-secondary:hover {
color: var(--text);
border-color: var(--muted);
}
.import-status {
font-size: 0.78rem;
letter-spacing: 0.04em;
}
.import-status.success { color: var(--ok); }
.import-status.error { color: var(--err); }
/* ── Tandoor Fallback ───────────────────────────────────────────────── */
#tandoor-fallback {
margin-top: 12px;
padding: 22px;
}
#tandoor-fallback h3 {
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--muted);
margin-bottom: 8px;
}
.fallback-note {
color: var(--muted);
font-size: 0.82rem;
margin-bottom: 12px;
line-height: 1.5;
}
.json-textarea {
width: 100%;
min-height: 180px;
resize: vertical;
background: var(--surface);
border: 1px solid var(--line);
border-radius: var(--radius-md);
color: var(--muted-light);
padding: 12px;
font-family: ui-monospace, 'JetBrains Mono', Menlo, Consolas, monospace;
font-size: 0.8rem;
margin-bottom: 10px;
line-height: 1.5;
}
/* ── Error ──────────────────────────────────────────────────────────── */
.error-box {
border: 1px solid rgba(184, 84, 84, 0.25);
background: rgba(184, 84, 84, 0.07);
color: var(--err);
border-radius: var(--radius-md);
padding: 14px 18px;
font-size: 0.88rem;
line-height: 1.5;
}
/* ── History ────────────────────────────────────────────────────────── */
.section-heading {
margin: 28px 0 12px;
}
.history-card {
margin-bottom: 10px;
}
.history-card-header {
display: flex;
gap: 14px;
align-items: flex-start;
padding: 14px;
}
.history-thumb {
width: 72px;
height: 64px;
border-radius: var(--radius-sm);
object-fit: cover;
flex-shrink: 0;
}
.history-info {
min-width: 0;
flex: 1;
}
.history-title {
font-size: 0.95rem;
font-weight: 700;
margin-bottom: 4px;
color: var(--text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.history-desc {
color: var(--muted-light);
font-size: 0.82rem;
margin-bottom: 6px;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
line-height: 1.5;
}
.history-meta {
display: flex;
gap: 8px;
flex-wrap: wrap;
margin-top: 8px;
align-items: center;
}
.history-date {
color: var(--muted);
font-size: 0.7rem;
letter-spacing: 0.04em;
}
.history-imported-badge {
background: rgba(77, 148, 104, 0.12);
color: var(--ok);
border: 1px solid rgba(77, 148, 104, 0.2);
border-radius: var(--radius-sm);
padding: 2px 7px;
font-size: 0.64rem;
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
}
.history-controls {
display: flex;
flex-direction: column;
gap: 4px;
flex-shrink: 0;
}
.history-toggle-btn,
.history-delete-btn {
width: 28px;
height: 28px;
border-radius: var(--radius-sm);
border: 1px solid var(--line);
background: var(--surface);
color: var(--muted);
cursor: pointer;
transition: all 120ms ease;
font-size: 0.76rem;
display: flex;
align-items: center;
justify-content: center;
}
.history-toggle-btn:hover {
color: var(--text);
border-color: var(--muted);
}
.history-delete-btn:hover {
color: var(--err);
border-color: rgba(184, 84, 84, 0.35);
background: rgba(184, 84, 84, 0.07);
}
.history-card-body {
border-top: 1px solid var(--line);
}
/* ── Utility ────────────────────────────────────────────────────────── */
.hidden { display: none !important; }
/* ── Responsive ─────────────────────────────────────────────────────── */
@media (max-width: 780px) {
.url-form {
grid-template-columns: 1fr;
}
#extract-btn {
min-height: 46px;
}
.recipe-body {
grid-template-columns: 1fr;
}
.ingredients-section {
border-right: 0;
border-bottom: 1px solid var(--line);
}
.recipe-image {
width: 100%;
height: 200px;
}
.recipe-header {
flex-direction: column;
}
}
+119
View File
@@ -0,0 +1,119 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Rezept0r</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Cormorant+Garamond:ital,wght@0,400;0,600;1,400;1,600&family=Syne:wght@400;500;600;700;800&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/v1.css?v=2">
</head>
<body>
<div class="bg-orb orb-a"></div>
<div class="bg-orb orb-b"></div>
<main class="page-shell">
<section class="hero">
<p class="eyebrow">Rezept0r v1</p>
<h1>Rezepte aus Social, Video<br>und Web extrahieren</h1>
<p class="hero-copy">
URL einfügen, analysieren lassen und direkt nach Tandoor importieren.
</p>
<section class="input-section">
<div class="url-form">
<input
type="url"
id="url-input"
placeholder="https://example.com/rezept oder YouTube/TikTok URL..."
autocomplete="off"
/>
<button id="extract-btn" type="button">Extrahieren</button>
</div>
<div class="platform-strip" aria-label="Unterstützte Plattformen">
<div class="platform-pill"><span class="platform-icon">▶</span><span>YouTube</span></div>
<div class="platform-pill"><span class="platform-icon">♪</span><span>TikTok</span></div>
<div class="platform-pill"><span class="platform-icon">◎</span><span>Instagram</span></div>
<div class="platform-pill"><span class="platform-icon">P</span><span>Pinterest</span></div>
<div class="platform-pill"><span class="platform-icon">⛶</span><span>Webseiten</span></div>
</div>
</section>
</section>
<section id="progress-section" class="hidden">
<div class="progress-bar-container">
<div class="progress-bar" id="progress-bar"></div>
</div>
<p id="progress-message" class="progress-message"></p>
</section>
<section id="result-section" class="hidden">
<div id="recipe-card" class="recipe-card">
<div class="recipe-header">
<img id="recipe-image" class="recipe-image hidden" src="" alt="Rezeptbild" />
<div class="recipe-meta">
<h2 id="recipe-title"></h2>
<p id="recipe-description" class="recipe-description"></p>
<div class="meta-tags">
<span id="recipe-servings" class="meta-tag hidden">
<span id="servings-value"></span>
</span>
<span id="recipe-prep" class="meta-tag hidden">
Vorbereitung: <span id="prep-value"></span> Min.
</span>
<span id="recipe-cook" class="meta-tag hidden">
Kochen: <span id="cook-value"></span> Min.
</span>
<span id="recipe-kcal" class="meta-tag hidden">
<span id="kcal-value"></span> kcal
<span id="kcal-source" class="kcal-source"></span>
</span>
</div>
<div id="recipe-tags" class="tag-list"></div>
</div>
</div>
<div class="recipe-body">
<div class="ingredients-section">
<h3>Zutaten</h3>
<ul id="ingredients-list" class="ingredients-list"></ul>
</div>
<div class="steps-section">
<h3>Zubereitung</h3>
<ol id="steps-list" class="steps-list"></ol>
</div>
</div>
<div class="recipe-actions">
<a id="source-link" href="#" target="_blank" class="btn btn-secondary">Originalrezept</a>
<button id="import-btn" class="btn btn-primary">In Tandoor importieren</button>
<span id="import-status" class="import-status"></span>
</div>
</div>
<div id="tandoor-fallback" class="hidden">
<h3>Tandoor JSON (manueller Import)</h3>
<p class="fallback-note">Kopiere dieses JSON in Tandoor unter Einstellungen → Import.</p>
<textarea id="tandoor-json" class="json-textarea" readonly></textarea>
<button id="copy-json-btn" class="btn btn-secondary">JSON kopieren</button>
</div>
</section>
<section id="error-section" class="hidden">
<div class="error-box">
<p id="error-message"></p>
</div>
</section>
<section id="history-section" class="hidden">
<h2 class="section-heading">Verlauf</h2>
<div id="history-list"></div>
</section>
</main>
<script src="/static/app.js?v=4"></script>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
services:
rezeptor:
build: .
image: rezeptor:latest
container_name: rezeptor
restart: unless-stopped
env_file:
- .env
ports:
- "88:8000"
volumes:
- ./tmp:/app/tmp
mem_limit: 512m
memswap_limit: 512m # No swap – protect SD card
BIN
View File
Binary file not shown.
+74
View File
@@ -0,0 +1,74 @@
import os
from contextlib import asynccontextmanager
from pathlib import Path
from fastapi import FastAPI
from fastapi.staticfiles import StaticFiles
from fastapi.responses import FileResponse
from app.config import get_settings
from app.api import extract, tandoor, history
from app.services.history_service import init_db
settings = get_settings()
@asynccontextmanager
async def lifespan(app: FastAPI):
os.makedirs(settings.tmp_dir, exist_ok=True)
for f in Path(settings.tmp_dir).iterdir():
if f.is_file():
f.unlink(missing_ok=True)
init_db()
yield
app = FastAPI(title="Rezept0r", version="1.0.0", lifespan=lifespan)
app.include_router(extract.router, prefix="/api")
app.include_router(tandoor.router, prefix="/api")
app.include_router(history.router, prefix="/api")
static_dir = Path(__file__).parent / "app" / "static"
app.mount("/static", StaticFiles(directory=static_dir), name="static")
@app.get("/")
async def root():
return FileResponse(static_dir / "index.html")
@app.get("/v1")
async def v1():
return FileResponse(static_dir / "v1.html")
@app.get("/v2")
async def v2():
return FileResponse(static_dir / "v2.html")
@app.get("/v3")
async def v3():
return FileResponse(static_dir / "v3.html")
@app.get("/v4")
async def v4():
return FileResponse(static_dir / "v4.html")
@app.get("/v5")
async def v5():
return FileResponse(static_dir / "v5.html")
@app.get("/api/health")
async def health():
s = get_settings()
return {
"status": "ok",
"tandoor_configured": bool(s.tandoor_api_token and s.tandoor_base_url),
}
if __name__ == "__main__":
import uvicorn
uvicorn.run("main:app", host=settings.app_host, port=settings.app_port, workers=1)
+12
View File
@@ -0,0 +1,12 @@
fastapi>=0.115
uvicorn>=0.30
pydantic>=2.9
pydantic-settings>=2.5
httpx>=0.27
python-multipart>=0.0.18
recipe-scrapers>=15.0
beautifulsoup4>=4.12
lxml>=5.2
openai>=1.50
python-dotenv>=1.0
aiofiles>=23.0