open-navigator / scripts /discovery /civicclerk_public_api.py
jcbowyer's picture
Clean HuggingFace deployment without binary files
e59d91d
Raw
History Blame Contribute Delete
8.58 kB
"""
CivicClerk public portal OData API (no browser calendar navigation required).
Northport embeds ``clerkEmbedProps = { tenant: "northportal" }`` on
https://www.northportal.gov/129/Agendas-Minutes ; events and agenda PDFs are available at
``https://{tenant}.api.civicclerk.com/v1/Events`` with file blobs via ``Meetings/GetMeetingFile``.
"""
from __future__ import annotations
import json
import os
import re
from datetime import datetime, timezone
from typing import Any, Dict, Iterator, List, Optional, Tuple
from urllib.parse import quote, urlparse
import httpx
_CLERK_EMBED_TENANT_RE = re.compile(
r"""clerkEmbedProps\s*=\s*\{[^}]*["']tenant["']\s*:\s*["']([^"']+)["']""",
re.I | re.S,
)
_PORTAL_HOST_RE = re.compile(r"^([a-z0-9-]+)\.portal\.civicclerk\.com$", re.I)
def civicclerk_api_base(tenant: str) -> str:
t = (tenant or "").strip().lower().replace(".portal.civicclerk.com", "")
if not t:
raise ValueError("CivicClerk tenant slug is required")
return f"https://{t}.api.civicclerk.com/v1"
def civicclerk_portal_event_url(tenant: str, event_id: int) -> str:
t = (tenant or "").strip().lower()
return f"https://{t}.portal.civicclerk.com/event/{int(event_id)}"
def detect_civicclerk_tenant(
*,
homepage_url: str = "",
html: str = "",
extra_urls: Optional[List[str]] = None,
) -> Optional[str]:
"""Infer tenant slug from embed script, portal links, or ``{slug}.api.civicclerk.com``."""
for blob in [html or ""] + list(extra_urls or []):
m = _CLERK_EMBED_TENANT_RE.search(blob)
if m:
return m.group(1).strip().lower()
for raw in list(extra_urls or []) + ([homepage_url] if homepage_url else []):
try:
host = (urlparse(raw).netloc or "").lower()
except Exception:
continue
pm = _PORTAL_HOST_RE.match(host.split(":")[0])
if pm:
return pm.group(1).lower()
if host.endswith(".api.civicclerk.com"):
return host.split(".")[0].lower()
try:
host = (urlparse(homepage_url).netloc or "").lower().split(":")[0]
except Exception:
host = ""
# northportal.gov -> northportal (CivicPlus site slug often matches Clerk tenant)
if host and "." in host:
label = host.split(".")[0]
if label and label not in ("www", "cityof"):
return label
return None
def civicclerk_doc_type(file_type: str, file_name: str = "") -> str:
ft = (file_type or "").strip().lower()
name = (file_name or "").strip().lower()
if "minute" in ft or "minute" in name:
return "minutes"
if "agenda packet" in ft or ("agenda" in ft and "packet" in ft):
return "agenda_packet"
if "agenda" in ft or "agenda" in name:
return "agenda"
if "packet" in ft or "packet" in name:
return "agenda_packet"
if "notice" in ft:
return "packet"
return "unknown"
def civicclerk_portal_file_url(tenant: str, event_id: int, pf: Dict[str, Any]) -> str:
"""
Public portal deep link (e.g. ``…/event/3545/files/agenda/3290``) for a published file row.
"""
t = (tenant or "").strip().lower()
eid = int(event_id)
fid = int(pf.get("fileId") or 0)
if not t or not eid:
return ""
if not fid:
return civicclerk_portal_event_url(t, eid)
return f"https://{t}.portal.civicclerk.com/event/{eid}/files/agenda/{fid}"
def event_needs_civicclerk_detail(event: Dict[str, Any]) -> bool:
"""True when the event may have downloadable agenda/minutes (flags or embedded ``publishedFiles``)."""
if event.get("hasAgenda") or event.get("hasMedia") or int(event.get("agendaId") or 0) > 0:
return True
return bool(published_files_from_event(event))
def _years_window() -> Tuple[int, int]:
now = datetime.now(timezone.utc).year
back = int((os.getenv("SCRAPED_MEETINGS_CIVICCLERK_YEARS_BACK") or "12").strip() or "12")
ahead = int((os.getenv("SCRAPED_MEETINGS_CIVICCLERK_YEARS_AHEAD") or "3").strip() or "3")
return now - max(1, back), now + max(0, ahead)
def iter_events_for_year(
client: httpx.Client,
tenant: str,
calendar_year: int,
*,
page_size: int = 100,
) -> Iterator[Dict[str, Any]]:
"""Yield event summaries for one calendar year (OData ``year(startDateTime)`` filter)."""
base = civicclerk_api_base(tenant)
skip = 0
while True:
filt = f"year(startDateTime) eq {int(calendar_year)}"
url = (
f"{base}/Events?$filter={quote(filt)}"
f"&$orderby=startDateTime desc&$top={int(page_size)}&$skip={int(skip)}"
)
resp = client.get(url, timeout=120.0)
resp.raise_for_status()
data = resp.json()
batch = data.get("value") if isinstance(data, dict) else None
if not isinstance(batch, list) or not batch:
break
for row in batch:
if isinstance(row, dict):
yield row
if len(batch) < page_size:
break
skip += page_size
def fetch_event_detail(client: httpx.Client, tenant: str, event_id: int) -> Dict[str, Any]:
base = civicclerk_api_base(tenant)
resp = client.get(f"{base}/Events/{int(event_id)}", timeout=120.0)
resp.raise_for_status()
data = resp.json()
return data if isinstance(data, dict) else {}
async def resolve_meeting_file_download_url(
client: httpx.AsyncClient,
api_file_url: str,
) -> Tuple[Optional[str], str]:
"""
``GetMeetingFile`` returns JSON ``{ "blobUri": "https://...pdf?..." }``.
"""
raw = (api_file_url or "").strip()
if not raw:
return None, "empty_url"
try:
resp = await client.get(raw, timeout=120.0)
if resp.status_code != 200:
return None, f"get_meeting_file_http_{resp.status_code}"
payload = resp.json()
except (httpx.HTTPError, json.JSONDecodeError) as exc:
return None, f"get_meeting_file:{exc!r}"
if not isinstance(payload, dict):
return None, "get_meeting_file_not_object"
blob = str(payload.get("blobUri") or "").strip()
if not blob:
return None, "no_blob_uri"
return blob, ""
async def download_meeting_file_bytes(
client: httpx.AsyncClient,
api_file_url: str,
) -> Tuple[Optional[bytes], str]:
blob_url, why = await resolve_meeting_file_download_url(client, api_file_url)
if not blob_url:
return None, why
try:
resp = await client.get(blob_url, timeout=180.0, follow_redirects=True)
if resp.status_code != 200:
return None, f"blob_http_{resp.status_code}"
if not resp.content or len(resp.content) < 128:
return None, "blob_empty_or_tiny"
return resp.content, ""
except httpx.HTTPError as exc:
return None, f"blob_fetch:{exc!r}"
def published_files_from_event(event: Dict[str, Any]) -> List[Dict[str, Any]]:
files = event.get("publishedFiles")
return list(files) if isinstance(files, list) else []
def event_anchor_text(
event: Dict[str, Any],
pf: Dict[str, Any],
*,
meeting_date: Optional[Any] = None,
) -> str:
"""Title for PDF naming; calendar date belongs in the filename prefix, not repeated here."""
from datetime import date as _date
from scrapers.discovery.meeting_document_naming import strip_redundant_meeting_date_from_title
name = str(pf.get("name") or "").strip()
if meeting_date and isinstance(meeting_date, _date):
name = strip_redundant_meeting_date_from_title(name, meeting_date) or name
en = str(event.get("eventName") or "").strip()
if meeting_date and isinstance(meeting_date, _date):
en = strip_redundant_meeting_date_from_title(en, meeting_date) or en
cat = str(event.get("categoryName") or event.get("eventCategoryName") or "").strip()
parts = [p for p in (name, en, cat) if p]
return " — ".join(parts)[:500]
def iter_all_events(
client: httpx.Client,
tenant: str,
*,
years_back: Optional[int] = None,
years_ahead: Optional[int] = None,
newest_calendar_years_first: bool = False,
) -> Iterator[Dict[str, Any]]:
y_min, y_max = _years_window()
if years_back is not None:
y_min = datetime.now(timezone.utc).year - int(years_back)
if years_ahead is not None:
y_max = datetime.now(timezone.utc).year + int(years_ahead)
years = list(range(y_min, y_max + 1))
if newest_calendar_years_first:
years = list(reversed(years))
for year in years:
yield from iter_events_for_year(client, tenant, year)