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:
Kyle Dammann
2026-10-30 21:53:28 +01:00
co-authored by Claude Opus 5.5
parent 1bf8ef8b65
commit 60785e2437
22 changed files with 1081 additions and 16 deletions
+112 -4
View File
@@ -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")