Files
kfj001 03eeb6bb62 feat(dashboard): add forward "what's next" signal + fix upcoming floor
The daily-file bill queue is a backward snapshot, so "upcoming" could only
resurface the latest past day (the 08-31 snapshot read as today's queue) and
there was no "what is scheduled next." leginfo bill status/history are
backward-only, so the genuine future-dated signal is the chambers' "upcoming
events" calendars.

- api.py: floor the "upcoming" view at *today* (not the DB max date) when no
  as_of is pinned; surface next_legislative_day + scheduled_events (next 7
  days) on /bills. Add _today_iso / _next_legislative_day / _scheduled_events,
  all degrading to a recess marker / [] (never 5xx).
- relational.py: schema v2 adds the scheduled_events table +
  store_scheduled_events (idempotent per-day refresh), load_scheduled_events,
  next_legislative_day.
- parsers.py: parse_events_page + classify_event_kind for the chamber events
  calendar; skip empty-title rows (decompose the <time> before the fallback).
- ingest.py + cli.py: --store-events lane (cache-first, recess-safe) that
  parses the calendar HTML into scheduled_events.
- sources.py + config.yaml: register assembly_events / senate_events
  (kind: events, empty_is_ok: true).
- run_ingest.sh: run the events lane after the daily-file lane as non-fatal
  (skippable via CALI_SCRAPER_SKIP_EVENTS=1); daily-file exit still gates cron.
- frontend: "Next legislative day" line + "What's next · next 7 days" band
  with honest recess empty states.
- DASHBOARD.md: document the forward signal + known limitations (leginfo
  backward-only gap, recess empty state, 08-31 snapshot is analysis-only).
- tests: 5 new API tests + 18 parse-events tests; 170 pass, 92% coverage.
2026-09-06 19:50:02 -07:00

424 lines
17 KiB
Python

"""HTML parsers: transform cached raw Daily File HTML into BillAction records.
Selector contracts follow DAILY_FILE_PARSE_SPEC.md section 3:
- Committee hearings : table.committee-hearing-table / tr.committee-hearing-details
- Bills on the floor : table.floor-bills-table / tr.floor-bill-details
- Bills in committee : table.committee-bills-table / tr[class~=committee-bills-details]
Robustness (task acceptance criteria):
* malformed rows (missing bill link, no number, decode failures) are skipped
with a logged warning and never abort the batch;
* encoding issues are handled by a charset decode with fallback + logging;
* multi-page / multi-file input is handled by iterating every *.html the
input resolves to.
"""
from __future__ import annotations
import glob
import logging
import os
import re
from datetime import datetime
from typing import List, Optional
from .bill_id import parse_bill_id, parse_display, parse_supplement, origin_house
from .schema import BillAction, ASSEMBLY_DAILY_FILE
log = logging.getLogger("scraper.parsers")
# ---- section selectors ---------------------------------------------------
HEARING_SELECTOR = "table.committee-hearing-table tr.committee-hearing-details"
FLOOR_SELECTOR = "table.floor-bills-table tr.floor-bill-details"
COMMITTEE_SELECTOR = "table.committee-bills-table tr[class~=committee-bills-details]"
# Looser fallbacks in case a table class is missing but rows are still present.
HEARING_ROW = "tr[class*=committee-hearing-details]"
FLOOR_ROW = "tr[class*=floor-bill-details]"
COMMITTEE_ROW = "tr[class*=committee-bills-details]"
_SUPP = re.compile(r"Supplemental\s+(\d+)", re.IGNORECASE)
_DATE_SPLIT = re.compile(r"-\d+$") # strip display suffix like '-001'
# --- cell helpers ---------------------------------------------------------
def _attr_or_text(td, prefer: str = "data-sort") -> Optional[str]:
"""Return td[prefer] when present else trimmed textContent."""
if prefer and td is not None and td.get(prefer):
return td.get(prefer).strip()
return td.get_text(" ", strip=True) if td is not None else None
def normalise_date(raw: Optional[str]) -> Optional[str]:
"""'08/31/26-001' / '08/31/26' -> '2026-08-31'. Non-date -> None."""
if not raw:
return None
s = _DATE_SPLIT.sub("", raw.strip()).strip()
if not s:
return None
for fmt in ("%m/%d/%y", "%m/%d/%Y", "%Y-%m-%d"):
try:
return datetime.strptime(s, fmt).strftime("%Y-%m-%d")
except ValueError:
continue
return None
# --- section parsers ------------------------------------------------------
def _parse_hearings(soup, house, source, file_date=None):
rows = soup.select(HEARING_SELECTOR) or soup.select(HEARING_ROW)
records = []
for i, tr in enumerate(rows):
try:
date_td = tr.select_one("td.committee_hearing-date")
time_td = tr.select_one("td.committee_hearing-time")
comm_td = tr.select_one("td.committee_hearing-name")
loc_td = tr.select_one("td.committee_hearing-location")
menu = tr.select_one("td.committee_hearing-menu")
date = normalise_date(_attr_or_text(date_td))
committee = _attr_or_text(comm_td, "data-sort")
ics = None
agenda = None
if menu is not None:
ics_a = menu.select_one("a[href^='/api/ics/generate']")
if ics_a:
ics = ics_a.get("href")
ag_a = menu.select_one("a[href^='/api/dailyfile/agenda/']")
if ag_a:
agenda = ag_a.get("href", "").rsplit("/", 1)[-1]
if not committee and date is None:
raise ValueError("hearing row has neither committee nor date")
records.append(BillAction(
date=date,
house=house,
source=source,
record_type="hearing",
committee=committee,
hearing_time=_attr_or_text(time_td, "text") or None,
location=_attr_or_text(loc_td, "data-sort"),
ics_url=ics,
agenda_id=agenda,
raw_selector="committee-hearing-table",
))
except Exception as e: # noqa: BLE001
log.warning("skip malformed hearing row #%d: %s", i, e)
# backfill a missing per-row date from the file date
for h in records:
if not h.date and file_date:
h.date = file_date
return records
def _extract_bill_measure(measure_td):
"""Return (bill_number, bill_id, supplement) from a measure / number cell."""
supplement = None
a = measure_td.select_one("a")
bill_id = None
bill_number = None
if a is not None and a.get("href"):
m = re.search(r"bill_id=([A-Za-z0-9]+)", a["href"])
if m:
bill_id = m.group(1)
dec = parse_bill_id(bill_id)
if dec:
bill_number = dec["display"]
if not bill_number:
# Fallback: the display text before any <br> (e.g. "AB 28"). Only accept
# a clean "PREFIX N" form; anything with extra text is a malformed row
# and is left None so the caller can skip it.
display = a.get_text(" ", strip=True) if a is not None else measure_td.get_text(" ", strip=True)
m2 = re.match(r"^(AB|SB|ACR|SCR|HR|SJR|ABR)\s*(\d+)$", (display or "").strip())
if m2:
bill_number = f"{m2.group(1)} {int(m2.group(2))}"
bill_id = None # display only; leave bill_id None
parsed_supp = parse_supplement(measure_td.get_text(" ", strip=True))
if parsed_supp:
supplement = parsed_supp
return bill_number, bill_id, supplement
def _parse_floor_bills(soup, house, source, file_date):
rows = soup.select(FLOOR_SELECTOR) or soup.select(FLOOR_ROW)
records = []
for i, tr in enumerate(rows):
try:
measure = tr.select_one("td.floor_bill-measure")
if measure is None:
raise ValueError("missing floor_bill-measure cell")
bill_number, bill_id, supplement = _extract_bill_measure(measure)
if not (bill_number or bill_id):
raise ValueError("no bill number/id in floor row")
dec = parse_bill_id(bill_id) if bill_id else None
chamber_origin = dec["house"].upper() if dec else origin_house((bill_number or "").split(" ")[0])
session = dec["session"] if dec else None
view = tr.select_one("td.floor_bill-view-details")
description = None
if view is not None:
description = view.get_text(" ", strip=True) or None
item_td = tr.select_one("td.floor_bill-item")
item_text = _attr_or_text(item_td, "data-sort") or _attr_or_text(item_td, "text")
item_number = int(item_text.strip()) if item_text and item_text.strip().isdigit() else None
short_title = _attr_or_text(tr.select_one("td.floor_bill-subject"), "text")
records.append(BillAction(
date=file_date,
house=house,
source=source,
record_type="bill",
bill_number=bill_number,
bill_id=bill_id,
session=session,
chamber_origin=chamber_origin,
author=_attr_or_text(tr.select_one("td.floor_bill-author"), "data-sort"),
short_title=short_title,
description=description or short_title,
location=_attr_or_text(tr.select_one("td.floor_bill-location"), "data-sort"),
item_number=item_number,
supplement=supplement,
raw_selector="floor-bills-table",
))
except Exception as e: # noqa: BLE001
log.warning("skip malformed floor row #%d: %s", i, e)
return records
def _parse_committee_bills(soup, house, source, file_date):
rows = soup.select(COMMITTEE_SELECTOR) or soup.select(COMMITTEE_ROW)
records = []
for i, tr in enumerate(rows):
try:
num_td = tr.select_one("td.committee_bill-number")
if num_td is None:
raise ValueError("missing committee_bill-number cell")
bill_number, bill_id, _ = _extract_bill_measure(num_td)
if not (bill_number or bill_id):
raise ValueError("no bill number/id in committee-bill row")
dec = parse_bill_id(bill_id) if bill_id else None
chamber_origin = dec["house"].upper() if dec else origin_house((bill_number or "").split(" ")[0])
short_title = _attr_or_text(tr.select_one("td.committee_bill-subject"), "text")
records.append(BillAction(
date=file_date,
house=house,
source=source,
record_type="bill",
bill_number=bill_number,
bill_id=bill_id,
session=dec["session"] if dec else None,
chamber_origin=chamber_origin,
author=_attr_or_text(tr.select_one("td.committee_bill-author"), "data-sort"),
short_title=short_title,
description=short_title,
committee=_attr_or_text(tr.select_one("td.committee_bill-committee"), "data-sort"),
raw_selector="committee-bills-table",
))
except Exception as e: # noqa: BLE001
log.warning("skip malformed committee-bill row #%d: %s", i, e)
return records
def parse_html(html: str, *, house: str = "assembly",
source: str = ASSEMBLY_DAILY_FILE, parser: str = "lxml") -> List[BillAction]:
"""Parse a single HTML document into a list of BillAction records.
The date is derived from the committee-hearing table (the daily file's date)
and applied to the bill rows, per spec 3.2 (bills carry no per-row date).
"""
from bs4 import BeautifulSoup
engine = parser if parser in ("lxml", "html.parser") else "html.parser"
try:
soup = BeautifulSoup(html, engine)
except Exception as e: # noqa: BLE001
log.error("failed to parse document (%s): %s", type(e).__name__, e)
return []
hearings = _parse_hearings(soup, house, source, file_date=None)
file_date = next((h.date for h in hearings if h.date), None)
for h in hearings:
if not h.date:
h.date = file_date
# Bills first (floor, then committee), then hearings — so a parsed batch
# leads with bill records, each carrying bill_number/session/etc.
records = []
records.extend(_parse_floor_bills(soup, house, source, file_date))
records.extend(_parse_committee_bills(soup, house, source, file_date))
records.extend(hearings)
return records
def _read_text(path: str) -> str:
"""Read a file with charset detection + an encoding fallback."""
with open(path, "rb") as fh:
raw = fh.read()
head = raw[:2048].decode("utf-8", "ignore").lower()
charset = "utf-8"
meta = re.search(r"content-type=\"[^\"]*?charset=([a-z0-9_-]+)", head)
if meta:
charset = meta.group(1)
else:
decl = re.search(r'charset\s*=\s*["\']?\s*([a-z0-9_-]+)', head)
if decl:
charset = decl.group(1)
try:
text = raw.decode(charset)
except (UnicodeDecodeError, LookupError):
log.warning("bad charset %r in %s, falling back to UTF-8/replace", charset, path)
text = raw.decode("utf-8", "replace")
if "\ufffd" in text:
log.warning("replacement chars detected while decoding %s", path)
return text
def _resolve_inputs(input_path: str) -> List[str]:
"""Expand an input into HTML files: a file, a directory (all *.html), or a glob."""
if os.path.isdir(input_path):
return sorted(glob.glob(os.path.join(input_path, "*.html")))
if any(ch in input_path for ch in "*?[]"):
return sorted(glob.glob(input_path))
return [input_path]
def parse(input_path: str, *, house: str = "assembly",
source: str = ASSEMBLY_DAILY_FILE, parser: str = "lxml",
include_hearings: bool = True) -> List[BillAction]:
"""Parse one or more cached HTML files into a flat list of records.
Robust to malformed rows (logged + skipped), bad encodings (logged +
fallback), and multi-file/multi-page input. Never aborts the batch on a
single bad file or row.
"""
files = _resolve_inputs(input_path)
if not files:
log.warning("no input files matched %r", input_path)
return []
records: List[BillAction] = []
for path in files:
if not os.path.exists(path):
log.warning("input file not found: %s", path)
continue
try:
html = _read_text(path)
except OSError as e:
log.warning("cannot read %s: %s", path, e)
continue
doc_records = parse_html(html, house=house, source=source, parser=parser)
if not include_hearings:
doc_records = [r for r in doc_records if r.record_type != "hearing"]
records.extend(doc_records)
return records
# -------------------------------------------------------------------
# Chamber "upcoming events" calendar (scheduled_events source, v2)
#
# The chamber calendar (assembly.ca.gov/events/upcoming, senate.ca.gov/
# calendar) is a Drupal "views" table (<table class="cols-3"> with Date /
# Title / Video columns). Each row is a scheduled hearing / floor session --
# the only *forward-looking* signal (the daily file is a backward snapshot).
# Rows are stored in scheduled_events (relational.store_scheduled_events).
# An empty page (a recess) is a legitimate empty result, not an error.
# -------------------------------------------------------------------
# Title keywords that classify an event's kind (best-effort, never crash).
_EV_KIND_PATTERNS = (
(re.compile(r"\bfloor\b", re.I), "FLOOR"),
(re.compile(r"\bcommittee\b", re.I), "COMMITTEE"),
)
def classify_event_kind(title):
"""Map an event title to a kind: FLOOR / COMMITTEE / OTHER."""
t = title or ""
for pat, kind in _EV_KIND_PATTERNS:
if pat.search(t):
return kind
return "OTHER"
def parse_events_page(html, chamber="assembly", source="assembly_events",
base_url="", parser="lxml"):
"""Parse a chamber events calendar page into scheduled-event dicts.
Returns a list of dicts with keys: date (ISO YYYY-MM-DD), chamber
(upper), kind (FLOOR|COMMITTEE|OTHER), title, source_url. An empty /
recess page yields []. Rows without a resolvable date are skipped
(logged). Header rows (<th>) are ignored.
"""
from bs4 import BeautifulSoup
engine = parser if parser in ("lxml", "html.parser") else "html.parser"
try:
soup = BeautifulSoup(html, engine)
except Exception as e: # noqa: BLE001
log.error("events: failed to parse document (%s): %s",
type(e).__name__, e)
return []
out = []
# Fall back to any <tr> carrying a <time> cell when table.cols-3 is
# absent, so a layout change still yields rows rather than a silent
# empty page.
base = (base_url or "").rstrip("/")
host = base.rsplit("/", 2)[0] if base else "https://www.assembly.ca.gov"
tables = soup.select("table.cols-3")
rows = [tr for tbl in tables for tr in tbl.find_all("tr")]
if not rows:
rows = [tr for tr in soup.find_all("tr") if tr.find("time")]
for i, tr in enumerate(rows):
# Skip header rows (column labels render as <th>).
if tr.find("th"):
continue
try:
time_el = tr.find("time")
if time_el is None:
raise ValueError("event row has no date cell")
# Prefer the datetime attr: "2026-09-01T00:00:00-07:00".
dt = time_el.get("datetime") or ""
date = dt[:10] if len(dt) >= 10 and dt[4:5] == "-" else None
if not date:
date = normalise_date(time_el.get_text(strip=True))
title_td = tr.select_one("td.views-field-title")
if title_td is not None:
title = title_td.get_text(" ", strip=True)
else:
t = tr.find("time")
if t is not None:
t.decompose()
title = tr.get_text(" ", strip=True)
if not date or not title:
raise ValueError("event row has no date or title")
# source_url: the row's own link (event / video detail) when
# present, else the calendar page (base_url) as provenance.
a = tr.find("a", href=True)
source_url = None
if a is not None:
href = (a.get("href") or "").strip()
if href.startswith("http"):
source_url = href
elif href.startswith("/"):
source_url = host + href
if not source_url:
source_url = base or None
out.append({
"date": date,
"chamber": chamber.strip().upper(),
"kind": classify_event_kind(title),
"title": title,
"source_url": source_url,
"source": source,
})
except Exception as e: # noqa: BLE001
log.warning("skip malformed event row #%d: %s", i, e)
return out