Spaces:
Running on CPU Upgrade
Running on CPU Upgrade
| #!/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() | |