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 17:29 +0000

1import tempfile 1a

2import zipfile 1a

3from pathlib import Path 1a

4 

5from bs4 import BeautifulSoup 1a

6 

7from mealie.services.scraper import cleaner 1a

8 

9from ._migration_base import BaseMigrator 1a

10from .utils.migration_alias import MigrationAlias 1a

11from .utils.migration_helpers import parse_iso8601_duration 1a

12 

13 

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}. ") 

19 

20 return instructions 

21 except Exception: 

22 return instructions 

23 

24 

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 

43 

44 

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 

54 

55 

56def to_list(x: list[str] | str) -> list[str]: 1a

57 if isinstance(x, str): 

58 return [x] 

59 return x 

60 

61 

62class RecipeKeeperMigrator(BaseMigrator): 1a

63 def __init__(self, **kwargs): 1a

64 super().__init__(**kwargs) 

65 

66 self.name = "recipekeeper" 

67 

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 ] 

85 

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) 

90 

91 source_dir = self.get_zip_base_path(Path(tmpdir)) 

92 

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)) 

98 

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 

106 

107 except StopIteration: 

108 continue 

109 

110 self.import_image(slug, recipe.image, recipe_id)