open-navigator / scripts /discovery /civicclerk_meetings_sync.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
14.8 kB
#!/usr/bin/env python3
"""
Sync CivicClerk public API events and agenda/minutes PDFs into scraped meetings cache.
Bypasses the JS calendar on pages like https://www.northportal.gov/129/Agendas-Minutes by calling
``https://{tenant}.api.civicclerk.com/v1/Events`` (OData) and downloading ``publishedFiles``.
Examples::
.venv/bin/python scripts/discovery/civicclerk_meetings_sync.py \\
--state AL --geoid 0155200 --type municipality \\
--tenant northportal --max-pdfs 80
.venv/bin/python -m scripts.discovery.comprehensive_discovery_pipeline_jurisdiction \\
--state AL --geoid 0155200 --type municipality \\
--url https://www.northportal.gov/129/Agendas-Minutes \\
--civicclerk-tenant northportal --max-pdfs 80 --max-pages 20
"""
from __future__ import annotations
import argparse
import asyncio
import json
import os
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional, Set, Tuple
import httpx
_root = Path(__file__).resolve().parents[2]
for _p in (str(_root), str(_root / "packages" / "core-lib" / "src")):
if _p not in sys.path:
sys.path.insert(0, _p)
from scripts.discovery.civicclerk_public_api import (
civicclerk_doc_type,
civicclerk_portal_event_url,
civicclerk_portal_file_url,
detect_civicclerk_tenant,
download_meeting_file_bytes,
event_anchor_text,
event_needs_civicclerk_detail,
fetch_event_detail,
iter_all_events,
published_files_from_event,
)
from scrapers.discovery.meeting_document_naming import (
allocate_unique_pdf_path,
infer_calendar_folder_year,
parse_iso_calendar_date_prefix,
)
from scripts.discovery.meetings_platform_heuristics import classify_document
from core_lib.gdrive_paths import resolve_scraped_meetings_output_root
from scripts.utils.http_url_normalize import normalize_http_url_path_encoding as _normalize_http_url_path_encoding
_DEFAULT_UA = (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/120.0.0.0 Safari/537.36 OpenNavigatorMeetings/1.0"
)
_DOWNLOADABLE_TYPES = frozenset(
{"agenda", "agenda packet", "minutes", "notice", "other", "packet", "supplement"}
)
def _civicclerk_sync_enabled() -> bool:
v = (os.getenv("SCRAPED_MEETINGS_CIVICCLERK_SYNC") or "true").strip().lower()
return v not in ("0", "false", "no", "off")
def _jurisdiction_base_dir(output_root: Path, state: str, jurisdiction_id: str) -> Path:
from llm.gemini.transcript_cache_paths import scraped_meetings_jurisdiction_dir
return scraped_meetings_jurisdiction_dir(
output_root,
state_code=state,
jurisdiction_id=jurisdiction_id,
)
def _event_summary_row(tenant: str, event: Dict[str, Any]) -> Dict[str, Any]:
eid = int(event.get("id") or 0)
start = str(event.get("startDateTime") or event.get("eventDate") or "")
year_s = start[:4] if len(start) >= 4 and start[:4].isdigit() else ""
return {
"event_id": eid,
"start_date_time": start,
"year": year_s,
"event_name": str(event.get("eventName") or "")[:500],
"category_name": str(event.get("categoryName") or event.get("eventCategoryName") or "")[:200],
"portal_url": civicclerk_portal_event_url(tenant, eid) if eid else None,
"has_agenda": bool(event.get("hasAgenda")),
"has_media": bool(event.get("hasMedia")),
"is_published": str(event.get("isPublished") or ""),
"published_file_count": len(published_files_from_event(event)),
}
async def sync_civicclerk_meetings_async(
*,
output_root: Path,
state: str,
jurisdiction_id: str,
tenant: str,
homepage_url: str,
max_pdfs: int,
existing_pdfs: Optional[List[Dict[str, Any]]] = None,
pdf_count_start: int = 0,
timeout_s: float = 120.0,
detail_concurrency: int = 6,
) -> Tuple[List[Dict[str, Any]], List[Dict[str, Any]], List[str], int]:
"""
Return ``(civicclerk_events, new_pdf_rows, errors, pdf_count)``.
"""
errors: List[str] = []
civicclerk_events: List[Dict[str, Any]] = []
new_pdf_rows: List[Dict[str, Any]] = []
pdf_count = int(pdf_count_start)
pdfs_seen: Set[str] = set()
for row in existing_pdfs or []:
u = str(row.get("url") or "").strip()
if u:
pdfs_seen.add(_normalize_http_url_path_encoding(u))
base_dir = _jurisdiction_base_dir(output_root, state, jurisdiction_id)
year_now = datetime.now(timezone.utc).year
def _target_dir(year: int) -> Path:
return base_dir / str(year)
reserved_names: Set[str] = set()
reserved_paths: Set[str] = set()
need_detail: List[Dict[str, Any]] = []
with httpx.Client(
headers={"User-Agent": _DEFAULT_UA, "Accept": "application/json"},
follow_redirects=True,
timeout=timeout_s,
) as sync_client:
summaries = list(
iter_all_events(sync_client, tenant, newest_calendar_years_first=True)
)
for ev in summaries:
if ev.get("isDeleted"):
continue
if str(ev.get("isPublished") or "").lower() not in ("published",):
continue
eid = int(ev.get("id") or 0)
if not eid:
continue
civicclerk_events.append(_event_summary_row(tenant, ev))
if event_needs_civicclerk_detail(ev):
need_detail.append(ev)
sem = asyncio.Semaphore(max(1, detail_concurrency))
async def _fetch_one(ev: Dict[str, Any]) -> Dict[str, Any]:
eid = int(ev.get("id") or 0)
async with sem:
return await asyncio.to_thread(fetch_event_detail, sync_client, tenant, eid)
details: List[Dict[str, Any]] = []
if need_detail:
details = await asyncio.gather(*[_fetch_one(ev) for ev in need_detail])
details.sort(key=lambda e: str(e.get("startDateTime") or ""), reverse=True)
async with httpx.AsyncClient(
headers={"User-Agent": _DEFAULT_UA, "Accept": "application/json, application/pdf"},
follow_redirects=True,
timeout=timeout_s,
) as client:
for event in details:
if pdf_count >= max_pdfs:
break
eid = int(event.get("id") or 0)
start_raw = str(event.get("startDateTime") or event.get("eventDate") or "")
ys = start_raw[:4]
if ys.isdigit() and len(ys) == 4:
y = int(ys)
else:
y = infer_calendar_folder_year(
civicclerk_portal_event_url(tenant, eid),
str(event.get("eventName") or ""),
"",
fallback_year=year_now,
)
dest_dir = _target_dir(y)
dest_dir.mkdir(parents=True, exist_ok=True)
for pf in published_files_from_event(event):
if pdf_count >= max_pdfs:
break
ft = str(pf.get("type") or "").strip()
if ft.lower() not in _DOWNLOADABLE_TYPES and "agenda" not in ft.lower():
continue
api_url = str(pf.get("url") or "").strip()
if not api_url or api_url in pdfs_seen:
continue
pdfs_seen.add(api_url)
blob, why = await download_meeting_file_bytes(client, api_url)
if blob is None:
errors.append(f"civicclerk_download:{eid}:{api_url[:80]}:{why}")
continue
meeting_d = parse_iso_calendar_date_prefix(start_raw)
anchor = event_anchor_text(event, pf, meeting_date=meeting_d)
doc_label = civicclerk_doc_type(ft, str(pf.get("name") or ""))
if doc_label == "unknown":
doc_label = classify_document(api_url, anchor)
dest = allocate_unique_pdf_path(
dest_dir,
api_url,
anchor,
doc_label,
year_fallback=str(y),
reserved_basenames=reserved_names,
reserved_paths=reserved_paths,
meeting_date=meeting_d,
)
dest.write_bytes(blob)
reserved_names.add(dest.name)
try:
reserved_paths.add(str(dest.resolve()))
except OSError:
reserved_paths.add(str(dest))
portal_file = civicclerk_portal_file_url(tenant, eid, pf)
row: Dict[str, Any] = {
"url": api_url,
"path": str(dest.resolve()),
"year": str(y),
"bytes": len(blob),
"doc_type": doc_label,
"anchor_text": anchor,
"storage_suffix": ".pdf",
"source_kind": "civicclerk_api",
"civicclerk_event_id": eid,
"civicclerk_file_id": int(pf.get("fileId") or 0) or None,
"civicclerk_file_type": ft,
"civicclerk_portal_url": civicclerk_portal_event_url(tenant, eid),
"civicclerk_portal_file_url": portal_file or None,
"discovered_on": homepage_url or portal_file or civicclerk_portal_event_url(tenant, eid),
}
new_pdf_rows.append(row)
pdf_count += 1
return civicclerk_events, new_pdf_rows, errors, pdf_count
def merge_manifest_civicclerk(
manifest_path: Path,
*,
civicclerk_events: List[Dict[str, Any]],
new_pdf_rows: List[Dict[str, Any]],
errors: List[str],
tenant: str,
) -> None:
try:
data: Dict[str, Any] = json.loads(manifest_path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
data = {}
prior_events = data.get("civicclerk_events")
if not isinstance(prior_events, list):
prior_events = []
seen_eid = {int(r.get("event_id") or 0) for r in prior_events if isinstance(r, dict)}
for row in civicclerk_events:
if not isinstance(row, dict):
continue
eid = int(row.get("event_id") or 0)
if eid and eid not in seen_eid:
prior_events.append(row)
seen_eid.add(eid)
pdfs = data.get("pdfs")
if not isinstance(pdfs, list):
pdfs = []
seen_url = {str(r.get("url") or "").strip() for r in pdfs if isinstance(r, dict)}
for row in new_pdf_rows:
u = str(row.get("url") or "").strip()
if u and u not in seen_url:
pdfs.append(row)
seen_url.add(u)
stacks = data.get("detected_stacks")
if not isinstance(stacks, list):
stacks = []
if "civicclerk" not in stacks:
stacks.append("civicclerk")
data["civicclerk_events"] = prior_events
data["civicclerk_tenant"] = tenant
data["pdfs"] = pdfs
data["detected_stacks"] = stacks
data["civicclerk_synced_at"] = datetime.now(timezone.utc).isoformat()
errs = data.get("errors")
if not isinstance(errs, list):
errs = []
errs.extend(errors[:50])
data["errors"] = errs
manifest_path.write_text(json.dumps(data, indent=2), encoding="utf-8")
async def _async_main(args: argparse.Namespace) -> int:
output_root = (
Path(args.output_root).expanduser().resolve()
if args.output_root.strip()
else resolve_scraped_meetings_output_root().resolve()
)
jtype = "county" if args.type == "county" else "municipality"
jid = f"{jtype}_{args.geoid.strip()}"
base_dir = _jurisdiction_base_dir(output_root, args.state, jid)
manifest_path = base_dir / "_manifest.json"
tenant = (args.tenant or "").strip().lower()
if not tenant and manifest_path.is_file():
try:
prior = json.loads(manifest_path.read_text(encoding="utf-8"))
tenant = str(prior.get("civicclerk_tenant") or "").strip().lower()
except (OSError, json.JSONDecodeError):
pass
if not tenant:
html = ""
crawl = base_dir / "_crawl_html"
for name in ("page__129_Agendas-Minutes.html", "page__AgendaCenter.html"):
p = crawl / name
if p.is_file():
html = p.read_text(encoding="utf-8", errors="replace")
break
tenant = detect_civicclerk_tenant(
homepage_url=args.homepage_url or "https://www.northportal.gov/",
html=html,
) or ""
if not tenant:
print("Could not detect CivicClerk tenant; pass --tenant northportal", file=sys.stderr)
return 1
prior_pdfs: List[Dict[str, Any]] = []
if manifest_path.is_file():
try:
prior = json.loads(manifest_path.read_text(encoding="utf-8"))
prior_pdfs = list(prior.get("pdfs") or [])
except (OSError, json.JSONDecodeError):
pass
events, pdf_rows, errors, _ = await sync_civicclerk_meetings_async(
output_root=output_root,
state=args.state,
jurisdiction_id=jid,
tenant=tenant,
homepage_url=args.homepage_url or "https://www.northportal.gov/129/Agendas-Minutes",
max_pdfs=max(0, int(args.max_pdfs)),
existing_pdfs=prior_pdfs,
pdf_count_start=len(prior_pdfs),
timeout_s=float(args.timeout),
)
base_dir.mkdir(parents=True, exist_ok=True)
merge_manifest_civicclerk(
manifest_path,
civicclerk_events=events,
new_pdf_rows=pdf_rows,
errors=errors,
tenant=tenant,
)
print(
json.dumps(
{
"tenant": tenant,
"events_indexed": len(events),
"pdfs_downloaded": len(pdf_rows),
"errors": len(errors),
"manifest": str(manifest_path),
},
indent=2,
)
)
return 0 if not errors else 0
def main() -> None:
ap = argparse.ArgumentParser(description="Sync CivicClerk API agendas/minutes into scrape cache.")
ap.add_argument("--state", required=True)
ap.add_argument("--geoid", required=True)
ap.add_argument("--type", default="municipality", choices=("municipality", "county"))
ap.add_argument("--tenant", default="", help="CivicClerk tenant slug (e.g. northportal)")
ap.add_argument("--homepage-url", default="https://www.northportal.gov/129/Agendas-Minutes")
ap.add_argument("--output-root", default="")
ap.add_argument("--max-pdfs", type=int, default=80)
ap.add_argument("--timeout", type=float, default=120.0)
args = ap.parse_args()
raise SystemExit(asyncio.run(_async_main(args)))
if __name__ == "__main__":
main()