- 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]>
192 lines
8.5 KiB
Python
192 lines
8.5 KiB
Python
"""Recipe import: photo/screenshot (AI vision), pasted text (AI) and links.
|
||
|
||
Every path ends in the normal recipe form, pre-filled as an editable draft and posted to
|
||
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_GET, require_POST
|
||
|
||
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(
|
||
label="Foto oder Screenshot",
|
||
widget=MultipleFileInput(attrs={"accept": "image/*"}),
|
||
help_text=f"Bis zu {ai.MAX_IMAGES} Bilder vom selben Rezept, z. B. Kochbuchseite oder Screenshot.",
|
||
)
|
||
|
||
def clean_photos(self):
|
||
photos = self.cleaned_data["photos"]
|
||
if not photos:
|
||
raise forms.ValidationError("Bitte wähle ein Bild aus.")
|
||
if len(photos) > ai.MAX_IMAGES:
|
||
raise forms.ValidationError(f"Höchstens {ai.MAX_IMAGES} Bilder.")
|
||
return photos
|
||
|
||
|
||
class TextImportForm(forms.Form):
|
||
text = forms.CharField(
|
||
label="Rezepttext",
|
||
max_length=ai.MAX_INPUT_CHARS,
|
||
widget=forms.Textarea(attrs={"rows": 8, "placeholder": "Rezept hier einfügen, z. B. die Beschreibung eines Instagram-Beitrags"}),
|
||
)
|
||
source_url = forms.URLField(label="Quelle (optional)", required=False, max_length=1000, assume_scheme="https")
|
||
|
||
|
||
def render_draft(request, initial, notice, ai_draft=None, extra_context=None):
|
||
"""Show the recipe form filled with a draft; it posts to recipes:create."""
|
||
form = RecipeForm(initial=initial)
|
||
return render(request, "recipes/form.html", {
|
||
"form": form, "is_new": True, "draft_notice": notice, "ai_draft": ai_draft,
|
||
"form_action": reverse("recipes:create"), **(extra_context or {}),
|
||
})
|
||
|
||
|
||
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
|
||
def import_photo(request):
|
||
form = PhotoImportForm(request.POST, request.FILES)
|
||
if not form.is_valid():
|
||
return import_start(request, photo_form=form)
|
||
try:
|
||
jpegs = [images.prepare_for_ai(f) for f in form.cleaned_data["photos"]]
|
||
draft = ai.extract_from_images(request.user, jpegs)
|
||
except (ai.AIError, images.ImageError) as error:
|
||
messages.error(request, getattr(error, "user_message", str(error)))
|
||
return redirect("recipes:import")
|
||
return render_draft(request, draft.to_form_initial(), "Aus dem Bild erkannt", ai_draft=draft)
|
||
|
||
|
||
@require_POST
|
||
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:
|
||
messages.error(request, error.user_message)
|
||
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")
|