Skip to content

Commit 7ef57bf

Browse files
Updated file,fixed comments
1 parent 73320c5 commit 7ef57bf

1 file changed

Lines changed: 49 additions & 78 deletions

File tree

scrapers/mt/events.py

Lines changed: 49 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import datetime
2+
import html
23
import re
34
from typing import Optional
45

@@ -10,55 +11,40 @@
1011

1112

1213
class 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. &#8217;)
417-
import html
418-
389+
# the WP API hands back HTML entities like &#8217;
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

Comments
 (0)