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:
@@ -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
|
||||
|
||||
@@ -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 `<h4 class="m-list__title">` section headers
|
||||
- **Tags**: `<a class="m-tags__tagItem">` 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` → `<p>` tags, with `<h3><strong>` 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:
|
||||
|
||||
+63
-2
@@ -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()})
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">⚠ ' + 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">⚠ Ez a recept már létezik: ' + warnings.join(' | ') + '</span>';
|
||||
if (dupWarnings.length > 0) {
|
||||
statusParts.push('<span class="text-warning">⚠ 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">✓ 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('⚠ ' + 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">⚠ Duplikátum: ' + dupWarnings.join(' | ') + '</span>';
|
||||
bulkStatusParts.push('⚠ 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 = '';
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user