diff --git a/src/django_project/tests/unit/web/test_tasks.py b/src/django_project/tests/unit/web/test_tasks.py index 3b98804..dbebf3b 100644 --- a/src/django_project/tests/unit/web/test_tasks.py +++ b/src/django_project/tests/unit/web/test_tasks.py @@ -12,7 +12,8 @@ django.setup() from model_bakery import baker from web.models import Event -from web.tasks import ingest_future_eventbrite_events, post_event_to_linkedin +from web.tasks import ingest_future_eventbrite_events, ingest_future_meetup_events, post_event_to_linkedin +from web.utilities.scrapers.meetup import get_event_information class TestIngestFutureEventbriteEvents(TestCase): @@ -109,6 +110,76 @@ def test_truncates_eventbrite_fields_to_model_limits(self, mock_get_events_for_o self.assertEqual(event.location_address, long_location_address[:256]) +class TestMeetupEventInformation(TestCase): + @patch("web.utilities.scrapers.meetup.fetch_content_with_playwright") + def test_reads_venue_when_meetup_json_field_order_changes(self, mock_fetch_content): + mock_fetch_content.return_value = """ + + """ + + event = get_event_information("https://www.meetup.com/example/events/123456/") + + self.assertEqual(event["location_name"], "Startup Spokane") + self.assertEqual(event["location_address"], "25 W Main Ave, Spokane, WA, US") + + @patch("web.utilities.scrapers.meetup.fetch_content_with_playwright") + def test_leaves_missing_or_tbd_venue_blank(self, mock_fetch_content): + mock_fetch_content.return_value = """ + + """ + + event = get_event_information("https://www.meetup.com/example/events/123456/") + + self.assertEqual(event["location_name"], "") + self.assertEqual(event["location_address"], "") + + @patch("web.utilities.scrapers.meetup.fetch_content_with_playwright") + def test_leaves_location_blank_when_venue_is_absent(self, mock_fetch_content): + mock_fetch_content.return_value = '' + + event = get_event_information("https://www.meetup.com/example/events/123456/") + + self.assertEqual(event["location_name"], "") + self.assertEqual(event["location_address"], "") + + +class TestIngestFutureMeetupEvents(TestCase): + def setUp(self): + self.platform = baker.make("web.SocialPlatform", name="Meetup") + self.group = baker.make("web.TechGroup", name="Test Meetup Group", platform=self.platform) + self.link = baker.make( + "web.Link", + name=f"{self.group.name} {self.group.platform.name} page", + url="https://www.meetup.com/test-meetup-group", + ) + self.group.links.add(self.link) + + @patch("web.tasks.get_event_information") + @patch("web.tasks.get_event_links") + def test_truncates_venue_fields_before_saving(self, mock_get_event_links, mock_get_event_information): + mock_get_event_links.return_value = ["https://www.meetup.com/test-meetup-group/events/123456/"] + mock_get_event_information.return_value = { + "name": "Test event", + "url": "https://www.meetup.com/test-meetup-group/events/123456/", + "social_platform_id": "123456", + "start_datetime": "2026-09-20T18:00:00Z", + "location_name": "N" * 65, + "location_address": "A" * 257, + } + + ingest_future_meetup_events(self.group.pk) + + event = Event.objects.get(social_platform_id="123456") + self.assertEqual(event.location_name, "N" * 64) + self.assertEqual(event.location_address, "A" * 256) + + class TestPostEventToLinkedIn(TestCase): def test_skips_when_post_to_linkedin_setting_is_false(self): event = baker.make("web.Event") diff --git a/src/django_project/web/tasks.py b/src/django_project/web/tasks.py index 333730e..c6ce1f9 100644 --- a/src/django_project/web/tasks.py +++ b/src/django_project/web/tasks.py @@ -116,6 +116,10 @@ def ingest_future_meetup_events(group_pk) -> str: event_info.setdefault("location_name", "") event_info.setdefault("location_address", "") event_info.setdefault("map_link", "") + event_info["location_name"] = _truncate_for_model(Event, "location_name", event_info["location_name"]) + event_info["location_address"] = _truncate_for_model( + Event, "location_address", event_info["location_address"] + ) if not event_info.get("name", None): logger.error("error parsing name for event hosted by %s; data = %s", group.name, event_info) continue diff --git a/src/django_project/web/utilities/scrapers/meetup.py b/src/django_project/web/utilities/scrapers/meetup.py index 4bcab7e..372ecf0 100644 --- a/src/django_project/web/utilities/scrapers/meetup.py +++ b/src/django_project/web/utilities/scrapers/meetup.py @@ -1,6 +1,7 @@ from __future__ import annotations import html +import json import re from datetime import datetime, timedelta, timezone from typing import Any @@ -10,6 +11,60 @@ from web.utilities.html_utils import fetch_content, fetch_content_with_playwright +_TBD_LOCATION_NAMES = {"tbd", "tdb", "to be determined"} + + +def _iter_mappings(value: Any): + """Yield every dictionary contained in a decoded Meetup data payload.""" + if isinstance(value, dict): + yield value + for child in value.values(): + yield from _iter_mappings(child) + elif isinstance(value, list): + for child in value: + yield from _iter_mappings(child) + + +def _get_venue(page_content: str, soup: BeautifulSoup) -> dict[str, Any] | None: + """Find Meetup's Venue object without depending on JSON field order.""" + for script in soup.find_all("script"): + if not script.string: + continue + try: + payload = json.loads(script.string) + except json.JSONDecodeError: + continue + for item in _iter_mappings(payload): + if item.get("__typename") == "Venue": + return item + + # Meetup sometimes embeds serialized JSON in its page data rather than a + # standalone JSON script. Decode the Venue object when it is available. + venue_match = re.search(r'\{[^{}]*"__typename"\s*:\s*"Venue"[^{}]*\}', page_content) + if venue_match: + try: + return json.loads(venue_match.group()) + except json.JSONDecodeError: + pass + return None + + +def _format_venue_address(venue: dict[str, Any]) -> str: + """Return the display address supplied by Meetup, or an empty string.""" + address = venue.get("address") + if isinstance(address, dict): + return str(address.get("localized_address_display") or address.get("display") or "") + if address: + country = venue.get("country") + parts = [ + address, + venue.get("city"), + venue.get("state"), + country.upper() if isinstance(country, str) else country, + ] + return ", ".join(str(part) for part in parts if part) + return "" + def get_end_datetime(datetime_string: str, time_string: str) -> datetime | None: """create a datetime object with timezone information from information parsed from a meetup.com event page @@ -142,27 +197,25 @@ def get_event_information(url: str) -> dict: else: event_info["end_datetime"] = None - location_name: str | Any = None - match = re.search(r'"__typename":"Venue","id":"\d+","name":"([^"]+)"', page_content) - if match: - location_name = match.group(1) - if not location_name: + venue = _get_venue(page_content, soup) + location_name: str = "" + location_address: str = "" + is_tbd_location = False + if venue: + location_name = str(venue.get("name") or "").strip() + location_address = _format_venue_address(venue) + + # Meetup uses TBD for events whose physical location is not yet + # announced. Treat it as no location rather than displaying it. + if location_name.casefold() in _TBD_LOCATION_NAMES: + is_tbd_location = True + location_name = "" + location_address = "" + if not location_name and not is_tbd_location: online_p = soup.find("p", class_="ds2-k16 text-ds2-text-fill-primary-enabled") if online_p and online_p.get_text(strip=True) == "Online event": location_name = "Online event" event_info["location_name"] = location_name - - location_address: str = "" - address_match: re.Match[str] | None = re.search( - r'"__typename":"Venue","id":"\d+","name":"[^"]+","address":"([^"]+)","city":"([^"]+)","state":"([^"]+)","country":"([^"]+)"', - page_content, - ) - if address_match: - street: str | Any = address_match.group(1) - city: str | Any = address_match.group(2) - state: str | Any = address_match.group(3) - country: str | Any = address_match.group(4) - location_address = f"{street}, {city}, {state}, {country.upper()}" event_info["location_address"] = location_address map_link: str = ""