Files
Kyle DammannandClaude Opus 5.5 60785e2437 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]>
2026-10-30 21:53:28 +01:00

192 lines
8.5 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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")