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.
230 lines
8.1 KiB
Python
230 lines
8.1 KiB
Python
# CLI entry point: `python -m scraper <command>`.
|
|
#
|
|
# ingest --date YYYY-MM-DD [--source NAME] [--force] [--json]
|
|
# list
|
|
# check --date YYYY-MM-DD
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import sys
|
|
|
|
from .config import load_config
|
|
from .ingest import Ingestor
|
|
from .parse_cli import register, handle_parse
|
|
from .relate_cli import register as relate_register
|
|
from .relate_cli import handle_migrate, handle_correlate
|
|
from . import analyze_cli as _ac
|
|
from .analyze_cli import handle_analyze
|
|
from .dashboard import dashboard_cli as _dcli
|
|
from .dashboard.dashboard_cli import handle_dashboard
|
|
|
|
|
|
def cmd_ingest(args) -> int:
|
|
# --date is required by the task contract; if omitted, default to today
|
|
# so the cron wrapper can run with no arguments.
|
|
date = args.date or _today()
|
|
if getattr(args, "validate", False):
|
|
date = _validate_date(date)
|
|
|
|
exit_code = 0
|
|
try:
|
|
ing = Ingestor(config_path=args.config, force=args.force)
|
|
sources = _sources_arg(args)
|
|
if getattr(args, "store_events", False):
|
|
# Forward-looking chamber calendar -> scheduled_events table. Reuses
|
|
# the same cache-first pipeline; a recess (empty calendar) is a
|
|
# no-op, not a failure. Default to the events sources when none are
|
|
# named (the daily-file sources are a different lane).
|
|
if not sources:
|
|
sources = [n for n, s in ing.cfg.sources.items()
|
|
if s.get("kind") == "events"]
|
|
result = ing.ingest_events(date, sources=sources,
|
|
db_path=getattr(args, "db", None))
|
|
else:
|
|
result = ing.ingest(date, sources=sources)
|
|
ing.close()
|
|
except SystemExit as exc:
|
|
# config / argparse errors surface here
|
|
print("ERROR: %s" % exc, file=sys.stderr)
|
|
return int(exc.code or 2)
|
|
|
|
if args.json:
|
|
print(json.dumps(result.to_dict(), indent=2))
|
|
else:
|
|
_print_summary(result)
|
|
|
|
# Non-zero exit only on a *real* failure (antibot/transient/unexpected
|
|
# 404). An expected "no publication" 404 is a legitimate outcome for a
|
|
# non-legislative day; the cron job must not treat it as a failure.
|
|
for c in result.chambers:
|
|
if c.status in ("antibot", "transient"):
|
|
exit_code = 1
|
|
return exit_code
|
|
|
|
|
|
def _print_summary(result) -> None:
|
|
print("=== Ingest %s ===" % result.date)
|
|
for c in result.chambers:
|
|
tag = {
|
|
"ok": "OK",
|
|
"cached": "CACHE",
|
|
"not_found": "MISS",
|
|
"antibot": "ANTIBOT",
|
|
"transient": "FAIL",
|
|
}.get(c.status, "-----")
|
|
path = c.path
|
|
if path is not None:
|
|
path = path.name if hasattr(path, "name") else str(path)
|
|
print(" [%s] %-18s %-10s %8dB attempts=%d %s"
|
|
% (tag, c.source, c.status, c.bytes, c.attempts, c.note))
|
|
if getattr(c, "as_of", None):
|
|
print(" as_of=%s url=%s" % (c.as_of, c.url))
|
|
print(" all_ok=%s" % result.all_ok)
|
|
|
|
|
|
def cmd_list(args) -> int:
|
|
cfg = load_config(args.config)
|
|
print("Configured sources (from %s):" % cfg.path)
|
|
for name, s in cfg.sources.items():
|
|
print(" * %s (%s)" % (name, s.get("kind")))
|
|
for key in ("url", "index_url", "pdf_base_url", "pdf_filename_pattern"):
|
|
if key in s:
|
|
val = s[key]
|
|
if isinstance(val, list):
|
|
print(" %s: %s" % (key, " / ".join(val)))
|
|
else:
|
|
print(" %s: %s" % (key, val))
|
|
return 0
|
|
|
|
|
|
def cmd_check(args) -> int:
|
|
# Report cache status for a date without touching the network.
|
|
from .cache import RawCache
|
|
cfg = load_config(args.config)
|
|
rc = RawCache(cfg.raw_dir)
|
|
date = args.date or _today()
|
|
all_cached = True
|
|
print("=== Cache check %s ===" % date)
|
|
for name in cfg.source_names():
|
|
s = cfg.source(name)
|
|
ext = "pdf" if s.get("kind") == "pdf" else "html"
|
|
hit = rc.exists(name, date, ext)
|
|
all_cached = all_cached and hit
|
|
print(" %-18s %s" % (name, "CACHED" if hit else "MISSING"))
|
|
return 0 if all_cached else 3
|
|
|
|
|
|
def _sources_arg(args):
|
|
# --source accepts a single value or a comma-separated list.
|
|
src = getattr(args, "source", None)
|
|
if not src:
|
|
return None
|
|
if isinstance(src, (list, tuple)):
|
|
return list(src)
|
|
return [p.strip() for p in src.split(",") if p.strip()]
|
|
|
|
|
|
def _today() -> str:
|
|
# Local date so the cron wrapper can run with no args.
|
|
import datetime
|
|
return datetime.date.today().isoformat()
|
|
|
|
|
|
def _validate_date(date: str) -> str:
|
|
# Enforce clean YYYY-MM-DD; a bad date is a clear error, not a crash.
|
|
import datetime
|
|
try:
|
|
datetime.date.fromisoformat(date)
|
|
except ValueError:
|
|
raise SystemExit(
|
|
"Invalid --date '%s': expected YYYY-MM-DD, e.g. 2026-09-02." % date)
|
|
return date
|
|
|
|
|
|
def build_parser() -> argparse.ArgumentParser:
|
|
p = argparse.ArgumentParser(
|
|
prog="python -m scraper",
|
|
description="Ingest the CA Legislature Daily File pages.")
|
|
p.add_argument("--config", help="Path to config.yaml (default: ./config.yaml)")
|
|
sub = p.add_subparsers(dest="command", required=True)
|
|
|
|
# Parser lane (task t_fb8081e7): additive, guarded so it never shadows a
|
|
# sibling owner of the dispatcher if one registers `parse` later.
|
|
try:
|
|
register(sub)
|
|
except Exception:
|
|
pass
|
|
|
|
# Correlation lane (task t_2736f40d, spec 2.5): additive + guarded, same
|
|
# pattern as `parse`. Registers `migrate` + `correlate`; does not touch the
|
|
# ingest/parse/list/check handlers.
|
|
try:
|
|
relate_register(sub)
|
|
except Exception:
|
|
pass
|
|
|
|
# LLM analysis lane (task t_950780e0, spec §4): additive + guarded, same
|
|
# pattern as parse + correlate. No collision with sibling handlers.
|
|
try:
|
|
_ac.register(sub)
|
|
except Exception:
|
|
pass
|
|
|
|
# Dashboard UI lane (task t_b38e9af2, spec §5): additive + guarded, same
|
|
# pattern. Registers `dashboard`; does not touch sibling handlers.
|
|
try:
|
|
_dcli.register(sub)
|
|
except Exception:
|
|
pass
|
|
|
|
pi = sub.add_parser("ingest", help="Download + cache daily-file page(s)")
|
|
pi.add_argument("--date", help="YYYY-MM-DD (default: today)")
|
|
pi.add_argument("--source",
|
|
help="Chamber name(s), comma-separated "
|
|
"(assembly_daily_file, senate_daily_file). Default: all.")
|
|
pi.add_argument("--force", action="store_true",
|
|
help="Re-fetch even if already cached.")
|
|
pi.add_argument("--json", action="store_true",
|
|
help="Emit the result as JSON.")
|
|
pi.add_argument("--store-events", dest="store_events", action="store_true",
|
|
help="Fetch + parse the chamber 'upcoming events' calendar "
|
|
"and store it in the scheduled_events table "
|
|
"(forward-looking; the daily-file queue is backward).")
|
|
pi.add_argument("--db", dest="db", default=None,
|
|
help="SQLite path for --store-events "
|
|
"(default: the relational lane's db/daily_file.sqlite).")
|
|
|
|
pl = sub.add_parser("list", help="List configured sources + URLs")
|
|
pc = sub.add_parser("check", help="Cache status for a date (no network)")
|
|
pc.add_argument("--date", help="YYYY-MM-DD (default: today)")
|
|
|
|
return p
|
|
|
|
|
|
def main(argv=None) -> int:
|
|
# argv defaults to sys.argv[1:] when the module is run directly.
|
|
parser = build_parser()
|
|
args = parser.parse_args(argv)
|
|
args.validate = True
|
|
handlers = {
|
|
"ingest": cmd_ingest,
|
|
"list": cmd_list,
|
|
"check": cmd_check,
|
|
"parse": handle_parse,
|
|
"migrate": handle_migrate,
|
|
"correlate": handle_correlate,
|
|
"analyze": handle_analyze,
|
|
"dashboard": handle_dashboard,
|
|
}
|
|
handler = handlers.get(args.command)
|
|
if handler is None:
|
|
parser.print_help()
|
|
return 2
|
|
return handler(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|