diff --git a/main.py b/main.py index 8ed844c..82ba80e 100644 --- a/main.py +++ b/main.py @@ -4,6 +4,7 @@ import product, recipe, db from pydantic import BaseModel from typing import List, Annotated from fastapi import FastAPI, Depends, Query +from fastapi.responses import JSONResponse from fastapi.middleware.cors import CORSMiddleware app = FastAPI() @@ -27,7 +28,10 @@ async def get_db(): @app.get("/recipes/parse") async def parse_recipe_handler(url: str, conn: sqlite3.Connection = Depends(get_db)) -> recipe.Recipe: - return await recipe.parse_recipe(conn, url) + parsed = await recipe.parse_recipe(conn, url) + if not parsed: + return JSONResponse(status_code=400, content={'message': 'Recipe not found'}) + return parsed @app.get("/recipes/ingredients/parse") async def parse_ingredients(lines: Annotated[ @@ -45,4 +49,22 @@ class ProductUrl(BaseModel): @app.post("/products/") async def create_product(url: ProductUrl, conn: sqlite3.Connection = Depends(get_db)) -> product.Product: - return await product.get_or_create(conn, url.url, url.tags) \ No newline at end of file + return await product.get_or_create(conn, url.url, url.tags) + +@app.post('/recipes/') +async def create_recipe(item: recipe.Recipe, conn: sqlite3.Connection = Depends(get_db)) -> recipe.Recipe: + if not item.ingredients: + return JSONResponse(status_code=400, content={'message': 'Recipe must have at least one ingredient'}) + + for ingredient in item.ingredients: + if not ingredient.product: + return JSONResponse(status_code=400, content={'message': 'Ingredient must have a product'}) + + await recipe.insert_recipe(conn, item) + for ingredient in item.ingredients: + ingredient.recipe_id = item.id + ingredient.product_id = ingredient.product.id + await recipe.insert_ingredient(conn, ingredient) + await conn.commit() + + return item \ No newline at end of file diff --git a/recipe/__init__.py b/recipe/__init__.py index a22a8df..5c7354c 100644 --- a/recipe/__init__.py +++ b/recipe/__init__.py @@ -1,12 +1,12 @@ from product import Product -from recipe.db import Recipe, Ingredient +from recipe.db import Recipe, Ingredient, insert_recipe, insert_ingredient from recipe.scraping import scrape_recipe from ingredient_parser import parse_multiple_ingredients from typing import List from product import find_product_by_tag -import json +import json, units async def find_existing_product(conn, ingredient: str) -> Product: async for item in find_product_by_tag(conn, ingredient): @@ -18,10 +18,13 @@ def parse_ingredient_from_nlp(ingredients: List[str]) -> Ingredient: for ingredient in parse_multiple_ingredients(ingredients): name = ingredient.name.text if ingredient.name else '' - quantity, unit = None, None + quantity, unit = 0, units.ITEMS.name if ingredient.amount: amount = ingredient.amount[0] - quantity, unit = amount.quantity, amount.unit + + real_unit = units.get_unit(unit) + unit = real_unit.name if real_unit else units.ITEMS.name + quantity = amount.quantity if amount.quantity else 0 results.append(Ingredient(id=0, line=ingredient.sentence, @@ -54,7 +57,7 @@ async def _get_recipe_from_ldata(conn, url: str, ldata: dict) -> dict: ) async def parse_recipe(conn, url: str) -> dict: - ldata = await scrape_recipe(conn, url) + ldata = await scrape_recipe(url) if ldata: return await _get_recipe_from_ldata(conn, url, ldata) return None \ No newline at end of file diff --git a/recipe/db.py b/recipe/db.py index ab57735..d9ff3c9 100644 --- a/recipe/db.py +++ b/recipe/db.py @@ -3,17 +3,18 @@ import aiosqlite from product import Product from pydantic import BaseModel -from typing import List, ClassVar +from typing import List, ClassVar, Optional class Ingredient(BaseModel): - KEYS: ClassVar[List[str]] = ['id', 'name', 'line', 'preparation', 'unit', 'quantity', 'product_id'] + KEYS: ClassVar[List[str]] = ['id', 'name', 'line', 'preparation', 'unit', 'quantity', 'product_id', 'recipe_id'] id: int name: str line: str unit: str quantity: float preparation: str - product_id: int + product_id: Optional[int] = None + recipe_id: Optional[int] = None product: Product = None class Recipe(BaseModel): diff --git a/recipe/scraping.py b/recipe/scraping.py index 1947b3f..3a6a26c 100644 --- a/recipe/scraping.py +++ b/recipe/scraping.py @@ -2,8 +2,18 @@ from bs4 import BeautifulSoup import httpx import json +def _is_recipe_ldata(ldata_node): + if '@type' in ldata_node: + typ = ldata_node['@type'] + if isinstance(typ, list): + typ = typ[0] -async def scrape_recipe(conn, url: str) -> dict: + if isinstance(typ, str) and typ.lower() == 'recipe': + return True + + return None + +async def scrape_recipe(url: str) -> dict: headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36", "Accept-Language": "en-US,en;q=0.9", @@ -21,19 +31,24 @@ async def scrape_recipe(conn, url: str) -> dict: try: data = json.loads(ld.text) #_dump_json_data_to_log(data) - if '@type' in data and data['@type'].lower() == 'recipe': + if _is_recipe_ldata(data): return data if '@graph' in data: for item in data['@graph']: - if '@type' in item and item['@type'].lower() == 'recipe': + if _is_recipe_ldata(item): + return item + + if isinstance(data, list): + for item in data: + if _is_recipe_ldata(item): return item except (json.decoder.JSONDecodeError, KeyError): pass return None - + def _dump_json_data_to_log(data: dict) -> str: import os, re dir = './dump' diff --git a/units.py b/units.py new file mode 100644 index 0000000..ba800b1 --- /dev/null +++ b/units.py @@ -0,0 +1,46 @@ +from typing import Union + +class Unit: + def __init__(self, name: str, symbols: list, unit_type: str, conversion_to_base: float = 1.0): + self.name = name + self.symbols = symbols + self.unit_type = unit_type + self.conversion_to_base = conversion_to_base + + def convert_to_base(self, quantity: float) -> float: + """Converts a quantity to the base unit.""" + return quantity * self.conversion_to_base + + def convert_from_base(self, quantity: float) -> float: + """Converts a quantity from the base unit to this unit.""" + return quantity / self.conversion_to_base + +# Define common base units in SI units +ITEMS = Unit("Items", ["item", "items"], "count", 1) +LITRE = Unit("Litre", ["litre", "liter", "l"], "volume", 1) +GRAM = Unit("Gram", ["gram", "g"], "weight", 1) + +# Add conversion factors for common cooking measurements +CUP = Unit("Cup", ["cup", "c"], "volume", 240) +TABLESPOON = Unit("Tablespoon", ["tablespoon", "tablespoons", "tbsp"], "volume", 15) +TEASPOON = Unit("Teaspoon", ["teaspoon", "teaspoons", "tsp"], "volume", 5) +OUNCE = Unit("Ounce", ["ounce", "ounces", "oz"], "weight", 28.3495) +POUND = Unit("Pound", ["pound", "pounds", "lb"], "weight", 453.592) +FLUID_OUNCE = Unit("Fluid Ounce", ["fluid ounce", "fl oz"], "volume", 29.5735) +PINT = Unit("Pint", ["pint", "pt"], "volume", 473.176) +QUART = Unit("Quart", ["quart", "qt"], "volume", 946.353) +GALLON = Unit("Gallon", ["gallon", "gal"], "volume", 3785.41) +MILLILITRE = Unit("Millilitre", ["millilitre", "millilitres", "ml"], "volume", 1) +MILLIGRAM = Unit("Milligram", ["milligram", "milligrams", "mg"], "weight", 1) +KILOGRAM = Unit("Kilogram", ["kilogram", "kilograms", "kg"], "weight", 1000) + +# Big list of units +ALL_UNITS = [ITEMS, LITRE, GRAM, CUP, TABLESPOON, TEASPOON, OUNCE, POUND, FLUID_OUNCE, PINT, QUART, GALLON, MILLILITRE, MILLIGRAM, KILOGRAM] + +def get_unit(alias: str) -> Union[Unit, None]: + """Returns the corresponding unit based on alias or abbreviation.""" + alias_lower = alias.lower() + for unit in ALL_UNITS: + if alias_lower in unit.symbols or alias_lower == unit.name.lower(): + return unit + return None \ No newline at end of file