Inital commit for agent
This commit is contained in:
commit
82fdc33771
12 files changed
+497
No files matched your search
@@ -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.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,3 @@
|
||||
"""
|
||||
Nutritional Recipe Parser Package
|
||||
"""
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
+211
@@ -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)
|
||||
@@ -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 []
|
||||
@@ -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
|
||||
@@ -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.
|
||||
Reference in new issue
Block a user