feat: improve KitchenOwl import parsing and tagging
This commit is contained in:
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user