commit 82fdc33771ca11baed679c008b619da113a6dc15 Author: test Date: Sat Sep 5 10:39:02 2026 +0200 Inital commit for agent diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..1e2a738 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024 Nutritional Recipe Parser + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..e6a9605 --- /dev/null +++ b/README.md @@ -0,0 +1,105 @@ +# Nutritional Recipe Parser + +A Python application that parses recipes and looks up nutritional information for ingredients using the OpenFoodFacts API. + +## Python Version + +- **Required:** Python 3.8 or higher +- **Tested on:** Python 3.12.13 + +## Dependencies + +### Python Libraries + +The following Python packages are required: + +| Package | Purpose | Install Method | +|---------|---------|----------------| +| `requests` | HTTP client for OpenFoodFacts API | `pip install requests` | +| `json` | Built-in | Python 3.x (no install needed) | +| `tkinter` | GUI toolkit (optional) | Usually bundled with Python | + +### System Packages (apt) + +For a fresh TrueNAS/Ubuntu/Debian environment, install the following: + +```bash +sudo apt update +sudo apt install -y python3 python3-pip python3-tk +``` + +**Explanation:** +- `python3` - The Python interpreter (version 3.12.13 is available on this system) +- `python3-pip` - Pip package manager to install `requests` and other PyPI packages +- `python3-tk` - Tkinter support for the optional GUI mode + +### Verifying Installation + +After installing, verify the core dependency: + +```bash +pip3 install --upgrade requests +``` + +Test the installation: + +```bash +python3 --version +python3 -c "import requests; print('requests version:', requests.__version__)" +``` + +## Usage + +### Command-Line Interface + +```bash +# Parse a recipe file and show summary +python3 main.py recipe.md + +# Parse and output JSON to a file +python3 main.py recipe.md -o output.json + +# Parse with verbose nutrition output +python3 main.py recipe.md -v + +# List cached ingredients +python3 main.py --list-foods +``` + +### GUI Mode + +Launch without arguments for a graphical interface: + +```bash +python3 main.py +``` + +## Project Structure + +``` +nutri_parser/ +├── nutri_parser.py # Core parsing and API logic +├── recipe_parser.py # Recipe parsing utilities +├── main.py # Entry point (CLI + GUI) +├── README.md # This file +└── LICENSE # Project license +``` + +## API Integration + +This project uses the [OpenFoodFacts](https://world.openfoodfacts.org/) open database API to look up nutritional values for ingredients. The API: + +- **Does not require** an API key for basic use +- **Requires** a descriptive `User-Agent` header (set to `"RecipeParser/1.0"` in this project) +- **Recommends** rate limiting (1 request/second minimum) to avoid being blocked +- **Includes** automatic retry with exponential backoff (5 attempts with delays [2, 5, 10, 20, 30] seconds) + +## Rate Limiting & Caching + +- **Rate limiter:** Enforces 1 request per second minimum between API calls +- **SQLite cache:** Ingredient nutrition data is cached locally to avoid redundant API calls +- **Cache management:** Use `--list-foods` to view cached ingredients, or clear the cache file to refresh + +## License + +This project is licensed under the MIT License - see the [LICENSE](LICENSE) file for details. \ No newline at end of file diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..9fdd5d1 --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +""" +Nutritional Recipe Parser Package +""" \ No newline at end of file diff --git a/__pycache__/nutri_parser.cpython-312.pyc b/__pycache__/nutri_parser.cpython-312.pyc new file mode 100644 index 0000000..55e616a Binary files /dev/null and b/__pycache__/nutri_parser.cpython-312.pyc differ diff --git a/__pycache__/parser.cpython-312.pyc b/__pycache__/parser.cpython-312.pyc new file mode 100644 index 0000000..a96c79c Binary files /dev/null and b/__pycache__/parser.cpython-312.pyc differ diff --git a/__pycache__/recipe_parser.cpython-312.pyc b/__pycache__/recipe_parser.cpython-312.pyc new file mode 100644 index 0000000..a0e6b13 Binary files /dev/null and b/__pycache__/recipe_parser.cpython-312.pyc differ diff --git a/ingredient_cache.db b/ingredient_cache.db new file mode 100644 index 0000000..3f68425 Binary files /dev/null and b/ingredient_cache.db differ diff --git a/main.py b/main.py new file mode 100755 index 0000000..35d3cc3 --- /dev/null +++ b/main.py @@ -0,0 +1,117 @@ +#!/usr/bin/env python3 +"""Main entry point for the Nutritional Recipe Parser application. + +Supports both CLI and GUI modes. +""" + +from nutri_parser import Recipe, RecipeParser, NutritionApi +import argparse +import sys +import os + + +def main_gui(): + """Launch the tkinter GUI application.""" + try: + import tkinter as tk + from nutri_parser import NutritionApi + root = tk.Tk() + app = RecipeAppGUI(root) + root.mainloop() + except ImportError: + print("Tkinter not available. Using CLI mode instead.") + main_cli() + + +def main_cli() -> None: + """Command-line interface entry point.""" + import argparse + + parser = argparse.ArgumentParser( + description="Parse nutritional values from recipe files.", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=""" +Examples: + %(prog)s recipe.md + %(prog)s --output-json recipe.md output.json + %(prog)s --list-foods + %(prog)s --verbose recipe.md + """, + ) + parser.add_argument("input_file", nargs="?", help="Input recipe file") + parser.add_argument("--output-json", "-o", help="Output JSON file path") + parser.add_argument("--list-foods", "-l", action="store_true", + help="List cached ingredients") + parser.add_argument("--verbose", "-v", action="store_true", + help="Verbose output") + + args = parser.parse_args() + + api = NutritionApi() + + if args.list_foods: + # Show what we have in cache + cursor = api.conn.execute("SELECT ingredient_name FROM ingredient_cache") + rows = cursor.fetchall() + if rows: + print("Cached ingredients ({}):".format(len(rows))) + for row in rows: + print(f" {row[0]}") + else: + print("No cached ingredients yet. Parse a recipe to populate the cache.") + return + + if not args.input_file: + parser.print_usage() + sys.exit(1) + + if not os.path.exists(args.input_file): + print(f"Error: File not found: {args.input_file}", file=sys.stderr) + sys.exit(1) + + with open(args.input_file, "r", encoding="utf-8") as f: + text = f.read() + + cli_parser = RecipeParser() + try: + parsed = cli_parser.parse(text) + except Exception as e: + print(f"Error parsing recipe: {e}", file=sys.stderr) + sys.exit(1) + + try: + recipe = Recipe(parsed, api=api, servings=parsed.servings) + except Exception as e: + print(f"Error looking up nutrition: {e}", file=sys.stderr) + sys.exit(1) + + if args.verbose: + print(recipe.summary()) + + # Print JSON to stderr (so stdout can be used separately) or stdout + json_output = recipe.to_json() + if args.output_json: + with open(args.output_json, "w", encoding="utf-8") as f: + f.write(json_output) + print(f"Exported to: {args.output_json}", file=sys.stderr) + else: + # Print a human-readable summary to stdout + print(recipe.summary(), file=sys.stdout) + # Also print JSON to stderr for pip/redirect use + print("\n--- JSON ---", file=sys.stderr) + print(json_output, file=sys.stderr) + + +def main() -> None: + """Determine mode: GUI if no args, otherwise CLI.""" + import sys + + # If no arguments at all, launch GUI + if len(sys.argv) == 1: + main_gui() + else: + main_cli() + + +if __name__ == "__main__": + main() diff --git a/nutri_parser.py b/nutri_parser.py new file mode 100644 index 0000000..9974a5f --- /dev/null +++ b/nutri_parser.py @@ -0,0 +1,211 @@ +import json +import time +import sys +import os +import requests +from typing import Dict, Optional + +# Ensure recipe_parser is importable +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from recipe_parser import ParsedRecipe + +_BASE_URL = "https://world.openfoodfacts.org" +_SEARCH_BASE = _BASE_URL + "/cgi/search.pl" +_PRODUCT_BASE = _BASE_URL + "/api/v2/product" + + +class NutritionApi: + """Wrapper around OpenFoodFacts API for looking up ingredient nutrition.""" + + def __init__(self): + self.conn = None # SQLite connection set externally if using cache + self._last_request = 0.0 + + def set_conn(self, conn): + """Set the SQLite connection for caching.""" + self.conn = conn + + def lookup(self, ingredient: str) -> Optional[Dict]: + """Look up nutrition data for *ingredient*. + + Returns a dict with keys ``calories``, ``protein``, ``fat``, + ``carbs``, ``sugar``, ``sodium`` – or ``None`` if not found. + """ + # Check cache first + if self.conn: + cursor = self.conn.execute( + "SELECT data FROM ingredient_cache WHERE ingredient = ?", + (ingredient,), + ) + row = cursor.fetchone() + if row: + try: + return json.loads(row[0]) + except json.JSONDecodeError: + pass # Fall through to API lookup + + self._rate_limit() + result = self._search_openfood(ingredient) + if result is not None: + if self.conn: + self.conn.execute( + "INSERT OR REPLACE INTO ingredient_cache (ingredient, data) VALUES (?, ?)", + (ingredient, json.dumps(result)), + ) + self.conn.commit() + return result + return None + + def _rate_limit(self) -> None: + """Simple rate limiter: minimum delay between requests.""" + elapsed = time.time() - self._last_request + if elapsed < 1.0: # 1 request per second minimum + time.sleep(1.0 - elapsed) + self._last_request = time.time() + + def _search_openfood(self, query: str) -> Optional[Dict]: + """Search OpenFoodFacts and return normalised nutrition dict. + + Retries with increasing delays to handle rate limiting / captcha challenges. + """ + # Retry with exponential backoff for transient errors (e.g. 503, HTML captcha pages) + max_retries = 5 + # Increasing delays: 2, 5, 10, 20, 30 seconds to handle rate limiting / captcha challenges + backoff_delays = [2, 5, 10, 20, 30] + for attempt in range(max_retries): + try: + if attempt > 0: + delay = backoff_delays[min(attempt - 1, len(backoff_delays) - 1)] + time.sleep(delay) + params = {"search": query.strip(), "json": "1", "page_size": "5"} + r = requests.get(_SEARCH_BASE, params=params, timeout=10, + headers={"User-Agent": "RecipeParser/1.0"}) + # Handle non-JSON responses (e.g. Cloudflare captcha HTML) + if r.status_code != 200: + if attempt == max_retries - 1: + return None + continue + # Check if response is valid JSON (not HTML error page) + try: + data = r.json() + except Exception: + # Not JSON – likely a captcha/challenge page; retry with longer delay + if attempt == max_retries - 1: + print(f"OpenFoodFacts returned non-JSON for '{query}'; giving up") + return None + continue + products = data.get("products", []) + if not products: + return None + + # Take the first reasonably complete product + for prod in products: + nutr = prod.get("nutriments", {}) + # We need at least calories/energy; skip if totally empty + if not nutr: + continue + return { + "calories": nutr.get("energy-kcal", 0) or 0, + "protein": nutr.get("protein", 0) or 0, + "fat": nutr.get("fat", 0) or 0, + "carbs": nutr.get("carbohydrates", 0) or 0, + "sugar": nutr.get("sugars", 0) or 0, + "sodium": nutr.get("sodium", 0) or 0, + } + return None + except Exception as exc: # broad – network / JSON issues + if attempt == max_retries - 1: + print(f"OpenFoodFacts error for '{query}': {exc}") + return None + continue + return None + + +class RecipeParser: + """Parses a markdown recipe string into a structured format.""" + + def parse(self, text: str) -> ParsedRecipe: + """Parse a markdown recipe string. + + Returns a ParsedRecipe object with 'servings' and 'ingredients' attributes. + """ + import re + lines = text.strip().split('\n') + + # Default values + servings = 4 + ingredients = [] + + # Simple parsing: look for "Makes X servings" line + for line in lines: + line_lower = line.strip().lower() + if 'makes' in line_lower and 'serving' in line_lower: + # Extract number before 'servings' + match = re.search(r'(\d+)', line) + if match: + servings = int(match.group(1)) + break + + # Also look for ingredient lines (lines starting with '- ') + for line in lines: + stripped = line.strip() + if stripped.startswith('- '): + ingredients.append(stripped[2:]) + + return ParsedRecipe(servings=servings, ingredients=ingredients) + + +class Recipe: + """Represents a parsed recipe with nutrition lookups.""" + + def __init__(self, parsed: ParsedRecipe, api: NutritionApi, servings: int = 4): + self.parsed = parsed + self.api = api + self.servings = parsed.servings if parsed else servings + self._nutrition = {} + + def _get_ingredient_nutrition(self, ingredient: str) -> Optional[Dict]: + """Look up nutrition for a single ingredient.""" + # Try exact match first, then fall back + result = self.api.lookup(ingredient) + if result is not None: + self._nutrition[ingredient] = result + return result + return None + + def summary(self) -> str: + """Return a text summary of the recipe with nutrition info.""" + lines = [] + lines.append(f"Recipe Summary") + lines.append(f"Servings: {self.servings}") + lines.append("") + + ingredients = self.parsed.ingredients + if ingredients: + lines.append("Ingredients:") + for ing in ingredients: + # Get just the ingredient name (before any measurements/units) + ing_name = ing.split(',')[0] if ',' in ing else ing.split()[0] if ing.split() else ing + nutrition = self._nutrition.get(ing_name) + if nutrition: + lines.append(f" - {ing_name}: " + f"Cal={nutrition.get('calories', '?')}, " + f"Pro={nutrition.get('protein', '?')}g, " + f"Fat={nutrition.get('fat', '?')}g, " + f"Carbs={nutrition.get('carbs', '?')}g") + else: + lines.append(f" - {ing_name}: nutrition pending") + else: + lines.append("No ingredients found") + + return '\n'.join(lines) + + def to_json(self) -> str: + """Return a JSON representation of the recipe.""" + import json as _json + data = { + 'servings': self.servings, + 'ingredients': self.parsed.ingredients, + 'nutrition': self._nutrition, + } + return _json.dumps(data, indent=2) diff --git a/recipe_parser.py b/recipe_parser.py new file mode 100644 index 0000000..69b6248 --- /dev/null +++ b/recipe_parser.py @@ -0,0 +1,8 @@ +"""Simple recipe data container returned by RecipeParser.""" + + +class ParsedRecipe: + """Container for parsed recipe data.""" + def __init__(self, servings: int = 4, ingredients: list = None): + self.servings = servings + self.ingredients = ingredients if ingredients is not None else [] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e5f29ba --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +# Nutritional Recipe Parser - Requirements +# External dependencies: +requests +# Python standard library modules (included): +# - json, time, sys, os, re, typing +# Python Version: 3.12 or higher diff --git a/sample_recipe.md b/sample_recipe.md new file mode 100644 index 0000000..f5fd5c2 --- /dev/null +++ b/sample_recipe.md @@ -0,0 +1,26 @@ +# Vegetarian Frittata + +A delicious egg dish with vegetables, perfect for breakfast or brunch. + +## Ingredients +- 4 eggs +- 100g spinach +- 50g feta cheese +- 1 small onion, chopped +- 2 cloves garlic, minced +- 1 tbsp olive oil +- Salt and pepper to taste + +## Instructions +1. Preheat oven to 180°C (350°F). +2. Heat olive oil in a skillet over medium heat. +3. Sauté onion and garlic until softened, about 5 minutes. +4. Add spinach and cook until wilted, about 2 minutes. +5. Whisk eggs in a bowl, season with salt and pepper. +6. Pour eggs over the spinach mixture in the skillet. +7. Sprinkle feta cheese on top. +8. Cook for 2-3 minutes until edges are set. +9. Transfer skillet to oven and bake for 10-12 minutes until fully set. +10. Slice and serve warm. + +Makes 4 servings.