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
+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")
|
||||
|
||||
Reference in New Issue
Block a user