Repository: alltheplaces/alltheplaces

Issue:
LinkedDataParser fails to parse valid opening hours
Specific sub case of https://github.com/alltheplaces/alltheplaces/issues/4241

https://validator.schema.org/#url=https%3A%2F%2Fwww.newseasonsmarket.com%2Ffind-a-store%2Fwilliams which validates it.

Structure is:
```
  "openingHoursSpecification": {
    "@type": "OpeningHoursSpecification",
    "dayOfWeek": [
      "Monday",
      "Tuesday",
      "Wednesday",
      "Thursday",
      "Friday",
      "Saturday",
      "Sunday"
    ],
    "opens": "07:00",
    "closes": "22:00"
  },
```

Fails with:
```
    def from_linked_data(self, linked_data, time_format: str = "%H:%M"):
        if linked_data.get("openingHoursSpecification"):
            for rule in linked_data["openingHoursSpecification"]:
                if not isinstance(rule, dict):
```

rule is a str here, as it's a key from the dict.



Discussion:

Resolving patch:
diff --git a/locations/hours.py b/locations/hours.py
--- a/locations/hours.py
+++ b/locations/hours.py
@@ -1,3 +1,4 @@
+import logging
 import re
 import time
 from collections import defaultdict
@@ -603,6 +604,8 @@
 
 DELIMITERS_RU = DELIMITERS_EN + ["с", "по", "до", "в", "во"]
 
+logger = logging.getLogger(__name__)
+
 
 def day_range(start_day, end_day):
     start_ix = DAYS.index(sanitise_day(start_day))
@@ -702,27 +705,77 @@ def as_opening_hours(self) -> str:
 
         return opening_hours
 
-    def from_linked_data(self, linked_data, time_format: str = "%H:%M"):
-        if linked_data.get("openingHoursSpecification"):
-            for rule in linked_data["openingHoursSpecification"]:
-                if not isinstance(rule, dict):
-                    continue
-                if not rule.get("dayOfWeek") or not rule.get("opens") or not rule.get("closes"):
-                    continue
+    # Parses an openingHoursSpecification dict
+    # IE:
+    # {
+    #  "@type": "OpeningHoursSpecification",
+    #  "closes":  "17:00:00",
+    #  "dayOfWeek": "https://schema.org/Sunday",
+    #  "opens":  "09:00:00"
+    # }
+    # See https://schema.org/OpeningHoursSpecification for further examples.
+    def _parse_opening_hours_specification(self, rule: dict, time_format: str):
+        if not rule.get("dayOfWeek") or not rule.get("opens") or not rule.get("closes"):
+            return
 
-                days = rule["dayOfWeek"]
-                if not isinstance(days, list):
-                    days = [days]
+        days = rule["dayOfWeek"]
+        if not isinstance(days, list):
+            days = [days]
 
-                for day in days:
-                    self.add_range(
-                        day=day,
-                        open_time=rule["opens"],
-                        close_time=rule["closes"],
-                        time_format=time_format,
-                    )
-        elif linked_data.get("openingHours"):
-            rules = linked_data["openingHours"]
+        for day in days:
+            self.add_range(
+                day=day,
+                open_time=rule["opens"],
+                close_time=rule["closes"],
+                time_format=time_format,
+            )
+
+    # Parse an individual https://schema.org/openingHours property value such as
+    # "Mo,Tu,We,Th 09:00-12:00"
+    # "Mo-Fr 10:00-19:00"
+    def _parse_opening_hours(self, rule: str, time_format: str):
+        days, time_ranges = rule.split(" ", 1)
+
+        if "-" not in time_ranges:
+            return
+
+        for time_range in time_ranges.split(","):
+            start_time, end_time = time_range.split("-")
+
+            start_time = start_time.strip()
+            end_time = end_time.strip()
+
+            if "-" in days:
+                start_day, end_day = days.split("-")
+
+                start_day = sanitise_day(start_day)
+                end_day = sanitise_day(end_day)
+
+                for day in day_range(start_day, end_day):
+                    self.add_range(day, start_time, end_time, time_format)
+            else:
+                for day in days.split(","):
+                    if d := sanitise_day(day):
+                        self.add_range(d, start_time, end_time, time_format)
+
+    def from_linked_data(self, linked_data, time_format: str = "%H:%M"):
+        """
+        Deprecated, please use LinkedDataParser.parse_opening_hours
+        """
+        # TODO: move to LinkedDataParser
+        if spec := linked_data.get("openingHoursSpecification"):
+            if isinstance(spec, list):
+                for rule in linked_data["openingHoursSpecification"]:
+                    if not isinstance(rule, dict):
+                        continue
+                    self._parse_opening_hours_specification(rule, time_format)
+            elif isinstance(spec, dict):
+                self._parse_opening_hours_specification(spec, time_format)
+            else:
+                logger.info("Unknown openingHoursSpecification structure, ignoring")
+                logger.debug(linked_data.get("openingHoursSpecification"))
+
+        elif rules := linked_data.get("openingHours"):
             if not isinstance(rules, list):
                 rules = re.findall(
                     r"((\w{2,3}|\w{2,3}\s?\-\s?\w{2,3}|(\w{2,3},)+\w{2,3})\s(\d\d:\d\d)\s?\-\s?(\d\d:\d\d))",
@@ -733,29 +786,8 @@ def from_linked_data(self, linked_data, time_format: str = "%H:%M"):
             for rule in rules:
                 if not rule:
                     continue
-                days, time_ranges = rule.split(" ", 1)
 
-                if "-" not in time_ranges:
-                    continue
-
-                for time_range in time_ranges.split(","):
-                    start_time, end_time = time_range.split("-")
-
-                    start_time = start_time.strip()
-                    end_time = end_time.strip()
-
-                    if "-" in days:
-                        start_day, end_day = days.split("-")
-
-                        start_day = sanitise_day(start_day)
-                        end_day = sanitise_day(end_day)
-
-                        for day in day_range(start_day, end_day):
-                            self.add_range(day, start_time, end_time, time_format)
-                    else:
-                        for day in days.split(","):
-                            if d := sanitise_day(day):
-                                self.add_range(d, start_time, end_time, time_format)
+                self._parse_opening_hours(rule, time_format)
 
     @staticmethod
     def delimiters_regex(delimiters: list[str] = DELIMITERS_EN) -> str:
diff --git a/locations/linked_data_parser.py b/locations/linked_data_parser.py
--- a/locations/linked_data_parser.py
+++ b/locations/linked_data_parser.py
@@ -1,4 +1,6 @@
 import json
+import logging
+import traceback
 
 import chompjs
 import json5
@@ -6,6 +8,8 @@
 from locations.hours import OpeningHours
 from locations.items import Feature, add_social_media
 
+logger = logging.getLogger(__name__)
+
 
 class LinkedDataParser:
     @staticmethod
@@ -116,8 +120,12 @@ def parse_ld(ld, time_format: str = "%H:%M") -> Feature:  # noqa: C901
             oh = OpeningHours()
             oh.from_linked_data(ld, time_format=time_format)
             item["opening_hours"] = oh
-        except:
-            pass
+        except ValueError as e:
+            # Explicitly handle a ValueError, which is likely time_format related
+            logger.warning(f"Unable to parse opening hours - check time_format? Error was: {str(e)}")
+        except Exception as e:
+            logger.warning(f"Unhandled error, unable to parse opening hours. Error was: {type(e)} {str(e)}")
+            logger.debug(traceback.print_exc())
 
         if image := ld.get("image"):
             if isinstance(image, list):
@@ -163,6 +171,12 @@ def parse(response, wanted_type, json_parser="json") -> Feature:
 
             return item
 
+    @staticmethod
+    def parse_opening_hours(linked_data, time_format: str = "%H:%M") -> OpeningHours:
+        oh = OpeningHours()
+        oh.from_linked_data(linked_data, time_format)
+        return oh
+
     @staticmethod
     def get_clean(obj, key):
         if value := obj.get(key):
diff --git a/locations/spiders/new_seasons_market_us.py b/locations/spiders/new_seasons_market_us.py
new file mode 100644
--- /dev/null
+++ b/locations/spiders/new_seasons_market_us.py
@@ -0,0 +1,17 @@
+from scrapy.spiders import SitemapSpider
+
+from locations.structured_data_spider import StructuredDataSpider
+
+
+class NewSeasonsMarketUSSpider(SitemapSpider, StructuredDataSpider):
+    name = "new_seasons_market_us"
+    item_attributes = {"brand": "New Seasons Market", "brand_wikidata": "Q7011463"}
+    sitemap_urls = ["https://www.newseasonsmarket.com/robots.txt"]
+    sitemap_rules = [(r"/find-a-store/[\w-]+$", "parse_sd")]
+    wanted_types = ["GroceryStore"]
+    search_for_facebook = False
+
+    def post_process_item(self, item, response, ld_data, **kwargs):
+        item["branch"] = response.xpath('//h1[@class="h2"]/text()').get()
+
+        yield item


Test patch:
diff --git a/tests/test_ld_parser.py b/tests/test_ld_parser.py
--- a/tests/test_ld_parser.py
+++ b/tests/test_ld_parser.py
@@ -142,6 +142,120 @@ def test_ld_lowercase_attributes():
     assert i["lon"] == -99.08411
 
 
+def test_ld_opening_hours_specification_as_dict():
+    i = LinkedDataParser.parse_ld(
+        {
+            "@context": "https://schema.org",
+            "@type": "GroceryStore",
+            "name": "New Seasons Market",
+            "image": "https://www.newseasonsmarket.com/getattachment/1fcd06e5-9cc4-4f64-b887-4b2e169db283/williams_1080x1000_v3.jpg?lang=en-US&ext=.jpg",
+            "@id": "https://www.newseasonsmarket.com/find-a-store/williams",
+            "url": "https://www.newseasonsmarket.com/find-a-store/williams",
+            "telephone": "(503) 528-2888",
+            "priceRange": "$$",
+            "address": {
+                "@type": "PostalAddress",
+                "streetAddress": "3445 N Williams Ave",
+                "addressLocality": "Portland",
+                "addressRegion": "OR",
+                "postalCode": "97227",
+                "addressCountry": "US",
+            },
+            "geo": {"@type": "GeoCoordinates", "latitude": 45.5481269199285600, "longitude": -122.6673199714278200},
+            "openingHoursSpecification": {
+                "@type": "OpeningHoursSpecification",
+                "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
+                "opens": "07:00",
+                "closes": "22:00",
+            },
+            "department": {
+                "@type": "Restaurant",
+                "name": "Hot Bar",
+                "image": "",
+                "telephone": "(503) 528-2888",
+                "openingHoursSpecification": {
+                    "@type": "OpeningHoursSpecification",
+                    "dayOfWeek": ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"],
+                    "opens": "07:00",
+                    "closes": "22:00",
+                },
+            },
+        }
+    )
+
+    assert i["opening_hours"].as_opening_hours() == "Mo-Su 07:00-22:00"
+
+
+def test_ld_opening_hours_specification_as_list():
+    i = LinkedDataParser.parse_ld(
+        ld={
+            "@context": ["https://schema.org", {"@language": "en-ca"}],
+            "@type": "Library",
+            "address": {
+                "@id": "_:school_edu_library_1",
+                "@type": "PostalAddress",
+                "addressCountry": "Canada",
+                "addressLocality": "Sudbury",
+                "addressRegion": "ON",
+                "contactType": "Mailing address",
+                "postalCode": "P3E 2C6",
+                "streetAddress": "School of Education - Music Resource Centre Laurentian University",
+            },
+            "email": "mailto:dscott@laurentian.ca",
+            "location": {"@id": "_:school_edu_library_1"},
+            "name": "Music Resource Centre",
+            "parentOrganization": "https://laurentian.concat.ca/eg/opac/library/LUSYS",
+            "openingHoursSpecification": [
+                {
+                    "@type": "OpeningHoursSpecification",
+                    "closes": "17:00:00",
+                    "dayOfWeek": "https://schema.org/Sunday",
+                    "opens": "09:00:00",
+                },
+                {
+                    "@type": "OpeningHoursSpecification",
+                    "closes": "17:00:00",
+                    "dayOfWeek": "https://schema.org/Saturday",
+                    "opens": "09:00:00",
+                },
+                {
+                    "@type": "OpeningHoursSpecification",
+                    "closes": "17:00:00",
+                    "dayOfWeek": "https://schema.org/Thursday",
+                    "opens": "09:00:00",
+                },
+                {
+                    "@type": "OpeningHoursSpecification",
+                    "closes": "17:00:00",
+                    "dayOfWeek": "https://schema.org/Tuesday",
+                    "opens": "09:00:00",
+                },
+                {
+                    "@type": "OpeningHoursSpecification",
+                    "closes": "17:00:00",
+                    "dayOfWeek": "https://schema.org/Friday",
+                    "opens": "09:00:00",
+                },
+                {
+                    "@type": "OpeningHoursSpecification",
+                    "closes": "17:00:00",
+                    "dayOfWeek": "https://schema.org/Monday",
+                    "opens": "09:00:00",
+                },
+                {
+                    "@type": "OpeningHoursSpecification",
+                    "closes": "17:00:00",
+                    "dayOfWeek": "https://schema.org/Wednesday",
+                    "opens": "09:00:00",
+                },
+            ],
+        },
+        time_format="%H:%M:%S",
+    )
+
+    assert i["opening_hours"].as_opening_hours() == "Mo-Su 09:00-17:00"
+
+
 def test_ld_lat_lon():
     i = LinkedDataParser.parse_ld(
         json.loads(