Repository: hhursev/recipe-scrapers
Pull request #1151 (2024-06-16 22:09:43)

Adds a test to check JSON key order consistency
Adds a test to compare the order of keys in each test JSON file against the order discussed in #1124 to make sure they match. 1 test case update was required

Example error:
```python
======================================================================
FAIL: test_json_key_order (tests.library.test_json_order.TestJsonOrder.test_json_key_order)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "*/recipe-scrapers/tests/library/test_json_order.py", line 50, in test_json_key_order
    self.assertEqual(expected_keys, actual_keys)
AssertionError: File: tests/test_data/afghankitchenrecipes.com/afghankitchenrecipes.json
Lists differ: ['aut[76 chars]s', 'ingredient_groups', 'instructions', 'inst[66 chars]age'] != ['aut[76 chars]s', 'total_time', 'ingredient_groups', 'instru[66 chars]age']

First differing element 7:
'ingredient_groups'
'total_time'

  ['author',
   'canonical_url',
   'site_name',
   'host',
   'language',
   'title',
   'ingredients',
+  'total_time',
   'ingredient_groups',
   'instructions',
   'instructions_list',
   'category',
   'yields',
-  'total_time',
   'ratings',
   'image']

----------------------------------------------------------------------
Ran 1 test in 0.006s

FAILED (failures=1)
```

jayaddison: This generally looks good to me!  It'll be reassuring to have this in place to maintain test style consistency (and therefore readability / reviewability!).

Related to the exception comment/suggestion: it would be helpful if we can provide a script to automatically fix otherwise-well-formed test JSON files that don't currently use the expected key ordering.

If we have that, then we can adjust the message in the test failure so that it both explains what the problem is, and also offers a ready-to-use command-line suggestion so that the developer can fix it (not a zero-frustration experience perhaps, but less frustrating than a hard-to-understand test failure that requires manual fixup for something that seems like it should be automatable).
jknndy: 
> Related to the exception comment/suggestion: it would be helpful if we can provide a script to automatically fix otherwise-well-formed test JSON files that don't currently use the expected key ordering.

I just pushed a commit with a `reorder_json_keys.py` in the `plugins` folder (not sure if this is the best place for it but seemed like maybe the *best* fit?). This file iterates through all the json files and compares their `key` order against the designated order and corrects any files that do not match.
 
> If we have that, then we can adjust the message in the test failure so that it both explains what the problem is, and also offers a ready-to-use command-line suggestion so that the developer can fix it (not a zero-frustration experience perhaps, but less frustrating than a hard-to-understand test failure that requires manual fixup for something that seems like it should be automatable).

Also added an error message output to the test that directs the user to use the helper!

```markdown
To automatically reorder the keys, run the following command:
python recipe_scrapers/plugins/reorder_json_keys.py

```


jknndy: @jayaddison, moving outside the review discussion as this is a large change. Let me know what you think of these changes.
*tests expected to fail details below.

**adds a new util function `get_abstract_methods`** 
- dynamically gathers the methods from the `AbstractScraper` class excluding `links`, `to_json` & any function prefaced with a _
- currently uses a new import of [`inspect`](https://docs.python.org/3/library/inspect.html#module-inspect) which can be removed with a slight refactor to use `re` but this appears cleaner.
- adds a test for new function with a hard coded list, this will require an update if a new method were to be added.

**_abstract**
- reorders abstract class to match the discussed order but moved `host` to the top of the list. the current setup of the abstract requires that it be first for the implementation to work. I'm not opposed to having the host at the top line of the JSON as it is a key bit of info, but could explore the cause of the requirement for it to be first if anyone disagrees.
- *I did not order all of the test JSON as it'll create a massive diff that can wait till after initial review.

**other**
- implements `get_abstract_methods` in both `reorder_json_keys.py` & `test_json_order.py`
jayaddison:  > * reorders abstract class to match the discussed order but moved `host` to the top of the list. the current setup of the abstract requires that it be first for the implementation to work. I'm not opposed to having the host at the top line of the JSON as it is a key bit of info, but could explore the cause of the requirement for it to be first if anyone disagrees.

It's OK for it to be at the top, but it doesn't have to be - the test suite runs fine when I relocate it locally, and I do think it would be worth figuring out why keeping it at the top seemed necessary (was there an error message / exception that appeared?).
jknndy: > It's OK for it to be at the top, but it doesn't have to be - the test suite runs fine when I relocate it locally, and I do think it would be worth figuring out why keeping it at the top seemed necessary (was there an error message / exception that appeared?).

When I move `host` anywhere lower than directly below the `@classmethod` every scraper produced the error below. 
I tested on a second machine as well to make sure it wasn't an local error.

```
======================================================================
ERROR: test_author (tests.legacy.test_woolworths.TestWoolworthsScraper.test_author)
----------------------------------------------------------------------
Traceback (most recent call last):
  File "/recipe-scrapers/tests/legacy/test_woolworths.py", line 25, in test_author
    self.assertEqual("Fresh Ideas", self.harvester_class.author())
                                    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/recipe-scrapers/recipe_scrapers/plugins/static_values.py", line 46, in decorated_method_wrapper
    return decorated(self, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/recipe-scrapers/recipe_scrapers/plugins/schemaorg_fill.py", line 57, in decorated_method_wrapper
    return decorated(self, *args, **kwargs)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
TypeError: AbstractScraper.author() takes 1 positional argument but 2 were given
```
<details>
  <summary>abstract that produced error</summary>
  
 ```python
    @classmethod
    def author(self):
        """Author of the recipe."""
        raise NotImplementedError("This should be implemented.")

    def canonical_url(self):
        """Canonical or original URL of the recipe."""
        canonical_link = self.soup.find("link", {"rel": "canonical", "href": True})
        if canonical_link:
            return urljoin(self.url, canonical_link["href"])
        return self.url

    def site_name(self):
        """Name of the website."""
        raise NotImplementedError("This should be implemented.")

    def host(cls) -> str:
        """Host domain of the recipe URL."""
        raise NotImplementedError("This should be implemented.")

    def language(self):
        """Language the recipe is written in."""
        candidate_languages = OrderedDict()
        html = self.soup.find("html", {"lang": True})

```
  
</details>
jayaddison: Thanks a lot for working through my suggested changes @jknndy, including some that I think were/are a bit faulty.

In future I might be tempted to refactor some of the script to see whether we can remove the dependency on `recipe_scrapers` entirely -- perhaps using `ast` (not a module I'm hugely familiar with yet).  But unless you'd like to try that yourself here, I think we can merge this after the two remaining items (the print statement and the comparison adjustment) are in place.
jknndy: Just completed all the adjustments discussed above, should be good to go here!

> In future I might be tempted to refactor some of the script to see whether we can remove the dependency on `recipe_scrapers` entirely -- perhaps using `ast` (not a module I'm hugely familiar with yet). But unless you'd like to try that yourself here, I think we can merge this after the two remaining items (the print statement and the comparison adjustment) are in place.

I think this is worth exploring but i think this is ok merging as is




Patch:
diff --git a/recipe_scrapers/_abstract.py b/recipe_scrapers/_abstract.py
index 393c9595..401bfc7d 100644
--- a/recipe_scrapers/_abstract.py
+++ b/recipe_scrapers/_abstract.py
@@ -63,9 +63,8 @@ class AbstractScraper:
                 setattr(self.__class__, name, current_method)
             setattr(self.__class__, "plugins_initialized", True)
 
-    @classmethod
-    def host(cls) -> str:
-        """Host domain of the recipe URL."""
+    def author(self):
+        """Author of the recipe."""
         raise NotImplementedError("This should be implemented.")
 
     def canonical_url(self):
@@ -75,44 +74,13 @@ class AbstractScraper:
             return urljoin(self.url, canonical_link["href"])
         return self.url
 
-    def title(self):
-        """Title of the recipe."""
-        raise NotImplementedError("This should be implemented.")
-
-    def category(self):
-        """Category of the recipe."""
-        raise NotImplementedError("This should be implemented.")
-
-    def total_time(self):
-        """Total time needed to prepare and cook the recipe in minutes."""
-        raise NotImplementedError("This should be implemented.")
-
-    def cook_time(self):
-        """Cooking time in minutes."""
-        raise NotImplementedError("This should be implemented.")
-
-    def prep_time(self):
-        """Preparation time in minutes."""
-        raise NotImplementedError("This should be implemented.")
-
-    def cooking_method(self):
-        """The method of cooking the recipe"""
-        raise NotImplementedError("This should be implemented.")
-
-    def yields(self):
-        """Total servings or items in the recipe."""
-        raise NotImplementedError("This should be implemented.")
-
-    def image(self):
-        """An image URL for the recipe."""
-        raise NotImplementedError("This should be implemented.")
-
-    def nutrients(self):
-        """Nutrients of the recipe."""
+    def site_name(self):
+        """Name of the website."""
         raise NotImplementedError("This should be implemented.")
 
-    def keywords(self):
-        """Keywords or tags used to describe the recipe"""
+    @classmethod
+    def host(cls) -> str:
+        """Host domain of the recipe URL."""
         raise NotImplementedError("This should be implemented.")
 
     def language(self):
@@ -146,6 +114,10 @@ class AbstractScraper:
         else:
             raise ElementNotFoundInHtml("Could not find language.")
 
+    def title(self):
+        """Title of the recipe."""
+        raise NotImplementedError("This should be implemented.")
+
     def ingredients(self):
         """Ingredients of the recipe."""
         raise NotImplementedError("This should be implemented.")
@@ -166,38 +138,70 @@ class AbstractScraper:
             if instruction
         ]
 
-    def ratings(self):
-        """Ratings of the recipe."""
+    def category(self):
+        """Category of the recipe."""
         raise NotImplementedError("This should be implemented.")
 
-    def ratings_count(self):
-        """Total number of ratings of the recipe."""
+    def yields(self):
+        """Total servings or items in the recipe."""
         raise NotImplementedError("This should be implemented.")
 
-    def author(self):
-        """Author of the recipe."""
+    def description(self):
+        """Description of the recipe."""
+        raise NotImplementedError("This should be implemented.")
+
+    def total_time(self):
+        """Total time needed to prepare and cook the recipe in minutes."""
+        raise NotImplementedError("This should be implemented.")
+
+    def cook_time(self):
+        """Cooking time in minutes."""
+        raise NotImplementedError("This should be implemented.")
+
+    def prep_time(self):
+        """Preparation time in minutes."""
         raise NotImplementedError("This should be implemented.")
 
     def cuisine(self):
         """Cuisine of the recipe."""
         raise NotImplementedError("This should be implemented.")
 
-    def description(self):
-        """Description of the recipe."""
+    def cooking_method(self):
+        """The method of cooking the recipe"""
         raise NotImplementedError("This should be implemented.")
 
-    def reviews(self):
-        """Reviews of the recipe."""
+    def ratings(self):
+        """Ratings of the recipe."""
+        raise NotImplementedError("This should be implemented.")
+
+    def ratings_count(self):
+        """Total number of ratings of the recipe."""
         raise NotImplementedError("This should be implemented.")
 
     def equipment(self):
         """Equipment needed for the recipe."""
         raise NotImplementedError("This should be implemented.")
 
+    def reviews(self):
+        """Reviews of the recipe."""
+        raise NotImplementedError("This should be implemented.")
+
+    def nutrients(self):
+        """Nutrients of the recipe."""
+        raise NotImplementedError("This should be implemented.")
+
     def dietary_restrictions(self):
         """The specified dietary restrictions or guidelines for which this recipe is suitable"""
         raise NotImplementedError("This should be implemented.")
 
+    def image(self):
+        """An image URL for the recipe."""
+        raise NotImplementedError("This should be implemented.")
+
+    def keywords(self):
+        """Keywords or tags used to describe the recipe"""
+        raise NotImplementedError("This should be implemented.")
+
     def links(self):
         """Links found in the recipe."""
         invalid_href = {"#", ""}
@@ -205,10 +209,6 @@ class AbstractScraper:
 
         return [link.attrs for link in links_html if link["href"] not in invalid_href]
 
-    def site_name(self):
-        """Name of the website."""
-        raise NotImplementedError("This should be implemented.")
-
     def to_json(self):
         """Recipe information in JSON format."""
         json_dict = {}
diff --git a/recipe_scrapers/_utils.py b/recipe_scrapers/_utils.py
index 6d6fe164..7bd445b7 100644
--- a/recipe_scrapers/_utils.py
+++ b/recipe_scrapers/_utils.py
@@ -1,4 +1,5 @@
 import html
+import inspect
 import math
 import re
 
@@ -311,3 +312,16 @@ def change_keys(obj, convert):
 def get_url_slug(url):
     path = url_path_to_dict(url).get("path")
     return path.split("/")[-1]
+
+
+def get_abstract_methods():
+    from ._abstract import AbstractScraper
+
+    return [
+        name
+        for name, value in AbstractScraper.__dict__.items()  # Attributes of the abstract scraper class..
+        if not name.startswith("_")  # ... that are not private ...
+        and inspect.isfunction(value)  # ... and are functions ...
+        and name not in {"links", "to_json"}  # ... and not excluded as special-cases.
+        or name == "host"  # ... explicitly include the `host` method.
+    ]
diff --git a/scripts/reorder_json_keys.py b/scripts/reorder_json_keys.py
new file mode 100644
index 00000000..863bbada
--- /dev/null
+++ b/scripts/reorder_json_keys.py
@@ -0,0 +1,31 @@
+import json
+import os
+
+from recipe_scrapers._utils import get_abstract_methods
+
+KEYS = get_abstract_methods()
+
+
+def reorder_json_keys(file_path):
+    with open(file_path, encoding="utf-8") as file:
+        data = json.load(file)
+
+    reordered_data = {key: data[key] for key in KEYS if key in data}
+
+    if list(data.keys()) != list(reordered_data.keys()):
+        with open(file_path, "w", encoding="utf-8") as file:
+            print(f"Re-ordering JSON keys for: {file_path}")
+            json.dump(reordered_data, file, indent=2, ensure_ascii=False)
+            file.write("\n")
+
+
+def process_directory(directory):
+    for root, _, files in os.walk(directory):
+        for file in files:
+            if file.endswith(".json"):
+                file_path = os.path.join(root, file)
+                reorder_json_keys(file_path)
+
+
+if __name__ == "__main__":
+    process_directory("tests/test_data")


Test patch:
diff --git a/tests/library/test_json_order.py b/tests/library/test_json_order.py
new file mode 100644
index 00000000..0b71b3a7
--- /dev/null
+++ b/tests/library/test_json_order.py
@@ -0,0 +1,36 @@
+import json
+import os
+import unittest
+
+from recipe_scrapers._utils import get_abstract_methods
+
+
+class TestJsonOrder(unittest.TestCase):
+
+    def test_json_key_order(self):
+        correct_order = get_abstract_methods()
+        json_directory = "tests/test_data/"
+
+        for root, dirs, files in os.walk(json_directory):
+            for filename in files:
+                if filename.endswith(".json"):
+                    with open(os.path.join(root, filename), encoding="utf-8") as f:
+                        data = json.load(f)
+
+                    filtered_data_keys = [key for key in correct_order if key in data]
+
+                    expected_keys = filtered_data_keys
+                    actual_keys = list(data.keys())
+
+                    try:
+                        self.assertEqual(expected_keys, actual_keys)
+                    except AssertionError as e:
+                        recommend_reorder_helper = (
+                            "\n\nTo automatically reorder the keys, run the following command:\n"
+                            "python scripts/reorder_json_keys.py\n"
+                        )
+                        raise AssertionError(
+                            f"File: {os.path.join(root, filename)}\n"
+                            f"{str(e)}"
+                            f"{recommend_reorder_helper}"
+                        ) from e
diff --git a/tests/library/test_utils.py b/tests/library/test_utils.py
index 5a5c7a9e..95b33f95 100644
--- a/tests/library/test_utils.py
+++ b/tests/library/test_utils.py
@@ -2,6 +2,7 @@ import unittest
 
 from recipe_scrapers._utils import (
     _extract_fractional,
+    get_abstract_methods,
     get_minutes,
     get_url_slug,
     url_path_to_dict,
@@ -116,3 +117,35 @@ class TestUtils(unittest.TestCase):
         input_url = "https://example.com/first/second/last"
         url_slug = get_url_slug(input_url)
         self.assertEqual("last", url_slug)
+
+    def test_get_abstract_methods(self):
+        abstract_methods = get_abstract_methods()
+        expected_methods = [
+            "author",
+            "canonical_url",
+            "site_name",
+            "host",
+            "language",
+            "title",
+            "ingredients",
+            "ingredient_groups",
+            "instructions",
+            "instructions_list",
+            "category",
+            "yields",
+            "description",
+            "total_time",
+            "cook_time",
+            "prep_time",
+            "cuisine",
+            "cooking_method",
+            "ratings",
+            "ratings_count",
+            "equipment",
+            "reviews",
+            "nutrients",
+            "dietary_restrictions",
+            "image",
+            "keywords",
+        ]
+        self.assertEqual((expected_methods), (abstract_methods))
diff --git a/tests/test_data/15gram.be/fifteengram.json b/tests/test_data/15gram.be/fifteengram.json
index fdde9d71..bf0ef251 100644
--- a/tests/test_data/15gram.be/fifteengram.json
+++ b/tests/test_data/15gram.be/fifteengram.json
@@ -1,9 +1,10 @@
 {
   "author": "15gram",
   "canonical_url": "https://15gram.be/recepten/mac-n-cheese-met-gehakt-en-pompoen",
+  "site_name": "15gram",
   "host": "15gram.be",
-  "description": "We nemen je mee op skivakantie! Of toch naar de après-ski maaltijd. De pompoenblokjes zijn al voorgesneden en mixen we door de saus. Daardoor kleurt die ook mooi oranje!",
-  "image": "https://image.15gram.be/uploads/recipes/5193_1706264487-1920x1280.jpg",
+  "language": "nl",
+  "title": "Mac 'n cheese met gehakt en pompoen",
   "ingredients": [
     "400 gr pompoenblokjes",
     "1 teentje knoflook",
@@ -42,10 +43,9 @@
     "Giet de pasta af en schik opnieuw in de pot. Meng met de roomsaus en het gehakt. Stort in een ovenschaal en strooi de rest van de kaas erover. Gratineer nog 5-10 min. in de oven voor een mooi kaaskorstje.",
     "TIP: Geef de saus wat extra punch met paprikapoeder, chilivlokken of bouillon."
   ],
-  "language": "nl",
-  "site_name": "15gram",
-  "title": "Mac 'n cheese met gehakt en pompoen",
+  "yields": "2 servings",
+  "description": "We nemen je mee op skivakantie! Of toch naar de après-ski maaltijd. De pompoenblokjes zijn al voorgesneden en mixen we door de saus. Daardoor kleurt die ook mooi oranje!",
   "total_time": 30,
   "cook_time": 30,
-  "yields": "2 servings"
+  "image": "https://image.15gram.be/uploads/recipes/5193_1706264487-1920x1280.jpg"
 }
diff --git a/tests/test_data/aldi.com.au/aldi.json b/tests/test_data/aldi.com.au/aldi.json
index 57408251..765d3e90 100644
--- a/tests/test_data/aldi.com.au/aldi.json
+++ b/tests/test_data/aldi.com.au/aldi.json
@@ -1,9 +1,10 @@
 {
   "author": "ALDI AU",
   "canonical_url": "https://www.aldi.com.au/recipes/breakfast-recipes/overloaded-cheese-toastie-recipe/",
+  "site_name": "Aldi",
   "host": "aldi.com.au",
-  "image": "https://www.aldi.com.au/fileadmin/_processed_/8/b/csm_1000340_WK23_PD_455x315__OVERLOADED-CHEESE-TOASTIE__1_RET_4506d46579.jpg",
-  "category": "Breakfast",
+  "language": "en-AU",
+  "title": "Overloaded Cheese Toastie Recipe",
   "ingredients": [
     "1 tbsp Remano pesto",
     "1 slice Westacre tasty cheese",
@@ -30,11 +31,10 @@
     "Place the sandwich in the press and cook for 3-4 minutes or until the cheese melts and the sandwich is golden brown.",
     "Allow to cool slightly before serving."
   ],
-  "language": "en-AU",
-  "site_name": "Aldi",
-  "title": "Overloaded Cheese Toastie Recipe",
-  "prep_time": 5,
-  "cook_time": 5,
+  "category": "Breakfast",
+  "yields": "1 serving",
   "total_time": 10,
-  "yields": "1 serving"
+  "cook_time": 5,
+  "prep_time": 5,
+  "image": "https://www.aldi.com.au/fileadmin/_processed_/8/b/csm_1000340_WK23_PD_455x315__OVERLOADED-CHEESE-TOASTIE__1_RET_4506d46579.jpg"
 }
diff --git a/tests/test_data/davidlebovitz.com/davidlebovitz.json b/tests/test_data/davidlebovitz.com/davidlebovitz.json
index 22e59f02..ec965704 100644
--- a/tests/test_data/davidlebovitz.com/davidlebovitz.json
+++ b/tests/test_data/davidlebovitz.com/davidlebovitz.json
@@ -53,7 +53,7 @@
     "Scrape the pâté into a small serving bowl and refrigerate for a few hours, until firm."
   ],
   "category": null,
-  "total_time": null,
   "description": "Adapted from Très Green, Très Clean, Très Chic by Rebecca Leffler Lentils double in volume when cooked, so 1 cup (160g) of dried lentils will yield close to the correct amount. They usually take about 20 to 30 minutes to cook until soft, but check the directions on the package for specific guidelines. If avoiding gluten, use tamari instead of soy sauce. For a vegan version, replace the butter with the same quantity of olive oil, for a total of 1/4 cup (60ml) of olive oil. The cognac or brandy is optional, but it does give the faux gras a little je ne sais quoi.",
+  "total_time": null,
   "image": "https://www.davidlebovitz.com/wp-content/uploads/2015/06/Faux-Gras-Lentil-Pate-8.jpg"
 }
diff --git a/tests/test_data/dobruchut.aktuality.sk/dobruchutaktualitysk.json b/tests/test_data/dobruchut.aktuality.sk/dobruchutaktualitysk.json
index 7b54d19e..62318094 100644
--- a/tests/test_data/dobruchut.aktuality.sk/dobruchutaktualitysk.json
+++ b/tests/test_data/dobruchut.aktuality.sk/dobruchutaktualitysk.json
@@ -1,11 +1,10 @@
 {
-  "host": "dobruchut.aktuality.sk",
+  "author": "Howardcooks",
   "canonical_url": "https://dobruchut.aktuality.sk/recept/79940/tvarohove-knedlicky-s-okamzitou-omackou/",
   "site_name": "DobrúChuť.sk",
-  "author": "Howardcooks",
+  "host": "dobruchut.aktuality.sk",
   "language": "sk",
   "title": "Tvarohové knedličky s okamžitou omáčkou",
-  "prep_time": 15,
   "ingredients": [
     "250 g hrudkovitý tvaroh",
     "1 ks vajce",
@@ -45,8 +44,9 @@
     "Dobrú chuť!",
     "Ďalšie overené recepty na chutné gnocchi nájdete TU!"
   ],
-  "total_time": 20,
   "yields": "2 servings",
-  "image": "https://img.aktuality.sk/foto/MHgyMzQ6MjU2MHgxNjc0L2ZpdC1pbi84ODB4NDAwL2ZpbHRlcnM6d2F0ZXJtYXJrKGh0dHA6Ly9sb2NhbGhvc3Q6ODEvaW1nL2RjaDEsLTEwLC0xMCw1MCkvaW1n/-FKw-ZNVSAbTY3mcAAS5LQ.jpg?st=9Dtr7drqJY-DJYKA1up51XKWiqAZ2hLVOvE3M5rbPyo&ts=1720619390&e=0",
-  "description": "Rýchle knedličky z tvarohu s ešte rýchlejšou syrovo-bylinkovou omáčkou."
+  "description": "Rýchle knedličky z tvarohu s ešte rýchlejšou syrovo-bylinkovou omáčkou.",
+  "total_time": 20,
+  "prep_time": 15,
+  "image": "https://img.aktuality.sk/foto/MHgyMzQ6MjU2MHgxNjc0L2ZpdC1pbi84ODB4NDAwL2ZpbHRlcnM6d2F0ZXJtYXJrKGh0dHA6Ly9sb2NhbGhvc3Q6ODEvaW1nL2RjaDEsLTEwLC0xMCw1MCkvaW1n/-FKw-ZNVSAbTY3mcAAS5LQ.jpg?st=9Dtr7drqJY-DJYKA1up51XKWiqAZ2hLVOvE3M5rbPyo&ts=1720619390&e=0"
 }
diff --git a/tests/test_data/kwestiasmaku.com/kwestiasmaku.json b/tests/test_data/kwestiasmaku.com/kwestiasmaku.json
index a094eb4d..c31971e1 100644
--- a/tests/test_data/kwestiasmaku.com/kwestiasmaku.json
+++ b/tests/test_data/kwestiasmaku.com/kwestiasmaku.json
@@ -38,9 +38,9 @@
     "Wstawić do nagrzanego piekarnika i piec przez 30 minut. Po upieczeniu doprawić solą i posiekaną natką pietruszki."
   ],
   "category": null,
-  "total_time": null,
   "yields": "2 servings",
   "description": "To sprawdzony sposób na pieczonego kalafiora :) smakowity w wersji z kurkumą i czarnuszką, ale równie dobry z innym zestawem ziół. Sprawdzą się oregano, rozmaryn, papryka, ale również garam masala oraz przyprawa curry. Warto dodać kurkumę dla koloru. W zasadzie do zapamiętania są tutaj parametry pieczenia, a mianowicie 200 stopni C - 30 minut. Kalafiora pieczemy na dużej blaszce. Kalafiora można posypać po upieczeniu zieleniną, można podać z pokruszoną fetą lub sosem czosnkowym. Sprawdzi się jako przekąska lub dodatek np. do sałatki.",
+  "total_time": null,
   "ratings": 4.77,
   "ratings_count": 52,
   "image": "https://www.kwestiasmaku.com/sites/v123.kwestiasmaku.com/files/pieczony-kalafior-01_0.jpg"
diff --git a/tests/test_data/littlesunnykitchen.com/littlesunnykitchen.json b/tests/test_data/littlesunnykitchen.com/littlesunnykitchen.json
index 871ab915..a1cd4c01 100644
--- a/tests/test_data/littlesunnykitchen.com/littlesunnykitchen.json
+++ b/tests/test_data/littlesunnykitchen.com/littlesunnykitchen.json
@@ -1,8 +1,8 @@
 {
-  "host": "littlesunnykitchen.com",
+  "author": "Diana",
   "canonical_url": "https://littlesunnykitchen.com/mexican-chicken-marinade/",
   "site_name": "Little Sunny Kitchen",
-  "author": "Diana",
+  "host": "littlesunnykitchen.com",
   "language": "en-US",
   "title": "Mexican Chicken Marinade",
   "ingredients": [
@@ -41,11 +41,14 @@
     "Place in the fridge and allow to marinate for 2-24 hours.",
     "Remove the chicken from the bag, and grill it or bake it until the internal temperature of the chicken reaches 165°F/74°C with an Instant read thermometer."
   ],
-  "total_time": 130,
-  "prep_time": 10,
   "category": "Main Course",
   "yields": "6 servings",
+  "description": "Make this fresh Mexican chicken marinade to season all of your favorite cuts of chicken. Made with lime, garlic, olive oil, oregano, and warm spices to give your chicken an amazing flavor!",
+  "total_time": 130,
+  "prep_time": 10,
   "cuisine": "American,Mexican",
+  "ratings": 4.95,
+  "ratings_count": 57,
   "nutrients": {
     "calories": "49 kcal",
     "carbohydrateContent": "2 g",
@@ -57,11 +60,8 @@
     "sugarContent": "1 g",
     "servingSize": "1 serving"
   },
-  "ratings": 4.95,
-  "ratings_count": 57,
+  "image": "https://littlesunnykitchen.com/wp-content/uploads/2021/05/Mexican-Chicken-Marinade-1.jpg",
   "keywords": [
     "Mexican Chicken Marinade"
-  ],
-  "image": "https://littlesunnykitchen.com/wp-content/uploads/2021/05/Mexican-Chicken-Marinade-1.jpg",
-  "description": "Make this fresh Mexican chicken marinade to season all of your favorite cuts of chicken. Made with lime, garlic, olive oil, oregano, and warm spices to give your chicken an amazing flavor!"
+  ]
 }
diff --git a/tests/test_data/receptyprevas.sk/receptyprevas.json b/tests/test_data/receptyprevas.sk/receptyprevas.json
index b242febb..08eca4a6 100644
--- a/tests/test_data/receptyprevas.sk/receptyprevas.json
+++ b/tests/test_data/receptyprevas.sk/receptyprevas.json
@@ -1,8 +1,8 @@
 {
-  "host": "receptyprevas.sk",
+  "author": "Martina",
   "canonical_url": "https://www.receptyprevas.sk/recepty/buchty-na-pare-babickin-recept-na-parene-buchty/",
   "site_name": "Recepty pre Vás",
-  "author": "Martina",
+  "host": "receptyprevas.sk",
   "language": "sk-SK",
   "title": "Buchty na pare, babičkin RECEPT na parené buchty",
   "ingredients": [
@@ -72,14 +72,14 @@
     "Vložíme buchty, nie príliš blízko seba, lebo parením narastú. Dávame pozor, para vie popáliť. Buchty paríme 8-10 minút. Potom pokrievku pomaly odstránime, aby nám kvapky pary nekvapli na buchty, a tak nepľasli. Opatrne ich vyberieme na tanier, popicháme ostrou špajdlou alebo ihlou. Na kvalitnej panvici opražíme maslo.",
     "Hotové buchty podávame ešte teplé, posypané s obľúbenými posýpkami - kakaovou, makovou či orechovou, poliate voňavým opraženým maslom. Ak buchty budeme konzumovať neskôr, potrieme ich olejom a prikryjeme, aby nevyschli. Keď vychladnú, môžeme ich zamraziť, a túto sladkú pochúťku máme hotovú raz-dva."
   ],
-  "total_time": 120,
+  "category": "Bezmäsitá strava,Detské recepty,Hlavné jedlá,Klasické jedlá,Múčne jedlá",
   "yields": "4 servings",
-  "image": "https://www.receptyprevas.sk/wp-content/uploads/2022/05/buchty_na_pare.jpg",
   "description": "Buchty na pare – parené buchty s lekvárom. Skvelá tradičná pochúťka pre deti, ale aj dospelých. Nech sa páči, dnes mám pre vás pripravený babičkin osvedčený recept. Recept je s podrobným fotopostupom, ktorý zvládnu aj začiatočníci. Sladké maškrtenie pre celú rodinu. Posýpky na parené buchty. Môže byť kakaová – Granko alebo kakao zmiešané s práškovým cukrom. Vynikajúca je posýpka orechová či maková – pomletý mak (orechy) zmiešame s práškovým cukrom. Dôležité je však voňavé opražené maslo, ktoré buchty a posýpku prepojí do dokonalej chuti. Dobrá rada. Buchty na pare paríme 8-10 minút. Tento čas potrebujeme, aby boli pekne nadýchané. Po naparení ich popicháme ostrou špajdlou alebo ihlou, uvoľní sa para. Buchty nesmú príliš dlho kysnúť, aby neprekysli. 1 porcia – 4 buchty (recept na 16 buchiet)",
-  "category": "Bezmäsitá strava,Detské recepty,Hlavné jedlá,Klasické jedlá,Múčne jedlá",
+  "total_time": 120,
   "cuisine": "Slovenská",
   "nutrients": {
     "servingSize": "1",
     "calories": "790"
-  }
+  },
+  "image": "https://www.receptyprevas.sk/wp-content/uploads/2022/05/buchty_na_pare.jpg"
 }
diff --git a/tests/test_data/rosannapansino.com/rosannapansino.json b/tests/test_data/rosannapansino.com/rosannapansino.json
index 3591395c..949bf800 100644
--- a/tests/test_data/rosannapansino.com/rosannapansino.json
+++ b/tests/test_data/rosannapansino.com/rosannapansino.json
@@ -33,7 +33,7 @@
     "Oil your hands to prevent the marshmallow from sticking to you. Knead in the sugar until you have the consistency of soft taffy and the fondant no longer sticks to your hands. If you are tinting the fondant, add food coloring now and knead until the color is fully incorporated. (Coat your hands with shortening so the colors don't stain them.)",
     "If you are not using the fondant right away, store it tightly wrapped or in a plastic bag at room temperature."
   ],
-  "total_time": null,
   "description": "A delicious and easy Marshmallow Fondant recipe!\nMakes 24 ounces",
+  "total_time": null,
   "image": "http://rosannapansino.com/cdn/shop/articles/1Y6A6281.jpg?v=1567109086"
 }
diff --git a/tests/test_data/varecha.pravda.sk/varechapravdask.json b/tests/test_data/varecha.pravda.sk/varechapravdask.json
index 76b380c4..6973f5a3 100644
--- a/tests/test_data/varecha.pravda.sk/varechapravdask.json
+++ b/tests/test_data/varecha.pravda.sk/varechapravdask.json
@@ -1,21 +1,10 @@
 {
-  "host": "varecha.pravda.sk",
+  "author": "charllote",
   "canonical_url": "varecha.pravda.sk",
   "site_name": "Varecha.sk",
-  "author": "charllote",
+  "host": "varecha.pravda.sk",
   "language": "sk",
   "title": "Palacinky (fotorecept)",
-  "keywords": [
-    "Bezmäsité jedlá",
-    "Chody",
-    "Dezerty",
-    "Múčne jedlá",
-    "Na sladko",
-    "Palacinky",
-    "Pečené",
-    "* Recepty z Pravdy"
-  ],
-  "prep_time": 10,
   "ingredients": [
     "mlieko, 500 ml",
     "múka hladká, 10 PL",
@@ -42,8 +31,19 @@
     "cesto nám postálo 20 minút skontrolujeme či ma správnu hustotu a môžeme sa pustiť do pečenia :) Vymastíme si panvicu s olejom úplne jemnučko nie veľa oleja a poriadne rozohrejeme panvicu a môžeme piecť.. naozaj je dosť dôležité na akej panvici palacinky pečieme ja som piekla na tefal panvici, čo sa aj odzrkadlilo lebo sa mi ani jedna palacinka nepotrhala ani ta prvá....",
     "upečené palacinky si ochutíme podľa vlastného gusta :) ja dávam do palacinky nutelu, čerstvé ovocie nakrájané jahody,maliny,čučoriedky a šľahačku, keď dávam šľahačku do vnútra je to super fakt a určite to vyskúšajte :) ozdobila som topingom , šľahačkou, ovocím a mätou... Dobrú chuť :)"
   ],
-  "total_time": 10,
   "yields": "15 servings",
+  "description": "Palacinky sa dajú robiť na milión spôsobov - na sladko, na slano, tenké, hrubé, plnené, zapekané, flambované, to samozrejme všetci vieme.... :) Vyskúšala som mnoho receptov ale nedarilo sa a nedávno som našla recept na palacinky ktoré sú naozaj jemné, nadýchané, chutné,nelepia sa ale to samozrejme závisí aj od panvice na ktorej palacinky pečieme :)",
+  "total_time": 10,
+  "prep_time": 10,
   "image": "https://varecha.pravda.sk/usercontent/images/velky-recept66949_1444894775.jpg",
-  "description": "Palacinky sa dajú robiť na milión spôsobov - na sladko, na slano, tenké, hrubé, plnené, zapekané, flambované, to samozrejme všetci vieme.... :) Vyskúšala som mnoho receptov ale nedarilo sa a nedávno som našla recept na palacinky ktoré sú naozaj jemné, nadýchané, chutné,nelepia sa ale to samozrejme závisí aj od panvice na ktorej palacinky pečieme :)"
+  "keywords": [
+    "Bezmäsité jedlá",
+    "Chody",
+    "Dezerty",
+    "Múčne jedlá",
+    "Na sladko",
+    "Palacinky",
+    "Pečené",
+    "* Recepty z Pravdy"
+  ]
 }