Add phase 5: link import, Instagram fallback and share target
- SSRF-safe fetcher: public IPs only, connects to the checked address, redirects re-validated, size/time limits, honest user agent - schema.org/Recipe JSON-LD extraction (graphs, HowToSections, ISO durations, yields, keywords, images) without extra dependencies; AI fallback on plain page text when no structured recipe exists - Instagram: one login-free request to the public embed page; when Instagram withholds the caption, the import page explains how to paste the text or import a screenshot instead - Suggested source photo can be downloaded and attached on save - Text import works without AI (text becomes the description) - Installable PWA with manifest, icons and Android share target - Offline tests for SSRF rules, parsers, Instagram and import views Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
1bf8ef8b65
commit
60785e2437
@@ -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.")
|
||||
+8
-1
@@ -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"
|
||||
|
||||
+112
-4
@@ -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")
|
||||
|
||||
@@ -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 <div class="Caption"> 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
|
||||
@@ -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, <meta> tags, the <title> 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"<br\s*/?>|</p>|</li>", "\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
|
||||
@@ -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/<int:pk>/", views.recipe_detail, name="detail"),
|
||||
path("rezepte/<int:pk>/bearbeiten/", views.recipe_edit, name="edit"),
|
||||
path("rezepte/<int:pk>/loeschen/", views.recipe_delete, name="delete"),
|
||||
|
||||
+21
-2
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user