11import datetime
2+ import html
23import re
34from typing import Optional
45
1011
1112
1213class MTEventScraper (Scraper ):
13- """
14- Montana events scraper.
15-
16- In mid-2025 Montana retired the old SLIQ/Harmony video portal
17- (sg001-harmony.sliq.net) that this scraper used to rely on. Event data now
18- lives across three separate JSON APIs, all of which are used here:
19-
20- 1. The public WordPress site at www.legmt.gov exposes upcoming/past events
21- via "The Events Calendar" plugin REST API. This gives us the canonical
22- list of meetings with title, date/time, venue, and a `website` link that
23- points at the Committee Explorer SPA
24- (committees.legmt.gov/#/nonStandingCommittees/<id>).
14+ """Montana events.
2515
26- 2. The Committee Explorer is backed by api-public.legmt.gov, whose
27- committee-meetings search endpoints return each meeting's agenda items
28- inline (title + description), keyed by committee id and meeting time.
16+ MT shut down the old SLIQ/Harmony portal (sg001-harmony.sliq.net) in
17+ mid-2025, so we now piece events together from three sources:
2918
30- 3. api-public.legmt.gov/docs exposes agenda and minutes PDF documents for a
31- meeting; each document carries a direct download URL.
19+ - the legmt.gov WordPress site ("The Events Calendar" plugin) for the event
20+ list itself -- title, time, venue, and a link to the committee page
21+ - api-public.legmt.gov committee endpoints for the matching meeting record,
22+ which includes agenda items inline
23+ - api-public.legmt.gov/docs for the agenda / minutes PDFs
3224 """
3325
3426 _tz = pytz .timezone ("America/Denver" )
3527
36- # WordPress "The Events Calendar" REST API
3728 _wp_events_url = "https://www.legmt.gov/wp-json/tribe/events/v1/events"
38-
39- # api-public.legmt.gov service roots
4029 _committees_api = "https://api-public.legmt.gov/committees/v1"
4130 _docs_api = "https://api-public.legmt.gov/docs/v1"
4231
4332 _bill_re = re .compile (r"\b(?:SB|HB|SR|HR|SJ|HJ|LC)\s*\d+\b" )
4433
45- # How far in the future we'll trust the committee API's inline `agendaItems`
46- # when no published agenda PDF exists for the meeting. That field doubles as
47- # a staff working draft and can be fully populated for meetings months out
48- # that have no publicly visible agenda yet, so we only accept it for
49- # meetings that are imminent (or already past). See scrape_committee_meeting.
34+ # The committee API's inline agenda items double as a staff scratchpad, so a
35+ # meeting a year out can already carry a full "agenda" that nobody's actually
36+ # published yet. When there's no published agenda PDF to back it up, only
37+ # trust those items if the meeting is within this many days (or already past).
5038 _agenda_trust_window_days = 14
5139
52- # cache of (committee_type, committee_id) -> committee detail dict
53- _committee_cache = {}
54- # cache of (committee_type, committee_id) -> list of meeting dicts
55- _meetings_cache = {}
40+ _committee_cache = {} # (committee_type, committee_id) -> committee dict
41+ _meetings_cache = {} # (committee_type, committee_id) -> [meeting dict]
5642
5743 def scrape (self ):
58- # Build a legislatureId -> ordinal lookup from the session metadata in
59- # __init__.py, so we don't have to make an extra API request. The docs
60- # API keys on ` legislatureId`, which matches the session's
61- # ` newAPIIdentifier` .
44+ # The docs API wants a legislature "ordinal" (e.g. 69). Rather than hit
45+ # another endpoint for it, pull it from the session metadata we already
46+ # keep in __init__.py. The API's legislatureId lines up with the
47+ # session's newAPIIdentifier.
6248 self ._legislature_ordinals = {
6349 session ["extras" ]["newAPIIdentifier" ]: session ["extras" ][
6450 "legislatureOrdinal"
@@ -67,18 +53,15 @@ def scrape(self):
6753 if session .get ("extras" , {}).get ("newAPIIdentifier" ) is not None
6854 }
6955
70- # Scrape a window around today: recent past meetings (which may now have
71- # minutes/agenda documents attached) plus all upcoming meetings .
56+ # Look back far enough to catch minutes/agendas that show up after a
57+ # meeting, and forward far enough to cover the published schedule .
7258 today = datetime .date .today ()
7359 start = today - datetime .timedelta (days = 45 )
74- # The events calendar publishes meetings well into the future; grab a
75- # generous window so we don't miss anything on the schedule.
7660 end = today + datetime .timedelta (days = 365 )
7761
7862 seen = set ()
7963 for event in self .scrape_events (start , end ):
80- # de-dupe on (name, start_date); the same meeting can occasionally
81- # appear more than once in the source feed
64+ # the feed occasionally lists the same meeting twice
8265 key = (event .name , event .start_date )
8366 if key in seen :
8467 continue
@@ -96,7 +79,7 @@ def scrape_events(self, start: datetime.date, end: datetime.date):
9679 "page" : page ,
9780 }
9881 resp = self .get (self ._wp_events_url , params = params )
99- # The API returns a 400 once you page past the last page.
82+ # paging past the last page returns a 400
10083 if resp .status_code != 200 :
10184 break
10285 data = resp .json ()
@@ -114,7 +97,7 @@ def scrape_events(self, start: datetime.date, end: datetime.date):
11497 def scrape_event (self , row : dict ) -> Optional [Event ]:
11598 title = self ._clean_text (row ["title" ])
11699
117- # ignore obvious test events
100+ # skip placeholder/ test events
118101 if title .lower () in ("test" , "other" ):
119102 return None
120103
@@ -137,12 +120,11 @@ def scrape_event(self, row: dict) -> Optional[Event]:
137120 )
138121 event .add_source (row ["url" ])
139122
140- # Only add a committee tag for real committee names , not bill hearings.
123+ # bill hearings get titled after the bill , not a committee
141124 if "HB" not in title and "SB" not in title :
142125 event .add_committee (title )
143126
144- # Attach agenda items and documents from the committee API, keyed by the
145- # committee referenced in the WP event's `website` field.
127+ # the event links back to a committee; use that to pull agenda + docs
146128 committee_ref = self ._parse_committee_ref (row .get ("website" ))
147129 if committee_ref is not None :
148130 committee_type , committee_id = committee_ref
@@ -159,9 +141,10 @@ def scrape_event(self, row: dict) -> Optional[Event]:
159141 return event
160142
161143 def _build_location (self , row : dict ) -> str :
162- """Assemble a human-readable location from venue + room custom field."""
144+ """Build a location string from the venue plus the room custom field."""
163145 parts = []
164146
147+ # room lives in a custom field, not the venue block
165148 room = None
166149 custom = row .get ("custom_fields" ) or {}
167150 for field in custom .values ():
@@ -208,7 +191,7 @@ def scrape_committee_meeting(
208191 committee_id : int ,
209192 when : datetime .datetime ,
210193 ):
211- """Match this event to a committee meeting and attach agenda + docs ."""
194+ """Attach agenda items and documents for the matching committee meeting ."""
212195 meetings = self ._get_meetings (committee_type , committee_id )
213196 if not meetings :
214197 return
@@ -217,22 +200,17 @@ def scrape_committee_meeting(
217200 if meeting is None :
218201 return
219202
220- # Agenda + minutes PDFs live in the docs service. Scrape them first so we
221- # know whether a published agenda exists for this meeting .
203+ # Grab the PDFs first -- whether a published agenda exists tells us
204+ # whether the inline agenda items below can be trusted .
222205 has_published_agenda = False
223206 committee = self ._get_committee (committee_type , committee_id )
224207 if committee is not None :
225208 has_published_agenda = self ._scrape_documents (event , committee , meeting )
226209
227- # The committee API also returns agenda items inline on the meeting
228- # record, but that field doubles as a staff working draft: it can be
229- # fully populated for meetings months in the future that have no publicly
230- # visible agenda yet, and it carries no "finalized" flag to distinguish
231- # the two. Only trust the inline items when a human web visitor could
232- # verify them, i.e. either a published agenda PDF exists, or the meeting
233- # is imminent/past (within the trust window) so the agenda is effectively
234- # final. Otherwise we still publish the event and any documents, but hold
235- # back the speculative agenda items.
210+ # The inline agenda items are only reliable once someone could actually
211+ # go look them up: either there's a published agenda PDF, or the meeting
212+ # is close enough that the agenda is effectively set. Otherwise we still
213+ # keep the event and its documents, we just leave off the draft agenda.
236214 if has_published_agenda or self ._meeting_within_trust_window (when ):
237215 for item in meeting .get ("agendaItems" , []) or []:
238216 text = self ._clean_text (item .get ("title" , "" ))
@@ -244,14 +222,11 @@ def scrape_committee_meeting(
244222 agenda .add_bill (self ._normalize_bill_id (bill ))
245223
246224 def _meeting_within_trust_window (self , when : datetime .datetime ) -> bool :
247- """
248- True if the meeting is already past or starts within
249- `_agenda_trust_window_days` from now. Past/imminent meetings have
250- effectively finalized agendas, so the inline agenda items are trustworthy
251- even when no published agenda PDF is available.
252- """
253- now = datetime .datetime .now (self ._tz )
254- return when <= now + datetime .timedelta (days = self ._agenda_trust_window_days )
225+ """Whether the meeting is past or close enough to trust its agenda."""
226+ cutoff = datetime .datetime .now (self ._tz ) + datetime .timedelta (
227+ days = self ._agenda_trust_window_days
228+ )
229+ return when <= cutoff
255230
256231 def _get_meetings (self , committee_type : str , committee_id : int ):
257232 cache_key = (committee_type , committee_id )
@@ -287,7 +262,6 @@ def _get_meetings(self, committee_type: str, committee_id: int):
287262 if offset >= total_pages :
288263 break
289264
290- # Drop canceled meetings.
291265 meetings = [
292266 m for m in meetings if (m .get ("status" ) or "" ).upper () != "CANCELED"
293267 ]
@@ -296,10 +270,10 @@ def _get_meetings(self, committee_type: str, committee_id: int):
296270 return meetings
297271
298272 def _match_meeting (self , meetings : list , when : datetime .datetime ):
299- """
300- Find the meeting whose start matches the event. The committee API
301- ` meetingTime` is a naive local datetime string (America/Denver).
302- We match on date, then prefer the closest start time.
273+ """Pick the meeting matching this event's start time.
274+
275+ meetingTime comes back as a naive local (America/Denver) string, so we
276+ match on the date and then take whichever start time is closest .
303277 """
304278 target = when .replace (tzinfo = None )
305279 candidates = []
@@ -335,11 +309,10 @@ def _get_committee(self, committee_type: str, committee_id: int):
335309 return committee
336310
337311 def _scrape_documents (self , event : Event , committee : dict , meeting : dict ) -> bool :
338- """
339- Fetch agenda + minutes PDFs for a meeting and attach them.
312+ """Attach agenda + minutes PDFs, returning whether an agenda was found.
340313
341- Returns True if a published agenda PDF was found for this meeting, which
342- the caller uses to decide whether to trust the API's inline agenda items.
314+ The agenda result is what the caller uses to decide whether the inline
315+ agenda items are trustworthy .
343316 """
344317 details = committee .get ("committeeDetails" ) or {}
345318 code = details .get ("committeeCode" ) or {}
@@ -353,7 +326,7 @@ def _scrape_documents(self, event: Event, committee: dict, meeting: dict) -> boo
353326 if ordinal is None :
354327 return False
355328
356- # standing committees carry a sessionId; interim committees do not
329+ # standing committees carry a sessionId, interim committees don't
357330 committee_type = "STANDING" if "sessionId" in meeting else "NON-STANDING"
358331
359332 chamber = committee .get ("chamber" , "" ) or ""
@@ -413,9 +386,7 @@ def _document_url(doc: dict) -> Optional[str]:
413386 def _clean_text (text : str ) -> str :
414387 if not text :
415388 return ""
416- # normalize HTML entities the WP API returns (e.g. ’)
417- import html
418-
389+ # the WP API hands back HTML entities like ’
419390 text = html .unescape (text )
420391 text = re .sub (r"\s+\u2013\s+" , " - " , text )
421392 return re .sub (r"\s+" , " " , text ).strip ()
0 commit comments