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

217 lines
7.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.
"""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