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
+216
View File
@@ -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