From 7822dae7404a86d2e854de2a8cc6b531a8aaf68d Mon Sep 17 00:00:00 2001 From: Jeffrey Belt Date: Sun, 16 Nov 2025 10:02:05 +0000 Subject: [PATCH 1/6] Update to new EventBrite tags, scrape new EventBrite date format, don't crash on addresses without a newline. Signed-off-by: Jeffrey Belt --- .../scraper/eventbrite.py | 48 +++++++++------- .../utils/date_and_time.py | 57 ++++++++++++++++++- .../utils/date_and_time_test.py | 57 ++++++++++++++++++- 3 files changed, 139 insertions(+), 23 deletions(-) diff --git a/src/trouver_une_fresque_scraper/scraper/eventbrite.py b/src/trouver_une_fresque_scraper/scraper/eventbrite.py index 89703d1..659cac4 100644 --- a/src/trouver_une_fresque_scraper/scraper/eventbrite.py +++ b/src/trouver_une_fresque_scraper/scraper/eventbrite.py @@ -15,7 +15,7 @@ from selenium.webdriver.support import expected_conditions as EC from trouver_une_fresque_scraper.db.records import get_record_dict -from trouver_une_fresque_scraper.utils.date_and_time import get_dates +from trouver_une_fresque_scraper.utils.date_and_time import get_dates_from_element from trouver_une_fresque_scraper.utils.errors import ( FreskError, FreskDateBadFormat, @@ -184,12 +184,15 @@ def get_eventbrite_data(sources, service, options): ########################################################### # Is it an online event? ################################################################ - online = False - try: - online_el = driver.find_element(By.CSS_SELECTOR, "p.location-info__address-text") - online = is_online(online_el.text) - except NoSuchElementException: - pass + online = is_online(title) + if not online: + try: + online_el = driver.find_element( + By.CSS_SELECTOR, "span.start-date-and-location__location" + ) + online = is_online(online_el.text) + except NoSuchElementException: + pass ################################################################ # Location data @@ -205,11 +208,10 @@ def get_eventbrite_data(sources, service, options): country_code = "" if not online: - location_el = driver.find_element(By.CSS_SELECTOR, "div.location-info__address") - full_location_text = location_el.text.split("\n") - location_name = full_location_text[0] - address_and_city = full_location_text[1] - full_location = f"{location_name}, {address_and_city}" + location_el = driver.find_element( + By.CSS_SELECTOR, ".start-date-and-location__location" + ) + full_location = location_el.text.replace("\n", ", ") try: address_dict = get_address(full_location) @@ -231,7 +233,7 @@ def get_eventbrite_data(sources, service, options): # Description ################################################################ try: - description_title_el = driver.find_element(By.CSS_SELECTOR, "div.eds-text--left") + description_title_el = driver.find_element(By.CSS_SELECTOR, "div.event-description") description = description_title_el.text except NoSuchElementException: logging.info("Rejecting record: Description not found.") @@ -276,12 +278,13 @@ def get_eventbrite_data(sources, service, options): by=By.CSS_SELECTOR, value="span.date-info__full-datetime", ) - event_time = date_info_el.text except NoSuchElementException: raise FreskDateNotFound try: - event_start_datetime, event_end_datetime = get_dates(event_time) + event_start_datetime, event_end_datetime = get_dates_from_element( + date_info_el + ) except FreskDateBadFormat as error: logging.info(f"Reject record: {error}") continue @@ -307,7 +310,12 @@ def get_eventbrite_data(sources, service, options): if not already_scanned: event_info.append( - [uuid, event_start_datetime, event_end_datetime, tickets_link] + [ + uuid, + event_start_datetime, + event_end_datetime, + tickets_link, + ] ) # There is only one event on this page. @@ -320,13 +328,11 @@ def get_eventbrite_data(sources, service, options): by=By.CSS_SELECTOR, value="span.date-info__full-datetime", ) - event_time = date_info_el.text - except NoSuchElementException as error: - logging.info(f"Reject record: {error}") - continue + except NoSuchElementException: + raise FreskDateNotFound try: - event_start_datetime, event_end_datetime = get_dates(event_time) + event_start_datetime, event_end_datetime = get_dates_from_element(date_info_el) except FreskDateBadFormat as error: logging.info(f"Reject record: {error}") continue diff --git a/src/trouver_une_fresque_scraper/utils/date_and_time.py b/src/trouver_une_fresque_scraper/utils/date_and_time.py index 7269492..b8cf657 100644 --- a/src/trouver_une_fresque_scraper/utils/date_and_time.py +++ b/src/trouver_une_fresque_scraper/utils/date_and_time.py @@ -272,7 +272,7 @@ def get_dates(event_time): FRENCH_MONTHS[match.group("month")], int(match.group("day")), int(start_parts[0]), - int(start_parts[1]) if len(start_parts) > 1 and len(start_parts[1]) else 0, + (int(start_parts[1]) if len(start_parts) > 1 and len(start_parts[1]) else 0), ) end_parts = match.group("end_time").split("h") event_end_datetime = datetime( @@ -291,3 +291,58 @@ def get_dates(event_time): if not isinstance(e, FreskError): traceback.print_exc() raise FreskDateBadFormat(event_time) + + +def get_dates_from_element(el): + """Returns start and end datetime objects extracted from the element. + + The "datetime" attribute of the element is used if present to extract the date, otherwise falls back on get_dates to parse the day and hours from the element text. Returns None, None on failure. + + May throw FreskDateDifferentTimezone, FreskDateBadFormat and any exception thrown by get_dates. + """ + event_day = el.get_attribute("datetime") + event_time = el.text + + # Leverage the datetime attribute if present. + # datetime: 2025-12-05 + # text: déc. 5 de 9am à 12pm UTC+1 + if event_day: + day_match = re.match(r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})", event_day) + # TODO: add support for minutes not 0. + # TODO: add proper support for timezone. + # We use re.search to skip the text for the date at the beginning of the string. + hour_match = re.search( + r"de\s" + r"(?P\d{1,2})(?P[ap]m)\s" + r"à\s" + r"(?P\d{1,2})(?P[ap]m)\s" + r"(UTC(?P.*))?", + event_time, + ) + if day_match and hour_match: + timezone = hour_match.group("timezone") + if timezone and timezone not in ("+1", "+2"): + raise FreskDateDifferentTimezone(event_time) + hour_offset = 12 + dt = datetime( + int(day_match.group("year")), + int(day_match.group("month")), + int(day_match.group("day")), + ) + return datetime( + dt.year, + dt.month, + dt.day, + int(hour_match.group("start_time")) + + (12 if hour_match.group("start_am_or_pm") == "pm" else 0), + 0, + ), datetime( + dt.year, + dt.month, + dt.day, + int(hour_match.group("end_time")) + + (12 if hour_match.group("end_am_or_pm") == "pm" else 0), + 0, + ) + + return get_dates(event_time) diff --git a/src/trouver_une_fresque_scraper/utils/date_and_time_test.py b/src/trouver_une_fresque_scraper/utils/date_and_time_test.py index 38b5290..ac81da7 100644 --- a/src/trouver_une_fresque_scraper/utils/date_and_time_test.py +++ b/src/trouver_une_fresque_scraper/utils/date_and_time_test.py @@ -1,11 +1,12 @@ from datetime import datetime import logging +from attrs import define from trouver_une_fresque_scraper.utils import date_and_time -def run_tests(): +def run_get_dates_tests(): # tuple fields: # 1. Test case name or ID # 2. Input date string @@ -80,3 +81,57 @@ def run_tests(): logging.error(f"{test_case[0]}: expected {test_case[2]} but got {actual_start_time}") if actual_end_time != test_case[3]: logging.error(f"{test_case[0]}: expected {test_case[3]} but got {actual_end_time}") + + +@define +class MockWebDriverElement: + text: str + dt: str | None + + def get_attribute(self, ignored: str) -> str | None: + return self.dt + + +def run_get_dates_from_element_tests(): + # tuple fields: + # 1. Test case name or ID + # 2. Input date string + # 3. Expected output start datetime + # 4. Expected output end datetime + test_cases = [ + ( + "BilletWeb: no datetime, fallback on text parsing", + None, + "Thu Oct 19, 2023 from 01:00 PM to 02:00 PM", + datetime(2023, 10, 19, 13, 0), + datetime(2023, 10, 19, 14, 0), + ), + ( + "EventBrite: morning", + "2025-12-05", + "déc. 5 de 8am à 11am UTC", + datetime(2025, 12, 5, 8, 0), + datetime(2025, 12, 5, 11, 0), + ), + ( + "EventBrite: evening", + "2025-12-12", + "déc. 12 de 6pm à 9pm UTC+1", + datetime(2025, 12, 12, 18, 0), + datetime(2025, 12, 12, 21, 0), + ), + ] + for test_case in test_cases: + logging.info(f"Running {test_case[0]}") + actual_start_time, actual_end_time = date_and_time.get_dates_from_element( + MockWebDriverElement(dt=test_case[1], text=test_case[2]) + ) + if actual_start_time != test_case[3]: + logging.error(f"{test_case[0]}: expected {test_case[3]} but got {actual_start_time}") + if actual_end_time != test_case[4]: + logging.error(f"{test_case[0]}: expected {test_case[4]} but got {actual_end_time}") + + +def run_tests(): + run_get_dates_tests() + run_get_dates_from_element_tests() From 2a7eaa213fb267af99f598f0544ed8326d2ff4d1 Mon Sep 17 00:00:00 2001 From: Obersand <121788640+Obersand@users.noreply.github.com> Date: Tue, 18 Nov 2025 22:29:19 +0100 Subject: [PATCH 2/6] chore: update date and location tags The class starting with `Location-module__addressWrapper___` contains the full address rather than just the address "title" which is the address line. --- src/trouver_une_fresque_scraper/scraper/eventbrite.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/trouver_une_fresque_scraper/scraper/eventbrite.py b/src/trouver_une_fresque_scraper/scraper/eventbrite.py index 659cac4..000fbb7 100644 --- a/src/trouver_une_fresque_scraper/scraper/eventbrite.py +++ b/src/trouver_une_fresque_scraper/scraper/eventbrite.py @@ -188,7 +188,7 @@ def get_eventbrite_data(sources, service, options): if not online: try: online_el = driver.find_element( - By.CSS_SELECTOR, "span.start-date-and-location__location" + By.CSS_SELECTOR, 'div[class^="Location-module__addressWrapper___"' ) online = is_online(online_el.text) except NoSuchElementException: @@ -209,7 +209,7 @@ def get_eventbrite_data(sources, service, options): if not online: location_el = driver.find_element( - By.CSS_SELECTOR, ".start-date-and-location__location" + By.CSS_SELECTOR, 'div[class^="Location-module__addressWrapper___"' ) full_location = location_el.text.replace("\n", ", ") @@ -276,7 +276,7 @@ def get_eventbrite_data(sources, service, options): try: date_info_el = driver.find_element( by=By.CSS_SELECTOR, - value="span.date-info__full-datetime", + value="time.start-date-and-location__date", ) except NoSuchElementException: raise FreskDateNotFound @@ -326,7 +326,7 @@ def get_eventbrite_data(sources, service, options): try: date_info_el = driver.find_element( by=By.CSS_SELECTOR, - value="span.date-info__full-datetime", + value="time.start-date-and-location__date", ) except NoSuchElementException: raise FreskDateNotFound From c94878723f68f73f2dc29d291b0277f9cbcdaa93 Mon Sep 17 00:00:00 2001 From: Jeffrey Belt Date: Fri, 21 Nov 2025 20:17:46 +0000 Subject: [PATCH 3/6] fix: add missing iframe for Fresque de l'Economie Circulaire. Signed-off-by: Jeffrey Belt --- countries/ch.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/countries/ch.json b/countries/ch.json index 52d379d..f07f17c 100644 --- a/countries/ch.json +++ b/countries/ch.json @@ -138,8 +138,9 @@ { "name": "Fresque de l'Economie Circulaire", "url": "https://www.billetweb.fr/pro/lafresquedeleconomiecirculaire", - "language_code": "fr", + "language_code": "fr", "type": "scraper", + "iframe": "event41148", "id": 300 }, { From b86f6aa0107b16ef8085e5c7f0ed740aaf89b2f0 Mon Sep 17 00:00:00 2001 From: Jeffrey Belt Date: Sat, 22 Nov 2025 14:50:25 +0000 Subject: [PATCH 4/6] feat: parse dates in German, parse minutes instead of just the hour. Signed-off-by: Jeffrey Belt --- .../utils/date_and_time.py | 107 +++++++++++------- .../utils/date_and_time_test.py | 14 +++ 2 files changed, 80 insertions(+), 41 deletions(-) diff --git a/src/trouver_une_fresque_scraper/utils/date_and_time.py b/src/trouver_une_fresque_scraper/utils/date_and_time.py index b8cf657..2cff617 100644 --- a/src/trouver_une_fresque_scraper/utils/date_and_time.py +++ b/src/trouver_une_fresque_scraper/utils/date_and_time.py @@ -1,5 +1,6 @@ import re import traceback +import logging from datetime import datetime, timedelta from dateutil.parser import parse @@ -290,6 +291,7 @@ def get_dates(event_time): except Exception as e: if not isinstance(e, FreskError): traceback.print_exc() + logging.error(f"get_dates: {event_time}") raise FreskDateBadFormat(event_time) @@ -303,46 +305,69 @@ def get_dates_from_element(el): event_day = el.get_attribute("datetime") event_time = el.text - # Leverage the datetime attribute if present. - # datetime: 2025-12-05 - # text: déc. 5 de 9am à 12pm UTC+1 - if event_day: - day_match = re.match(r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})", event_day) - # TODO: add support for minutes not 0. - # TODO: add proper support for timezone. - # We use re.search to skip the text for the date at the beginning of the string. - hour_match = re.search( - r"de\s" - r"(?P\d{1,2})(?P[ap]m)\s" - r"à\s" - r"(?P\d{1,2})(?P[ap]m)\s" - r"(UTC(?P.*))?", - event_time, - ) - if day_match and hour_match: - timezone = hour_match.group("timezone") - if timezone and timezone not in ("+1", "+2"): - raise FreskDateDifferentTimezone(event_time) - hour_offset = 12 - dt = datetime( - int(day_match.group("year")), - int(day_match.group("month")), - int(day_match.group("day")), - ) - return datetime( - dt.year, - dt.month, - dt.day, - int(hour_match.group("start_time")) - + (12 if hour_match.group("start_am_or_pm") == "pm" else 0), - 0, - ), datetime( - dt.year, - dt.month, - dt.day, - int(hour_match.group("end_time")) - + (12 if hour_match.group("end_am_or_pm") == "pm" else 0), - 0, + try: + # Leverage the datetime attribute if present. + # datetime: 2025-12-05 + # text: déc. 5 de 9am à 12pm UTC+1 + if event_day: + day_match = re.match(r"(?P\d{4})-(?P\d{1,2})-(?P\d{1,2})", event_day) + + def PATTERN_TIME(hour_name, minute_name, pm_name): + return ( + r"(?P<" + + hour_name + + r">\d{1,2})(?P<" + + minute_name + + r">:\d{2})?(?P<" + + pm_name + + r">(am|pm|vorm.|nachm.))" + ) + + PATTERN_PM = ["pm", "nachm."] + + # TODO: add proper support for timezone. + # We use re.search to skip the text for the date at the beginning of the string. + hour_match = re.search( + r"(de|von)\s" + + PATTERN_TIME("start_hour", "start_minute", "start_am_or_pm") + + r"\s" + + r"(à|bis)\s" + + PATTERN_TIME("end_hour", "end_minute", "end_am_or_pm") + + r"\s" + + r"(UTC(?P.*))", + event_time, ) + if day_match and hour_match: + timezone = hour_match.group("timezone") + if timezone and timezone not in ("+1", "+2"): + raise FreskDateDifferentTimezone(event_time) + dt = datetime( + int(day_match.group("year")), + int(day_match.group("month")), + int(day_match.group("day")), + ) + start_minute = hour_match.group("start_minute") + end_minute = hour_match.group("end_minute") + return datetime( + dt.year, + dt.month, + dt.day, + int(hour_match.group("start_hour")) + + (12 if hour_match.group("start_am_or_pm") in PATTERN_PM else 0), + int(start_minute[1:]) if start_minute else 0, + ), datetime( + dt.year, + dt.month, + dt.day, + int(hour_match.group("end_hour")) + + (12 if hour_match.group("end_am_or_pm") in PATTERN_PM else 0), + int(end_minute[1:]) if end_minute else 0, + ) + + return get_dates(event_time) - return get_dates(event_time) + except Exception as e: + if not isinstance(e, FreskError): + traceback.print_exc() + logging.error(f"get_dates_from_element: {event_time}") + raise FreskDateBadFormat(event_time) diff --git a/src/trouver_une_fresque_scraper/utils/date_and_time_test.py b/src/trouver_une_fresque_scraper/utils/date_and_time_test.py index ac81da7..86177e7 100644 --- a/src/trouver_une_fresque_scraper/utils/date_and_time_test.py +++ b/src/trouver_une_fresque_scraper/utils/date_and_time_test.py @@ -120,6 +120,20 @@ def run_get_dates_from_element_tests(): datetime(2025, 12, 12, 18, 0), datetime(2025, 12, 12, 21, 0), ), + ( + "EventBrite: afternoon in German", + "2024-12-16", + "Dez. 16 von 5nachm. bis 8nachm. UTC", + datetime(2024, 12, 16, 17, 0), + datetime(2024, 12, 16, 20, 0), + ), + ( + "EventBrite: afternoon with minutes in German", + "2024-12-03", + "Dez. 3 von 5:30nachm. bis 8:30nachm. UTC", + datetime(2024, 12, 3, 17, 30), + datetime(2024, 12, 3, 20, 30), + ), ] for test_case in test_cases: logging.info(f"Running {test_case[0]}") From 799a597a2f3d65ea491758cb1267ce2044c9a014 Mon Sep 17 00:00:00 2001 From: Jeffrey Belt Date: Mon, 24 Nov 2025 07:25:29 +0000 Subject: [PATCH 5/6] feat: support one more offline event type. EventBrite events have two locations, a "short" one at the top and a full one with address and a lot more text like routing directions at the bottom. The elements and therefore the CSS selectors vary depending on the event being offline (just text) or online (link to another element on the page). The scraper now detects whether an element is online by searching for the short location (CSS selector `span.start-date-and-location__location`) which is present in both cases. The scraper also doesn't crash if it expects an offline element and cannot find the full location. Instead, it logs an error and continues to the next event. This can happen when the scraper doesn't detect that the event is online. Signed-off-by: Jeffrey Belt --- .../scraper/eventbrite.py | 33 +++++++++++++++---- 1 file changed, 26 insertions(+), 7 deletions(-) diff --git a/src/trouver_une_fresque_scraper/scraper/eventbrite.py b/src/trouver_une_fresque_scraper/scraper/eventbrite.py index 000fbb7..cabd048 100644 --- a/src/trouver_une_fresque_scraper/scraper/eventbrite.py +++ b/src/trouver_une_fresque_scraper/scraper/eventbrite.py @@ -187,10 +187,10 @@ def get_eventbrite_data(sources, service, options): online = is_online(title) if not online: try: - online_el = driver.find_element( - By.CSS_SELECTOR, 'div[class^="Location-module__addressWrapper___"' + short_location_el = driver.find_element( + By.CSS_SELECTOR, "span.start-date-and-location__location" ) - online = is_online(online_el.text) + online = is_online(short_location_el.text) except NoSuchElementException: pass @@ -208,10 +208,16 @@ def get_eventbrite_data(sources, service, options): country_code = "" if not online: - location_el = driver.find_element( - By.CSS_SELECTOR, 'div[class^="Location-module__addressWrapper___"' - ) - full_location = location_el.text.replace("\n", ", ") + try: + full_location_el = driver.find_element( + By.CSS_SELECTOR, 'div[class^="Location-module__addressWrapper___"' + ) + except NoSuchElementException: + logging.error( + f"Location element not found for offline event {link}.", + ) + continue + full_location = full_location_el.text.replace("\n", ", ") try: address_dict = get_address(full_location) @@ -320,6 +326,19 @@ def get_eventbrite_data(sources, service, options): # There is only one event on this page. except TimeoutException: + ################################################################ + # Single event with multiple dates (a "collection"). + ################################################################ + try: + check_availability_btn = driver.find_element( + by=By.CSS_SELECTOR, value="button.check-availability-btn__button" + ) + # TODO: add support for this. + logging.error(f"EventBrite collection not supported in event {link}.") + continue + except NoSuchElementException: + pass + ################################################################ # Dates ################################################################ From 5a1b6257719497bf0072293ce466e61711f81fb1 Mon Sep 17 00:00:00 2001 From: Jeffrey Belt Date: Mon, 24 Nov 2025 08:00:40 +0000 Subject: [PATCH 6/6] fix: don't add 12 hours to 12 PM, parse German UTC (MEZ) Signed-off-by: Jeffrey Belt --- .../utils/date_and_time.py | 39 ++++++++++--------- .../utils/date_and_time_test.py | 16 +++++++- 2 files changed, 36 insertions(+), 19 deletions(-) diff --git a/src/trouver_une_fresque_scraper/utils/date_and_time.py b/src/trouver_une_fresque_scraper/utils/date_and_time.py index 2cff617..4d0d6cb 100644 --- a/src/trouver_une_fresque_scraper/utils/date_and_time.py +++ b/src/trouver_une_fresque_scraper/utils/date_and_time.py @@ -323,7 +323,18 @@ def PATTERN_TIME(hour_name, minute_name, pm_name): + r">(am|pm|vorm.|nachm.))" ) - PATTERN_PM = ["pm", "nachm."] + def ParseTime(match_object, hour_name, minute_name, pm_name): + hour = int(match_object.group(hour_name)) + PATTERN_PM = ["pm", "nachm."] + if match_object.group(pm_name) in PATTERN_PM and hour < 12: + hour += 12 + + minute = 0 + match_minute = hour_match.group(minute_name) + if match_minute: + minute = int(match_minute[1:]) + + return hour, minute # TODO: add proper support for timezone. # We use re.search to skip the text for the date at the beginning of the string. @@ -334,7 +345,7 @@ def PATTERN_TIME(hour_name, minute_name, pm_name): + r"(à|bis)\s" + PATTERN_TIME("end_hour", "end_minute", "end_am_or_pm") + r"\s" - + r"(UTC(?P.*))", + + r"((UTC|MEZ)(?P.*))", event_time, ) if day_match and hour_match: @@ -346,22 +357,14 @@ def PATTERN_TIME(hour_name, minute_name, pm_name): int(day_match.group("month")), int(day_match.group("day")), ) - start_minute = hour_match.group("start_minute") - end_minute = hour_match.group("end_minute") - return datetime( - dt.year, - dt.month, - dt.day, - int(hour_match.group("start_hour")) - + (12 if hour_match.group("start_am_or_pm") in PATTERN_PM else 0), - int(start_minute[1:]) if start_minute else 0, - ), datetime( - dt.year, - dt.month, - dt.day, - int(hour_match.group("end_hour")) - + (12 if hour_match.group("end_am_or_pm") in PATTERN_PM else 0), - int(end_minute[1:]) if end_minute else 0, + start_hour, start_minute = ParseTime( + hour_match, "start_hour", "start_minute", "start_am_or_pm" + ) + end_hour, end_minute = ParseTime( + hour_match, "end_hour", "end_minute", "end_am_or_pm" + ) + return datetime(dt.year, dt.month, dt.day, start_hour, start_minute), datetime( + dt.year, dt.month, dt.day, end_hour, end_minute ) return get_dates(event_time) diff --git a/src/trouver_une_fresque_scraper/utils/date_and_time_test.py b/src/trouver_une_fresque_scraper/utils/date_and_time_test.py index 86177e7..d8baed4 100644 --- a/src/trouver_une_fresque_scraper/utils/date_and_time_test.py +++ b/src/trouver_une_fresque_scraper/utils/date_and_time_test.py @@ -130,10 +130,24 @@ def run_get_dates_from_element_tests(): ( "EventBrite: afternoon with minutes in German", "2024-12-03", - "Dez. 3 von 5:30nachm. bis 8:30nachm. UTC", + "Dez. 3 von 5:30nachm. bis 8:30nachm. MEZ", datetime(2024, 12, 3, 17, 30), datetime(2024, 12, 3, 20, 30), ), + ( + "EventBrite: PM adds 12 to the hours only from 1 PM onwards", + "2025-12-14", + "déc. 14 de 9:30am à 12:30pm UTC+1", + datetime(2025, 12, 14, 9, 30), + datetime(2025, 12, 14, 12, 30), + ), + ( + "EventBrite: start and end minutes differ", + "2026-01-21", + "janv. 21 de 9am à 12:30pm UTC+1", + datetime(2026, 1, 21, 9, 0), + datetime(2026, 1, 21, 12, 30), + ), ] for test_case in test_cases: logging.info(f"Running {test_case[0]}")