feat: improve KitchenOwl import parsing and tagging
This commit is contained in:
@@ -57,6 +57,91 @@ class ScriptExtractor(HTMLParser):
|
||||
self.title = (self.title or "") + data
|
||||
|
||||
|
||||
|
||||
|
||||
class RecipeDomExtractor(HTMLParser):
|
||||
def __init__(self) -> None:
|
||||
super().__init__()
|
||||
self._stack: list[str] = []
|
||||
self._ingredient_depth: int | None = None
|
||||
self._direction_depth: int | None = None
|
||||
self._current_li: list[str] | None = None
|
||||
self._current_heading: list[str] | None = None
|
||||
self.ingredients: list[str] = []
|
||||
self.sections: list[dict[str, Any]] = []
|
||||
self._current_section: dict[str, Any] | None = None
|
||||
|
||||
def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None:
|
||||
attrs_d = dict(attrs)
|
||||
classes = set((attrs_d.get("class") or "").split())
|
||||
self._stack.append(tag)
|
||||
|
||||
if "ingredients-list" in classes:
|
||||
self._ingredient_depth = len(self._stack)
|
||||
if "directions-list" in classes:
|
||||
self._direction_depth = len(self._stack)
|
||||
|
||||
if self.in_ingredients and tag == "li":
|
||||
self._current_li = []
|
||||
elif self.in_directions and tag == "li":
|
||||
self._current_li = []
|
||||
|
||||
if self.in_directions and tag == "p":
|
||||
self._current_heading = []
|
||||
|
||||
def handle_endtag(self, tag: str) -> None:
|
||||
if self.in_ingredients and tag == "li" and self._current_li is not None:
|
||||
text = normalize_whitespace("".join(self._current_li))
|
||||
if text:
|
||||
self.ingredients.append(text)
|
||||
self._current_li = None
|
||||
elif self.in_directions and tag == "li" and self._current_li is not None:
|
||||
text = normalize_whitespace("".join(self._current_li))
|
||||
if text:
|
||||
self._ensure_section().setdefault("itemListElement", []).append({"@type": "HowToStep", "text": text})
|
||||
self._current_li = None
|
||||
|
||||
if self.in_directions and tag == "p" and self._current_heading is not None:
|
||||
heading = normalize_whitespace("".join(self._current_heading)).rstrip(":*").strip()
|
||||
if heading:
|
||||
self._current_section = {"@type": "HowToSection", "name": heading, "itemListElement": []}
|
||||
self.sections.append(self._current_section)
|
||||
self._current_heading = None
|
||||
|
||||
if self._stack:
|
||||
self._stack.pop()
|
||||
|
||||
if self._ingredient_depth is not None and len(self._stack) < self._ingredient_depth:
|
||||
self._ingredient_depth = None
|
||||
if self._direction_depth is not None and len(self._stack) < self._direction_depth:
|
||||
self._direction_depth = None
|
||||
|
||||
def handle_data(self, data: str) -> None:
|
||||
if self.in_ingredients and self._current_li is not None:
|
||||
self._current_li.append(data)
|
||||
return
|
||||
if not self.in_directions:
|
||||
return
|
||||
if self._current_li is not None:
|
||||
self._current_li.append(data)
|
||||
elif self._current_heading is not None:
|
||||
self._current_heading.append(data)
|
||||
|
||||
@property
|
||||
def in_ingredients(self) -> bool:
|
||||
return self._ingredient_depth is not None
|
||||
|
||||
@property
|
||||
def in_directions(self) -> bool:
|
||||
return self._direction_depth is not None
|
||||
|
||||
def _ensure_section(self) -> dict[str, Any]:
|
||||
if self._current_section is None:
|
||||
self._current_section = {"@type": "HowToSection", "name": "Instructions", "itemListElement": []}
|
||||
self.sections.append(self._current_section)
|
||||
return self._current_section
|
||||
|
||||
|
||||
def fetch_url(url: str, timeout: int = 30) -> str:
|
||||
req = request.Request(
|
||||
url,
|
||||
@@ -134,6 +219,17 @@ def parse_json_ld_recipe(html: str) -> tuple[dict[str, Any] | None, str | None]:
|
||||
nodes.extend(flatten_graph(data))
|
||||
return choose_recipe_node(nodes), (parser.title.strip() if parser.title else None)
|
||||
|
||||
def enrich_recipe_node_from_dom(recipe_node: dict[str, Any], html: str) -> dict[str, Any]:
|
||||
extractor = RecipeDomExtractor()
|
||||
extractor.feed(html)
|
||||
enriched = dict(recipe_node)
|
||||
if extractor.ingredients and not (enriched.get("recipeIngredient") or []):
|
||||
enriched["recipeIngredient"] = extractor.ingredients
|
||||
if extractor.sections and not extract_instruction_steps(enriched.get("recipeInstructions")):
|
||||
enriched["recipeInstructions"] = extractor.sections
|
||||
return enriched
|
||||
|
||||
|
||||
|
||||
def coerce_text(value: Any) -> str:
|
||||
if value is None:
|
||||
@@ -223,12 +319,18 @@ def build_description(recipe_node: dict[str, Any]) -> str:
|
||||
if steps:
|
||||
rendered_steps = []
|
||||
step_number = 1
|
||||
for idx, step in enumerate(steps, start=1):
|
||||
in_notes = False
|
||||
for step in steps:
|
||||
if step.endswith(":"):
|
||||
rendered_steps.append(step)
|
||||
in_notes = step[:-1].strip().lower() == "notes"
|
||||
step_number = 1
|
||||
else:
|
||||
rendered_steps.append(f"{step_number}. {step}")
|
||||
step_number += 1
|
||||
if in_notes:
|
||||
rendered_steps.append(f"- {step}")
|
||||
else:
|
||||
rendered_steps.append(f"{step_number}. {step}")
|
||||
step_number += 1
|
||||
parts.append("\n".join(rendered_steps))
|
||||
elif intro_parts[1:]:
|
||||
parts.append("\n".join(intro_parts[1:]))
|
||||
@@ -376,4 +478,5 @@ def normalize_recipe_from_url(url: str, timeout: int = 30) -> dict[str, Any]:
|
||||
recipe_node, title = parse_json_ld_recipe(html)
|
||||
if not recipe_node:
|
||||
raise RuntimeError(f"No schema.org Recipe JSON-LD found for {url}")
|
||||
recipe_node = enrich_recipe_node_from_dom(recipe_node, html)
|
||||
return normalize_recipe(url, recipe_node, title)
|
||||
|
||||
Reference in New Issue
Block a user