Coverage for opt/mealie/lib/python3.12/site-packages/mealie/services/migrations/recipekeeper.py: 15%
69 statements
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 15:48 +0000
« prev ^ index » next coverage.py v7.10.6, created at 2025-11-25 15:48 +0000
1import tempfile 1a
2import zipfile 1a
3from pathlib import Path 1a
5from bs4 import BeautifulSoup 1a
7from mealie.services.scraper import cleaner 1a
9from ._migration_base import BaseMigrator 1a
10from .utils.migration_alias import MigrationAlias 1a
11from .utils.migration_helpers import parse_iso8601_duration 1a
14def clean_instructions(instructions: list[str]) -> list[str]: 1a
15 try:
16 for i, instruction in enumerate(instructions):
17 if instruction.startswith(f"{i + 1}. "):
18 instructions[i] = instruction.removeprefix(f"{i + 1}. ")
20 return instructions
21 except Exception:
22 return instructions
25def parse_recipe_div(recipe, image_path): 1a
26 meta = {}
27 for item in recipe.find_all(lambda x: x.has_attr("itemprop")):
28 if item.name == "meta":
29 meta[item["itemprop"]] = item["content"]
30 elif item.name == "div":
31 meta[item["itemprop"]] = list(item.stripped_strings)
32 elif item.name == "img":
33 meta[item["itemprop"]] = str(image_path / item["src"])
34 else:
35 meta[item["itemprop"]] = item.string
36 # merge nutrition keys into their own dict.
37 nutrition = {}
38 for k in meta:
39 if k.startswith("recipeNut"):
40 nutrition[k.removeprefix("recipeNut")] = meta[k].strip()
41 meta["nutrition"] = nutrition
42 return meta
45def get_value_as_string_or_none(dictionary: dict, key: str): 1a
46 value = dictionary.get(key)
47 if value is not None:
48 try:
49 return str(value)
50 except Exception:
51 return None
52 else:
53 return None
56def to_list(x: list[str] | str) -> list[str]: 1a
57 if isinstance(x, str):
58 return [x]
59 return x
62class RecipeKeeperMigrator(BaseMigrator): 1a
63 def __init__(self, **kwargs): 1a
64 super().__init__(**kwargs)
66 self.name = "recipekeeper"
68 self.key_aliases = [
69 MigrationAlias(
70 key="recipeIngredient",
71 alias="recipeIngredients",
72 ),
73 MigrationAlias(key="recipeInstructions", alias="recipeDirections", func=clean_instructions),
74 MigrationAlias(key="performTime", alias="cookTime", func=parse_iso8601_duration),
75 MigrationAlias(key="prepTime", alias="prepTime", func=parse_iso8601_duration),
76 MigrationAlias(key="image", alias="photo0"),
77 MigrationAlias(key="tags", alias="recipeCourse", func=to_list),
78 MigrationAlias(key="recipe_category", alias="recipeCategory", func=to_list),
79 MigrationAlias(key="notes", alias="recipeNotes"),
80 MigrationAlias(key="nutrition", alias="nutrition", func=cleaner.clean_nutrition),
81 MigrationAlias(key="rating", alias="recipeRating"),
82 MigrationAlias(key="orgURL", alias="recipeSource"),
83 MigrationAlias(key="recipe_yield", alias="recipeYield"),
84 ]
86 def _migrate(self) -> None: 1a
87 with tempfile.TemporaryDirectory() as tmpdir:
88 with zipfile.ZipFile(self.archive) as zip_file:
89 zip_file.extractall(tmpdir)
91 source_dir = self.get_zip_base_path(Path(tmpdir))
93 recipes_as_dicts: list[dict] = []
94 with open(source_dir / "recipes.html") as fp:
95 soup = BeautifulSoup(fp, "lxml")
96 for recipe_div in soup.body.find_all("div", "recipe-details"):
97 recipes_as_dicts.append(parse_recipe_div(recipe_div, source_dir))
99 recipes = [self.clean_recipe_dictionary(x) for x in recipes_as_dicts]
100 results = self.import_recipes_to_database(recipes)
101 for (slug, recipe_id, status), recipe in zip(results, recipes, strict=False):
102 if status:
103 try:
104 if not recipe or not recipe.image:
105 continue
107 except StopIteration:
108 continue
110 self.import_image(slug, recipe.image, recipe_id)