Skip to content

Commit

Permalink
Chrome extension and new APIs (#383)
Browse files Browse the repository at this point in the history
* Initial parse-recipe-html tests

* Function to parse html as a file

* working version

* Added QR code

* scan page for test

* Added qrcode generation and scanning

* Fixed warnings

* Fixed qrcode size and removed duplicate code

* Fixed tests

* UI adjustments to the chrome extnsion

* Attempt to fix flaky test
  • Loading branch information
jlucaspains authored Jan 23, 2025
1 parent e9ab49c commit 1c8817f
Show file tree
Hide file tree
Showing 26 changed files with 6,540 additions and 372 deletions.
4 changes: 3 additions & 1 deletion api/function_app.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import azure.functions as func
from functions.parse_recipe import bp as parse_recipe_bp
from functions.parse_recipe_html import bp as parse_recipe_html_bp
from functions.process_backup import bp as process_backup_bp
from functions.process_image import bp as process_image_bp
from functions.receive_recipe import bp as receive_recipe_bp
Expand All @@ -11,4 +12,5 @@
app.register_functions(process_backup_bp)
app.register_functions(process_image_bp)
app.register_functions(receive_recipe_bp)
app.register_functions(share_recipe_bp)
app.register_functions(share_recipe_bp)
app.register_functions(parse_recipe_html_bp)
61 changes: 2 additions & 59 deletions api/functions/parse_recipe.py
Original file line number Diff line number Diff line change
@@ -1,17 +1,13 @@
import logging
import json
import re

import azure.functions as func
from contextlib import suppress

from pint import UnitRegistry
from uuid import uuid4
from time import perf_counter

from .util import parse_recipe_ingredient, parse_recipe_instruction, get_recipe_image, get_html
from .util import get_recipe_from_scraper, get_html

ureg = UnitRegistry()
bp = func.Blueprint()

@bp.route(route="parse-recipe", methods=["POST"])
Expand All @@ -26,28 +22,7 @@ def parse_recipe(req: func.HttpRequest) -> func.HttpResponse:
logging.info(f"processing parse request id {correlation_id} for url: {url}")
scraper = get_html(url)

lang = scraper.language() or "en"

ingredients = map(lambda x: parse_recipe_ingredient(x, lang, ureg), scraper.ingredients())
instructions = map(lambda x: parse_recipe_instruction(x, lang), scraper.instructions_list())
yields, yields_description = parse_yields(scraper.yields())
result = {
"title": scraper.title(),
"totalTime": scraper.total_time(),
"yields": yields,
"yieldsDescription": yields_description,
"ingredients": list(ingredients),
"steps": list(instructions),
"image": scraper.image(),
"host": scraper.host(),
"language": scraper.language()
}

# since nutrients are not always available, we need to suppress the exception
with suppress(NotImplementedError):
result["nutrients"] = parse_nutrients(scraper.nutrients())

result["image"] = get_recipe_image(result["image"]) if download_image else result["image"]
result = get_recipe_from_scraper(scraper, download_image)

return func.HttpResponse(json.dumps(result), status_code=200, mimetype="application/json")
except Exception as e:
Expand All @@ -57,35 +32,3 @@ def parse_recipe(req: func.HttpRequest) -> func.HttpResponse:
finally:
end = perf_counter()
logging.info(f"Finished processing parse request id {correlation_id}. Time taken: {end - start:0.4f}s")

def parse_nutrients(nutrients: dict):
return {
"calories": parse_nutrient_value(nutrients.get("calories")),
"totalFat": parse_nutrient_value(nutrients.get("fatContent")),
"saturatedFat": parse_nutrient_value(nutrients.get("saturatedFatContent")),
"unsaturatedFat": parse_nutrient_value(nutrients.get("unsaturatedFatContent")),
"transFat": parse_nutrient_value(nutrients.get("transFatContent")),
"carbohydrates": parse_nutrient_value(nutrients.get("carbohydrateContent")),
"sugar": parse_nutrient_value(nutrients.get("sugarContent")),
"cholesterol": parse_nutrient_value(nutrients.get("cholesterolContent")),
"sodium": parse_nutrient_value(nutrients.get("sodiumContent")),
"protein": parse_nutrient_value(nutrients.get("proteinContent")),
"fiber": parse_nutrient_value(nutrients.get("fiberContent"))
}

def parse_yields(yields: str):
if not yields:
return 0, ""

parts = yields.split(" ")

return float(parts[0]), parts[1] if len(parts) > 1 else ""

def parse_nutrient_value(value: str) -> float:
if not value:
return 0

qty_re = re.search(r"^(?P<Value>\d{1,5})", value)
qty = qty_re.group("Value")

return float(qty) if qty else 0
38 changes: 38 additions & 0 deletions api/functions/parse_recipe_html.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import logging
import json

import azure.functions as func

from uuid import uuid4
from time import perf_counter

from recipe_scrapers import scrape_html, AbstractScraper

from .util import get_recipe_from_scraper

bp = func.Blueprint()

@bp.route(route="parse-recipe-html", methods=["POST"])
def parse_recipe_html(req: func.HttpRequest) -> func.HttpResponse:
start = perf_counter()
correlation_id = uuid4()

url: str = req.form.get("url")
html: str = req.form.get("html")
scraper: AbstractScraper
download_image: bool = req.form.get("downloadImage") or False
try:
logging.info(f"processing parse request id {correlation_id} for url: {html}")
for file in req.files.values():
scraper = scrape_html(file.stream.read(), url, wild_mode=True)

result = get_recipe_from_scraper(scraper, download_image)

return func.HttpResponse(json.dumps(result), status_code=200, mimetype="application/json")
except Exception as e:
logging.error(f"Failed to process parse request id {correlation_id}. Error: {e}")

return func.HttpResponse("Could not find a recipe in the web page", status_code=400)
finally:
end = perf_counter()
logging.info(f"Finished processing parse request id {correlation_id}. Time taken: {end - start:0.4f}s")
11 changes: 10 additions & 1 deletion api/functions/share_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
import random
import string
import jsonschema
import qrcode
import qrcode.image.svg

import azure.functions as func
from jsonschema import validate
Expand All @@ -16,6 +18,7 @@

repository = Repository()
bp = func.Blueprint()
qr = qrcode.QRCode(image_factory=qrcode.image.svg.SvgPathImage)

def mock_repository(mock_repository: Repository):
global repository
Expand Down Expand Up @@ -50,7 +53,13 @@ def share_recipe(req: func.HttpRequest) -> func.HttpResponse:
operation_result = repository.create_item(new_item)
logging.debug(operation_result)

result = json.dumps({ "id": share_id, "ttl": ttl })
qr = qrcode.QRCode(image_factory=qrcode.image.svg.SvgPathImage)
qr.add_data(share_id)
qr.make(fit=True)

img = qr.make_image()

result = json.dumps({ "id": share_id, "qr_code": img.to_string(encoding='unicode'), "ttl": ttl })

return func.HttpResponse(result, status_code=202, mimetype="application/json")
except jsonschema.exceptions.ValidationError as e:
Expand Down
70 changes: 69 additions & 1 deletion api/functions/util.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from contextlib import suppress
import io
from zipfile import ZipFile
from fractions import Fraction
Expand All @@ -10,6 +11,8 @@
import pillow_avif
from recipe_scrapers import scrape_html, AbstractScraper

ureg = UnitRegistry()

def parse_recipe_ingredients(text: str, ureg: UnitRegistry):
"""Parses a recipe collection of ingredientes that are formatted in a single string separated by \n
Args:
Expand Down Expand Up @@ -186,4 +189,69 @@ def get_recipe_image(image_url: str):
def get_html(url: str) -> AbstractScraper:
html = requests.get(url, headers=request_headers).content

return scrape_html(html, url, wild_mode=True)
return scrape_html(html, url, wild_mode=True)

def get_recipe_from_scraper(scraper: AbstractScraper, download_image: bool = False):
"""Parses a recipe from a scraper
Args:
scraper (AbstractScraper): scraper object
download_image (bool): whether to download the image or not, default is False
Returns:
dict: dictionary with recipe information
"""
lang = scraper.language() or "en"

ingredients = map(lambda x: parse_recipe_ingredient(x, lang, ureg), scraper.ingredients())
instructions = map(lambda x: parse_recipe_instruction(x, lang), scraper.instructions_list())
yields, yields_description = parse_yields(scraper.yields())
result = {
"title": scraper.title(),
"totalTime": scraper.total_time(),
"yields": yields,
"yieldsDescription": yields_description,
"ingredients": list(ingredients),
"steps": list(instructions),
"image": scraper.image(),
"host": scraper.host(),
"language": scraper.language()
}

# since nutrients are not always available, we need to suppress the exception
with suppress(NotImplementedError):
result["nutrients"] = parse_nutrients(scraper.nutrients())

result["image"] = get_recipe_image(result["image"]) if download_image else result["image"]

return result

def parse_nutrients(nutrients: dict):
return {
"calories": parse_nutrient_value(nutrients.get("calories")),
"totalFat": parse_nutrient_value(nutrients.get("fatContent")),
"saturatedFat": parse_nutrient_value(nutrients.get("saturatedFatContent")),
"unsaturatedFat": parse_nutrient_value(nutrients.get("unsaturatedFatContent")),
"transFat": parse_nutrient_value(nutrients.get("transFatContent")),
"carbohydrates": parse_nutrient_value(nutrients.get("carbohydrateContent")),
"sugar": parse_nutrient_value(nutrients.get("sugarContent")),
"cholesterol": parse_nutrient_value(nutrients.get("cholesterolContent")),
"sodium": parse_nutrient_value(nutrients.get("sodiumContent")),
"protein": parse_nutrient_value(nutrients.get("proteinContent")),
"fiber": parse_nutrient_value(nutrients.get("fiberContent"))
}

def parse_yields(yields: str):
if not yields:
return 0, ""

parts = yields.split(" ")

return float(parts[0]), parts[1] if len(parts) > 1 else ""

def parse_nutrient_value(value: str) -> float:
if not value:
return 0

qty_re = re.search(r"^(?P<Value>\d{1,5})", value)
qty = qty_re.group("Value")

return float(qty) if qty else 0
3 changes: 2 additions & 1 deletion api/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,5 @@ pillow-avif-plugin~=1.4
pytest~=8.3
azure-cosmos~=4.9
jsonschema~=4.23
lxml==5.1.0
lxml==5.1.0
qrcode==8.0
1 change: 1 addition & 0 deletions api/test/test_share_recipe.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ def test_share_recipe_success():
parsed_response = json.loads(response.get_body().decode())

assert len(parsed_response["id"]) == 6
assert parsed_response["qr_code"].startswith("<svg")
assert parsed_response["ttl"] == 3600

def test_share_recipe_bad_data():
Expand Down
5,732 changes: 5,732 additions & 0 deletions api/tools/parse_recipe_html.http

Large diffs are not rendered by default.

79 changes: 79 additions & 0 deletions browser-extensions/background.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
chrome.runtime.onMessage.addListener(
function (request, sender, sendResponse) {
if (request.contentScriptQuery == "parseRecipe") {
parseRecipe(request.url, request.downloadImage, request.body, sendResponse);

return true; // Will respond asynchronously.
}
});

function parseRecipe(url, downloadImage, body, sendResponse) {
const postFile = new Blob([body], {
type: 'application/x-object'
});

const data = new FormData();
data.append('file', postFile);
data.append('url', url);
data.append('downloadImage', downloadImage);

let options = {
method: "POST",
body: data
};

fetch(`https://sharpcooking.lpains.net/api/parse-recipe-html`, options)
.then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error(`Something went wrong: ${response.status}`);
}
})
.then(data => {
shareParsedRecipe(data, sendResponse);
})
.catch(error => {
chrome.runtime.sendMessage(
{ contentScriptQuery: "parseRecipeResult", error: error },
);
console.log(error);
});
}

function shareParsedRecipe(parseResult, sendResponse) {
const body = {
title: parseResult.title,
ingredients: parseResult.ingredients.map(item => item.raw),
steps: parseResult.steps.map(item => item.raw),
notes: "",
source: parseResult.host ?? "imported from browser",
media: []
};
let options = {
method: "POST",
headers: { 'content-type': 'application/json' },
body: JSON.stringify(body)
};

fetch(`https://delightful-flower-0c3edd710-383.centralus.2.azurestaticapps.net/api/share-recipe`, options)
.then(response => {
if (response.ok) {
return response.json();
} else {
throw new Error(`Something went wrong: ${response.status}`);
}
})
.then(data => {
sendResponse(data.id)
chrome.runtime.sendMessage(
{ contentScriptQuery: "parseRecipeResult", code: data.id },
);
})
.catch(error => {
chrome.runtime.sendMessage(
{ contentScriptQuery: "parseRecipeResult", error: error },
);
console.log(error);
});
}
3 changes: 3 additions & 0 deletions browser-extensions/content.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
chrome.runtime.sendMessage(
{ contentScriptQuery: "parseRecipe", url: document.URL, downloadImage: false, body: document.documentElement.outerHTML },
response => console.log(response));
Binary file added browser-extensions/images/icon-128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added browser-extensions/images/icon-48.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
25 changes: 25 additions & 0 deletions browser-extensions/manifest.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
{
"name": "Sharp Cooking Recipe Importer",
"version": "1.0.0",
"description": "Import recipes from the webpage.",
"manifest_version": 3,
"author": "lpains",
"permissions": [
"activeTab",
"scripting"
],
"action": {
"default_popup": "popup.html"
},
"background": {
"service_worker": "background.js"
},
"host_permissions": [
"https://sharpcooking.lpains.net/api/*",
"https://delightful-flower-0c3edd710-383.centralus.2.azurestaticapps.net/api/*"
],
"icons": {
"48": "images/icon-48.png",
"128": "images/icon-128.png"
}
}
Loading

0 comments on commit 1c8817f

Please sign in to comment.