feat: improve KitchenOwl import parsing and tagging

This commit is contained in:
Fizzlepoof
2026-05-21 23:31:32 +00:00
parent 269354286d
commit 3f333595a2
4 changed files with 528 additions and 54 deletions

View File

@@ -120,6 +120,91 @@ def api_request(method: str, url: str, token: str, *, params: dict[str, Any] | N
return exc.code, payload, dict(exc.headers.items())
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 = DEFAULT_TIMEOUT) -> str:
req = request.Request(
url,
@@ -197,6 +282,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
DURATION_RE = re.compile(r'^P(?:T(?:(?P<hours>\d+)H)?(?:(?P<minutes>\d+)M)?(?:(?P<seconds>\d+)S)?)?$')
@@ -310,12 +406,18 @@ def build_description(recipe_node: dict[str, Any]) -> str:
if steps:
rendered_steps = []
step_number = 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:]))
@@ -499,6 +601,7 @@ def import_one(args: argparse.Namespace, base_url: str, token: str, household_id
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)
payload = normalize_recipe(url, recipe_node, title)
result['strategy'] = 'json-ld-fallback'
if scrape_error: