feat: structured ingredients with unit/food resolution
Scraper returns {quantity, unit, food, extra} dicts instead of flat
strings. UI shows 4-column ingredient editor. Mealie client resolves
unit/food IDs via API (creates missing ones automatically).
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
+129
-7
@@ -16,6 +16,8 @@ class MealieClient:
|
|||||||
"Authorization": f"Bearer {api_key}",
|
"Authorization": f"Bearer {api_key}",
|
||||||
"Accept": "application/json",
|
"Accept": "application/json",
|
||||||
})
|
})
|
||||||
|
self._units_cache = None # {name_or_abbr: {id, name}}
|
||||||
|
self._foods_cache = {} # {lowercase_name: {id, name}}
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Public
|
# Public
|
||||||
@@ -61,19 +63,99 @@ class MealieClient:
|
|||||||
|
|
||||||
return slug
|
return slug
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
# Unit / food resolution
|
||||||
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _load_units(self):
|
||||||
|
"""Fetch all units from Mealie, build lookup by name and abbreviation."""
|
||||||
|
if self._units_cache is not None:
|
||||||
|
return
|
||||||
|
self._units_cache = {}
|
||||||
|
r = self.session.get(f"{self.api_url}/api/units", params={"perPage": -1}, timeout=10)
|
||||||
|
r.raise_for_status()
|
||||||
|
for u in r.json().get("items", []):
|
||||||
|
entry = {"id": u["id"], "name": u["name"]}
|
||||||
|
self._units_cache[u["name"].lower()] = entry
|
||||||
|
abbr = u.get("abbreviation", "")
|
||||||
|
if abbr:
|
||||||
|
self._units_cache[abbr.lower()] = entry
|
||||||
|
|
||||||
|
def _ensure_unit(self, name: str):
|
||||||
|
"""Look up a unit by name/abbreviation. Create if missing.
|
||||||
|
Returns {"id": ..., "name": ...} or None for empty name."""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
self._load_units()
|
||||||
|
key = name.lower().strip()
|
||||||
|
if key in self._units_cache:
|
||||||
|
return self._units_cache[key]
|
||||||
|
|
||||||
|
# Create new unit
|
||||||
|
r = self.session.post(
|
||||||
|
f"{self.api_url}/api/units",
|
||||||
|
json={"name": name, "abbreviation": name, "pluralName": name},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.ok:
|
||||||
|
u = r.json()
|
||||||
|
entry = {"id": u["id"], "name": u["name"]}
|
||||||
|
self._units_cache[key] = entry
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _ensure_food(self, name: str):
|
||||||
|
"""Look up a food by name. Create if missing.
|
||||||
|
Returns {"id": ..., "name": ...} or None for empty name."""
|
||||||
|
if not name:
|
||||||
|
return None
|
||||||
|
key = name.lower().strip()
|
||||||
|
if key in self._foods_cache:
|
||||||
|
return self._foods_cache[key]
|
||||||
|
|
||||||
|
# Search existing foods
|
||||||
|
r = self.session.get(
|
||||||
|
f"{self.api_url}/api/foods",
|
||||||
|
params={"search": name, "perPage": 50},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.ok:
|
||||||
|
for f in r.json().get("items", []):
|
||||||
|
if f["name"].lower() == key:
|
||||||
|
entry = {"id": f["id"], "name": f["name"]}
|
||||||
|
self._foods_cache[key] = entry
|
||||||
|
return entry
|
||||||
|
|
||||||
|
# Create new food
|
||||||
|
r = self.session.post(
|
||||||
|
f"{self.api_url}/api/foods",
|
||||||
|
json={"name": name},
|
||||||
|
timeout=10,
|
||||||
|
)
|
||||||
|
if r.ok:
|
||||||
|
f = r.json()
|
||||||
|
entry = {"id": f["id"], "name": f["name"]}
|
||||||
|
self._foods_cache[key] = entry
|
||||||
|
return entry
|
||||||
|
return None
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
# Internal
|
# Internal
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
|
|
||||||
def _build_payload(self, recipe: dict) -> dict:
|
def _build_payload(self, recipe: dict) -> dict:
|
||||||
ingredients = []
|
ingredients = []
|
||||||
for line in recipe.get("ingredients", []):
|
for item in recipe.get("ingredients", []):
|
||||||
ingredients.append({
|
if isinstance(item, dict):
|
||||||
"referenceId": str(uuid.uuid4()),
|
ingredients.append(self._build_ingredient(item))
|
||||||
"note": line,
|
else:
|
||||||
"isFood": False,
|
# Legacy: plain string
|
||||||
"disableAmount": True,
|
ingredients.append({
|
||||||
})
|
"referenceId": str(uuid.uuid4()),
|
||||||
|
"note": str(item),
|
||||||
|
"isFood": False,
|
||||||
|
"disableAmount": True,
|
||||||
|
})
|
||||||
|
|
||||||
instructions = []
|
instructions = []
|
||||||
for text in recipe.get("instructions", []):
|
for text in recipe.get("instructions", []):
|
||||||
@@ -93,6 +175,46 @@ class MealieClient:
|
|||||||
"recipeYield": "",
|
"recipeYield": "",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def _build_ingredient(self, item: dict) -> dict:
|
||||||
|
"""Build a Mealie ingredient from a structured dict."""
|
||||||
|
qty_str = str(item.get("quantity", "")).strip()
|
||||||
|
unit_str = item.get("unit", "").strip()
|
||||||
|
food_str = item.get("food", "").strip()
|
||||||
|
extra = item.get("extra", "").strip()
|
||||||
|
|
||||||
|
has_structured = bool(qty_str or unit_str)
|
||||||
|
|
||||||
|
if has_structured and food_str:
|
||||||
|
unit_ref = self._ensure_unit(unit_str) if unit_str else None
|
||||||
|
food_ref = self._ensure_food(food_str)
|
||||||
|
|
||||||
|
qty = 0
|
||||||
|
try:
|
||||||
|
qty = float(qty_str.replace(",", "."))
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
pass
|
||||||
|
|
||||||
|
return {
|
||||||
|
"referenceId": str(uuid.uuid4()),
|
||||||
|
"quantity": qty,
|
||||||
|
"unit": unit_ref,
|
||||||
|
"food": food_ref,
|
||||||
|
"note": extra,
|
||||||
|
"isFood": True,
|
||||||
|
"disableAmount": False,
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# No quantity/unit — put food+extra in note
|
||||||
|
note = food_str
|
||||||
|
if extra:
|
||||||
|
note = f"{note} ({extra})" if note else extra
|
||||||
|
return {
|
||||||
|
"referenceId": str(uuid.uuid4()),
|
||||||
|
"note": note,
|
||||||
|
"isFood": False,
|
||||||
|
"disableAmount": True,
|
||||||
|
}
|
||||||
|
|
||||||
def _upload_image(self, slug: str, image_url: str):
|
def _upload_image(self, slug: str, image_url: str):
|
||||||
"""Download image from *image_url* and upload it to the recipe."""
|
"""Download image from *image_url* and upload it to the recipe."""
|
||||||
img_resp = requests.get(image_url, timeout=30, headers={
|
img_resp = requests.get(image_url, timeout=30, headers={
|
||||||
|
|||||||
+21
-26
@@ -26,7 +26,7 @@ def scrape(url: str) -> dict:
|
|||||||
"title": str,
|
"title": str,
|
||||||
"description": str,
|
"description": str,
|
||||||
"image_url": str | None,
|
"image_url": str | None,
|
||||||
"ingredients": [str, ...],
|
"ingredients": [{"quantity": str, "unit": str, "food": str, "extra": str}, ...],
|
||||||
"instructions": [str, ...],
|
"instructions": [str, ...],
|
||||||
"original_url": str,
|
"original_url": str,
|
||||||
}
|
}
|
||||||
@@ -65,41 +65,33 @@ def _parse_mindmegette(soup: BeautifulSoup, url: str) -> dict:
|
|||||||
ing_container = soup.find("div", class_="ingredients")
|
ing_container = soup.find("div", class_="ingredients")
|
||||||
if ing_container:
|
if ing_container:
|
||||||
for row in ing_container.find_all("div", class_="ingredients-meta"):
|
for row in ing_container.find_all("div", class_="ingredients-meta"):
|
||||||
parts = []
|
|
||||||
# Actual HTML: <strong>qty</strong> <span>unit</span>
|
# Actual HTML: <strong>qty</strong> <span>unit</span>
|
||||||
# <a class="ingredients-link">name</a>
|
# <a class="ingredients-link">name</a> <small>(extra)</small>
|
||||||
qty_el = row.find("strong")
|
qty_el = row.find("strong")
|
||||||
# Unit: first plain <span> (not one with a specific class like
|
|
||||||
# "ingredients-checkbox" etc.)
|
|
||||||
unit_el = None
|
unit_el = None
|
||||||
for sp in row.find_all("span"):
|
for sp in row.find_all("span"):
|
||||||
if not sp.get("class"):
|
if not sp.get("class"):
|
||||||
unit_el = sp
|
unit_el = sp
|
||||||
break
|
break
|
||||||
name_el = row.find("a", class_="ingredients-link")
|
name_el = row.find("a", class_="ingredients-link")
|
||||||
# Extra info: <small>(darált)</small> or <span class="extra">
|
|
||||||
extra_el = row.find("small") or row.find("span", class_="extra")
|
extra_el = row.find("small") or row.find("span", class_="extra")
|
||||||
|
|
||||||
if qty_el:
|
qty = _text(qty_el)
|
||||||
parts.append(_text(qty_el))
|
unit = _text(unit_el)
|
||||||
if unit_el:
|
food = _text(name_el)
|
||||||
parts.append(_text(unit_el))
|
extra = _text(extra_el).strip("() ")
|
||||||
if name_el:
|
|
||||||
parts.append(_text(name_el))
|
|
||||||
if extra_el:
|
|
||||||
extra = _text(extra_el)
|
|
||||||
if extra:
|
|
||||||
# Wrap in parens if not already
|
|
||||||
if not extra.startswith("("):
|
|
||||||
extra = f"({extra})"
|
|
||||||
parts.append(extra)
|
|
||||||
|
|
||||||
line = " ".join(p for p in parts if p)
|
if not food:
|
||||||
if not line:
|
# Fallback: grab whole row text
|
||||||
# Fallback: grab whole row text with spaces between elements
|
food = row.get_text(separator=" ", strip=True)
|
||||||
line = row.get_text(separator=" ", strip=True)
|
|
||||||
if line:
|
if food:
|
||||||
ingredients.append(line)
|
ingredients.append({
|
||||||
|
"quantity": qty,
|
||||||
|
"unit": unit,
|
||||||
|
"food": food,
|
||||||
|
"extra": extra,
|
||||||
|
})
|
||||||
|
|
||||||
# --- Instructions ---
|
# --- Instructions ---
|
||||||
instructions = []
|
instructions = []
|
||||||
@@ -150,7 +142,10 @@ def _parse_generic(soup: BeautifulSoup, url: str) -> dict:
|
|||||||
if isinstance(data, list):
|
if isinstance(data, list):
|
||||||
data = data[0]
|
data = data[0]
|
||||||
if data.get("@type") == "Recipe":
|
if data.get("@type") == "Recipe":
|
||||||
ingredients = data.get("recipeIngredient", [])
|
for line in data.get("recipeIngredient", []):
|
||||||
|
ingredients.append({
|
||||||
|
"quantity": "", "unit": "", "food": line, "extra": "",
|
||||||
|
})
|
||||||
raw_instructions = data.get("recipeInstructions", [])
|
raw_instructions = data.get("recipeInstructions", [])
|
||||||
for item in raw_instructions:
|
for item in raw_instructions:
|
||||||
if isinstance(item, str):
|
if isinstance(item, str):
|
||||||
|
|||||||
@@ -11,13 +11,35 @@
|
|||||||
border-radius: var(--radius);
|
border-radius: var(--radius);
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
}
|
}
|
||||||
|
.ingredient-header {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.3rem;
|
||||||
|
padding-right: 34px; /* space for remove btn */
|
||||||
|
}
|
||||||
|
.ingredient-header span {
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-dim);
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.03em;
|
||||||
|
}
|
||||||
|
.ingredient-header .col-qty { width: 60px; flex-shrink: 0; }
|
||||||
|
.ingredient-header .col-unit { width: 80px; flex-shrink: 0; }
|
||||||
|
.ingredient-header .col-food { flex: 1; }
|
||||||
|
.ingredient-header .col-extra { width: 120px; flex-shrink: 0; }
|
||||||
|
|
||||||
.ingredient-row {
|
.ingredient-row {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
margin-bottom: 0.4rem;
|
margin-bottom: 0.4rem;
|
||||||
}
|
}
|
||||||
.ingredient-row input { margin-bottom: 0; flex: 1; }
|
.ingredient-row input { margin-bottom: 0; }
|
||||||
|
.ingredient-row .ing-qty { width: 60px; flex-shrink: 0; }
|
||||||
|
.ingredient-row .ing-unit { width: 80px; flex-shrink: 0; }
|
||||||
|
.ingredient-row .ing-food { flex: 1; }
|
||||||
|
.ingredient-row .ing-extra { width: 120px; flex-shrink: 0; }
|
||||||
.ingredient-row button {
|
.ingredient-row button {
|
||||||
background: var(--danger);
|
background: var(--danger);
|
||||||
border: none;
|
border: none;
|
||||||
@@ -115,8 +137,14 @@
|
|||||||
|
|
||||||
<!-- Ingredients -->
|
<!-- Ingredients -->
|
||||||
<label>Hozzávalók</label>
|
<label>Hozzávalók</label>
|
||||||
|
<div class="ingredient-header">
|
||||||
|
<span class="col-qty">Menny.</span>
|
||||||
|
<span class="col-unit">Egység</span>
|
||||||
|
<span class="col-food">Hozzávaló</span>
|
||||||
|
<span class="col-extra">Megjegyzés</span>
|
||||||
|
</div>
|
||||||
<div id="ingredientsList"></div>
|
<div id="ingredientsList"></div>
|
||||||
<button class="add-btn mt-1 mb-2" onclick="addIngredient('')">+ Hozzávaló hozzáadása</button>
|
<button class="add-btn mt-1 mb-2" onclick="addIngredient({})">+ Hozzávaló hozzáadása</button>
|
||||||
|
|
||||||
<!-- Instructions -->
|
<!-- Instructions -->
|
||||||
<label>Elkészítés</label>
|
<label>Elkészítés</label>
|
||||||
@@ -206,11 +234,15 @@ function populatePreview(r) {
|
|||||||
(r.instructions || []).forEach(t => addInstruction(t));
|
(r.instructions || []).forEach(t => addInstruction(t));
|
||||||
}
|
}
|
||||||
|
|
||||||
function addIngredient(value) {
|
function addIngredient(item) {
|
||||||
|
if (typeof item === 'string') item = { food: item };
|
||||||
const list = document.getElementById('ingredientsList');
|
const list = document.getElementById('ingredientsList');
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ingredient-row';
|
row.className = 'ingredient-row';
|
||||||
row.innerHTML = '<input type="text" value="' + escHtml(value) + '">'
|
row.innerHTML = '<input type="text" class="ing-qty" placeholder="" value="' + escHtml(item.quantity || '') + '">'
|
||||||
|
+ '<input type="text" class="ing-unit" placeholder="" value="' + escHtml(item.unit || '') + '">'
|
||||||
|
+ '<input type="text" class="ing-food" placeholder="Hozzávaló" value="' + escHtml(item.food || '') + '">'
|
||||||
|
+ '<input type="text" class="ing-extra" placeholder="" value="' + escHtml(item.extra || '') + '">'
|
||||||
+ '<button onclick="this.parentElement.remove()">✕</button>';
|
+ '<button onclick="this.parentElement.remove()">✕</button>';
|
||||||
list.appendChild(row);
|
list.appendChild(row);
|
||||||
}
|
}
|
||||||
@@ -239,9 +271,14 @@ function renumberInstructions() {
|
|||||||
|
|
||||||
function gatherRecipe() {
|
function gatherRecipe() {
|
||||||
const ingredients = [];
|
const ingredients = [];
|
||||||
document.querySelectorAll('#ingredientsList .ingredient-row input').forEach(inp => {
|
document.querySelectorAll('#ingredientsList .ingredient-row').forEach(row => {
|
||||||
const v = inp.value.trim();
|
const qty = row.querySelector('.ing-qty').value.trim();
|
||||||
if (v) ingredients.push(v);
|
const unit = row.querySelector('.ing-unit').value.trim();
|
||||||
|
const food = row.querySelector('.ing-food').value.trim();
|
||||||
|
const extra = row.querySelector('.ing-extra').value.trim();
|
||||||
|
if (food || qty) {
|
||||||
|
ingredients.push({ quantity: qty, unit: unit, food: food, extra: extra });
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
const instructions = [];
|
const instructions = [];
|
||||||
|
|||||||
Reference in New Issue
Block a user