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,9 +406,15 @@ 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:
if in_notes:
rendered_steps.append(f'- {step}')
else:
rendered_steps.append(f'{step_number}. {step}')
step_number += 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:

View File

@@ -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,9 +319,15 @@ 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:
if in_notes:
rendered_steps.append(f"- {step}")
else:
rendered_steps.append(f"{step_number}. {step}")
step_number += 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)

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
import re
from typing import Any
from urllib.parse import urlparse
def slugify_tag(value: str) -> str:
@@ -44,24 +45,39 @@ COURSE_HINTS = {
"main dish": "main-course",
"salad": "salad",
"side dish": "side-dish",
"snack": "snack",
"soup": "soup",
}
METHOD_HINTS = {
"baked": "baked",
"fried": "fried",
"fry": "fried",
"grilled": "grilled",
"noodle": "noodles",
"ramen": "noodles",
"rice": "rice",
"skillet": "skillet",
"slow cooker": "slow-cooker",
"stir fry": "stir-fry",
"stir-fry": "stir-fry",
}
DISH_HINTS = {
"tater tot": {"tater-tots", "potato", "side-dish"},
"tater tots": {"tater-tots", "potato", "side-dish"},
"potato": {"potato"},
"potatoes": {"potato"},
}
CUISINE_PHRASE_HINTS = {
"halal": {"middle-eastern"},
"harissa": {"middle-eastern"},
"pita": {"middle-eastern"},
}
SWEET_HINTS = (
"brown sugar", "cake", "caramel", "chocolate", "cookie", "dessert", "frosting",
"honey", "icing", "maple syrup", "molasses", "muffin", "pie", "pudding", "sweet",
"brown sugar", "cake", "caramel", "chocolate", "coconut milk", "cookie", "dessert", "frosting",
"honey", "icing", "mango", "maple syrup", "molasses", "muffin", "pie", "pudding", "sugar", "sweet",
"vanilla", "whipped cream",
)
@@ -71,6 +87,41 @@ SAVORY_HINTS = (
"rice", "salt", "savory", "soy sauce", "stock", "thyme",
)
SOURCE_NOISE_WORDS = {
"and",
"better",
"chef",
"com",
"ever",
"guys",
"https",
"joshua",
"recipe",
"recipes",
"the",
"weissman",
"www",
}
PROTEIN_EXCLUSION_PATTERNS = {
"beef": re.compile(r"\bbeef\s+(?:tallow|stock|broth|bouillon)\b"),
"chicken": re.compile(r"\bchicken\s+(?:stock|broth|bouillon)\b"),
"pork": re.compile(r"\bpork\s+(?:fat|lard|stock|broth)\b"),
}
MANAGED_TAGS = {
*(tag for expanded in CUISINE_TAGS.values() for tag in expanded),
*(tag for expanded in CUISINE_PHRASE_HINTS.values() for tag in expanded),
*COURSE_HINTS.values(),
*METHOD_HINTS.values(),
*(tag for expanded in DISH_HINTS.values() for tag in expanded),
*PROTEIN_TAGS.values(),
"curry",
"pasta",
"savory",
"sweet",
}
def _flatten_texts(items: list[dict[str, Any]] | None) -> str:
flattened: list[str] = []
@@ -80,6 +131,54 @@ def _flatten_texts(items: list[dict[str, Any]] | None) -> str:
return " ".join(flattened)
def _tokenize_slug(value: str) -> set[str]:
return {part for part in slugify_tag(value).split("-") if part}
def _source_tokens(source: str) -> set[str]:
parsed = urlparse(source or "")
parts = re.split(r"[^a-z0-9]+", f"{parsed.netloc} {parsed.path}".lower())
return {part for part in parts if part}
def _is_boilerplate_existing_tag(tag: str, name: str, source: str) -> bool:
slug = slugify_tag(tag)
if not slug:
return True
if slug == slugify_tag(name):
return True
tag_tokens = set(slug.split("-"))
if not tag_tokens:
return True
combined_noise = _tokenize_slug(name) | _source_tokens(source) | SOURCE_NOISE_WORDS
meaningful_tokens = {token for token in tag_tokens if token not in combined_noise}
if not meaningful_tokens and len(tag_tokens) >= 3:
return True
if {"chef", "recipe"}.issubset(tag_tokens):
return True
return False
def _has_phrase(text: str, phrase: str) -> bool:
return re.search(rf"\b{re.escape(phrase)}\b", text) is not None
def _has_protein(text: str, protein: str) -> bool:
if not _has_phrase(text, protein):
return False
exclusion = PROTEIN_EXCLUSION_PATTERNS.get(protein)
if exclusion and exclusion.search(text):
stripped = exclusion.sub("", text)
return _has_phrase(stripped, protein)
return True
def curate_recipe_tags(
*,
name: str,
@@ -88,70 +187,118 @@ def curate_recipe_tags(
source: str = "",
existing_tags: list[str] | None = None,
) -> list[str]:
item_text = _flatten_texts(items)
haystack = " ".join(
part for part in [
str(name or ""),
str(description or ""),
_flatten_texts(items),
" ".join(existing_tags or []),
item_text,
str(source or ""),
]
if part
).lower()
protein_haystack = " ".join(
part for part in [
str(name or ""),
item_text,
str(source or ""),
]
if part
).lower()
inferred: list[str] = []
inferred_seen: set[str] = set()
def add_inferred(tag: str) -> None:
slug = slugify_tag(tag)
if slug and slug not in inferred_seen:
inferred_seen.add(slug)
inferred.append(slug)
for key, expanded in CUISINE_TAGS.items():
if _has_phrase(haystack, key):
for tag in expanded:
add_inferred(tag)
for key, expanded in CUISINE_PHRASE_HINTS.items():
if _has_phrase(haystack, key):
for tag in expanded:
add_inferred(tag)
for key, tag in COURSE_HINTS.items():
if _has_phrase(haystack, key):
add_inferred(tag)
for key, tag in METHOD_HINTS.items():
if _has_phrase(haystack, key):
add_inferred(tag)
for key, expanded in DISH_HINTS.items():
if _has_phrase(haystack, key):
for tag in expanded:
add_inferred(tag)
for key, tag in PROTEIN_TAGS.items():
if _has_protein(protein_haystack, key):
add_inferred(tag)
if "mango sticky rice" in haystack:
add_inferred("thai")
add_inferred("dessert")
add_inferred("sweet")
add_inferred("rice")
if "pasta chips" in haystack:
add_inferred("snack")
add_inferred("pasta")
add_inferred("savory")
if "kibbeh" in haystack:
add_inferred("middle-eastern")
add_inferred("lebanese")
add_inferred("main-course")
if "larb" in haystack or "laab" in haystack:
add_inferred("thai")
add_inferred("main-course")
if "curry" in haystack:
add_inferred("curry")
add_inferred("main-course")
if "pasta" in haystack:
add_inferred("pasta")
sweet_score = sum(1 for hint in SWEET_HINTS if hint in haystack)
savory_score = sum(1 for hint in SAVORY_HINTS if hint in haystack)
if sweet_score > savory_score and sweet_score > 0:
add_inferred("sweet")
elif savory_score > 0:
add_inferred("savory")
if "dessert" not in inferred_seen and "sweet" not in inferred_seen and not any(
tag in inferred_seen for tag in COURSE_HINTS.values()
):
add_inferred("main-course")
curated: list[str] = []
seen: set[str] = set()
def add(tag: str) -> None:
def add_final(tag: str) -> None:
slug = slugify_tag(tag)
if slug and slug not in seen:
seen.add(slug)
curated.append(slug)
for tag in existing_tags or []:
add(tag)
if _is_boilerplate_existing_tag(tag, name, source):
continue
slug = slugify_tag(tag)
if slug in MANAGED_TAGS and slug not in inferred_seen:
continue
add_final(tag)
for key, expanded in CUISINE_TAGS.items():
if key in haystack:
for tag in expanded:
add(tag)
for key, tag in COURSE_HINTS.items():
if key in haystack:
add(tag)
for key, tag in METHOD_HINTS.items():
if key in haystack:
add(tag)
for key, tag in PROTEIN_TAGS.items():
if key in haystack:
add(tag)
if "kibbeh" in haystack:
add("middle-eastern")
add("lebanese")
add("main-course")
if "larb" in haystack or "laab" in haystack:
add("thai")
add("main-course")
if "curry" in haystack:
add("curry")
add("main-course")
if "pasta" in haystack:
add("pasta")
sweet_score = sum(1 for hint in SWEET_HINTS if hint in haystack)
savory_score = sum(1 for hint in SAVORY_HINTS if hint in haystack)
if sweet_score > savory_score and sweet_score > 0:
add("sweet")
elif savory_score > 0:
add("savory")
if "dessert" not in seen and "sweet" not in seen and "main-course" not in seen and "side-dish" not in seen:
add("main-course")
for tag in inferred:
add_final(tag)
return curated[:20]

View File

@@ -0,0 +1,121 @@
import sys
import unittest
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from app.services.tagging import curate_recipe_tags
from app.services.recipe_importer import normalize_recipe_from_url
class TaggingTests(unittest.TestCase):
def test_tater_tots_drop_boilerplate_and_gain_meaningful_tags(self):
payload = normalize_recipe_from_url(
'https://www.joshuaweissman.com/recipes/the-best-tater-tots-ever-recipe'
)
self.assertNotIn('the-best-tater-tots-ever', payload['tags'])
self.assertNotIn('chef-joshua-weissman-recipe', payload['tags'])
self.assertIn('potato', payload['tags'])
self.assertIn('tater-tots', payload['tags'])
self.assertIn('side-dish', payload['tags'])
self.assertNotIn('beef', payload['tags'])
def test_halal_chicken_rice_drops_boilerplate_and_infers_cuisine(self):
payload = normalize_recipe_from_url(
'https://www.joshuaweissman.com/recipes/halal-guys-chicken-over-rice-but-better-recipe'
)
self.assertNotIn('halal-guys-chicken-over-rice-but-better', payload['tags'])
self.assertNotIn('chef-joshua-weissman-recipe', payload['tags'])
self.assertIn('middle-eastern', payload['tags'])
self.assertIn('chicken', payload['tags'])
self.assertIn('rice', payload['tags'])
self.assertNotIn('skillet', payload['tags'])
def test_existing_machine_tags_can_be_replaced_when_recipe_content_disagrees(self):
tags = curate_recipe_tags(
name='The Best Tater Tots Ever',
description='Homemade tater tots worth making. Fry until crisp.',
items=[
{'name': 'Russet potatoes', 'description': 'peeled', 'optional': False},
{'name': 'beef tallow', 'description': 'for frying', 'optional': False},
],
source='https://www.joshuaweissman.com/recipes/the-best-tater-tots-ever-recipe',
existing_tags=['beef', 'main-course', 'savory'],
)
self.assertNotIn('beef', tags)
self.assertNotIn('main-course', tags)
self.assertIn('side-dish', tags)
self.assertIn('savory', tags)
def test_existing_human_curated_tags_are_preserved(self):
tags = curate_recipe_tags(
name='Simple Roast Chicken',
description='A chicken dinner with herbs.',
items=[{'name': 'chicken', 'description': '', 'optional': False}],
source='https://example.com/roast-chicken',
existing_tags=['family-favorite', 'weeknight'],
)
self.assertIn('family-favorite', tags)
self.assertIn('weeknight', tags)
def test_mango_sticky_rice_is_treated_as_dessert_not_savory_main(self):
tags = curate_recipe_tags(
name='Perfect Mango Sticky Rice At Home',
description='Mango sticky rice is something everyone can, and should, make.',
items=[
{'name': 'glutinous rice', 'description': '1.5 cups', 'optional': False},
{'name': 'full-fat coconut milk', 'description': '1 can', 'optional': False},
{'name': 'mangos', 'description': '2', 'optional': False},
{'name': 'granulated sugar', 'description': '1/2 cup', 'optional': False},
],
source='https://www.joshuaweissman.com/recipes/perfect-mango-sticky-rice-recipe',
existing_tags=['chef-joshua-weissman-recipe', 'main-course', 'savory'],
)
self.assertIn('dessert', tags)
self.assertIn('sweet', tags)
self.assertIn('thai', tags)
self.assertNotIn('main-course', tags)
self.assertNotIn('savory', tags)
def test_description_only_chicken_reference_does_not_force_chicken_tag(self):
tags = curate_recipe_tags(
name='Quick Asian Beef Ramen Noodles',
description='Try the chicken version next time if you want a variation.',
items=[
{'name': 'ground beef', 'description': '200g', 'optional': False},
{'name': 'ramen noodles', 'description': '2 packs', 'optional': False},
{'name': 'soy sauce', 'description': '1 tbsp', 'optional': False},
],
source='https://www.recipetineats.com/asian-beef-and-noodles/',
existing_tags=['asian', 'noodles'],
)
self.assertIn('beef', tags)
self.assertNotIn('chicken', tags)
def test_pasta_chips_are_snack_not_main_course(self):
tags = curate_recipe_tags(
name='Air Fryer Pasta Chips',
description='This could be one of the greatest homemade snacks of all time.',
items=[
{'name': 'farfalle pasta', 'description': '8 oz', 'optional': False},
{'name': 'pecorino romano', 'description': 'to taste', 'optional': False},
{'name': 'olive oil', 'description': 'drizzle', 'optional': False},
],
source='https://www.joshuaweissman.com/recipes/air-fryer-pasta-chips-recipe',
existing_tags=['italian', 'main-course', 'pasta', 'savory'],
)
self.assertIn('snack', tags)
self.assertIn('pasta', tags)
self.assertNotIn('main-course', tags)
if __name__ == '__main__':
unittest.main()