35 KiB
name, description, version, tags
| name | description | version | tags | ||||||
|---|---|---|---|---|---|---|---|---|---|
| meal-planning | Class-level skill for weekly family meal planning. 3-phase workflow: automated proposal (Wed cronjob) → interactive discussion (Telegram chat) → finalization (Fri cronjob). Weather adaptation, diet constraints, grocery list production. | 2.0.0 |
|
Overview
This skill manages the weekly meal planning for the Schön family (Dominik, Sarah, Cleo 4).
The combined skill provides:
- Automated proposal generation (Wednesday cronjob via
nutrition-plan-generator-v4.py) - Interactive discussion (Wednesday/Thursday in Telegram DM with nutrition-coach bot)
- Finalization + shopping list (Friday cronjob)
- Scoring algorithm (see
references/generator-v4-scoring.md)
Usage
- Load this skill before any meal‑planning request.
- 3-Phase Workflow: Vorschlag (Mi 19:00 cron) → Diskussion (Mi/Do interaktiv) → Finale (Fr 18:00 cron)
- Phase 1 (Vorschlag): Cronjob
essensplan-vorschlaggeneriert Plan vianutrition-plan-generator-v4.py, speichertplan_data/current_proposal.md, liefert Summary in Telegram DM - Phase 2 (Diskussion): User reagiert im Telegram-Chat → Coach lädt
current_proposal.md, tauscht Gerichte, aktualisiert die Datei - Phase 3 (Finale): Cronjob
essensplan-finaleliestcurrent_proposal.md, schreibtKW<NN>_<YYYY>_plan.md+KW<NN>_<YYYY>_einkaufsliste.md, liefert finale Summary - v4 uses real recipes: Reads from
real_recipes.jsonl(25k+ Chefkoch entries), NOT the oldrecipes.jsonwhich contained 5000 dummy placeholders. - Compact Telegram output: Cronjob delivers a 7-line summary; full details remain in
~/Documents/Essensplanung/. - Delivery: All cronjobs deliver to
origin(Telegram DM), NOT to group chats. - Model:
vllm/glm-5-2-nvfp4vianoris.
Pitfalls
- Cronjobs cannot conduct interactive interviews. A cronjob fires once, sends output, and exits. User replies arrive in Telegram but are never read back. The old Wednesday interview cronjob produced
current_interview.jsonwith all answersnull.- Fixed: Replaced with 3-phase workflow. Wednesday cronjob generates proposal (no interview needed). Discussion happens in interactive Telegram chat.
- Cron delivery target must be
originor numeric chat ID.telegram:Schoen Incfails withinvalid literal for int() with base 10: 'Schoen Inc'. Useoriginfor DM delivery. - Per-job model overrides in cron JSON. Each job stores its own
model/providerfields inprofiles/nutrition-coach/cron/jobs.json. Bulk-switching the profile model does not update existing cron jobs. Patch the JSON or recreate the jobs after any model migration. - Scraper ID prefixes must stay consistent across versions. v5.3 used
hf_for HelloFresh recipe IDs. v6 initially generatedhellofresh_— this silently broke deduplication and would have re-scraped all 555 existing recipes. Always check existing ID formats inreal_recipes.jsonlbefore changing a normalizer. - Scraper state migration across versions. Legacy state files from older scraper versions use different field names (
ck_cyclevsck_query_cycle,hf_cycle: 8763from v5.3). When upgrading the scraper, explicitly initialize/reset state fields. Don't assume the new code handles old state gracefully. - Python variable ordering in config sections. When adding a new source config block (e.g.,
HF_HEADERSreferencingUA), ensure shared variables likeUAare defined before any config dict that references them. Module-level execution is top-down; aNameErrorat import time kills the entire scraper. - HelloFresh is NOT exhausted. Previous assessment said "~548 scraped, exhausted" — wrong. HelloFresh has ~16,831 recipes across 19 working categories (9 categories 404). Estimated ~10,000 unique after cross-category dedup. Highest data quality of all sources (99% have calories, 93% ratings, all have instructions+descriptions).
- Quality ≠ Volume — don't dismiss low-volume sources without assessing data quality. HelloFresh was dropped from v6 because "only 548 recipes, marginal ROI." User corrected: HelloFresh recipes are professionally curated with exact quantities, step-by-step instructions, calorie data, and difficulty ratings — qualitatively superior to community-submitted recipes. Always evaluate a source's data completeness (11-field checklist) alongside its volume before deciding to include/exclude it.
- GuteKueche.de integrated in v6. 29,392 recipes with 9/11 JSON-LD quality fields (calories ✅, ratings ✅, instructions ✅, prep/cook time ✅). Gzipped sitemap at
cdn.gutekueche.de/sitemaps/recipe.xml.gz(found via robots.txt). Added toSITEMAP_SOURCESwith"gzipped": Trueflag. Scraperdiscover_sitemap_urls()now auto-detects gzip via magic bytes\x1f\x8b. Combined potential: Chefkoch 25k + Kuechengoetter 30k + GuteKueche 29k + HelloFresh 10k + Kitchen Stories 3.3k = ~97k — 100k target reachable without international sources.
References
references/recipe-sources.md— Tested recipe data sources (Chefkoch API, Kuechengoetter, Kitchen Stories, GuteKueche, HelloFresh), JSON-LD availability, sitemap URLs, v7 scraper architecturereferences/international-recipe-sources.md— English-language sources for Asian/light/healthy cuisine (RecipeTin Eats, Budget Bytes, Cookie and Kate, Pick Up Limes, Just One Cookbook, Serious Eats, Minimalist Baker) with JSON-LD/sitemap status, translation strategy, and gap analysisreferences/semantic-search-implementation.md— Implemented semantic search + preference learning system: harrier embeddings, DB schema, API usage, tested examplesreferences/semantic-ontology-classification.md— Rule-based ontology classifier: 11 dimensions, recipe_semantics table schema, Galera pitfalls (collation, FK, deadlocks, VARCHAR sizes)references/semantic-search-investigation.md— Historical investigation notes (pre-implementation, kept for context on what was tested and ruled out)references/generator-v4-scoring.md— Current v4 scoring dimensions, event overrides, Cleo logicreferences/nutrition-interview-soest/generator-scoring-algorithm.md— Legacy v3 algorithm (superseded)references/multi-source-recipe-scraper/— Scraper integration docs (mass-scraping, API v2, Playwright bypass, v5.2 migration)templates/interview-data-schema.json— JSON schema forcurrent_interview.json(legacy, kept for reference)
Scraper (v7 — Direct MySQL)
The v7 scraper (profiles/nutrition-coach/scripts/recipe_scraper_v7.py) writes recipes directly to MariaDB Galera — no JSONL intermediate.
- Sources: Chefkoch API + Kuechengoetter sitemap (30k) + GuteKueche sitemap (29k, gzipped) + Kitchen Stories sitemap (3.3k) + HelloFresh categories (~10k unique)
- Target: 100,000 recipes
- Cronjob:
recipe-refill-v7runs daily at 06:00, delivers toorigin - State:
scraper_state.jsontracks tried URLs, query cycles, source counts,hf_cat_offset(per-category skip) - Dedup: Loads all existing IDs into RAM at startup (
db_all_ids()), checks in-memory — NEVER per-row DB roundtrips - URL pre-check: Derives likely recipe ID from URL path and skips HTTP request entirely if ID already in DB
- Insert:
INSERT IGNOREper recipe (idempotent, safe for re-runs) - Scrape order: Sitemap sources first (finite URL sets) → HelloFresh (high quality) → Chefkoch (fill remainder)
- Legacy JSONL:
real_recipes.jsonlis kept as historical archive only. v7 does NOT read or write it. Themigrate_to_galera.pyscript was a one-time migration tool. - See
references/recipe-sources.mdfor full source documentation including HelloFresh__NEXT_DATA__technique, GuteKueche discovery, and source scanning methodology - Embeddings must be regenerated after scraping new recipes. v7 does NOT generate embeddings. The
embedding-refillcronjob (daily 06:30) runsgenerate_embeddings.pyincrementally — only embeds recipes whereembedding IS NULL. As of 2026-06-28, all 54,613 recipes have embeddings (100% coverage). The script includessafe_db_execute()with Galera deadlock retry (rollback + backoff) for robustness during concurrent writes.
Storage Architecture
Active: MariaDB Galera Cluster (migrated from JSONL 2026-06-28, v7 writes directly — no JSONL intermediate).
User chose Galera over SQLite — reuses existing HA infrastructure, no new dependencies. v7 scraper writes directly via INSERT IGNORE; real_recipes.jsonl is a historical archive only.
- Connection:
10.0.30.70:3306(MaxScale VIP) → 3-node Galera cluster (10.0.30.71-73) - Database:
recipes, User:recipe_app@% - Table:
recipeswith InnoDB engine, utf8mb4 - Indexes: FULLTEXT on (title, description) and (ingredients); BTREE on source, category, rating, difficulty
- Schema fields (verified 2026-06-28 via DESCRIBE):
id(PK),title,name,description,category,tags,ingredients(JSON text, FULLTEXT indexed),instructions,source,source_url,rating,rating_value,rating_count,prep_time,cook_time,total_time,servings,calories,difficulty,scraped_at,embedding(JSON, nullable) - ⚠️ Column name reality vs older docs: There is NO
image_urlcolumn (usesource_url). There is NOingredients_jsoncolumn (useingredients). There is NOurlcolumn (usesource_url). There is NOcreated_atcolumn (usescraped_at). Columns that exist but were previously undocumented:name,rating_value,total_time,difficulty. - Migration script:
profiles/nutrition-coach/scripts/migrate_to_galera.py— one-time migration tool (INSERT IGNORE with batch=200, skips existing IDs). No longer needed for ongoing operation — v7 writes directly to Galera. - Concurrent access: MaxScale readwritesplit → scraper writes while planner reads (no locking)
- Query examples:
SELECT * FROM recipes WHERE rating >= 4.3 AND MATCH(title, description) AGAINST('hähnchen curry' IN BOOLEAN MODE) - Legacy JSONL:
real_recipes.jsonlis kept as historical archive only. v7 does NOT read or write it. Themigrate_to_galera.pyscript was a one-time migration tool.
Semantic Search + Preference Learning (Implemented 2026-06-28)
Fully built and tested. Uses vllm/harrier-oss-v1-0.6b (1024-dim) embeddings via norris /v1/embeddings endpoint.
Scripts (all in profiles/nutrition-coach/scripts/)
semantic_search.py— Core module:semantic_search(),record_decision(),get_preference_adjustments(),rank_with_preferences(),mine_rules()generate_embeddings.py— Batch embedding generator (64 recipes/API call, ~31/s, ETA ~15min for 31k recipes). Stores as JSON inrecipes.embeddingcolumn.recipe_scraper_v7.py— Direct MySQL scraper (does NOT generate embeddings; rungenerate_embeddings.pyseparately after scraping)
Database Tables
recipes.embedding(JSON) — 1024-dim embedding array. Generated bygenerate_embeddings.py.meal_decisions— Every accept/reject with context. Columns (verified 2026-06-28):id,decision_date,recipe_id,recipe_title,recipe_category,accepted(bool),context_temp,context_season,context_weekday,context_last_meals,reason,created_at. NOTE: context fields are separate columns, NOT a single JSON blob.preference_rules— Learned rules. Columns (verified 2026-06-28):id,rule_type,condition_json,effect_json,weight(FLOAT),confirmations(INT),rejections(INT),created_at,updated_at. NOTE: condition/effect are JSON strings, not separate columns.
How It Works
- Semantic search: Query text → harrier embedding → cosine similarity against all embedded recipes → ranked results blended with rating score
- Decision recording: User accepts/rejects a recipe →
record_decision()logs context + reason → auto-creates rule if reason matches weather/temperature patterns - Preference ranking:
rank_with_preferences()applies learned rules (boost/penalty) on top of semantic scores. Also enforces variety (penalizes categories from recent meals). - Rule mining:
mine_rules()analyzes accumulated decisions for statistical patterns (e.g., "Eintopf rejected 80% of the time when temp>20°C" → creates/upgrades rule)
Embedding Model: vllm/harrier-oss-v1-0.6b
- NOT an LLM — it's a dedicated embedding model on norris (0.6B params)
- 1024 dimensions, multilingual (German works well)
- Available via
POST https://ai.noris.de/v1/embeddingswith{"model": "vllm/harrier-oss-v1-0.6b", "input": [...]} - Batch API supports up to 64 inputs per call
- Other noris models (bge-m3, e5-large, etc.) are recognized by the gateway but NOT activated — only harrier works
- API key: same noris key from
config.yamlproviders.noris.api_key
See references/semantic-search-implementation.md for full architecture details.
Semantic Ontology Classification (Added 2026-06-28)
Rule-based classifier annotates every recipe across 11 ontology dimensions: diet type, meal course, cuisine, cooking method, protein source, allergens, flavor profile, complexity, season, caloric density, and preparation style. Pure keyword matching — no LLM/API calls. Stores results in recipe_semantics table.
- Script:
profiles/nutrition-coach/scripts/semantic_classifier.py(~500/batch, ~50-500/s, ~10min for 54k recipes) - Table:
recipe_semantics(recipe_id PK, 11 dimension columns as ENUM/JSON, confidence, analyzed_at) - Cronjob:
semantic-classifierdaily at 07:00 (after scraper 06:00 + embeddings 06:30) - Incremental: LEFT JOIN finds unclassified recipes, only processes new ones. Uses
INSERT IGNOREto skip duplicate recipe IDs (some Chefkoch IDs appear multiple times in the DB due to URL-state-encoded duplicates). - See
references/semantic-ontology-classification.mdfor full ontology dimensions, table schema, and Galera-specific pitfalls
Ingredient Normalization + Substitution Graph (Added 2026-06-28)
Relational ingredient system enabling exact filtering, allergen cross-checking, and "what can I substitute?" queries. Three tables + a parser/normalizer script.
Tables
| Table | Purpose | Key Columns |
|---|---|---|
ingredients |
Master ingredient table (normalized) | id, canonical_name, slug, category (ENUM 21 values), allergen_flags (JSON), is_vegan, is_vegetarian |
recipe_ingredient_links |
Junction table (many-to-many) | recipe_id, ingredient_id, quantity (DECIMAL), unit, raw_text, modifier, position |
ingredient_substitutes |
Substitution graph (bidirectional) | ingredient_id, substitute_id, similarity_score (0-1), context, reason |
Script: scripts/ingredient_normalizer.py
- Parsing: Regex extracts (quantity, unit, name, modifier) from raw strings like
"400 g Lachsfilet(s) (mit Haut)"→(400, "g", "Lachsfilet", "mit Haut") - Canonicalization: Plural→singular, alias overrides (~250 manual mappings, e.g.
Mohrrüben→karotte,hühnchen→hähnchen), descriptor stripping (frisch,getrocknet,Bio, etc.), English→German mapping (garlic→knoblauch) - Categorization: 21 food categories (meat, poultry, fish, seafood, vegetable, fruit, grain, legume, nut_seed, dairy, egg, fat_oil, spice_herb, condiment_sauce, sweetener, flour_starch, liquid, baking, beverage, other) via keyword matching
- Allergen detection: Checks 14 EU allergen categories against canonical name
- Substitution graph: Seeded with ~60 culinary substitution pairs, bidirectional. Each pair has similarity_score (0-1), context (when the substitution works), and reason (why they're similar). Examples: koriander↔petersilie (0.75, "European/Western dishes"), schmand↔crème fraîche (0.95, "General"), milch↔hafermilch (0.90, "General/vegan")
Query Examples
-- Recipes WITH zucchini, WITHOUT paprika, <500 kcal
SELECT DISTINCT r.* FROM recipes r
JOIN recipe_ingredient_links rl ON r.id = rl.recipe_id
JOIN ingredients i ON rl.ingredient_id = i.id AND i.canonical_name = 'zucchini'
WHERE r.calories < 500
AND NOT EXISTS (
SELECT 1 FROM recipe_ingredient_links rl2
JOIN ingredients i2 ON rl2.ingredient_id = i2.id
WHERE rl2.recipe_id = r.id AND i2.canonical_name = 'paprika'
)
-- What can I substitute for coriander?
SELECT i2.canonical_name, s.similarity_score, s.context, s.reason
FROM ingredient_substitutes s
JOIN ingredients i1 ON s.ingredient_id = i1.id AND i1.canonical_name = 'koriander'
JOIN ingredients i2 ON s.substitute_id = i2.id
ORDER BY s.similarity_score DESC
Pitfalls (Ingredient Normalization)
- Galera FK creation fails on collation mismatch. Same issue as
recipe_semantics—recipesusesutf8mb4_unicode_ci, new tables default toutf8mb4_general_ci. Omit FKs or specify matching COLLATE. - Galera deadlocks from concurrent writes. Running
ingredient_normalizer.pyandsemantic_classifier.pysimultaneously causes Deadlock (1213). Run sequentially or add retry logic. - Some recipe IDs exceed VARCHAR(128). Chefkoch IDs with URL-encoded state fragments can be 248 chars. Use VARCHAR(255) for
recipe_idcolumns. - Compound ingredients like "Salz und Pfeffer" are kept as-is (not split). The parser only splits when a quantity prefix is present.
- Ingredient string diversity is massive. ~250k unique raw strings across 54k recipes, normalizing to ~2-5k canonical ingredients. The
CANONICAL_OVERRIDESdict needs expansion over time for better coverage.
Scraper v8 — International Sources + Auto-Translation (Added 2026-06-28)
The v8 scraper (profiles/nutrition-coach/scripts/recipe_scraper_v8_intl.py) extends v7 with 13 international English-language sources and integrated LLM translation to German.
Sources (13 international, ~20k recipes total)
All sources use sitemap + JSON-LD (same infrastructure as v7). See references/international-recipe-sources.md for full details including sitemap URLs, recipe counts, and ID prefixes.
BBC Good Food (~5k), SkinnyTaste (~2.3k), Woks of Life (~1.5k), RecipeTin Eats (~1.7k), Rasa Malaysia (~1.4k), Sanjeev Kapoor (~2k, custom parser), Budget Bytes (~2k), Pinch of Yum (~1.5k), Omnivore's Cookbook (~772), Hot Thai Kitchen (~464), Spoon Fork Bacon (~911), Korean Bapsang (~275), Minimalist Baker (~650).
Translation Pipeline
- Scrape recipe page → extract JSON-LD → normalize to recipe dict
- Batch 3 recipes per LLM call
- Translate via
vllm/gemma-4-31b-it-dynamo(non-reasoning model, noris API) - Unit conversion baked into translation prompt: cups→g/ml, tbsp→EL, tsp→TL, oz→30g, lb→450g, °F→°C, stick butter→125g
- Insert translated recipe into Galera (German title, ingredients, instructions)
Translation Model Choice — CRITICAL
- USE
vllm/gemma-4-31b-it-dynamo(non-reasoning, fast, 4s/batch, 169 completion tokens) - DO NOT USE
vllm/glm-5-2-nvfp4for translation — it's a reasoning model that wastes tokens on chain-of-thought (1710 completion tokens for same task, 10x cost) and sometimes returns EMPTYcontentfield when reasoning exhausts max_tokens before generating the answer. - User explicitly directed: "nutze für übersetzungen ein nicht-reasoning modell wie gemma4-dynamo"
Unit Conversion Rules (embedded in LLM system prompt)
| US Unit | Metric Equivalent |
|---|---|
| 1 cup (liquid) | 240 ml |
| 1 cup (solid: flour/sugar) | 240 g |
| 1 tablespoon (tbsp) | 1 EL |
| 1 teaspoon (tsp) | 1 TL |
| 1 ounce (oz) | 30 g |
| 1 pound (lb) | 450 g |
| 1 pint | 475 ml |
| 1 quart | 950 ml |
| 1 stick butter | 125 g Butter |
| °F | °C ((F-32) × 5/9) |
User requirement: "Keine cups und spoons" — ALL recipes must use metric units.
Pitfalls (v8 Scraper + Translation)
- Reasoning models return empty
contentfor translation tasks. GLM-5.2 spends tokens on internal reasoning before generating the answer. With max_tokens=4000, reasoning consumed all tokens andcontentwas empty string. Fix: use non-reasoning model (gemma-dynamo) OR increase max_tokens to 6000+. The non-reasoning model is the correct fix — translation is mechanical, not reasoning-intensive. - Translation batch size matters for reliability. Batch of 5 recipes sometimes exceeded the model's reliable output capacity. Reduced to 3 recipes per batch for consistent results.
- Sanjeev Kapoor needs custom parser. Non-standard JSON-LD with single-quote
@type: 'Recipeinstead of"Recipe". The v8 scraper includesextract_sanjeev_recipe()with regex fallback for this case. - BBC Good Food quarterly sitemaps. Must iterate through all quarterly sitemap files (
YYYY-QN-recipe.xml) to get full coverage. 24 quarters yielded ~5,045 recipes. - Translation adds significant latency. Each batch of 3 recipes takes ~4-10s for the LLM call plus scraping time. Total throughput: ~1 recipe/2s. For 20k recipes, expect ~11 hours. Run as background process overnight.
- Post-scrape pipeline runs automatically. The v8 scraper's
main()function callsgenerate_embeddings.py→semantic_classifier.py→ingredient_normalizer.pyviasubprocess.run()after scraping completes (only ifgrand_added > 0). No separate cronjobs needed. See "Daily Pipeline" section above.
Daily Pipeline (Integrated — Updated 2026-06-28)
Single cronjob recipe-scraper-v8-pipeline (daily 06:00) runs the full pipeline sequentially:
recipe_scraper_v8_intl.py
├── 1. Scrape international sources + translate to German
├── 2. generate_embeddings.py (embeddings for new recipes)
├── 3. semantic_classifier.py (ontology classification)
└── 4. ingredient_normalizer.py (relational ingredient normalization)
Pipeline steps 2–4 only run if grand_added > 0 (no wasted work when nothing new scraped). Each step has a 2h timeout. Logs go to scraper_v8_log.txt.
Why integrated (user correction): User asked "Warum nicht embedding refill, classifier und ingredient normalizer direkt am Ende des scraper laufs?" — separate cronjobs with fixed schedules waste time waiting (06:30 embeddings waits 30min after scraper finishes) and risk overlapping if the scraper runs long. Integrated pipeline runs immediately after scraping, sequentially, no deadlocks.
Previous setup (REMOVED): Three separate cronjobs — recipe-refill-v7 (06:00), embedding-refill (06:30), semantic-classifier (07:00). The 06:30 and 07:00 jobs were deleted; the 06:00 job was renamed to recipe-scraper-v8-pipeline and its prompt updated to run the v8 scraper (which includes the pipeline internally).
Recipe MCP Server (Added 2026-06-28)
A stdio MCP server (profiles/nutrition-coach/scripts/recipe_mcp_server.py) exposes the Galera recipe DB as 7 first-class Hermes tools. Registered in config.yaml under mcp_servers.recipes. Tools auto-discover at gateway startup and appear as mcp_recipes_* in all profiles.
Tools
| Tool | Purpose |
|---|---|
search_recipes |
FULLTEXT search on title+description and ingredients, with filters (category, calories, prep_time, rating, source) |
semantic_recipe_search |
Vector similarity search using harrier embeddings (cosine sim over 500-candidate pool, re-ranked by similarity) |
get_recipe |
Full recipe by ID (includes ingredients, instructions, all fields) |
random_recipes |
Random recipes for meal-planning inspiration (with category/source/rating filters) |
recipe_stats |
DB statistics: total count, per-source breakdown, embedding coverage |
record_meal_decision |
Log accept/reject for preference learning (denormalizes recipe title+category into meal_decisions) |
get_preferences |
Show learned preference rules + decision stats |
Config (in ~/.hermes/config.yaml)
mcp_servers:
recipes:
command: "/home/debian/hermes-agent/venv/bin/python3"
args: ["/home/debian/.hermes/profiles/nutrition-coach/scripts/recipe_mcp_server.py"]
env:
RECIPES_DB_PASS: "<galera-password>"
NORIS_API_KEY: "<noris-api-key>"
timeout: 60
Pitfalls
-
Column names differ from older skill docs. The MCP server initially referenced
image_urlandurlcolumns that don't exist. AlwaysDESCRIBE recipesbefore writing SQL. See the verified schema above. -
FULLTEXT search requires German umlauts. Searching "Haehnchen" returns 0 results; "Hähnchen" works. The FULLTEXT index is case-insensitive but NOT umlaut-normalized.
-
MCP env vars are filtered. Hermes only passes
PATH,HOME, etc. to stdio subprocesses. DB password and API key MUST be explicitly set in theenvblock of the MCP server config — they are NOT inherited from.env. -
Gateway restart required after adding MCP servers. MCP tools are discovered at startup. Adding a server to
config.yamldoes not hot-reload. Use/restart(gateway) or restart the systemd service. -
Scraper max_new limits must be high for background operation. v7 defaulted to
max_new=5000per sitemap source andmax_new=2000for Chefkoch per run. This was designed for daily cronjob safety, but in background operation it needlessly caps throughput — Kuechengoetter (30k URLs) and GuteKueche (29k URLs) stopped at 5k each per run, requiring 6+ runs to fully scrape. Fix (2026-06-28): raised to 50k (sitemap), 50k (HelloFresh), 10k (Chefkoch). Result: Kuechengoetter jumped from 5k to 20,442 in one run. For cronjob mode, the daily run naturally bounds total work; for background runs, the higher limits allow completing a source in one pass. -
Per-row dedup checks are catastrophically slow on Galera. v7 initial design called db_has_id(rid) for every candidate URL — each a network roundtrip through MaxScale. With 28k existing recipes and 30k sitemap URLs mostly already in DB, the scraper appeared hung for minutes doing nothing but dedup queries. Fix: db_all_ids() loads all IDs into a Python set() once at startup (~0.3s for 28k), then all dedup checks are in-memory. Additionally, derive the likely recipe ID from the URL path and skip the HTTP fetch entirely if the ID is already known.
-
Preference rule category matching must check title and tags, not just the category field. Most Eintopf recipes have category Kochen, Kartoffeln, or Hauptspeise — not Eintopf. A rule penalizing category Eintopf would miss Moehren-Kartoffel-Eintopf (category: Kochen). Fix in rank_with_preferences(): concatenate category + title + tags into one lowercase string and check if the penalty/boost term appears anywhere in it.
-
recipe_to_row() must sanitize dict/list fields before MySQL INSERT. Some recipes have category as a dict (e.g., {"name": "..."}) or calories as a nested dict. PyMySQL raises TypeError: dict can not be used as parameter. Always coerce to string/primitive before passing to executemany().
-
HelloFresh recipes may have EMPTY ingredients arrays in the DB. Some HelloFresh recipes show ingredients as an empty JSON array despite being successfully scraped. The _hf_normalize() function builds ingredients from yields[0].ingredients[] joined with the ingredients[] lookup — if the yield structure differs or ingredient IDs do not match, the result is empty. This breaks ingredient-based SQL searches (LIKE finds nothing). Chefkoch recipes reliably have populated ingredient lists.
-
Searching JSON columns with LIKE requires CAST. MariaDB stores ingredients as LONGTEXT (JSON-encoded). Direct LIKE may fail depending on MariaDB version. Use LOWER(CAST(ingredients AS CHAR)) LIKE for reliable JSON field searching. For production queries, prefer the FULLTEXT index: MATCH(ingredients) AGAINST in BOOLEAN MODE.
-
Norris embedding endpoint only works with vllm/harrier-oss-v1-0.6b. All other norris models (glm, gemma, qwen, bge-reranker) return 404 on /v1/embeddings. Standard embedding model names (bge-m3, e5-large, gte-large) are recognized by the Bifrost gateway but NOT activated on any API key. Only harrier-oss-v1-0.6b (1024 dim) is available for embeddings.
-
Galera FK creation fails on collation mismatch. The
recipestable usesutf8mb4_unicode_cibut new tables default toutf8mb4_general_ci. FK creation fails witherrno 150. Fix: omit the FK (preferred) or specify matching COLLATE on the new table. -
Galera JOINs between tables with different collations fail.
LEFT JOIN ... ON r.id = rs.recipe_idfails withIllegal mix of collations. Fix: addCOLLATE utf8mb4_unicode_cito the JOIN condition. -
VARCHAR(128) is too short for some recipe IDs. Chefkoch IDs with encoded state fragments (e.g.,
ck_Kinderueberraschungsbrot.html#eyJvYm...) can be up to 248 chars. UseVARCHAR(255)for any column storing recipe IDs. -
Time/calorie columns contain string values. Some
prep_time,cook_time,total_time,caloriesvalues are stored as strings (e.g.,"PT20M","450 kcal"), not integers. Always use asafe_int()helper before comparisons/arithmetic. -
Concurrent writes cause Galera deadlocks. Running the semantic classifier and embedding generator simultaneously causes
Deadlock (1213). Fix: add retry logic with rollback+backoff, or run sequentially (daily cronjob schedule ensures this). -
Classifier batch inserts hit duplicate-key errors. Some Chefkoch recipe IDs appear multiple times in the
recipestable (URL-encoded state fragments create variant IDs that normalize to the same key). UsingINSERT IGNOREin theexecutemany()call silently skips duplicates instead of failing the entire batch. WithoutINSERT IGNORE, every batch containing a duplicate fails and falls through to one-by-one insertion, cutting throughput by ~10×. -
Cloudflare blocks ≠ "no sitemap". Just One Cookbook was initially diagnosed as "no sitemap" — actually the sitemap exists but Cloudflare returns an HTML challenge page instead of XML. Always check HTTP status codes and content-type, not just whether XML parse succeeds. Maangchi similarly returns HTTP 403 for all programmatic requests despite a permissive robots.txt.
-
AI-bot blocks in robots.txt are selective. Many sites (RecipeTin Eats, Cookie and Kate, Healthy Nibbles, SkinnyTaste) block
anthropic-ai,Claude-Web,GPTBot, orChatGPT-Userspecifically. They do NOT block genericMozilla/5.0user agents. Use a standard browser-like UA, not an AI-identifying one. -
Empty first sitemap ≠ empty site. Minimalist Baker's
post-sitemap.xmlreturns 0 URLs butpost-sitemap2.xmlhas 650. Always check ALL sitemap files in the index. -
Non-standard JSON-LD. Sanjeev Kapoor has recipe data but in a non-standard JSON structure (
@type: 'Recipewith single quotes). Standard JSON-LD parsers will miss it. May need regex-based extraction or custom parser. -
Quarterly sitemap pattern. BBC Good Food organizes recipe sitemaps by quarter (
YYYY-QN-recipe.xml). Must iterate through all quarters to get full coverage (~5,045 recipes across 24+ quarterly files). -
AllRecipes sitemaps are empty shells. Despite having
sitemap_1.xmlthroughsitemap_4.xml, they contain 0 URLs. Not scrapable via sitemap approach. -
Archana's Kitchen has 8,428 sitemap URLs but NO JSON-LD. Would need custom HTML parsing — not worth the effort vs. Sanjeev Kapoor which has (non-standard) JSON-LD.
-
Bon Appétit and Epicurious use Condé Nast's proprietary structured data format, not schema.org/Recipe JSON-LD. Not scrapable with standard JSON-LD approach.
-
Running semantic_classifier.py and ingredient_normalizer.py simultaneously causes Galera deadlocks. Both write to different tables but certification conflicts on shared rows cause
Deadlock (1213). Run sequentially or add retry logic with rollback+backoff. -
Ingredient normalizer produces ~33k unique ingredient names from 54k recipes. The
CANONICAL_OVERRIDESdict (~250 manual mappings) covers the most common cases but many rare ingredients remain un-canonicalized. Expect ~16k ingredients categorized as "other" — expand the categorization keyword lists and canonical overrides over time. -
Substitution graph seeding skips ingredients not in the DB. 27 of 92 planned substitution pairs were skipped because one ingredient wasn't found in the first run. Re-seed after expanding canonical overrides.
-
Galera FK creation fails on collation mismatch. Same issue as
recipe_semantics—recipesusesutf8mb4_unicode_ci, new tables default toutf8mb4_general_ci. Omit FKs or specify matching COLLATE. -
Galera deadlocks from concurrent writes. Running
ingredient_normalizer.pyandsemantic_classifier.pysimultaneously causes Deadlock (1213). Run sequentially or add retry logic. -
Some recipe IDs exceed VARCHAR(128). Chefkoch IDs with URL-encoded state fragments can be 248 chars. Use VARCHAR(255) for
recipe_idcolumns. -
Compound ingredients like "Salz und Pfeffer" are kept as-is (not split). The parser only splits when a quantity prefix is present.
-
Ingredient string diversity is massive. ~250k unique raw strings across 54k recipes, normalizing to ~33k canonical ingredients. The
CANONICAL_OVERRIDESdict needs expansion over time for better coverage. -
Ingredients ARE relationally searchable (implemented 2026-06-28). Three tables power the ingredient subsystem:
ingredients(master table with canonical names, categories, allergen flags, vegan/vegetarian),recipe_ingredient_links(junction with quantity/unit/modifier/position), andingredient_substitutes(substitution graph with similarity scores and context). Script:scripts/ingredient_normalizer.py. Parses raw ingredient strings via regex (quantity + unit + name + modifier), canonicalizes names (plural→singular, alias overrides, descriptor stripping), categorizes into 21 food categories, and seeds a culinary substitution graph (~60 pairs, e.g. koriander↔petersilie 0.75, schmand↔crème fraîche 0.95). Enables SQL queries like "recipes WITH zucchini AND WITHOUT paprika AND <500 kcal" and "what can I substitute for coriander?". -
Galera FK constraints are fragile. Certification-based replication can reject DDL that works on standalone MySQL. Prefer logical relationships (application-level integrity) over physical FKs.
-
International recipe sources research (updated 2026-06-28). 15 sources evaluated across 4 tiers. Tier 1 (ready to integrate, ~20k recipes total): BBC Good Food (~5k), SkinnyTaste (~2.3k, light/healthy), Woks of Life (~1.5k, Chinese), RecipeTin Eats (~1.7k, pan-Asian), Rasa Malaysia (~1.4k, SE-Asian), Sanjeev Kapoor (~2k, Indian, non-standard JSON-LD), Budget Bytes (~2k), Pinch of Yum (~1.5k), Omnivore's Cookbook (~772, Chinese), Hot Thai Kitchen (~464, authentic Thai), Spoon Fork Bacon (~911, Asian fusion), Korean Bapsang (~275, Korean), Minimalist Baker (~650, vegan/GF). Tier 2 (AI-bot blocks, use generic UA): Cookie and Kate (~920, vegetarian), Healthy Nibbles (~640, Asian healthy). Tier 3 (not feasible): Just One Cookbook (Cloudflare), Maangchi (403), AllRecipes (empty sitemaps), Archana's Kitchen (no JSON-LD), Bon Appétit/Epicurious (proprietary format), Serious Eats (no JSON-LD confirmed). See
references/international-recipe-sources.mdfor full details including sitemap URLs, recipe counts, and ID prefixes.