""" Lighthouse audit report payload for the Data explorer UI. Reads ``bronze.bronze_jurisdiction_website_lighthouse`` (same table as accessibility persist). """ from __future__ import annotations from typing import Any, Dict, List, Optional from fastapi import APIRouter, HTTPException, Query from loguru import logger from pydantic import BaseModel, Field from api.routes.stats_neon import get_db_pool router = APIRouter(prefix="/lighthouse", tags=["lighthouse"]) BRONZE_LIGHTHOUSE = "bronze.bronze_jurisdiction_website_lighthouse" class LighthouseScores(BaseModel): performance: Optional[int] = None accessibility: Optional[int] = None best_practices: Optional[int] = None seo: Optional[int] = None class LighthouseReportResponse(BaseModel): scan_key: str batch_id: str jurisdiction_id: str website_url: str final_url: Optional[str] = None scanned_at: Optional[str] = None status: str lighthouse_version: Optional[str] = None requested_url: Optional[str] = Field(None, description="From LHR requestedUrl when present") scores: LighthouseScores run_warnings: List[str] = Field(default_factory=list) screenshot_data_url: Optional[str] = Field( None, description="data: URL when final-screenshot is present in LHR" ) def _url_variants(url: str) -> List[str]: u = (url or "").strip() if not u: return [] no_slash = u.rstrip("/") with_slash = no_slash + "/" return list(dict.fromkeys([u, no_slash, with_slash])) def _dict_from_results(results: Any) -> Dict[str, Any]: if isinstance(results, dict): return results if results is None: return {} try: import json if isinstance(results, (bytes, bytearray)): results = results.decode("utf-8") if isinstance(results, str): return dict(json.loads(results)) except Exception: return {} return {} def _lhr_from_row(row_results: Any) -> Optional[Dict[str, Any]]: blob = _dict_from_row(row_results) lhr = blob.get("lhr") if isinstance(lhr, dict): return lhr return None def _score_to_int100(val: Any) -> Optional[int]: if val is None: return None try: v = float(val) if v <= 1.0: return int(round(v * 100)) return int(round(v)) except (TypeError, ValueError): return None def _category_score(lhr: Optional[Dict[str, Any]], key: str) -> Optional[int]: if not lhr: return None cats = lhr.get("categories") if not isinstance(cats, dict): return None cat = cats.get(key) if not isinstance(cat, dict): return None return _score_to_int100(cat.get("score")) def _merge_score(column: Optional[int], lhr: Optional[Dict[str, Any]], cat_key: str) -> Optional[int]: if column is not None: return column return _category_score(lhr, cat_key) def _extract_screenshot_data_url(lhr: Optional[Dict[str, Any]]) -> Optional[str]: if not lhr: return None audits = lhr.get("audits") if not isinstance(audits, dict): return None for key in ("final-screenshot", "full-page-screenshot"): audit = audits.get(key) if not isinstance(audit, dict): continue details = audit.get("details") if not isinstance(details, dict): continue if details.get("type") == "screenshot" and details.get("data"): data = str(details["data"]) mime = "image/jpeg" return f"data:{mime};base64,{data}" items = details.get("items") if isinstance(items, list) and items: first = items[0] if isinstance(first, dict) and first.get("data"): data = str(first["data"]) return f"data:image/jpeg;base64,{data}" thumbs = audits.get("screenshot-thumbnails") if isinstance(thumbs, dict): det = thumbs.get("details") if isinstance(det, dict): items = det.get("items") if isinstance(items, list) and items: last = items[-1] if isinstance(last, dict) and last.get("data"): return f"data:image/jpeg;base64,{str(last['data'])}" return None def _warnings_from_lhr(lhr: Optional[Dict[str, Any]]) -> List[str]: if not lhr: return [] raw = lhr.get("runWarnings") if not isinstance(raw, list): return [] out: List[str] = [] for w in raw: if isinstance(w, str) and w.strip(): out.append(w.strip()) return out @router.get("/report", response_model=LighthouseReportResponse) async def get_lighthouse_report( website_url: str = Query(..., min_length=4, description="Homepage URL as stored in website_url"), batch_id: Optional[str] = Query(None, description="Restrict to a specific accessibility batch_id"), jurisdiction_id: Optional[str] = Query(None, description="Optional jurisdiction_id filter"), ): """ Latest successful-looking Lighthouse row for a website (matches URL with/without trailing slash). """ variants = _url_variants(website_url) if not variants: raise HTTPException(status_code=400, detail="website_url is required") try: pool = await get_db_pool() except ValueError as e: logger.error(f"Lighthouse report: database not configured: {e}") raise HTTPException(status_code=503, detail="Database not configured") from e where_extra = "" params: List[Any] = [variants] idx = 2 if batch_id: where_extra += f" AND batch_id = ${idx}" params.append(batch_id) idx += 1 if jurisdiction_id: where_extra += f" AND jurisdiction_id = ${idx}" params.append(jurisdiction_id) idx += 1 sql = f""" SELECT scan_key, batch_id, jurisdiction_id, website_url, final_url, scanned_at, status, lighthouse_version, score_accessibility, score_performance, score_best_practices, results FROM {BRONZE_LIGHTHOUSE} WHERE website_url = ANY($1::text[]) {where_extra} ORDER BY scanned_at DESC NULLS LAST LIMIT 1 """ async with pool.acquire() as conn: row = await conn.fetchrow(sql, *params) if row is None: raise HTTPException( status_code=404, detail="No Lighthouse audit found for this URL (run the lighthouse scan and persist to bronze).", ) lhr = _lhr_from_row(row["results"]) perf = _merge_score(row["score_performance"], lhr, "performance") acc = _merge_score(row["score_accessibility"], lhr, "accessibility") bp = _merge_score(row["score_best_practices"], lhr, "best-practices") seo = _category_score(lhr, "seo") scanned_at = row["scanned_at"] scanned_iso = scanned_at.isoformat() if scanned_at is not None else None requested = None if isinstance(lhr, dict): requested = lhr.get("requestedUrl") or lhr.get("finalUrl") or lhr.get("finalDisplayedUrl") if requested is not None: requested = str(requested) return LighthouseReportResponse( scan_key=str(row["scan_key"]), batch_id=str(row["batch_id"]), jurisdiction_id=str(row["jurisdiction_id"]), website_url=str(row["website_url"]), final_url=str(row["final_url"]) if row["final_url"] else None, scanned_at=scanned_iso, status=str(row["status"]), lighthouse_version=str(row["lighthouse_version"]) if row["lighthouse_version"] else None, requested_url=requested, scores=LighthouseScores( performance=perf, accessibility=acc, best_practices=bp, seo=seo, ), run_warnings=_warnings_from_lhr(lhr), screenshot_data_url=_extract_screenshot_data_url(lhr), )