diff --git a/pyproject.toml b/pyproject.toml index 01aff33d..67376958 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,4 +8,6 @@ dependencies = [ "zensical>=0.0.32", "pyyaml>=6.0.1", "pillow>=12.2.0", + "beautifulsoup4>=4.12.0", + "pytest>=8.0.0", ] diff --git a/scripts/scrape_to_cook.py b/scripts/scrape_to_cook.py new file mode 100644 index 00000000..b2aaa701 --- /dev/null +++ b/scripts/scrape_to_cook.py @@ -0,0 +1,459 @@ +#!/usr/bin/env python3 +import sys +import re +import os +import argparse +import json +import urllib.request +import urllib.parse +from bs4 import BeautifulSoup +import yaml + +COMMON_UNITS = { + "cup", "cups", "tbsp", "tablespoon", "tablespoons", "tsp", "teaspoon", "teaspoons", + "g", "gram", "grams", "ml", "milliliter", "milliliters", "oz", "ounce", "ounces", + "lb", "pound", "pounds", "kg", "kilogram", "kilograms", "can", "cans", "clove", + "cloves", "pinch", "pinches", "slice", "slices", "package", "packages", "bag", + "bags", "canister", "canisters", "jar", "jars", "head", "heads", "bunch", "bunches", + "sprig", "sprigs", "piece", "pieces", "large", "medium", "small", "handful", "handfuls" +} + +COMMON_COOKWARE_KEYWORDS = [ + "bowl", "mixing bowl", "pan", "baking pan", "baking sheet", "saucepan", "pot", + "skillet", "frying pan", "griddle", "oven", "microwave", "blender", "food processor", + "stand mixer", "hand mixer", "whisk", "spatula", "wooden spoon", "knife", "cutting board", + "strainer", "colander", "peeler", "grater", "measuring cup", "measuring spoon", + "casserole dish", "tart pan", "muffin tin", "loaf pan", "cookie scoop", "wire rack" +] + +def parse_iso_duration(duration_str): + if not duration_str: + return "" + m = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?', duration_str) + if not m: + return duration_str + hours, minutes, seconds = m.groups() + parts = [] + if hours: + h_val = int(hours) + parts.append(f"{h_val} hour" + ("s" if h_val != 1 else "")) + if minutes: + m_val = int(minutes) + parts.append(f"{m_val} minute" + ("s" if m_val != 1 else "")) + return " ".join(parts) + +def parse_fraction(val_str): + val_str = val_str.strip() + unicode_fractions = { + '½': 0.5, '⅓': 0.33, '⅔': 0.67, '¼': 0.25, '¾': 0.75, + '⅕': 0.2, '⅖': 0.4, '⅗': 0.6, '⅘': 0.8, '⅙': 0.17, '⅚': 0.83, + '⅛': 0.125, '⅜': 0.375, '⅝': 0.625, '⅞': 0.875 + } + for char, float_val in unicode_fractions.items(): + if char in val_str: + val_str = val_str.replace(char, f" {float_val}") + + parts = val_str.split() + total = 0.0 + for part in parts: + if '/' in part: + try: + num, denom = part.split('/') + total += float(num) / float(denom) + except ValueError: + pass + else: + try: + total += float(part) + except ValueError: + pass + if total > 0.0: + if total.is_integer(): + return str(int(total)) + return f"{total:.2f}".rstrip('0').rstrip('.') + return val_str + +def parse_ingredient(ing_line): + ignored_ingredients = [] + try: + config_path = os.path.join("cook", "config", "ignored_ingredients.yaml") + if os.path.exists(config_path): + with open(config_path, "r", encoding="utf-8") as f: + ignored_ingredients = yaml.safe_load(f) or [] + except Exception: + pass + + ing_line_clean = ing_line.strip() + for ignored in ignored_ingredients: + if ing_line_clean.lower() == ignored.lower(): + return ing_line_clean + + qty_regex = r'^(\d+(?:\s+\d+/\d+|\s+[½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])?|\d+/\d+|\d+\.\d+|[½⅓⅔¼¾⅕⅖⅗⅘⅙⅚⅛⅜⅝⅞])' + qty_match = re.match(qty_regex, ing_line_clean) + + if not qty_match: + return f"@{ing_line_clean}{{}}" + + qty_raw = qty_match.group(1) + remaining = ing_line_clean[len(qty_raw):].strip() + qty = parse_fraction(qty_raw) + + words = remaining.split() + if not words: + return f"@ingredient{{{qty}}}" + + first_word = words[0].lower().rstrip(',') + unit = "" + ing_name_words = words + if first_word in COMMON_UNITS: + unit = first_word + ing_name_words = words[1:] + + if ing_name_words and ing_name_words[0].lower() == "of": + ing_name_words = ing_name_words[1:] + + name = " ".join(ing_name_words).strip() + + notes = "" + m_notes = re.search(r'\(([^)]+)\)$', name) + if m_notes: + notes = m_notes.group(1).strip() + name = name[:m_notes.start()].strip() + + name = name.rstrip(',').strip() + + for ignored in ignored_ingredients: + if name.lower() == ignored.lower(): + return ing_line_clean + + unit_part = f"%{unit}" if unit else "" + notes_part = f" ({notes})" if notes else "" + + return f"@{name}{{{qty}{unit_part}}}{notes_part}" + +def format_time_range(text): + pattern_range = r'(\d+)\s*(?:-|to)\s*(\d+)\s*(minutes|minute|hours|hour|seconds|second|secs|sec)' + m_range = re.search(pattern_range, text, re.IGNORECASE) + if m_range: + num1, num2, unit = m_range.groups() + replacement = f"{num1} to ~{{{num2}%{unit}}}" + return re.sub(pattern_range, replacement, text, flags=re.IGNORECASE) + + pattern_single = r'(about|approx|approximately)\s*(\d+)\s*(minutes|minute|hours|hour|seconds|second|secs|sec)' + m_single = re.search(pattern_single, text, re.IGNORECASE) + if m_single: + prefix, num, unit = m_single.groups() + replacement = f"{prefix} ~{{{num}%{unit}}}" + return re.sub(pattern_single, replacement, text, flags=re.IGNORECASE) + + return text + +def resolve_category(category_name): + if not category_name: + return "main" + category_lower = category_name.lower().strip() + + if any(kw in category_lower for kw in ["dessert", "sweet", "cake", "cookie", "bar"]): + if any(kw in category_lower for kw in ["cookie", "bar"]): + return "cookies-and-bars" + return "desserts" + if any(kw in category_lower for kw in ["bread", "bun", "dough", "roll"]): + return "breads" + if any(kw in category_lower for kw in ["breakfast", "brunch", "waffle", "pancake"]): + return "breakfast" + if any(kw in category_lower for kw in ["drink", "beverage", "cocktail", "smoothie"]): + return "beverages" + if any(kw in category_lower for kw in ["sauce", "dressing", "dip", "salsa", "gravy"]): + return "sauces-and-dressings" + if any(kw in category_lower for kw in ["soup", "stew", "chowder", "chili"]): + return "soups-and-stews" + if any(kw in category_lower for kw in ["salad", "side", "appetizer", "snack"]): + return "sides" + if any(kw in category_lower for kw in ["lunch"]): + return "lunches" + if any(kw in category_lower for kw in ["mediterranean"]): + return "mediterranean" + + return "main" + +def fetch_html(url): + headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'} + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req) as response: + return response.read().decode('utf-8') + +def find_recipe_in_json(data): + if isinstance(data, dict): + if data.get("@type") == "Recipe" or data.get("@type") == ["Recipe"]: + return data + for k, v in data.items(): + if k == "@graph" and isinstance(v, list): + for item in v: + recipe = find_recipe_in_json(item) + if recipe: + return recipe + elif isinstance(v, (dict, list)): + recipe = find_recipe_in_json(v) + if recipe: + return recipe + elif isinstance(data, list): + for item in data: + recipe = find_recipe_in_json(item) + if recipe: + return recipe + return None + +def extract_json_ld_recipe(html): + soup = BeautifulSoup(html, "html.parser") + scripts = soup.find_all("script", type="application/ld+json") + for script in scripts: + try: + data = json.loads(script.string or "") + recipe = find_recipe_in_json(data) + if recipe: + return recipe + except Exception: + continue + return None + +def parse_instructions(instructions_data): + steps = [] + if isinstance(instructions_data, str): + steps.append(instructions_data) + elif isinstance(instructions_data, list): + for item in instructions_data: + if isinstance(item, str): + steps.append(item) + elif isinstance(item, dict): + if item.get("@type") == "HowToSection": + elements = item.get("itemListElement") or [] + for elem in elements: + if isinstance(elem, dict) and elem.get("@type") == "HowToStep": + steps.append(elem.get("text") or elem.get("name") or "") + elif item.get("@type") == "HowToStep": + steps.append(item.get("text") or item.get("name") or "") + elif "text" in item: + steps.append(item.get("text") or "") + return [s.strip() for s in steps if s.strip()] + +def extract_wprm_recipe(html): + soup = BeautifulSoup(html, "html.parser") + recipe_container = soup.find(class_="wprm-recipe-container") + if not recipe_container: + return None + + recipe = {} + name_el = recipe_container.find(class_="wprm-recipe-name") + recipe["name"] = name_el.get_text(strip=True) if name_el else "Unknown Recipe" + + servings_el = recipe_container.find(class_="wprm-recipe-servings") + recipe["servings"] = servings_el.get_text(strip=True) if servings_el else "" + + prep_el = recipe_container.find(class_="wprm-recipe-prep_time") + recipe["prep_time"] = prep_el.get_text(strip=True) if prep_el else "" + cook_el = recipe_container.find(class_="wprm-recipe-cook_time") + recipe["cook_time"] = cook_el.get_text(strip=True) if cook_el else "" + total_el = recipe_container.find(class_="wprm-recipe-total_time") + recipe["total_time"] = total_el.get_text(strip=True) if total_el else "" + + image_el = recipe_container.find("img") + image_url = "" + if image_el: + image_url = image_el.get("data-lazy-src") or image_el.get("src") or "" + recipe["image_url"] = image_url + + ingredients = [] + ing_elements = recipe_container.find_all(class_="wprm-recipe-ingredient") + for ing in ing_elements: + amount_el = ing.find(class_="wprm-recipe-ingredient-amount") + unit_el = ing.find(class_="wprm-recipe-ingredient-unit") + name_el = ing.find(class_="wprm-recipe-ingredient-name") + notes_el = ing.find(class_="wprm-recipe-ingredient-notes") + + amount = amount_el.get_text(strip=True) if amount_el else "" + unit = unit_el.get_text(strip=True) if unit_el else "" + name = name_el.get_text(strip=True) if name_el else "" + notes = notes_el.get_text(strip=True) if notes_el else "" + + full_ing = f"{amount} {unit} {name}".replace(" ", " ").strip() + if notes: + full_ing += f" ({notes})" + ingredients.append(full_ing) + recipe["ingredients"] = ingredients + + instructions = [] + inst_elements = recipe_container.find_all(class_="wprm-recipe-instruction") + for inst in inst_elements: + text_el = inst.find(class_="wprm-recipe-instruction-text") + text = text_el.get_text(strip=True) if text_el else inst.get_text(strip=True) + instructions.append(text) + recipe["instructions"] = instructions + + return recipe + +def download_image(url, output_path): + headers = {'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64)'} + req = urllib.request.Request(url, headers=headers) + with urllib.request.urlopen(req) as response: + with open(output_path, "wb") as f: + f.write(response.read()) + +def tag_cookware(step_text): + keywords = sorted(COMMON_COOKWARE_KEYWORDS, key=len, reverse=True) + for kw in keywords: + pattern = rf'(?> source: {source_url}") + + servings = recipe_data.get("servings") + if servings: + lines.append(f">> serves: {servings}") + + prep_time = recipe_data.get("prep_time") + if prep_time: + lines.append(f">> prep time: {prep_time}") + cook_time = recipe_data.get("cook_time") + if cook_time: + lines.append(f">> cook time: {cook_time}") + total_time = recipe_data.get("total_time") + if total_time: + lines.append(f">> total time: {total_time}") + + lines.append("") + + # Declarations of all ingredients at the beginning (commented out) + parsed_ingredients = [] + raw_ing_names = [] + for ing in recipe_data.get("ingredients", []): + parsed = parse_ingredient(ing) + parsed_ingredients.append(parsed) + # Extract name from @name{...} format to tag in steps + m_name = re.match(r'^@([^{]+)\{', parsed) + if m_name: + raw_ing_names.append(m_name.group(1)) + + for parsed in parsed_ingredients: + lines.append(parsed) + + lines.append("") + + # Instruction steps + # Sort names by length descending to replace longest names first + raw_ing_names = sorted(list(set(raw_ing_names)), key=len, reverse=True) + + for step in recipe_data.get("instructions", []): + step_clean = format_time_range(step) + step_clean = tag_cookware(step_clean) + + # Tag ingredients in steps + for ing_name in raw_ing_names: + pattern = rf'(? + + + + + + """ + recipe = scrape_to_cook.extract_json_ld_recipe(html) + self.assertIsNotNone(recipe) + self.assertEqual(recipe.get("name"), "Dummy Chocolate Cake") + self.assertEqual(recipe.get("recipeYield"), ["8 servings"]) + self.assertEqual(recipe.get("recipeIngredient"), ["1 cup sugar", "2 cups flour"]) + + def test_extract_wprm_recipe(self): + html = """ +
+ WPRM Oatmeal Cookies + 12 +
+ 1 + cup + oats +
+
+
Bake cookies.
+
+
+ """ + recipe = scrape_to_cook.extract_wprm_recipe(html) + self.assertIsNotNone(recipe) + self.assertEqual(recipe["name"], "WPRM Oatmeal Cookies") + self.assertEqual(recipe["servings"], "12") + self.assertEqual(recipe["ingredients"], ["1 cup oats"]) + self.assertEqual(recipe["instructions"], ["Bake cookies."]) + +if __name__ == "__main__": + unittest.main() diff --git a/uv.lock b/uv.lock index 8935b5d7..8c5c387f 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,19 @@ version = 1 revision = 3 requires-python = ">=3.14" +[[package]] +name = "beautifulsoup4" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "soupsieve" }, + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/43/65/318323f98dbee45d42dff61d8f047181bc6f2268a9068cfad035a46be5af/beautifulsoup4-4.15.0.tar.gz", hash = "sha256:288e3ca7d54b06f2ac191970bc275c1939cb46d450b255bf6718b04aa37ab4f7", size = 632571, upload-time = "2026-06-07T16:44:20.453Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/88/c6/92fcd42f1ba33e1184263f25bfabf3d27c383410470f169e4b8163bf9c17/beautifulsoup4-4.15.0-py3-none-any.whl", hash = "sha256:d6f88de62e1d4e38ecb1077eb9724cd0eff29d2a08ca16a401e9b9e93f117cf9", size = 109924, upload-time = "2026-06-07T16:44:21.566Z" }, +] + [[package]] name = "click" version = "8.3.1" @@ -32,6 +45,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/2d/82/e5d2c1c67d19841e9edc74954c827444ae826978499bde3dfc1d007c8c11/deepmerge-2.0-py3-none-any.whl", hash = "sha256:6de9ce507115cff0bed95ff0ce9ecc31088ef50cbdf09bc90a09349a318b3d00", size = 13475, upload-time = "2024-08-30T05:31:48.659Z" }, ] +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, +] + [[package]] name = "jinja2" version = "3.1.6" @@ -83,6 +105,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/70/bc/6f1c2f612465f5fa89b95bead1f44dcb607670fd42891d8fdcd5d039f4f4/markupsafe-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:32001d6a8fc98c8cb5c947787c5d08b0a50663d139f1305bac5885d98d9b40fa", size = 14146, upload-time = "2025-09-27T18:37:28.327Z" }, ] +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -116,6 +147,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/ff/6e/cf826fae916b8658848d7b9f38d88da6396895c676e8086fc0988073aaf8/pillow-12.2.0-cp314-cp314t-win_arm64.whl", hash = "sha256:aa88ccfe4e32d362816319ed727a004423aab09c5cea43c01a4b435643fa34eb", size = 2556579, upload-time = "2026-04-01T14:45:52.529Z" }, ] +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, +] + [[package]] name = "pygments" version = "2.20.0" @@ -138,6 +178,22 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7e/85/545a951eecc270fcd688288c600017e2050a1aacb56c711d208586d3e470/pymdown_extensions-10.21.3-py3-none-any.whl", hash = "sha256:d7a5d08014fc571e80ca21dd6f854e31f94c489800350564d55d15b3c41e76b6", size = 269002, upload-time = "2026-05-13T12:57:30.296Z" }, ] +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181, upload-time = "2026-06-13T18:52:45.983Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453, upload-time = "2026-06-13T18:52:44.045Z" }, +] + [[package]] name = "pyyaml" version = "6.0.3" @@ -169,18 +225,31 @@ name = "recipes" version = "0.1.0" source = { virtual = "." } dependencies = [ + { name = "beautifulsoup4" }, { name = "pillow" }, + { name = "pytest" }, { name = "pyyaml" }, { name = "zensical" }, ] [package.metadata] requires-dist = [ + { name = "beautifulsoup4", specifier = ">=4.12.0" }, { name = "pillow", specifier = ">=12.2.0" }, + { name = "pytest", specifier = ">=8.0.0" }, { name = "pyyaml", specifier = ">=6.0.1" }, { name = "zensical", specifier = ">=0.0.32" }, ] +[[package]] +name = "soupsieve" +version = "2.8.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/47/2c/0a5f6f8ee0d5589e48c7640213ed5175d52cf540a06725b628cc1a45d6ce/soupsieve-2.8.4.tar.gz", hash = "sha256:e121fd02e975c695e4e9e8774a5ee35d74714b59307868dcc5319ad2d9e3328e", size = 121110, upload-time = "2026-05-24T13:55:57.154Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/5e/f5/0c41cb68dcae6b7de4fac4188a3a9589e21fb31df21ea3a2e888db95e6c9/soupsieve-2.8.4-py3-none-any.whl", hash = "sha256:e7e6b0769c8f51ed59acab6e994b00621096cfb1c640a7509295987388fbaf65", size = 37304, upload-time = "2026-05-24T13:55:55.406Z" }, +] + [[package]] name = "tomli" version = "2.4.1" @@ -208,6 +277,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, ] +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391, upload-time = "2025-08-25T13:49:26.313Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" }, +] + [[package]] name = "zensical" version = "0.0.43"