Skip to content

Commit 42bea1f

Browse files
WY: extract bill abstracts from digest/summary HTML
The API's summary/digest fields only contain PDF paths (e.g. 2026/Summaries/HB0002.pdf), which were being stored as the bill abstract. Extract the actual text from the digestHTML/summaryHTML fields instead: the digest 'AN ACT...' clause as the primary abstract and the plain-language summary as a secondary abstract.
1 parent 2977d56 commit 42bea1f

1 file changed

Lines changed: 74 additions & 2 deletions

File tree

scrapers/wy/bills.py

Lines changed: 74 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import pytz
22
import datetime
33
import json
4+
import re
5+
6+
import lxml.html
47

58
from openstates.scrape import Scraper, Bill, VoteEvent
69
from .actions import Categorizer
@@ -231,8 +234,22 @@ def scrape_bill(self, bill_num, session):
231234
chamber=sp_chamber,
232235
)
233236

234-
if bill_json["summary"]:
235-
bill.add_abstract(note="summary", abstract=bill_json["summary"])
237+
# The API's "summary"/"digest" fields are only PDF paths (e.g.
238+
# "2026/Summaries/HB0002.pdf"), not usable text. The actual text of
239+
# each is served as HTML in the "digestHTML"/"summaryHTML" fields, so
240+
# we extract the abstract text from those instead.
241+
#
242+
# The digest contains the "AN ACT ..." (or "A JOINT RESOLUTION ...")
243+
# clause -- a concise description of the bill -- which we use as the
244+
# primary abstract. The summary is a longer plain-language explanation
245+
# that we add as a secondary abstract when available.
246+
digest_abstract = self.extract_digest(bill_json.get("digestHTML"))
247+
if digest_abstract:
248+
bill.add_abstract(note="digest", abstract=digest_abstract)
249+
250+
summary_abstract = self.extract_summary(bill_json.get("summaryHTML"))
251+
if summary_abstract:
252+
bill.add_abstract(note="summary", abstract=summary_abstract)
236253

237254
if bill_json["enrolledNumber"]:
238255
bill.extras["wy_enrolled_number"] = bill_json["enrolledNumber"]
@@ -313,6 +330,61 @@ def scrape_vote(self, bill, vote_json, session):
313330

314331
yield v
315332

333+
@staticmethod
334+
def _html_to_text(raw_html):
335+
"""Strip tags from an API HTML field and collapse whitespace."""
336+
if not raw_html:
337+
return ""
338+
text = lxml.html.fromstring(raw_html).text_content()
339+
return " ".join(text.split())
340+
341+
def extract_digest(self, digest_html):
342+
"""Pull the "AN ACT ..." clause out of the digest HTML.
343+
344+
The digest HTML contains the bill number, sponsors, the enacting
345+
clause, and then the action history. We only want the enacting clause,
346+
which starts with "AN ACT" (bills) or "A JOINT RESOLUTION"
347+
(resolutions) and runs up to the first date in the action history.
348+
"""
349+
text = self._html_to_text(digest_html)
350+
if not text:
351+
return None
352+
353+
match = re.search(
354+
r"((?:AN ACT|A JOINT RESOLUTION).*?)(?=\s+\d{1,2}/\d{1,2}/\d{4})",
355+
text,
356+
)
357+
if not match:
358+
# No trailing action-history date to bound the clause; fall back to
359+
# everything from the enacting clause onward.
360+
match = re.search(r"(?:AN ACT|A JOINT RESOLUTION).*", text)
361+
return match.group(0).strip() if match else None
362+
363+
return match.group(1).strip()
364+
365+
def extract_summary(self, summary_html):
366+
"""Pull the plain-language summary out of the summary HTML.
367+
368+
The summary HTML is a form with several labeled fields; the descriptive
369+
text lives under the "Summary/Major Elements:" label. We also drop the
370+
boilerplate disclaimer that the Legislative Service Office appends.
371+
"""
372+
text = self._html_to_text(summary_html)
373+
if not text:
374+
return None
375+
376+
match = re.search(r"Summary/Major Elements:\s*(.*)", text)
377+
if not match:
378+
return None
379+
380+
summary = match.group(1).strip()
381+
# Remove the trailing LSO disclaimer if present.
382+
summary = re.split(
383+
r"\s*The above summary is not an official publication",
384+
summary,
385+
)[0].strip()
386+
return summary or None
387+
316388
def parse_local_date(self, date_str):
317389
# provided dates are ISO 8601, but in mountain time
318390
# and occasionally have fractional time at the end

0 commit comments

Comments
 (0)