From a0bcb62588ab0c480c1b0d5885d3bef2ca17fcd9 Mon Sep 17 00:00:00 2001 From: kisfenyo Date: Tue, 24 Feb 2026 18:07:05 +0100 Subject: [PATCH] v0.6.0: Sobors.hu parser, HTTP auth, recipe validation, UI polish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New sobors.hu parser with ingredient groups and section headers - Incomplete recipe warnings (missing ingredients/instructions) - Optional HTTP Basic Auth (configurable on settings page) - Brand text: "Recept" in white, "Importáló" in blue - Larger logo (36px), favicon using logo_notext.svg Co-Authored-By: Claude Opus 4.6 --- CHANGELOG.md | 12 +++++ README.md | 13 ++++++ app/main.py | 65 +++++++++++++++++++++++++- app/scraper.py | 92 +++++++++++++++++++++++++++++++++++++ app/templates/base.html | 8 ++-- app/templates/import.html | 33 ++++++++++--- app/templates/settings.html | 26 +++++++++++ 7 files changed, 237 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5ce196e..591e13c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Changelog +## v0.6.0 (2026-02-24) + +### Added +- Sobors.hu parser: ingredients (with groups), instructions (with section headers), tags +- Incomplete recipe validation: warnings when ingredients or instructions are missing +- Optional HTTP Basic Auth: configurable username/password on the settings page +- Favicon (browser tab icon) using felhom.eu logo + +### Changed +- Brand text: "Recept" in white, "Importáló" in blue (felhom.eu style) +- Logo enlarged in navigation bar (28px → 36px) + ## v0.5.1 (2026-02-24) ### Changed diff --git a/README.md b/README.md index 422c686..0068508 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ Docker container for importing recipes from Hungarian websites into [Mealie](htt | mindmegette.hu | Yes | Yes | Yes | Yes | | streetkitchen.hu | Yes (with groups) | Yes (ol/ul/paragraph) | Yes | Yes (from JSON-LD categories) | | nosalty.hu | Yes (with groups) | Yes (with section headers) | Yes | Yes | +| sobors.hu | Yes (with groups) | Yes (with section headers) | Yes | Yes | | *Other sites* | Fallback (schema.org JSON-LD) | Fallback (schema.org JSON-LD) | Yes (og:image) | Fallback (schema.org keywords) | ### Mindmegette.hu Parser @@ -81,6 +82,18 @@ Extracts data from the nosalty.hu recipe pages: - **Instructions**: `div#select` → `ol.m-list__list > li.m-list__item` steps; optional `

` section headers - **Tags**: `` inside `div.p-recipe__attributeList` +### Sobors.hu Parser + +Extracts data from the sobors.hu recipe pages: + +- **Title**: `h3.recept_nev` +- **Description**: `og:description` meta tag +- **Image**: `og:image` meta tag +- **Ingredients**: `div.hozzavalok-container` → `section` elements with `ul > li`, each containing `span.mennyiseg` (qty), `span.mertekegyseg` (unit), `span.hozzavalo` (food) +- **Ingredient groups**: `section > h4` headers (e.g., "A szószhoz:", "A húsgolyókhoz:") +- **Instructions**: `div.recept_leiras` → `

` tags, with `

` section headers +- **Tags**: `div.cikk-cimkek > ul.cikk-cimkek-list > li > a` (skips generic "Receptek" category) + ### Generic Fallback Parser For unsupported sites, attempts extraction via: diff --git a/app/main.py b/app/main.py index df1011b..8d8f2cc 100644 --- a/app/main.py +++ b/app/main.py @@ -1,9 +1,11 @@ """Flask application — recipe importer web UI.""" +import hashlib import os +import secrets import traceback -from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory +from flask import Flask, render_template, request, redirect, url_for, flash, jsonify, send_from_directory, Response from app import config from app.scraper import scrape, supported_sites @@ -20,6 +22,42 @@ app.secret_key = os.environ.get("SECRET_KEY", "recipe-importer-dev-key") VERSION = os.environ.get("VERSION", "dev") +# --------------------------------------------------------------------------- +# Basic auth middleware +# --------------------------------------------------------------------------- + +_AUTH_EXEMPT = {"/health", "/assets/logo.svg", "/assets/logo_notext.svg"} + + +@app.before_request +def _check_basic_auth(): + """Enforce HTTP Basic Auth if configured.""" + if request.path in _AUTH_EXEMPT or request.path.startswith("/assets/"): + return None + cfg = config.load() + auth_user = cfg.get("auth_username", "").strip() + auth_hash = cfg.get("auth_password_hash", "").strip() + if not auth_user or not auth_hash: + return None # auth not configured — allow all + + auth = request.authorization + if (auth + and auth.type == "basic" + and auth.username == auth_user + and _hash_password(auth.password) == auth_hash): + return None # credentials match + + return Response( + "Hitelesítés szükséges.", 401, + {"WWW-Authenticate": 'Basic realm="Recept Importáló"'}, + ) + + +def _hash_password(password: str) -> str: + """SHA-256 hex digest of password.""" + return hashlib.sha256(password.encode("utf-8")).hexdigest() + + # --------------------------------------------------------------------------- # Routes # --------------------------------------------------------------------------- @@ -46,6 +84,20 @@ def settings(): cfg["mealie_api_key"] = request.form.get("mealie_api_key", "").strip() cfg["tandoor_url"] = request.form.get("tandoor_url", "").strip().rstrip("/") cfg["tandoor_api_key"] = request.form.get("tandoor_api_key", "").strip() + + # Auth settings + new_user = request.form.get("auth_username", "").strip() + new_pass = request.form.get("auth_password", "").strip() + if new_user: + cfg["auth_username"] = new_user + if new_pass: + # Only update password hash if a new password was provided + cfg["auth_password_hash"] = _hash_password(new_pass) + else: + # Clear auth if username removed + cfg.pop("auth_username", None) + cfg.pop("auth_password_hash", None) + config.save(cfg) flash("Beállítások mentve.", "success") return redirect(url_for("settings")) @@ -125,8 +177,17 @@ def scrape_url(): except Exception: pass # non-fatal + # Validate completeness + warnings = [] + real_ingredients = [i for i in data.get("ingredients", []) if "group" not in i] + if not real_ingredients: + warnings.append("A recept nem tartalmaz hozzávalókat.") + if not data.get("instructions"): + warnings.append("A recept nem tartalmaz elkészítési lépéseket.") + return jsonify({"ok": True, "data": data, "duplicate": duplicate, - "tandoor_duplicate": tandoor_duplicate}) + "tandoor_duplicate": tandoor_duplicate, + "warnings": warnings}) except Exception as exc: return jsonify({"ok": False, "error": str(exc), "trace": traceback.format_exc()}) diff --git a/app/scraper.py b/app/scraper.py index 4aa506f..27aeb2a 100644 --- a/app/scraper.py +++ b/app/scraper.py @@ -418,6 +418,98 @@ def _parse_nosalty_ingredient(li, ingredients: list): }) +# --------------------------------------------------------------------------- +# sobors.hu +# --------------------------------------------------------------------------- + + +@_register("sobors") +def _parse_sobors(soup: BeautifulSoup, url: str) -> dict: + # Title: h3.recept_nev + title = "" + title_el = soup.find("h3", class_="recept_nev") + if title_el: + title = title_el.get_text(strip=True) + if not title: + title = _og(soup, "og:title") or _text(soup.find("title")) + if title: + title = re.sub(r"\s*[-–|]\s*SóBors.*$", "", title, flags=re.IGNORECASE).strip() + + description = _og(soup, "og:description") or "" + image_url = _og(soup, "og:image") + + # --- Ingredients --- + # Container: div.hozzavalok-container + # Groups: section > h4 (group header), section > ul > li + # Each li > span > span.mennyiseg, span.mertekegyseg, span.hozzavalo + ingredients = [] + ing_container = soup.find("div", class_="hozzavalok-container") + if ing_container: + for section in ing_container.find_all("section"): + h4 = section.find("h4") + if h4: + group_name = h4.get_text(strip=True).rstrip(":") + if group_name: + ingredients.append({"group": group_name}) + for li in section.find_all("li"): + qty_el = li.find("span", class_="mennyiseg") + unit_el = li.find("span", class_="mertekegyseg") + food_el = li.find("span", class_="hozzavalo") + food = _text(food_el) + if not food: + continue + qty = _text(qty_el) + unit = _text(unit_el) + ingredients.append({ + "quantity": qty, + "unit": unit, + "food": food, + "extra": "", + }) + + # --- Instructions --- + # Container: div.recept_leiras.recept_he-elkeszites + # Content:

tags for steps,

Section

for section headers + instructions = [] + inst_container = soup.find("div", class_="recept_leiras") + if inst_container: + for el in inst_container.find_all(["h3", "p"]): + if el.name == "h3": + header = el.get_text(strip=True) + if header: + instructions.append(f"--- {header} ---") + elif el.name == "p": + txt = el.get_text(strip=True) + if txt: + # Strip leading numbering like "1. " from reader recipes + txt = re.sub(r"^\d+\.\s+", "", txt) + instructions.append(txt) + + # --- Tags --- + # Container: div.cikk-cimkek > ul.cikk-cimkek-list > li > a + # Skip the generic "Receptek" category tag and "Olvasói receptek" tag + tags = [] + tag_container = soup.find("div", class_="cikk-cimkek") + if tag_container: + tag_list = tag_container.find("ul", class_="cikk-cimkek-list") + if tag_list: + skip = {"receptek", "olvasói receptek"} + for a in tag_list.find_all("a"): + tag_text = a.get_text(strip=True) + if tag_text and tag_text.lower() not in skip: + tags.append(tag_text) + + return { + "title": title or "Ismeretlen recept", + "description": description, + "image_url": image_url, + "ingredients": ingredients, + "instructions": instructions, + "tags": tags, + "original_url": url, + } + + def _split_qty_unit(raw: str) -> tuple[str, str]: """Split a merged quantity+unit string like '200g' into ('200', 'g').""" raw = raw.strip() diff --git a/app/templates/base.html b/app/templates/base.html index 9c92cda..c0f75e8 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -7,6 +7,7 @@ +