Repository: hhursev/recipe-scrapers

Issue:
Support retrieval of author information when it is cross-referenced from schema.org recipe entities
Sometimes, author information for a `schema.org` `Recipe` entity is provided directly within the entity itself, as nested fields/properties.

However, often the metadata we're extracting is presented using [JSON-LD](https://www.w3.org/TR/json-ld/), and some webpages -- particularly ones where the author details may be cross-referenced from multiple entities, and/or where the site would prefer to [normalize](https://en.wikipedia.org/wiki/Database_normalization) the data representation -- will present the `Recipe` entity and a distinct, sibling-level `Person` entity, with a referential link between them.

Strictly speaking I think the referential links should probably be using the `@id` property of the `Person` to link the two; in practice it seems that sometimes the `url` property is used for linkage.

We should begin supporting webpages that present author information in this normalized format, since it [seems to be increasing in popularity](https://github.com/hhursev/recipe-scrapers/pull/891#issuecomment-1752025780).

Discussion:

Patch:
diff --git a/recipe_scrapers/_schemaorg.py b/recipe_scrapers/_schemaorg.py
index a3817fc4..861b8648 100644
--- a/recipe_scrapers/_schemaorg.py
+++ b/recipe_scrapers/_schemaorg.py
@@ -24,13 +24,22 @@ class SchemaOrg:
         itemtypes = itemtype if isinstance(itemtype, list) else [itemtype]
         return schematype.lower() in "\n".join(itemtypes).lower()
 
+    def _find_entity(self, item, schematype):
+        if self._contains_schematype(item, schematype):
+            return item
+        for graph_item in item.get("@graph", []):
+            if self._contains_schematype(graph_item, schematype):
+                return graph_item
+
     def __init__(self, page_data, raw=False):
         if raw:
             self.format = "raw"
             self.data = page_data
+            self.people = {}
             return
         self.format = None
         self.data = {}
+        self.people = {}
 
         data = extruct.extract(
             page_data,
@@ -39,6 +48,15 @@ class SchemaOrg:
             uniform=True,
         )
 
+        # Extract person references
+        for syntax in SYNTAXES:
+            syntax_data = data.get(syntax, [])
+            for item in syntax_data:
+                if person := self._find_entity(item, "Person"):
+                    key = person.get("@id") or person.get("url")
+                    if key:
+                        self.people[key] = person
+
         for syntax in SYNTAXES:
             # make sure entries of type Recipe are always parsed first
             syntax_data = data.get(syntax, [])
@@ -53,18 +71,11 @@ class SchemaOrg:
                     continue
 
                 # If the item itself is a recipe, then use it directly as our datasource
-                if self._contains_schematype(item, "Recipe"):
+                if recipe := self._find_entity(item, "Recipe"):
                     self.format = syntax
-                    self.data = item
+                    self.data = recipe
                     return
 
-                # Check for recipe items within the item's entity graph
-                for graph_item in item.get("@graph", []):
-                    if self._contains_schematype(graph_item, "Recipe"):
-                        self.format = syntax
-                        self.data = graph_item
-                        return
-
                 # If the item is a webpage and describes a recipe entity, use the entity as our datasource
                 if self._contains_schematype(item, "WebPage"):
                     main_entity = item.get("mainEntity", {})
@@ -94,6 +105,10 @@ class SchemaOrg:
             and isinstance(author[0], dict)
         ):
             author = author[0]
+        if author and isinstance(author, dict):
+            author_key = author.get("@id") or author.get("url")
+            if author_key and author_key in self.people:
+                author = self.people[author_key]
         if author and isinstance(author, dict):
             author = author.get("name")
         if author:
diff --git a/recipe_scrapers/addapinch.py b/recipe_scrapers/addapinch.py
index f4629427..54277d49 100644
--- a/recipe_scrapers/addapinch.py
+++ b/recipe_scrapers/addapinch.py
@@ -10,7 +10,7 @@ class AddAPinch(AbstractScraper):
         return "addapinch.com"
 
     def author(self):
-        return self.soup.find("meta", {"name": "author"}).get("content")
+        return self.schema.author()
 
     def title(self):
         return self.schema.title()
diff --git a/recipe_scrapers/bakingmischief.py b/recipe_scrapers/bakingmischief.py
index 9c487f7b..aa4ae432 100644
--- a/recipe_scrapers/bakingmischief.py
+++ b/recipe_scrapers/bakingmischief.py
@@ -9,7 +9,7 @@ class BakingMischief(AbstractScraper):
         return "bakingmischief.com"
 
     def author(self):
-        return self.soup.find("meta", {"name": "author"}).get("content")
+        return self.schema.author()
 
     def title(self):
         return self.schema.title()
diff --git a/recipe_scrapers/bakingsense.py b/recipe_scrapers/bakingsense.py
index 1b7c9e01..8725f513 100644
--- a/recipe_scrapers/bakingsense.py
+++ b/recipe_scrapers/bakingsense.py
@@ -12,7 +12,7 @@ class BakingSense(AbstractScraper):
         return self.schema.title()
 
     def author(self):
-        return self.soup.find("meta", {"name": "author"}).get("content")
+        return self.schema.author()
 
     def total_time(self):
         return self.schema.total_time()
diff --git a/recipe_scrapers/bowlofdelicious.py b/recipe_scrapers/bowlofdelicious.py
index 12d31239..5f4bc0c7 100644
--- a/recipe_scrapers/bowlofdelicious.py
+++ b/recipe_scrapers/bowlofdelicious.py
@@ -10,6 +10,9 @@ class BowlOfDelicious(AbstractScraper):
     def title(self):
         return self.schema.title()
 
+    def author(self):
+        return self.schema.author()
+
     def total_time(self):
         return self.schema.total_time()
 
diff --git a/recipe_scrapers/budgetbytes.py b/recipe_scrapers/budgetbytes.py
index e0c1b52d..d9126b93 100644
--- a/recipe_scrapers/budgetbytes.py
+++ b/recipe_scrapers/budgetbytes.py
@@ -11,6 +11,9 @@ class BudgetBytes(AbstractScraper):
     def title(self):
         return self.schema.title()
 
+    def author(self):
+        return self.schema.author()
+
     def total_time(self):
         return self.schema.total_time()
 
diff --git a/recipe_scrapers/castironketo.py b/recipe_scrapers/castironketo.py
index 2bec2e22..d4d1ca48 100644
--- a/recipe_scrapers/castironketo.py
+++ b/recipe_scrapers/castironketo.py
@@ -10,6 +10,9 @@ class CastIronKeto(AbstractScraper):
     def title(self):
         return self.schema.title()
 
+    def author(self):
+        return self.schema.author()
+
     def total_time(self):
         return self.schema.total_time()
 
diff --git a/recipe_scrapers/copykat.py b/recipe_scrapers/copykat.py
index c047ddbd..e6f2fcaa 100644
--- a/recipe_scrapers/copykat.py
+++ b/recipe_scrapers/copykat.py
@@ -18,6 +18,9 @@ class CopyKat(AbstractScraper):
     def title(self):
         return self.schema.title()
 
+    def author(self):
+        return self.schema.author()
+
     def total_time(self):
         return self.schema.total_time()
 
diff --git a/recipe_scrapers/ditchthecarbs.py b/recipe_scrapers/ditchthecarbs.py
index a4442996..2dc217dc 100644
--- a/recipe_scrapers/ditchthecarbs.py
+++ b/recipe_scrapers/ditchthecarbs.py
@@ -10,6 +10,9 @@ class DitchTheCarbs(AbstractScraper):
     def title(self):
         return self.schema.title()
 
+    def author(self):
+        return self.schema.author()
+
     def total_time(self):
         return self.schema.total_time()
 
diff --git a/recipe_scrapers/fifteenspatulas.py b/recipe_scrapers/fifteenspatulas.py
index 4b71e0b7..7d048840 100644
--- a/recipe_scrapers/fifteenspatulas.py
+++ b/recipe_scrapers/fifteenspatulas.py
@@ -10,6 +10,9 @@ class FifteenSpatulas(AbstractScraper):
     def title(self):
         return self.schema.title()
 
+    def author(self):
+        return self.schema.author()
+
     def total_time(self):
         return self.schema.total_time()
 
diff --git a/recipe_scrapers/norecipes.py b/recipe_scrapers/norecipes.py
index c43f31d1..b03d9b2d 100644
--- a/recipe_scrapers/norecipes.py
+++ b/recipe_scrapers/norecipes.py
@@ -10,7 +10,7 @@ class NoRecipes(AbstractScraper):
         return "norecipes.com"
 
     def author(self):
-        return self.soup.find("meta", {"name": "author"}).get("content")
+        return self.schema.author()
 
     def title(self):
         return self.schema.title()


Test patch:
diff --git a/tests/test_addapinch_1.py b/tests/test_addapinch_1.py
index 34afd452..7cc722bc 100644
--- a/tests/test_addapinch_1.py
+++ b/tests/test_addapinch_1.py
@@ -13,7 +13,7 @@ class TestAddAPinchScraper(ScraperTest):
         self.assertEqual("addapinch.com", self.harvester_class.host())
 
     def test_author(self):
-        self.assertEqual("Robyn Stone", self.harvester_class.author())
+        self.assertEqual("Robyn Stone | Add a Pinch", self.harvester_class.author())
 
     def test_title(self):
         self.assertEqual(
diff --git a/tests/test_budgetbytes.py b/tests/test_budgetbytes.py
index 6d4c87c8..6d705dbf 100644
--- a/tests/test_budgetbytes.py
+++ b/tests/test_budgetbytes.py
@@ -25,7 +25,7 @@ class TestBudgetBytesScraper(ScraperTest):
         )
 
     def test_author(self):
-        self.assertEqual(self.harvester_class.author(), None)
+        self.assertEqual(self.harvester_class.author(), "Monti - Budget Bytes")
 
     def test_total_time(self):
         self.assertEqual(40, self.harvester_class.total_time())
diff --git a/tests/test_maangchi.py b/tests/test_maangchi.py
index 82b582a9..3de6aa13 100644
--- a/tests/test_maangchi.py
+++ b/tests/test_maangchi.py
@@ -12,7 +12,7 @@ class TestMaangchiScraper(ScraperTest):
         self.assertEqual("maangchi.com", self.harvester_class.host())
 
     def test_author(self):
-        self.assertEqual(None, self.harvester_class.author())
+        self.assertEqual("Maangchi", self.harvester_class.author())
 
     def test_title(self):
         self.assertEqual(