diff --git a/README.md b/README.md index 72d03de..0c09441 100644 --- a/README.md +++ b/README.md @@ -539,6 +539,18 @@ Additional packages: pip install openai tavily-python python-dotenv ``` +The USDA APHIS livestock scraper drives a headless browser to export the +Tableau crosstab. After installing `requirements.txt`, download the browser +once: + +```bash +playwright install chromium +``` + +If the browser is unavailable, that scraper degrades gracefully (it returns +no data and the generic fetcher is used instead) — the rest of the pipeline +is unaffected. + --- ## Environment Variables diff --git a/bioscancast/datasets/sources.yaml b/bioscancast/datasets/sources.yaml index dfa2a22..5a590d0 100644 --- a/bioscancast/datasets/sources.yaml +++ b/bioscancast/datasets/sources.yaml @@ -217,6 +217,11 @@ specific_pathogen_sources: url: "https://www.paho.org/en/topics/oropouche-virus-disease" geography: "Americas" + - id: "paho_oropouche_portal" + name: "ARBO Portal - Oropouche" + url: "https://www.paho.org/en/arbo-portal/arbo-portal-oropouche" + geography: "Americas" + enteric: cholera: - id: "who_cholera" diff --git a/bioscancast/stages/extraction/custom_scrapers/paho_oropouche_portal.py b/bioscancast/stages/extraction/custom_scrapers/paho_oropouche_portal.py new file mode 100644 index 0000000..ae81f12 --- /dev/null +++ b/bioscancast/stages/extraction/custom_scrapers/paho_oropouche_portal.py @@ -0,0 +1,318 @@ +"""Custom scraper for PAHO ARBO Portal Oropouche weekly CSV. + +The PAHO Oropouche portal is dashboard-backed. This scraper pulls the downloadable +underlying CSV and renders computed weekly/country analytics as compact HTML so +standard extraction + insight stages can consume the information. +""" + +from __future__ import annotations + +import html +import io +import re +import unicodedata +from datetime import datetime, timezone +from typing import Callable, Optional + +import numpy as np +import pandas as pd +from curl_cffi import requests as curl_requests + +from bioscancast.stages.extraction.config import ExtractionConfig +from bioscancast.stages.extraction.fetcher import FetchResult + +CSV_URL = ( + "https://phip.paho.org/vizql/w/AME_OROV_Cases/v/Oropouche/vudcsv/" + "sessions/21F758F6EAB9431B932EB51F61EBDE01-1:0/views/" + "10956961424773455462_12644916002446576356" + "?underlying_table_id=FZ_AME_OROV_Cases_SE_12E30DDB8F874DC6AE5888461B08AB74" + "&underlying_table_caption=Full%20Data" +) + +CsvFetcher = Callable[[str, ExtractionConfig], Optional[str]] + + +def _fetch_csv_text(url: str, cfg: ExtractionConfig) -> Optional[str]: + try: + resp = curl_requests.get( + url, + timeout=max(cfg.fetch_timeout_seconds, 30.0), + impersonate=cfg.impersonate, + allow_redirects=True, + ) + except Exception: + return None + + if resp.status_code != 200: + return None + + text = (resp.text or "").strip() + return text or None + + +def _normalize_col(name: str) -> str: + folded = unicodedata.normalize("NFKD", name or "") + folded = "".join(ch for ch in folded if not unicodedata.combining(ch)) + folded = folded.lower().strip() + folded = re.sub(r"[^a-z0-9]+", "_", folded) + return folded.strip("_") + + +def _resolve_columns(df: pd.DataFrame) -> tuple[str, str, str, str] | None: + mapping = {_normalize_col(c): c for c in df.columns} + + year = mapping.get("ano") + week = mapping.get("semanas_epi") + confirmed = mapping.get("week_lab_confirmed") + country = mapping.get("pais") + + if not all((year, week, confirmed, country)): + return None + return year, week, confirmed, country + + +def _r2(y: np.ndarray, yhat: np.ndarray) -> float: + ss_res = float(np.sum((y - yhat) ** 2)) + ss_tot = float(np.sum((y - np.mean(y)) ** 2)) + if ss_tot <= 0.0: + return 1.0 if ss_res <= 1e-12 else 0.0 + return max(0.0, min(1.0, 1.0 - (ss_res / ss_tot))) + + +def _fit_linear(counts: pd.Series) -> dict[str, float] | None: + if counts.shape[0] < 2: + return None + y = counts.to_numpy(dtype=float) + x = np.arange(y.shape[0], dtype=float) + slope, intercept = np.polyfit(x, y, 1) + yhat = slope * x + intercept + return { + "intercept": float(intercept), + "slope": float(slope), + "r2": _r2(y, yhat), + } + + +def _fit_exponential(counts: pd.Series) -> dict[str, float] | None: + if counts.shape[0] < 2: + return None + y = counts.to_numpy(dtype=float) + x = np.arange(y.shape[0], dtype=float) + + mask = y > 0 + if int(np.sum(mask)) < 2: + return None + + x_pos = x[mask] + y_pos = y[mask] + b, ln_a = np.polyfit(x_pos, np.log(y_pos), 1) + a = float(np.exp(ln_a)) + yhat = a * np.exp(b * x_pos) + + return { + "a": a, + "b": float(b), + "r2": _r2(y_pos, yhat), + } + + +def _fmt_stats(series: pd.Series) -> str: + if series.empty: + return "n=0" + desc = series.describe() + std = desc["std"] if pd.notna(desc["std"]) else 0.0 + return ( + f"n={int(desc['count'])}, mean={desc['mean']:.2f}, std={std:.2f}, " + f"min={desc['min']:.0f}, median={series.median():.0f}, max={desc['max']:.0f}" + ) + + +def _render_counts_table(title: str, counts: pd.Series, key_header: str) -> str: + rows = [] + for idx, value in counts.items(): + rows.append(f"
| {html.escape(key_header)} | sum_week_lab_confirmed |
|---|
Input points: {counts.shape[0]}.
") + if linear is None: + lines.append("Linear model: unavailable (insufficient data).
") + else: + lines.append( + "Linear model y = intercept + slope*x: " + f"intercept={linear['intercept']:.6f}, slope={linear['slope']:.6f}, R^2={linear['r2']:.6f}.
" + ) + + if exp is None: + lines.append( + "Exponential model y = a*exp(b*x): unavailable " + "(insufficient strictly-positive points).
" + ) + else: + lines.append( + "Exponential model y = a*exp(b*x): " + f"a={exp['a']:.6f}, b={exp['b']:.6f}, R^2={exp['r2']:.6f}.
" + ) + return "".join(lines) + + +def _render_country_summary(df: pd.DataFrame, country_col: str, value_col: str) -> str: + if df.empty: + return "No country data available.
" + + g = df.groupby(country_col)[value_col].sum().sort_values(ascending=False) + summary = g.describe() + std = summary["std"] if pd.notna(summary["std"]) else 0.0 + + rows = [] + for country, value in g.items(): + rows.append( + f"Affected states/countries (Pais values): {int(g.shape[0])}. " + f"Per-country cumulative summary: n={int(summary['count'])}, mean={summary['mean']:.2f}, " + f"std={std:.2f}, min={summary['min']:.0f}, median={g.median():.0f}, max={summary['max']:.0f}. " + f"Cumulative confirmed count across all Pais: {int(g.sum())}.
" + "| Pais | cumulative_week_lab_confirmed |
|---|
Source portal URL: {html.escape(url)}
" + f"CSV source URL: {html.escape(CSV_URL)}
" + f"Retrieved at: {fetched_at.isoformat()} | latest year_week: {html.escape(latest_week)}.
" + "Summary statistics (weekly summed Week Lab Confirmed): {_fmt_stats(weekly_series)}. " + f"Cumulative confirmed count across all weeks: {cumulative_all}.
" + f"{_render_counts_table('Counts by year_week', weekly_series, 'year_week')}" + f"{_render_model_section('Model fit on past 24 weeks (approx past 6 months)', recent_24_series, linear=lin, exp=exp)}" + "Grouped rows (year_week x Pais): {int(country_week.shape[0])}.
" + f"{_render_country_summary(df, country_col, confirmed_col)}" + "" + ).encode("utf-8") + + return FetchResult( + url=CSV_URL, + final_url=CSV_URL, + status_code=200, + content_type="text/html", + content_bytes=rendered, + fetched_at=fetched_at, + error=None, + ) diff --git a/bioscancast/stages/extraction/custom_scrapers/usda_aphis_livestock.py b/bioscancast/stages/extraction/custom_scrapers/usda_aphis_livestock.py new file mode 100644 index 0000000..23d9377 --- /dev/null +++ b/bioscancast/stages/extraction/custom_scrapers/usda_aphis_livestock.py @@ -0,0 +1,419 @@ +"""Custom scraper for USDA APHIS HPAI livestock detections. + +The public APHIS dashboard is a client-rendered Tableau page; this scraper uses +headless browser automation to export the Tableau crosstab CSV and renders +compact analytical prose/tables so the regular HTML parser + insight extraction +pipeline can operate without additional parser changes. +""" + +from __future__ import annotations + +import html +import logging +from datetime import datetime, timezone +from typing import Callable, Optional + +import numpy as np +import pandas as pd + +from bioscancast.stages.extraction.config import ExtractionConfig +from bioscancast.stages.extraction.fetcher import FetchResult + +logger = logging.getLogger(__name__) + +TABLEAU_VIEW_URL = ( + "https://publicdashboards.dl.usda.gov/t/MRP_PUB/views/" + "VS_Cattle_HPAIConfirmedDetections2024/HPAI2022ConfirmedDetections" +) + +TableauFetcher = Callable[[str, ExtractionConfig], Optional[pd.DataFrame]] + + +def _embed_url(url: str) -> str: + if ":embed=y" in url: + return url + sep = "&" if "?" in url else "?" + return f"{url}{sep}:showVizHome=no&:embed=y" + + +def _read_csv_with_fallback(path: str) -> Optional[pd.DataFrame]: + attempts = [ + {"encoding": "utf-16", "sep": "\t", "skiprows": 1}, + {"encoding": "utf-16-le", "sep": "\t", "skiprows": 1}, + {"encoding": "utf-8-sig", "sep": "\t", "skiprows": 1}, + {"encoding": "utf-8", "sep": "\t", "skiprows": 1}, + {"encoding": "utf-16"}, + {"encoding": "utf-16-le"}, + {"encoding": "utf-8-sig"}, + {"encoding": "utf-8"}, + {"encoding": "latin-1"}, + ] + for kwargs in attempts: + try: + df = pd.read_csv(path, **kwargs) + logger.info("USDA Tableau: parsed CSV with options=%s", kwargs) + return df + except UnicodeDecodeError: + logger.info("USDA Tableau: decode failed with options=%s", kwargs) + continue + except Exception as exc: + logger.info("USDA Tableau: csv parse error with options=%s: %s", kwargs, exc) + continue + return None + + +def _fetch_tableau_dataframe(url: str, cfg: ExtractionConfig) -> Optional[pd.DataFrame]: + try: + from playwright.sync_api import sync_playwright + except Exception as exc: + logger.info("USDA Tableau: Playwright unavailable: %s", exc) + return None + + timeout_ms = int(max(cfg.fetch_timeout_seconds, 45.0) * 1000) + + try: + with sync_playwright() as p: + logger.info("USDA Tableau: launching headless browser") + browser = p.chromium.launch(headless=True) + context = browser.new_context(accept_downloads=True) + page = context.new_page() + + target_url = _embed_url(url) + logger.info("USDA Tableau: navigating to %s", target_url) + page.goto(_embed_url(url), wait_until="domcontentloaded", timeout=timeout_ms) + + logger.info("USDA Tableau: waiting for download toolbar button") + for _ in range(200): + if page.locator("button[data-tb-test-id='viz-viewer-toolbar-button-download']").count() > 0: + break + page.wait_for_timeout(250) + download_btn_count = page.locator("button[data-tb-test-id='viz-viewer-toolbar-button-download']").count() + logger.info("USDA Tableau: download button count=%s", download_btn_count) + if download_btn_count == 0: + logger.info("USDA Tableau: download button not found before timeout") + context.close() + browser.close() + return None + + logger.info("USDA Tableau: opening download menu") + page.locator("button[data-tb-test-id='viz-viewer-toolbar-button-download']").first.click(timeout=20_000) + logger.info("USDA Tableau: selecting Crosstab") + page.locator("[data-tb-test-id='download-flyout-download-crosstab-MenuItem']").first.click(timeout=20_000) + page.wait_for_timeout(1500) + + logger.info("USDA Tableau: selecting worksheet 'Table Details by Date'") + page.locator("[data-tb-test-id^='sheet-thumbnail-']", has_text="Table Details by Date").first.click(timeout=20_000) + logger.info("USDA Tableau: selecting CSV export format") + page.locator("[data-tb-test-id='crosstab-options-dialog-radio-csv-Label']").first.click(timeout=20_000) + + downloads = [] + page.on("download", lambda d: downloads.append(d)) + logger.info("USDA Tableau: triggering export download") + page.locator("[data-tb-test-id='export-crosstab-export-Button']").first.click(timeout=20_000) + + waited = 0 + step = 250 + while waited <= timeout_ms and not downloads: + page.wait_for_timeout(step) + waited += step + logger.info("USDA Tableau: download events captured=%s after %sms", len(downloads), waited) + + if not downloads: + logger.info("USDA Tableau: no download event received") + context.close() + browser.close() + return None + download = downloads[0] + + path = download.path() + logger.info("USDA Tableau: download path resolved=%s", bool(path)) + if path is None: + logger.info("USDA Tableau: download path unavailable") + context.close() + browser.close() + return None + + logger.info("USDA Tableau: reading CSV from %s", path) + df = _read_csv_with_fallback(path) + context.close() + browser.close() + + if df is None: + logger.info("USDA Tableau: failed to parse CSV with fallback encodings") + return None + if df.empty: + logger.info("USDA Tableau: CSV parsed but DataFrame empty") + return None + logger.info("USDA Tableau: CSV parsed rows=%s cols=%s", df.shape[0], df.shape[1]) + return df + except Exception as exc: + logger.info("USDA Tableau: fetch failed with exception: %s", exc) + return None + + +def _resolve_column(df: pd.DataFrame, wanted: str) -> str | None: + by_lower = {str(c).strip().lower(): c for c in df.columns} + return by_lower.get(wanted.strip().lower()) + + +def _r2(y: np.ndarray, yhat: np.ndarray) -> float: + ss_res = float(np.sum((y - yhat) ** 2)) + ss_tot = float(np.sum((y - np.mean(y)) ** 2)) + if ss_tot <= 0.0: + return 1.0 if ss_res <= 1e-12 else 0.0 + return max(0.0, min(1.0, 1.0 - (ss_res / ss_tot))) + + +def _fit_linear(counts: pd.Series) -> dict[str, float] | None: + if counts.shape[0] < 2: + return None + y = counts.to_numpy(dtype=float) + x = np.arange(y.shape[0], dtype=float) + slope, intercept = np.polyfit(x, y, 1) + yhat = slope * x + intercept + return { + "intercept": float(intercept), + "slope": float(slope), + "r2": _r2(y, yhat), + } + + +def _fit_exponential(counts: pd.Series) -> dict[str, float] | None: + if counts.shape[0] < 2: + return None + y = counts.to_numpy(dtype=float) + x = np.arange(y.shape[0], dtype=float) + + # Log-linear fit requires strictly positive observations. + mask = y > 0 + if int(np.sum(mask)) < 2: + return None + + x_pos = x[mask] + y_pos = y[mask] + + b, ln_a = np.polyfit(x_pos, np.log(y_pos), 1) + a = float(np.exp(ln_a)) + yhat = a * np.exp(b * x_pos) + + return { + "a": a, + "b": float(b), + "r2": _r2(y_pos, yhat), + } + + +def _fmt_stats(series: pd.Series) -> str: + if series.empty: + return "n=0" + desc = series.describe() + return ( + f"n={int(desc['count'])}, mean={desc['mean']:.2f}, std={desc['std'] if pd.notna(desc['std']) else 0.0:.2f}, " + f"min={desc['min']:.0f}, median={series.median():.0f}, max={desc['max']:.0f}" + ) + + +def _render_counts_table(title: str, counts: pd.Series, key_header: str) -> str: + rows = [] + for idx, value in counts.items(): + rows.append( + f"| {html.escape(key_header)} | count |
|---|
No state data available.
" + + g = df.groupby("State").size().sort_values(ascending=False) + summary = g.describe() + + rows = [] + for state, value in g.items(): + rows.append(f"State-case-count statistics: n={int(summary['count'])}, " + f"mean={summary['mean']:.2f}, std={summary['std'] if pd.notna(summary['std']) else 0.0:.2f}, " + f"min={summary['min']:.0f}, median={g.median():.0f}, max={summary['max']:.0f}.
" + "| State | case_count |
|---|
No state data available.
" + + first_by_state = ( + df.groupby("State")["Confirmed Diagnosis"] + .min() + .sort_values() + ) + + rows = [] + for state, dt in first_by_state.items(): + rows.append( + "Total affected states: {int(first_by_state.shape[0])}.
" + "| State | first_confirmed_diagnosis_date |
|---|
Section title: {html.escape(title)}. " + f"Input points: {counts.shape[0]}.
" + ) + + if linear is None: + lines.append("Linear model: unavailable (insufficient data).
") + else: + lines.append( + "Linear model y = intercept + slope*x: " + f"intercept={linear['intercept']:.6f}, slope={linear['slope']:.6f}, " + f"R^2={linear['r2']:.6f}.
" + ) + + if exp is None: + lines.append( + "Exponential model y = a*exp(b*x): unavailable " + "(insufficient strictly-positive points).
" + ) + else: + lines.append( + "Exponential model y = a*exp(b*x): " + f"a={exp['a']:.6f}, b={exp['b']:.6f}, R^2={exp['r2']:.6f}.
" + ) + + return "".join(lines) + + +def fetch( + url: str, + *, + config: ExtractionConfig | None = None, + as_of_date: datetime | None = None, + region: str | None = None, + question_text: str | None = None, + tableau_fetcher: TableauFetcher | None = None, +) -> FetchResult | None: + cfg = config or ExtractionConfig() + fetched_at = datetime.now(timezone.utc) + + df = (tableau_fetcher or _fetch_tableau_dataframe)(TABLEAU_VIEW_URL, cfg) + if df is None or df.empty: + return None + + df = df.copy() + + # Requested behavior: ignore first row. + if df.shape[0] < 2: + return None + df = df.iloc[1:].copy() + + confirmed_col = _resolve_column(df, "Confirmed Diagnosis") + state_col = _resolve_column(df, "State") + if confirmed_col is None or state_col is None: + return None + + if confirmed_col != "Confirmed Diagnosis": + df = df.rename(columns={confirmed_col: "Confirmed Diagnosis"}) + if state_col != "State": + df = df.rename(columns={state_col: "State"}) + + df["Confirmed Diagnosis"] = pd.to_datetime( + df["Confirmed Diagnosis"], errors="coerce" + ) + df = df.dropna(subset=["Confirmed Diagnosis"]).copy() + if df.empty: + return None + + if as_of_date is not None: + cutoff = as_of_date.astimezone(timezone.utc).replace(tzinfo=None) + df = df[df["Confirmed Diagnosis"] <= cutoff] + if df.empty: + return None + + # Normalize to date only for groupings. + df["diagnosis_date"] = df["Confirmed Diagnosis"].dt.date + df["month"] = df["Confirmed Diagnosis"].dt.to_period("M").astype(str) + + monthly_counts = df.groupby("month").size().sort_index() + daily_counts = df.groupby("diagnosis_date").size().sort_index() + cumulative_case_count = int(df.shape[0]) + + if monthly_counts.empty or daily_counts.empty: + return None + + month_window = monthly_counts.iloc[-6:] + day_window = daily_counts.iloc[-30:] + + lin_month = _fit_linear(month_window) + exp_month = _fit_exponential(month_window) + lin_day = _fit_linear(day_window) + exp_day = _fit_exponential(day_window) + + latest = str(daily_counts.index.max()) + + rendered = ( + "" + "Source dashboard URL: {html.escape(url)}
" + f"Tableau source URL: {html.escape(TABLEAU_VIEW_URL)}
" + f"Retrieved at: {fetched_at.isoformat()} | latest confirmed diagnosis date: {html.escape(latest)}.
" + "This summary is computed from Tableau workbook data. The first data row was ignored by design.
" + "Cumulative confirmed cases in livestock (from CSV rows): {cumulative_case_count}.
" + f"{_render_first_case_by_state(df)}" + "Summary statistics (monthly case counts): {_fmt_stats(monthly_counts)}.
" + f"{_render_counts_table('Counts by month', monthly_counts, 'month')}" + f"{_render_model_section('Model fit on past 6 months (monthly counts)', month_window, linear=lin_month, exp=exp_month)}" + "Summary statistics (daily case counts): {_fmt_stats(daily_counts)}.
" + f"{_render_counts_table('Counts by day', daily_counts, 'date')}" + f"{_render_model_section('Model fit on past 30 days (daily counts)', day_window, linear=lin_day, exp=exp_day)}" + "