v0.6.0: Sobors.hu parser, HTTP auth, recipe validation, UI polish

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-24 18:07:05 +01:00
parent 76290770f4
commit a0bcb62588
7 changed files with 237 additions and 12 deletions
+63 -2
View File
@@ -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()})
+92
View File
@@ -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: <p> tags for steps, <h3><strong>Section</strong></h3> 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()
+5 -3
View File
@@ -7,6 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="icon" type="image/svg+xml" href="/assets/logo_notext.svg">
<style>
:root {
--bg: #0d1117;
@@ -53,11 +54,12 @@
gap: 0.6rem;
font-size: 1.25rem;
font-weight: 700;
color: var(--accent-light);
text-decoration: none;
}
nav .brand .brand-white { color: var(--text); }
nav .brand .brand-blue { color: var(--accent-light); }
nav .brand img {
height: 28px;
height: 36px;
width: auto;
}
nav a {
@@ -212,7 +214,7 @@
<nav>
<a href="/" class="brand">
<img src="/assets/logo.svg" alt="felhom.eu">
Recept Importáló
<span class="brand-white">Recept</span> <span class="brand-blue">Importáló</span>
</a>
<a href="/import" {% if request.path == '/import' %}class="active"{% endif %}>Importálás</a>
<a href="/settings" {% if request.path == '/settings' %}class="active"{% endif %}>Beállítások</a>
+26 -7
View File
@@ -561,17 +561,29 @@ async function scrapeRecipe() {
showSingleButtons();
document.getElementById('previewCard').classList.add('visible');
const warnings = [];
const statusParts = [];
// Completeness warnings (missing ingredients/instructions)
if (data.warnings && data.warnings.length > 0) {
statusParts.push('<span class="text-warning">&#9888; ' + data.warnings.map(escHtml).join(' | ') + '</span>');
}
// Duplicate warnings
const dupWarnings = [];
if (data.duplicate) {
warnings.push('Mealie: <a href="' + escHtml(data.duplicate.url) + '" target="_blank" style="color:var(--accent)">'
dupWarnings.push('Mealie: <a href="' + escHtml(data.duplicate.url) + '" target="_blank" style="color:var(--accent)">'
+ escHtml(data.duplicate.name) + '</a>');
}
if (data.tandoor_duplicate) {
warnings.push('Tandoor: <a href="' + escHtml(data.tandoor_duplicate.url) + '" target="_blank" style="color:var(--accent)">'
dupWarnings.push('Tandoor: <a href="' + escHtml(data.tandoor_duplicate.url) + '" target="_blank" style="color:var(--accent)">'
+ escHtml(data.tandoor_duplicate.name) + '</a>');
}
if (warnings.length > 0) {
status.innerHTML = '<span class="text-warning">&#9888; Ez a recept már létezik: ' + warnings.join(' | ') + '</span>';
if (dupWarnings.length > 0) {
statusParts.push('<span class="text-warning">&#9888; Ez a recept már létezik: ' + dupWarnings.join(' | ') + '</span>');
}
if (statusParts.length > 0) {
status.innerHTML = statusParts.join('<br>');
} else {
status.innerHTML = '<span class="text-success">&#10003; Beolvasva</span>';
}
@@ -1143,8 +1155,12 @@ async function scrapeForBulk(idx) {
document.getElementById('previewCard').classList.add('visible');
document.getElementById('previewCard').scrollIntoView({ behavior: 'smooth' });
// Show duplicate warning in bulk status
// Show warnings in bulk status
const bulkStatus = document.getElementById('bulkSendStatus');
const bulkStatusParts = [];
if (data.warnings && data.warnings.length > 0) {
bulkStatusParts.push('&#9888; ' + data.warnings.map(escHtml).join(' | '));
}
const dupWarnings = [];
if (data.duplicate && (bulkState.target === 'mealie' || bulkState.target === 'both')) {
dupWarnings.push('Mealie: ' + escHtml(data.duplicate.name));
@@ -1153,7 +1169,10 @@ async function scrapeForBulk(idx) {
dupWarnings.push('Tandoor: ' + escHtml(data.tandoor_duplicate.name));
}
if (dupWarnings.length > 0) {
bulkStatus.innerHTML = '<span class="text-warning" style="font-size:0.85rem">&#9888; Duplikátum: ' + dupWarnings.join(' | ') + '</span>';
bulkStatusParts.push('&#9888; Duplikátum: ' + dupWarnings.join(' | '));
}
if (bulkStatusParts.length > 0) {
bulkStatus.innerHTML = '<span class="text-warning" style="font-size:0.85rem">' + bulkStatusParts.join('<br>') + '</span>';
} else {
bulkStatus.innerHTML = '';
}
+26
View File
@@ -90,6 +90,32 @@
</div>
</div>
<div class="card">
<h2>Hozzáférés-védelem</h2>
<p class="text-dim mb-2" style="font-size:0.85rem;">
HTTP Basic Auth bekapcsolásával jelszóval védheted az alkalmazást.
Hagyd üresen a kikapcsoláshoz.
</p>
<label for="auth_username">Felhasználónév</label>
<input type="text" id="auth_username" name="auth_username"
value="{{ cfg.auth_username or '' }}"
placeholder="admin" autocomplete="username">
<label for="auth_password">Jelszó</label>
<input type="password" id="auth_password" name="auth_password"
value="" placeholder="{% if cfg.auth_password_hash %}(nem változott){% else %}Jelszó megadása{% endif %}"
autocomplete="new-password">
<p class="text-dim mb-2" style="font-size:0.85rem;">
{% if cfg.auth_password_hash %}
A védelem aktív. Új jelszó megadásával frissítheted, üresen hagyva marad a jelenlegi.
A felhasználónév törlésével kikapcsolhatod a védelmet.
{% else %}
Jelenleg nincs védelem beállítva.
{% endif %}
</p>
</div>
<div class="card">
<button type="submit" class="btn btn-primary">Mentés</button>
</div>