Skip to content

Commit 0abacb8

Browse files
committed
fix(events): populate Discord event Location; use meetup URL as description
Meetup's iCal feed ships no LOCATION field, so the venue is scraped from the event page's JSON-LD. The scrape never worked: the regex required a bare <script type="application/ld+json"> tag, but Meetup emits data-next-head="" alongside it, so every match failed and events fell back to the "See event details" placeholder. - Match ld+json script tags with any attributes/quote style - Extract _parse_jsonld_location: handles Place/PostalAddress, an address given as a plain string, a list of locations, and VirtualLocation (URL-only, which strip_urls would blank and Discord rejects) - Sync location/description on existing events, not just the title. The match branch returned early whenever the name was unchanged, so events created before this fix stayed on the placeholder forever. Location is only written when a scrape succeeded, so a transient failure can't clobber a good venue. - Event description is now the bare meetup URL (bare, not masked markdown, which scheduled-event descriptions don't render) Verified against the live BurbSec feed: all 8 upcoming events resolve a real venue, longest 66 chars.
1 parent 37d53d5 commit 0abacb8

1 file changed

Lines changed: 95 additions & 30 deletions

File tree

‎commands.py‎

Lines changed: 95 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -770,11 +770,13 @@ async def _scrape_event_page(self, session,
770770
return None
771771
html = await response.text()
772772

773-
# Extract JSON-LD Event data
773+
# Extract JSON-LD Event data. The script tag carries extra
774+
# attributes on Meetup (data-next-head=""), so match any
775+
# attribute order/content rather than an exact tag.
774776
ld_matches = re.findall(
775-
r'<script type="application/ld\+json">'
776-
r'(.*?)</script>',
777-
html, re.DOTALL)
777+
r'<script[^>]*type=["\']application/ld\+json["\']'
778+
r'[^>]*>(.*?)</script>',
779+
html, re.DOTALL | re.IGNORECASE)
778780

779781
for match in ld_matches:
780782
try:
@@ -797,6 +799,61 @@ async def _scrape_event_page(self, session,
797799
"Error scraping event page %s: %s", url, e)
798800
return None
799801

802+
def _parse_jsonld_location(self, location_data) -> str:
803+
"""Flatten a schema.org location value into a display string.
804+
805+
Handles Place (with a PostalAddress dict or plain-string
806+
address), VirtualLocation (online events), a bare string, and
807+
a list of any of the above.
808+
"""
809+
if not location_data:
810+
return ''
811+
812+
# Some feeds emit a list of locations (e.g. a venue plus an
813+
# online stream) — take the first one that yields anything.
814+
if isinstance(location_data, list):
815+
for item in location_data:
816+
parsed = self._parse_jsonld_location(item)
817+
if parsed:
818+
return parsed
819+
return ''
820+
821+
if isinstance(location_data, str):
822+
return self._strip_urls(location_data)
823+
824+
if not isinstance(location_data, dict):
825+
return ''
826+
827+
loc_name = str(location_data.get('name', '') or '').strip()
828+
829+
if location_data.get('@type') == 'VirtualLocation':
830+
# The only useful field is a URL, which _strip_urls would
831+
# wipe — and Discord rejects an empty external location.
832+
return loc_name or 'Online'
833+
834+
address = location_data.get('address')
835+
street = ''
836+
if isinstance(address, dict):
837+
# Meetup packs locality/region into streetAddress already
838+
# ("318 Union St, Mishawaka, IN"), so only fall back to the
839+
# separate fields when streetAddress is absent.
840+
street = str(address.get('streetAddress', '') or '').strip()
841+
if not street:
842+
street = ', '.join(
843+
part for part in (
844+
str(address.get('addressLocality', '') or '').strip(),
845+
str(address.get('addressRegion', '') or '').strip(),
846+
) if part)
847+
elif isinstance(address, str):
848+
street = address.strip()
849+
850+
if loc_name and street:
851+
location = f"{loc_name}, {street}"
852+
else:
853+
location = loc_name or street
854+
855+
return self._strip_urls(location)
856+
800857
def _parse_jsonld_event(self, data: dict, url: str,
801858
uid: str) -> Optional[Dict[str, Any]]:
802859
"""Parse a JSON-LD Event object into our event dict."""
@@ -805,25 +862,7 @@ def _parse_jsonld_event(self, data: dict, url: str,
805862
data.get('description', ''))
806863

807864
# Parse location
808-
location_data = data.get('location', {})
809-
location = ''
810-
if isinstance(location_data, dict):
811-
loc_name = location_data.get('name', '')
812-
address = location_data.get('address', {})
813-
if isinstance(address, dict):
814-
street = address.get('streetAddress', '')
815-
if loc_name and street:
816-
location = f"{loc_name}, {street}"
817-
elif loc_name:
818-
location = loc_name
819-
elif street:
820-
location = street
821-
elif loc_name:
822-
location = loc_name
823-
elif isinstance(location_data, str):
824-
location = location_data
825-
826-
location = self._strip_urls(location)
865+
location = self._parse_jsonld_location(data.get('location'))
827866

828867
# Parse dates
829868
start_str = data.get('startDate', '')
@@ -920,7 +959,10 @@ async def _create_discord_event(self, guild,
920959
# before we compare against fetched events or we'll never
921960
# dedup and will loop-recreate every run
922961
name = event['summary'].strip()[:100]
923-
description = event.get('description', '')[:1000]
962+
# Description is just the event page URL — a bare URL so
963+
# Discord auto-links it (masked [text](url) markdown is not
964+
# rendered in scheduled-event descriptions).
965+
description = (event.get('link') or '').strip()[:1000]
924966
start_time = event['start_date']
925967
end_time = event.get('end_date')
926968
location = event.get('location', '')
@@ -977,18 +1019,41 @@ def _prefix(n):
9771019
if _prefix(ev_name) != name_prefix:
9781020
continue
9791021

980-
# Same event (matched on prefix + time)
981-
if ev_name == name:
1022+
# Same event (matched on prefix + time) — sync any
1023+
# field that drifted. Name isn't the only thing that
1024+
# can change: events created before the location
1025+
# scraper worked are stuck on the placeholder, and
1026+
# they'd never be repaired if we gated on the title.
1027+
changes = {}
1028+
if ev_name != name:
1029+
changes['name'] = name
1030+
if description and (ev.description or '') != description:
1031+
changes['description'] = description
1032+
# Only sync location when we actually have one — a
1033+
# transient scrape failure yields the placeholder,
1034+
# and we must not clobber a good venue with it.
1035+
# Only external events carry a location; passing one
1036+
# for a voice/stage event raises TypeError.
1037+
if (location
1038+
and ev.entity_type == discord.EntityType.external
1039+
and (ev.location or '') != event_location):
1040+
changes['location'] = event_location
1041+
1042+
if not changes:
9821043
logger.info(
9831044
"Discord Event '%s' already up to date, "
9841045
"skipping", name)
9851046
return ev
9861047

987-
# Title changed — update in place
988-
await ev.edit(name=name)
1048+
# edit() requires end_time for external events that
1049+
# don't already have one set
1050+
if 'location' in changes and not ev.end_time:
1051+
changes['end_time'] = end_time
1052+
1053+
await ev.edit(**changes)
9891054
logger.info(
990-
"Updated Discord Event title: '%s' → '%s'",
991-
ev_name, name)
1055+
"Updated Discord Event '%s' (%s)",
1056+
name, ', '.join(sorted(changes)))
9921057
return ev
9931058

9941059
# No existing match — create new

0 commit comments

Comments
 (0)