|
1 | 1 | import pytz |
2 | 2 | import datetime |
3 | 3 | import json |
| 4 | +import re |
| 5 | + |
| 6 | +import lxml.html |
4 | 7 |
|
5 | 8 | from openstates.scrape import Scraper, Bill, VoteEvent |
6 | 9 | from .actions import Categorizer |
@@ -231,8 +234,22 @@ def scrape_bill(self, bill_num, session): |
231 | 234 | chamber=sp_chamber, |
232 | 235 | ) |
233 | 236 |
|
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) |
236 | 253 |
|
237 | 254 | if bill_json["enrolledNumber"]: |
238 | 255 | bill.extras["wy_enrolled_number"] = bill_json["enrolledNumber"] |
@@ -313,6 +330,61 @@ def scrape_vote(self, bill, vote_json, session): |
313 | 330 |
|
314 | 331 | yield v |
315 | 332 |
|
| 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 | + |
316 | 388 | def parse_local_date(self, date_str): |
317 | 389 | # provided dates are ISO 8601, but in mountain time |
318 | 390 | # and occasionally have fractional time at the end |
|
0 commit comments