diff --git a/CLAUDE.md b/CLAUDE.md
index 973ff6e..32d4f9e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -9,7 +9,7 @@ port; TLS, domain and proxy are out of scope.
- [x] Phase 2: Recipes CRUD, visibility (private/public), photos, notes, search, favourites
- [x] Phase 3: Comments, serving calculator (server-side `?portionen=`), cooking mode (wake lock)
- [x] Phase 4: AI (OpenAI): "Mit KI aufbereiten" on the form + photo/screenshot and text import
-- [ ] Phase 5: Link import (schema.org JSON-LD, Instagram with paste fallback, Android PWA share target)
+- [x] Phase 5: Link import (schema.org JSON-LD, Instagram best effort + paste/screenshot fallback, PWA share target)
- [ ] Phase 6: Backup script, full README
## Stack
@@ -36,6 +36,13 @@ no JS build step: plain forms with small progressive enhancements in `static/js/
re-validated by Pydantic (`AIRecipe`), `map_api_error` → German user messages, per-user/global daily
limits (0 = off). `_create_completion` is the only network call (stubbed in tests). Drafts go into
`RecipeForm(initial=…)` and are never saved directly. `import_views.py`: `/rezepte/import/` (photo, text).
+- `recipes/fetch.py` – the ONLY way to request user-supplied URLs: resolves once, requires public IPs,
+ connects to the checked IP (no DNS rebinding), manual redirects re-checked, size/time limits, honest UA.
+ `schema_org.py` parses JSON-LD Recipe + page text (stdlib HTMLParser). `instagram.py` makes one honest
+ request to the post's embed page; Instagram mostly answers with an empty shell → UI falls back to
+ paste/screenshot. Do not add UA spoofing, cookies or login-based scraping (owner's requirement).
+- `core/pwa.py` – manifest (share_target → `/teilen/`) and a no-cache service worker. Icons via
+ `scripts/make_icons.py`.
- `templates/`, `static/` – global templates, `app.css` (mobile first, dark mode), `app.js`.
- `docker/entrypoint.sh` – migrate → ensure_admin → purge_ratelimits → gunicorn.
- `scripts/` – `init-env.sh` (creates .env with secrets), `dev_server.py` (local run without Docker).
diff --git a/core/pwa.py b/core/pwa.py
new file mode 100644
index 0000000..c86af5b
--- /dev/null
+++ b/core/pwa.py
@@ -0,0 +1,48 @@
+"""Web app manifest and service worker (installable app, Android share target)."""
+
+from django.contrib.auth.decorators import login_not_required
+from django.http import HttpResponse, JsonResponse
+from django.templatetags.static import static
+from django.urls import reverse
+from django.views.decorators.cache import cache_control
+from django.views.decorators.http import require_GET
+
+# No caching of pages or API responses: private recipes must never sit in a shared cache,
+# and the app is online-only. The worker exists so browsers offer installation.
+SERVICE_WORKER = """self.addEventListener("install", () => self.skipWaiting());
+self.addEventListener("activate", (event) => event.waitUntil(self.clients.claim()));
+"""
+
+
+@login_not_required
+@require_GET
+@cache_control(max_age=3600)
+def manifest(request):
+ data = {
+ "name": "Kochbuch",
+ "short_name": "Kochbuch",
+ "lang": "de",
+ "start_url": reverse("recipes:list"),
+ "scope": "/",
+ "display": "standalone",
+ "background_color": "#faf7f2",
+ "theme_color": "#b4532a",
+ "icons": [
+ {"src": static("icons/icon-192.png"), "sizes": "192x192", "type": "image/png"},
+ {"src": static("icons/icon-512.png"), "sizes": "512x512", "type": "image/png"},
+ {"src": static("icons/icon-maskable-512.png"), "sizes": "512x512", "type": "image/png", "purpose": "maskable"},
+ ],
+ "share_target": {
+ "action": reverse("recipes:share"),
+ "method": "GET",
+ "params": {"title": "title", "text": "text", "url": "url"},
+ },
+ }
+ return JsonResponse(data, content_type="application/manifest+json", json_dumps_params={"ensure_ascii": False})
+
+
+@login_not_required
+@require_GET
+@cache_control(max_age=0, no_cache=True)
+def service_worker(request):
+ return HttpResponse(SERVICE_WORKER, content_type="application/javascript")
diff --git a/core/urls.py b/core/urls.py
index 1809821..15dec97 100644
--- a/core/urls.py
+++ b/core/urls.py
@@ -1,9 +1,11 @@
from django.urls import path
-from . import views
+from . import pwa, views
app_name = "core"
urlpatterns = [
path("healthz", views.healthz, name="healthz"),
+ path("manifest.webmanifest", pwa.manifest, name="manifest"),
+ path("sw.js", pwa.service_worker, name="service_worker"),
]
diff --git a/recipes/fetch.py b/recipes/fetch.py
new file mode 100644
index 0000000..eeb2a28
--- /dev/null
+++ b/recipes/fetch.py
@@ -0,0 +1,126 @@
+"""Outgoing HTTP requests to user-supplied URLs, hardened against SSRF.
+
+The host name is resolved once, every resolved address must be a public (global) IP, and
+the connection is made to that checked IP (TLS still verifies the real host name). That
+closes DNS-rebinding tricks. Redirects are followed manually and re-checked on every hop.
+"""
+
+import ipaddress
+import socket
+from dataclasses import dataclass
+from urllib.parse import urljoin, urlsplit
+
+import urllib3
+
+# Honest identification as a bot; sites that refuse it are handled by the paste fallback.
+USER_AGENT = "Mozilla/5.0 (compatible; Kochbuch-Rezeptimport/1.0; self-hosted recipe manager)"
+MAX_REDIRECTS = 5
+DEFAULT_MAX_BYTES = 3 * 1024 * 1024
+
+
+class FetchError(Exception):
+ def __init__(self, user_message):
+ super().__init__(user_message)
+ self.user_message = user_message
+
+
+@dataclass
+class FetchResult:
+ url: str
+ status: int
+ content_type: str
+ body: bytes
+
+ @property
+ def text(self):
+ charset = "utf-8"
+ for part in self.content_type.split(";")[1:]:
+ key, _, value = part.strip().partition("=")
+ if key.lower() == "charset" and value:
+ charset = value.strip("\"'")
+ try:
+ return self.body.decode(charset, errors="replace")
+ except LookupError:
+ return self.body.decode("utf-8", errors="replace")
+
+
+def _resolve_public(host, port):
+ try:
+ infos = socket.getaddrinfo(host, port, type=socket.SOCK_STREAM)
+ except (socket.gaierror, UnicodeError):
+ raise FetchError("Die Adresse konnte nicht gefunden werden.") from None
+ addresses = []
+ for info in infos:
+ ip = ipaddress.ip_address(info[4][0].split("%")[0])
+ if isinstance(ip, ipaddress.IPv6Address) and ip.ipv4_mapped:
+ ip = ip.ipv4_mapped
+ if not ip.is_global or ip.is_multicast:
+ raise FetchError("Diese Adresse ist nicht erlaubt (internes Netz).")
+ addresses.append(str(ip))
+ if not addresses:
+ raise FetchError("Die Adresse konnte nicht gefunden werden.")
+ return addresses[0]
+
+
+def _validate_url(url):
+ parts = urlsplit(url)
+ if parts.scheme not in ("http", "https") or not parts.hostname:
+ raise FetchError("Bitte gib eine gültige http(s)-Adresse ein.")
+ if parts.username or parts.password:
+ raise FetchError("Adressen mit Zugangsdaten werden nicht unterstützt.")
+ port = parts.port or (443 if parts.scheme == "https" else 80)
+ if port not in (80, 443, 8080, 8443):
+ raise FetchError("Dieser Port wird nicht unterstützt.")
+ return parts, port
+
+
+def _request_once(url, accept, timeout):
+ parts, port = _validate_url(url)
+ host = parts.hostname
+ ip = _resolve_public(host, port)
+ default_port = 443 if parts.scheme == "https" else 80
+ headers = {
+ "Host": host if port == default_port else f"{host}:{port}",
+ "User-Agent": USER_AGENT,
+ "Accept": accept,
+ "Accept-Language": "de-DE,de;q=0.9,en;q=0.7",
+ }
+ if parts.scheme == "https":
+ pool = urllib3.HTTPSConnectionPool(ip, port, server_hostname=host, assert_hostname=host,
+ cert_reqs="CERT_REQUIRED", retries=False, timeout=timeout)
+ else:
+ pool = urllib3.HTTPConnectionPool(ip, port, retries=False, timeout=timeout)
+ path = parts.path or "/"
+ if parts.query:
+ path += "?" + parts.query
+ try:
+ return pool.urlopen("GET", path, headers=headers, redirect=False, preload_content=False)
+ except urllib3.exceptions.TimeoutError:
+ raise FetchError("Die Seite hat nicht rechtzeitig geantwortet.") from None
+ except urllib3.exceptions.SSLError:
+ raise FetchError("Die sichere Verbindung zur Seite ist fehlgeschlagen.") from None
+ except urllib3.exceptions.HTTPError:
+ raise FetchError("Die Seite ist nicht erreichbar.") from None
+
+
+def fetch(url, accept="text/html,application/xhtml+xml;q=0.9,*/*;q=0.8", max_bytes=DEFAULT_MAX_BYTES, timeout=None):
+ timeout = timeout or urllib3.Timeout(connect=5, read=15)
+ for _ in range(MAX_REDIRECTS + 1):
+ response = _request_once(url, accept, timeout)
+ try:
+ if response.status in (301, 302, 303, 307, 308) and response.headers.get("Location"):
+ url = urljoin(url, response.headers["Location"])
+ continue
+ chunks, size = [], 0
+ for chunk in response.stream(64 * 1024, decode_content=True):
+ size += len(chunk)
+ if size > max_bytes:
+ raise FetchError("Die Seite ist zu groß.")
+ chunks.append(chunk)
+ return FetchResult(url=url, status=response.status,
+ content_type=response.headers.get("Content-Type", ""), body=b"".join(chunks))
+ except urllib3.exceptions.HTTPError:
+ raise FetchError("Die Seite konnte nicht vollständig geladen werden.") from None
+ finally:
+ response.release_conn()
+ raise FetchError("Zu viele Weiterleitungen.")
diff --git a/recipes/forms.py b/recipes/forms.py
index cdb1719..b794a9c 100644
--- a/recipes/forms.py
+++ b/recipes/forms.py
@@ -59,6 +59,9 @@ class RecipeForm(forms.ModelForm):
)
source_url = forms.URLField(label="Quelle (URL)", required=False, max_length=1000, assume_scheme="https")
photos = photo_field()
+ # Link import: photo suggested by the source page, downloaded only when the user keeps the tick.
+ import_image_url = forms.URLField(required=False, max_length=2000, widget=forms.HiddenInput, assume_scheme="https")
+ use_import_image = forms.BooleanField(label="Bild der Quelle als Foto übernehmen", required=False, initial=True)
class Meta:
model = Recipe
@@ -74,7 +77,8 @@ class RecipeForm(forms.ModelForm):
}
field_order = ["title", "description", "servings", "servings_label", "prep_minutes", "cook_minutes",
- "ingredients_text", "steps_text", "tags_text", "photos", "author_notes", "source_url", "visibility"]
+ "ingredients_text", "steps_text", "tags_text", "use_import_image", "photos", "author_notes",
+ "source_url", "visibility"]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -85,6 +89,9 @@ class RecipeForm(forms.ModelForm):
self.initial.setdefault("ingredients_text", parsing.ingredients_to_text(recipe.ingredients.all()))
self.initial.setdefault("steps_text", parsing.steps_to_text([s.text for s in recipe.steps.all()]))
self.initial.setdefault("tags_text", ", ".join(t.name for t in recipe.tags.all()))
+ if recipe.pk or not (self.initial.get("import_image_url") or self.data.get("import_image_url")):
+ del self.fields["import_image_url"]
+ del self.fields["use_import_image"]
if recipe.pk:
remaining = settings.IMAGE_MAX_PER_RECIPE - recipe.images.count()
self.fields["photos"].label = "Weitere Fotos"
diff --git a/recipes/import_views.py b/recipes/import_views.py
index ffbbc12..7432fb1 100644
--- a/recipes/import_views.py
+++ b/recipes/import_views.py
@@ -4,15 +4,30 @@ Every path ends in the normal recipe form, pre-filled as an editable draft and p
recipes:create. Nothing is saved before the user presses "Speichern".
"""
+import re
+from urllib.parse import urlencode
+
from django import forms
from django.contrib import messages
from django.shortcuts import redirect, render
from django.urls import reverse
-from django.views.decorators.http import require_POST
+from django.views.decorators.http import require_GET, require_POST
-from . import ai, images
+from . import ai, images, instagram, schema_org
+from .fetch import FetchError, fetch
from .forms import MultipleFileInput, MultipleImageField, RecipeForm
+_URL_RE = re.compile(r"https?://[^\s<>\"']+")
+
+
+class LinkImportForm(forms.Form):
+ url = forms.URLField(
+ label="Link",
+ max_length=2000,
+ assume_scheme="https",
+ widget=forms.URLInput(attrs={"placeholder": "https://… (Rezeptseite oder Instagram)", "inputmode": "url"}),
+ )
+
class PhotoImportForm(forms.Form):
photos = MultipleImageField(
@@ -48,11 +63,15 @@ def render_draft(request, initial, notice, ai_draft=None, extra_context=None):
})
-def import_start(request, photo_form=None, text_form=None):
+def import_start(request, photo_form=None, text_form=None, link_form=None, instagram_failed=False, status=200):
+ if link_form is None:
+ link_form = LinkImportForm(initial={"url": request.GET.get("url", "")})
return render(request, "recipes/import.html", {
"photo_form": photo_form or PhotoImportForm(),
"text_form": text_form or TextImportForm(),
- })
+ "link_form": link_form,
+ "instagram_failed": instagram_failed,
+ }, status=status)
@require_POST
@@ -74,6 +93,11 @@ def import_text(request):
form = TextImportForm(request.POST)
if not form.is_valid():
return import_start(request, text_form=form)
+ if not ai.is_configured():
+ # Without AI the pasted text becomes the description; the user splits it up by hand.
+ initial = {"description": form.cleaned_data["text"], "source_url": form.cleaned_data["source_url"]}
+ return render_draft(request, initial, "Text übernommen (KI nicht eingerichtet – bitte Zutaten und "
+ "Schritte aus der Beschreibung in die passenden Felder übertragen)")
try:
draft = ai.structure_text(request.user, form.cleaned_data["text"])
except ai.AIError as error:
@@ -81,3 +105,87 @@ def import_text(request):
return import_start(request, text_form=form)
initial = {**draft.to_form_initial(), "source_url": form.cleaned_data["source_url"]}
return render_draft(request, initial, "Aus dem Text erkannt", ai_draft=draft)
+
+
+@require_POST
+def import_link(request):
+ form = LinkImportForm(request.POST)
+ if not form.is_valid():
+ return import_start(request, link_form=form, status=400)
+ url = form.cleaned_data["url"]
+ code = instagram.shortcode(url)
+ if code:
+ return _import_instagram(request, url, code, form)
+ if instagram.is_instagram(url):
+ form.add_error("url", "Bitte den Link zu einem einzelnen Beitrag oder Reel verwenden.")
+ return import_start(request, link_form=form, status=400)
+ return _import_website(request, url, form)
+
+
+def _instagram_fallback(request, url, form, reason):
+ messages.warning(request, f"{reason} Füge die Beschreibung des Beitrags unten ein – oder lade einen Screenshot hoch.")
+ text_form = TextImportForm(initial={"source_url": url})
+ return import_start(request, link_form=form, text_form=text_form, instagram_failed=True)
+
+
+def _import_instagram(request, url, code, form):
+ try:
+ caption, image = instagram.fetch_caption(code)
+ except instagram.InstagramUnavailable:
+ return _instagram_fallback(request, url, form, "Instagram lässt den automatischen Abruf gerade nicht zu.")
+ extra = {"import_image_url": image} if image else {}
+ if not ai.is_configured():
+ initial = {"description": caption, "source_url": url, **extra}
+ return render_draft(request, initial, "Beschreibung von Instagram übernommen (KI nicht eingerichtet – "
+ "bitte Zutaten und Schritte selbst aufteilen)")
+ try:
+ draft = ai.structure_text(request.user, caption, hint="Beschreibung (Caption) eines Instagram-Beitrags:")
+ except ai.AIError as error:
+ messages.error(request, error.user_message)
+ return import_start(request, link_form=form, text_form=TextImportForm(initial={"text": caption, "source_url": url}))
+ return render_draft(request, {**draft.to_form_initial(), "source_url": url, **extra}, "Aus Instagram erkannt",
+ ai_draft=draft)
+
+
+def _import_website(request, url, form):
+ try:
+ result = fetch(url)
+ except FetchError as error:
+ form.add_error("url", error.user_message)
+ return import_start(request, link_form=form, status=400)
+ if result.status >= 400:
+ form.add_error("url", f"Die Seite antwortete mit Fehler {result.status}.")
+ return import_start(request, link_form=form, status=400)
+ if "html" not in result.content_type.lower():
+ form.add_error("url", "Unter dem Link liegt keine Webseite.")
+ return import_start(request, link_form=form, status=400)
+
+ initial, image, page = schema_org.extract(result.text)
+ extra = {"source_url": url, **({"import_image_url": image} if image else {})}
+ if initial:
+ return render_draft(request, {**initial, **extra}, "Von der Webseite übernommen")
+ if not ai.is_configured():
+ form.add_error("url", "Auf der Seite wurde kein strukturiertes Rezept gefunden, und die KI ist nicht "
+ "eingerichtet. Kopiere den Rezepttext und trag ihn von Hand ein.")
+ return import_start(request, link_form=form, status=400)
+ text = page.title.strip() + "\n\n" + page.visible_text()
+ try:
+ draft = ai.structure_text(request.user, text, hint=f"Text der Webseite {url}:")
+ except ai.AIError as error:
+ form.add_error("url", error.user_message)
+ return import_start(request, link_form=form, status=400)
+ return render_draft(request, {**draft.to_form_initial(), **extra}, "Von der Webseite (per KI) erkannt",
+ ai_draft=draft)
+
+
+@require_GET
+def share_target(request):
+ """Web Share Target (Android PWA): 'Teilen → Kochbuch' lands here with title/text/url."""
+ for value in (request.GET.get("url", ""), request.GET.get("text", ""), request.GET.get("title", "")):
+ match = _URL_RE.search(value)
+ if match:
+ return redirect(f"{reverse('recipes:import')}?{urlencode({'url': match.group(0)})}")
+ text = request.GET.get("text", "").strip()
+ if text:
+ return import_start(request, text_form=TextImportForm(initial={"text": text}))
+ return redirect("recipes:import")
diff --git a/recipes/instagram.py b/recipes/instagram.py
new file mode 100644
index 0000000..89ad166
--- /dev/null
+++ b/recipes/instagram.py
@@ -0,0 +1,114 @@
+"""Best-effort retrieval of an Instagram post caption without login.
+
+Instagram has no login-free API for captions any more (the official oEmbed endpoint needs a
+Meta app and no longer returns post content). We make a single, honest request to the
+public embed page of the post and read the caption from it. Instagram frequently serves an
+empty app shell to servers instead – then the caller falls back to "paste the caption" or
+"import a screenshot". No login, no cookies, no evasion of bot detection.
+"""
+
+import re
+from html.parser import HTMLParser
+from urllib.parse import urlsplit
+
+from .fetch import FetchError, fetch
+from .schema_org import parse_page
+
+HOSTS = {"instagram.com", "www.instagram.com", "m.instagram.com", "instagr.am", "www.instagr.am"}
+_PATH_RE = re.compile(r"^/(?:[\w.]+/)?(?:p|reel|reels|tv)/([A-Za-z0-9_-]{5,40})(?:/|$)")
+_OG_CAPTION_RE = re.compile(r':\s*["“](.+)["”]\s*\.?\s*$', re.DOTALL)
+
+
+class InstagramUnavailable(Exception):
+ pass
+
+
+def shortcode(url):
+ """Shortcode of a post/reel URL, or None if the URL is not an Instagram post."""
+ try:
+ parts = urlsplit(url.strip())
+ except ValueError:
+ return None
+ if (parts.hostname or "").lower() not in HOSTS:
+ return None
+ match = _PATH_RE.match(parts.path)
+ return match.group(1) if match else None
+
+
+def is_instagram(url):
+ return (urlsplit(url.strip()).hostname or "").lower() in HOSTS
+
+
+class _EmbedCaptionParser(HTMLParser):
+ """Reads
of the embed page, without username and comment links."""
+
+ def __init__(self):
+ super().__init__(convert_charrefs=True)
+ self.depth = 0 # >0 while inside the caption div
+ self.skip_depth = 0
+ self.parts = []
+ self.image = ""
+
+ def handle_starttag(self, tag, attrs):
+ attrs = dict(attrs)
+ classes = (attrs.get("class") or "").split()
+ if tag == "img" and "EmbeddedMediaImage" in classes and not self.image:
+ self.image = attrs.get("src", "")
+ if self.depth:
+ if tag == "br":
+ self.parts.append("\n")
+ return
+ if self.skip_depth or "CaptionUsername" in classes or "CaptionComments" in classes:
+ self.skip_depth += 1
+ if tag == "div":
+ self.depth += 1
+ elif tag == "div" and "Caption" in classes:
+ self.depth = 1
+
+ def handle_endtag(self, tag):
+ if not self.depth:
+ return
+ if self.skip_depth:
+ self.skip_depth -= 1
+ if tag == "div":
+ self.depth -= 1
+
+ def handle_data(self, data):
+ if self.depth and not self.skip_depth:
+ self.parts.append(data)
+
+ def caption(self):
+ text = "".join(self.parts)
+ text = re.sub(r"[ \t]+", " ", text)
+ return re.sub(r"\n{3,}", "\n\n", text).strip()
+
+
+def parse_embed(page_html):
+ """Return (caption, image_url) from an embed page; caption may be empty."""
+ parser = _EmbedCaptionParser()
+ try:
+ parser.feed(page_html)
+ except Exception:
+ return "", ""
+ caption = parser.caption()
+ image = parser.image
+ if not caption:
+ meta = parse_page(page_html).meta
+ match = _OG_CAPTION_RE.search(meta.get("og:description", "") or meta.get("og:title", ""))
+ caption = match.group(1).strip() if match else ""
+ image = image or meta.get("og:image", "")
+ return caption, image
+
+
+def fetch_caption(code):
+ """Try to read the caption of a post. Raises InstagramUnavailable if Instagram doesn't deliver it."""
+ try:
+ result = fetch(f"https://www.instagram.com/p/{code}/embed/captioned/")
+ except FetchError as error:
+ raise InstagramUnavailable(error.user_message) from None
+ if result.status != 200:
+ raise InstagramUnavailable(f"Instagram antwortete mit Status {result.status}.")
+ caption, image = parse_embed(result.text)
+ if len(caption) < 20:
+ raise InstagramUnavailable("Instagram hat die Beschreibung nicht herausgegeben.")
+ return caption, image
diff --git a/recipes/schema_org.py b/recipes/schema_org.py
new file mode 100644
index 0000000..29f4f74
--- /dev/null
+++ b/recipes/schema_org.py
@@ -0,0 +1,216 @@
+"""Extract schema.org/Recipe (JSON-LD) and page metadata from HTML without extra dependencies."""
+
+import html
+import json
+import re
+from html.parser import HTMLParser
+
+from . import parsing
+
+_DURATION_RE = re.compile(r"^P(?:(\d+)D)?(?:T(?:(\d+)H)?(?:(\d+)M)?(?:[\d.]+S)?)?$", re.IGNORECASE)
+_TAG_RE = re.compile(r"<[^>]+>")
+MAX_TEXT_CHARS = 15_000
+
+
+class PageParser(HTMLParser):
+ """Collects JSON-LD blocks,
tags, the
and the visible text of a page."""
+
+ SKIP_TEXT = {"script", "style", "noscript", "svg", "nav", "footer", "header", "form", "button", "iframe"}
+ BLOCK = {"p", "div", "li", "br", "h1", "h2", "h3", "h4", "h5", "h6", "tr", "section", "article", "ol", "ul"}
+
+ def __init__(self):
+ super().__init__(convert_charrefs=True)
+ self.json_ld, self.meta, self.title = [], {}, ""
+ self._stack, self._buffer, self._text = [], None, []
+ self._in_title = False
+
+ def handle_starttag(self, tag, attrs):
+ attrs = dict(attrs)
+ if tag == "script" and (attrs.get("type") or "").lower().startswith("application/ld+json"):
+ self._buffer = []
+ if tag == "meta":
+ key = (attrs.get("property") or attrs.get("name") or "").lower()
+ if key and attrs.get("content") and key not in self.meta:
+ self.meta[key] = attrs["content"]
+ if tag == "title":
+ self._in_title = True
+ if tag in self.BLOCK:
+ self._text.append("\n")
+ if tag not in ("br", "meta", "img", "link", "input", "hr"):
+ self._stack.append(tag)
+
+ def handle_endtag(self, tag):
+ if tag == "script" and self._buffer is not None:
+ self.json_ld.append("".join(self._buffer))
+ self._buffer = None
+ if tag == "title":
+ self._in_title = False
+ if tag in self._stack:
+ while self._stack and self._stack.pop() != tag:
+ pass
+
+ def handle_data(self, data):
+ if self._buffer is not None:
+ self._buffer.append(data)
+ return
+ if self._in_title:
+ self.title += data
+ if not any(t in self.SKIP_TEXT for t in self._stack):
+ self._text.append(data)
+
+ def visible_text(self):
+ text = "".join(self._text)
+ text = re.sub(r"[ \t\r\f\v]+", " ", text)
+ text = re.sub(r"\n\s*\n+", "\n", text)
+ return text.strip()[:MAX_TEXT_CHARS]
+
+
+def parse_page(page_html):
+ parser = PageParser()
+ try:
+ parser.feed(page_html)
+ parser.close()
+ except Exception: # malformed HTML must never break the import
+ pass
+ return parser
+
+
+def _types(node):
+ value = node.get("@type", [])
+ return {t.lower() for t in (value if isinstance(value, list) else [value]) if isinstance(t, str)}
+
+
+def find_recipe(data):
+ """Depth-first search for the first object whose @type includes Recipe."""
+ if isinstance(data, list):
+ for item in data:
+ found = find_recipe(item)
+ if found:
+ return found
+ elif isinstance(data, dict):
+ if "recipe" in _types(data):
+ return data
+ for key in ("@graph", "mainEntity", "mainEntityOfPage", "itemListElement", "item"):
+ if key in data:
+ found = find_recipe(data[key])
+ if found:
+ return found
+ return None
+
+
+def _clean(text):
+ if not isinstance(text, str):
+ return ""
+ text = html.unescape(_TAG_RE.sub(" ", text))
+ text = re.sub(r"\s+", " ", text).strip()
+ return re.sub(r"\s+([.,;:!?])", r"\1", text)
+
+
+def _duration_minutes(value):
+ if not isinstance(value, str):
+ return None
+ match = _DURATION_RE.match(value.strip())
+ if not match or not any(match.groups()):
+ return None
+ days, hours, minutes = (int(g) if g else 0 for g in match.groups())
+ total = days * 1440 + hours * 60 + minutes
+ return total if 0 < total <= 7 * 1440 else None
+
+
+def _yield(value):
+ if isinstance(value, list):
+ value = next((v for v in value if isinstance(v, (str, int))), None)
+ if isinstance(value, (int, float)):
+ return int(value), ""
+ if isinstance(value, str):
+ match = re.search(r"(\d+)", value)
+ if match:
+ label = _clean(value[match.end():]).strip(" ()")
+ return int(match.group(1)), label[:30]
+ return None, ""
+
+
+def _steps(value):
+ steps = []
+ if isinstance(value, str):
+ return parsing.parse_steps(_clean_multiline(value))
+ if isinstance(value, dict):
+ value = [value]
+ for item in value or []:
+ if isinstance(item, str):
+ steps.append(_clean(item))
+ elif isinstance(item, dict):
+ types = _types(item)
+ if "howtosection" in types:
+ steps.extend(_steps(item.get("itemListElement")))
+ else:
+ steps.append(_clean(item.get("text") or item.get("name") or ""))
+ return [s for s in steps if s]
+
+
+def _clean_multiline(text):
+ text = re.sub(r"
||", "\n", text, flags=re.IGNORECASE)
+ return html.unescape(_TAG_RE.sub("", text))
+
+
+def _keywords(recipe):
+ words = []
+ for key in ("recipeCategory", "recipeCuisine", "keywords"):
+ value = recipe.get(key)
+ if isinstance(value, str):
+ words.extend(value.split(","))
+ elif isinstance(value, list):
+ words.extend(v for v in value if isinstance(v, str))
+ return parsing.parse_tags(",".join(_clean(w) for w in words if len(w.strip()) <= 30))[:6]
+
+
+def _image(value):
+ if isinstance(value, str):
+ return value
+ if isinstance(value, list) and value:
+ return _image(value[0])
+ if isinstance(value, dict):
+ return value.get("url") or value.get("contentUrl") or ""
+ return ""
+
+
+def recipe_to_initial(recipe):
+ """Map a schema.org Recipe dict to RecipeForm initial values (+ image URL)."""
+ servings, label = _yield(recipe.get("recipeYield"))
+ ingredients = recipe.get("recipeIngredient") or recipe.get("ingredients") or []
+ if isinstance(ingredients, str):
+ ingredients = [ingredients]
+ prep = _duration_minutes(recipe.get("prepTime"))
+ cook = _duration_minutes(recipe.get("cookTime"))
+ if prep is None and cook is None:
+ prep = _duration_minutes(recipe.get("totalTime"))
+ initial = {
+ "title": _clean(recipe.get("name"))[:200],
+ "description": _clean(recipe.get("description")),
+ "prep_minutes": prep,
+ "cook_minutes": cook,
+ "ingredients_text": "\n".join(line for line in (_clean(i) for i in ingredients if isinstance(i, str)) if line),
+ "steps_text": parsing.steps_to_text(_steps(recipe.get("recipeInstructions"))),
+ "tags_text": ", ".join(_keywords(recipe)),
+ }
+ if servings and 1 <= servings <= 100:
+ initial["servings"] = servings
+ initial["servings_label"] = label if label and len(label) < 30 else "Portionen"
+ return initial, _image(recipe.get("image"))
+
+
+def extract(page_html):
+ """Return (initial, image_url, page) – initial is None when the page has no usable Recipe."""
+ page = parse_page(page_html)
+ for block in page.json_ld:
+ try:
+ data = json.loads(block.strip())
+ except json.JSONDecodeError:
+ # Some sites put several objects or trailing commas in one block; skip those.
+ continue
+ recipe = find_recipe(data)
+ if recipe:
+ initial, image = recipe_to_initial(recipe)
+ if initial["ingredients_text"] or initial["steps_text"]:
+ return initial, image or page.meta.get("og:image", ""), page
+ return None, page.meta.get("og:image", ""), page
diff --git a/recipes/urls.py b/recipes/urls.py
index ae44a91..ee0f670 100644
--- a/recipes/urls.py
+++ b/recipes/urls.py
@@ -10,6 +10,8 @@ urlpatterns = [
path("rezepte/import/", import_views.import_start, name="import"),
path("rezepte/import/foto/", import_views.import_photo, name="import_photo"),
path("rezepte/import/text/", import_views.import_text, name="import_text"),
+ path("rezepte/import/link/", import_views.import_link, name="import_link"),
+ path("teilen/", import_views.share_target, name="share"),
path("rezepte//", views.recipe_detail, name="detail"),
path("rezepte//bearbeiten/", views.recipe_edit, name="edit"),
path("rezepte//loeschen/", views.recipe_delete, name="delete"),
diff --git a/recipes/views.py b/recipes/views.py
index 3362bd2..a52f9df 100644
--- a/recipes/views.py
+++ b/recipes/views.py
@@ -3,6 +3,7 @@ from datetime import timedelta
from django.conf import settings
from django.contrib import messages
+from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.paginator import Paginator
from django.db import transaction
from django.db.models import Exists, OuterRef, Prefetch
@@ -14,7 +15,7 @@ from django.views.decorators.http import require_GET, require_POST
from core import ratelimit
-from . import ai, images
+from . import ai, fetch, images
from .forms import CommentForm, PhotoUploadForm, RecipeForm, SearchForm
from .models import Comment, Favorite, Recipe, RecipeImage, Tag
@@ -27,6 +28,19 @@ def _visible(request):
return Recipe.objects.visible_to(request.user)
+def download_image(request, url):
+ """Fetch a suggested source photo (SSRF-safe). Returns an uploaded-file object or None."""
+ try:
+ result = fetch.fetch(url, accept="image/*", max_bytes=images.max_upload_bytes())
+ except fetch.FetchError as error:
+ messages.warning(request, f"Das Bild der Quelle konnte nicht geladen werden: {error.user_message}")
+ return None
+ if result.status != 200 or not result.content_type.lower().startswith("image/"):
+ messages.warning(request, "Das Bild der Quelle konnte nicht geladen werden.")
+ return None
+ return SimpleUploadedFile("quelle.jpg", result.body, content_type=result.content_type)
+
+
def attach_photos(request, recipe, files):
"""Process uploaded files and attach them. Problems are reported as messages, not exceptions."""
has_cover = recipe.images.filter(is_cover=True).exists()
@@ -124,7 +138,12 @@ def recipe_create(request):
form = RecipeForm(request.POST or None, request.FILES or None)
if request.method == "POST" and form.is_valid():
recipe = form.save(owner=request.user)
- attach_photos(request, recipe, form.cleaned_data.get("photos") or [])
+ photos = list(form.cleaned_data.get("photos") or [])
+ if form.cleaned_data.get("use_import_image") and form.cleaned_data.get("import_image_url"):
+ downloaded = download_image(request, form.cleaned_data["import_image_url"])
+ if downloaded:
+ photos.insert(0, downloaded)
+ attach_photos(request, recipe, photos[: settings.IMAGE_MAX_PER_RECIPE])
messages.success(request, "Rezept gespeichert.")
return redirect("recipes:detail", pk=recipe.pk)
return _render_form(request, form)
diff --git a/scripts/make_icons.py b/scripts/make_icons.py
new file mode 100644
index 0000000..fb6896e
--- /dev/null
+++ b/scripts/make_icons.py
@@ -0,0 +1,41 @@
+"""Generates the PWA icons in static/icons (run once; output is committed)."""
+
+from pathlib import Path
+
+from PIL import Image, ImageDraw
+
+OUT = Path(__file__).resolve().parent.parent / "static" / "icons"
+ACCENT = (180, 83, 42)
+CREAM = (250, 247, 242)
+
+
+def draw_pot(size, padding):
+ img = Image.new("RGB", (size, size), ACCENT)
+ d = ImageDraw.Draw(img)
+ s = size - 2 * padding
+ x0, y0 = padding, padding
+ # pot body
+ d.rounded_rectangle([x0 + s * 0.18, y0 + s * 0.42, x0 + s * 0.82, y0 + s * 0.82], radius=s * 0.08, fill=CREAM)
+ # rim
+ d.rounded_rectangle([x0 + s * 0.12, y0 + s * 0.36, x0 + s * 0.88, y0 + s * 0.45], radius=s * 0.04, fill=CREAM)
+ # handles
+ d.rounded_rectangle([x0 + s * 0.05, y0 + s * 0.5, x0 + s * 0.2, y0 + s * 0.58], radius=s * 0.03, fill=CREAM)
+ d.rounded_rectangle([x0 + s * 0.8, y0 + s * 0.5, x0 + s * 0.95, y0 + s * 0.58], radius=s * 0.03, fill=CREAM)
+ # steam
+ for i, dx in enumerate((0.35, 0.5, 0.65)):
+ top = y0 + s * (0.12 if i == 1 else 0.17)
+ d.line([x0 + s * dx, top, x0 + s * dx, y0 + s * 0.3], fill=CREAM, width=max(2, int(s * 0.045)))
+ return img
+
+
+def main():
+ OUT.mkdir(parents=True, exist_ok=True)
+ draw_pot(192, 16).save(OUT / "icon-192.png")
+ draw_pot(512, 40).save(OUT / "icon-512.png")
+ draw_pot(512, 110).save(OUT / "icon-maskable-512.png") # content inside the safe zone
+ draw_pot(180, 14).save(OUT / "apple-touch-icon.png")
+ draw_pot(64, 4).save(OUT / "favicon.png")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/static/css/app.css b/static/css/app.css
index 8f33ef2..f1ee96d 100644
--- a/static/css/app.css
+++ b/static/css/app.css
@@ -280,3 +280,6 @@ h3 { font-size: 1rem; margin: 1rem 0 0.25rem; color: var(--muted); }
.preview .ingredients .amount { flex-basis: 5.5rem; }
button[disabled] { opacity: 0.6; cursor: progress; }
code { font-size: 0.9em; }
+.help-box { border-left: 4px solid var(--accent); }
+.help-box ol { padding-left: 1.2rem; margin: 0; }
+.help-box li { margin-bottom: 0.4rem; }
diff --git a/static/icons/apple-touch-icon.png b/static/icons/apple-touch-icon.png
new file mode 100644
index 0000000..1eace3a
Binary files /dev/null and b/static/icons/apple-touch-icon.png differ
diff --git a/static/icons/favicon.png b/static/icons/favicon.png
new file mode 100644
index 0000000..c20bf86
Binary files /dev/null and b/static/icons/favicon.png differ
diff --git a/static/icons/icon-192.png b/static/icons/icon-192.png
new file mode 100644
index 0000000..d5f4ad6
Binary files /dev/null and b/static/icons/icon-192.png differ
diff --git a/static/icons/icon-512.png b/static/icons/icon-512.png
new file mode 100644
index 0000000..89bbb5c
Binary files /dev/null and b/static/icons/icon-512.png differ
diff --git a/static/icons/icon-maskable-512.png b/static/icons/icon-maskable-512.png
new file mode 100644
index 0000000..058cd29
Binary files /dev/null and b/static/icons/icon-maskable-512.png differ
diff --git a/static/js/app.js b/static/js/app.js
index 353358b..8ce65fb 100644
--- a/static/js/app.js
+++ b/static/js/app.js
@@ -61,3 +61,8 @@ window.addEventListener("pageshow", () => {
b.textContent = b.dataset.label;
});
});
+
+// Register the (no-cache) service worker so the app can be installed and used as share target.
+if ("serviceWorker" in navigator) {
+ window.addEventListener("load", () => navigator.serviceWorker.register("/sw.js").catch(() => {}));
+}
diff --git a/templates/base.html b/templates/base.html
index c4062eb..62ad72f 100644
--- a/templates/base.html
+++ b/templates/base.html
@@ -6,6 +6,11 @@
{% block title %}Kochbuch{% endblock %} · Kochbuch
+
+
+
+
+
@@ -17,6 +22,8 @@
☰