"""Pipeline de ingesta diaria de Google Search Console (Search Analytics). Orquesta el snapshot diario de Search Console de una propiedad: autentica con una service account, extrae las filas de Search Analytics, las acumula de forma idempotente en una tabla DuckDB (la fuente de verdad histórica) y espeja la tabla completa a PostgreSQL para que herramientas BI como Metabase la lean. Es un pipeline (kind: pipeline -> siempre impuro): compone funciones del registry sin reescribir su lógica. Devuelve un dict sin lanzar excepciones, siguiendo el estilo del grupo duckdb/etl del registry: {status:'ok', ...} en éxito y {status:'error', error:str} en fallo. """ import os from datetime import date, timedelta from infra import gsc_auth, duckdb_execute from infra.duckdb_upsert import duckdb_upsert from datascience import pull_gsc_search_analytics from pipelines.duckdb_to_postgres import duckdb_to_postgres # DDL de la tabla acumulada. La restricción UNIQUE es exactamente la clave que # duckdb_upsert necesita para que el re-pull de los últimos días actualice en # lugar de duplicar (GSC corrige datos hasta ~3 días atrás). _TABLE_DDL = """ CREATE TABLE IF NOT EXISTS gsc_search_analytics ( snapshot_date DATE, data_date DATE, site_url TEXT, query TEXT, page TEXT, country TEXT, device TEXT, search_type TEXT, clicks INTEGER, impressions INTEGER, ctr DOUBLE, position DOUBLE, UNIQUE (site_url, data_date, query, page, country, device, search_type) ); """ # Columnas que forman la clave única; también las que usa el upsert. _KEY_COLS = ["site_url", "data_date", "query", "page", "country", "device", "search_type"] # Lag de la API de GSC: los datos no están consolidados hasta ~3 días después. _GSC_LAG_DAYS = 3 def ingest_gsc_search_analytics( site_url: str = "", duckdb_path: str = "", pg_dsn: str = "", start_date: str = "", end_date: str = "", lookback_days: int = 5, credentials_path: str = "", ) -> dict: """Ingesta diaria de Google Search Console: GSC -> DuckDB -> PostgreSQL. Pasos en orden: (1) resuelve defaults desde env; (2) resuelve fechas teniendo en cuenta el lag de ~3 días de la API; (3) crea la tabla DuckDB si no existe; (4) autentica con la service account; (5) extrae Search Analytics por las dimensiones date/query/page; (6) transforma cada fila a la forma de la tabla (renombrando ``date`` -> ``data_date`` y rellenando defaults estables para las dimensiones no pedidas); (7) hace upsert idempotente en DuckDB; (8) espeja la tabla completa a PostgreSQL en modo ``replace``. DuckDB es la verdad acumulada (histórico append idempotente). PostgreSQL es un espejo regenerado por completo en cada corrida (mode='replace') para que Metabase tenga siempre el snapshot íntegro sin acumular duplicados. Args: site_url: propiedad de Search Console (``sc-domain:ejemplo.com`` o la URL de prefijo ``https://ejemplo.com/``). Si está vacío, se lee de la env var ``GSC_SITE_URL``. Obligatorio (ValueError si falta). duckdb_path: ruta al archivo DuckDB de la fuente de verdad. Si está vacío, se lee de la env var ``SEO_DUCKDB`` y, en su defecto, se usa ``~/.fn_seo/seo.duckdb``. El directorio padre se crea si no existe. pg_dsn: cadena de conexión PostgreSQL del espejo BI. Si está vacío, se lee de la env var ``SEO_DSN``. Obligatorio (ValueError si falta). start_date: fecha inicial inclusiva ``YYYY-MM-DD``. Si está vacía, se calcula como hoy - (3 + lookback_days). end_date: fecha final inclusiva ``YYYY-MM-DD``. Si está vacía, se calcula como hoy - 3 (lag de la API). lookback_days: nº de días extra hacia atrás que se re-pullean para que el upsert corrija los datos que GSC ajusta a posteriori. Default 5. credentials_path: ruta al JSON de la service account. Se pasa tal cual a ``gsc_auth``, que ya hace su propio fallback a la env var ``GSC_SA_JSON``. Returns: dict. En éxito: ``{"status": "ok", "site_url", "start_date", "end_date", "rows_pulled", "duckdb", "postgres"}`` donde ``duckdb`` es el resultado del upsert y ``postgres`` el del espejo. En error (sin lanzar): ``{"status": "error", "error": str}``. """ try: # (1) Defaults desde env. site_url = site_url or os.environ.get("GSC_SITE_URL", "") pg_dsn = pg_dsn or os.environ.get("SEO_DSN", "") duckdb_path = ( duckdb_path or os.environ.get("SEO_DUCKDB", "") or os.path.expanduser("~/.fn_seo/seo.duckdb") ) if not site_url: raise ValueError( "ingest_gsc_search_analytics: falta site_url. Pásalo o define la " "env var GSC_SITE_URL con la propiedad de Search Console." ) if not pg_dsn: raise ValueError( "ingest_gsc_search_analytics: falta pg_dsn. Pásalo o define la " "env var SEO_DSN con la cadena de conexión PostgreSQL del espejo." ) # (2) Fechas: la API de GSC tiene ~3 días de lag. today = date.today() if not end_date: end_date = (today - timedelta(days=_GSC_LAG_DAYS)).isoformat() if not start_date: start_date = ( today - timedelta(days=_GSC_LAG_DAYS + int(lookback_days)) ).isoformat() # (3) Crear la tabla DuckDB si no existe (y su directorio padre). parent = os.path.dirname(duckdb_path) if parent: os.makedirs(parent, exist_ok=True) ddl_res = duckdb_execute(duckdb_path, _TABLE_DDL) if ddl_res.get("status") != "ok": return {"status": "error", "error": f"create table: {ddl_res.get('error')}"} # (4) Autenticar. service = gsc_auth(credentials_path) # (5) Extraer. Con dimensions=["date","query","page"] cada fila trae las # claves "date", "query", "page" más las métricas. raw = pull_gsc_search_analytics( service, site_url, start_date, end_date, dimensions=["date", "query", "page"], ) # (6) Transformar a la forma de la tabla. La columna se llama data_date, # no date -> renombrar. country/device se dejan vacíos y search_type="web" # como defaults estables para que la tupla UNIQUE sea consistente. snapshot_date = today.isoformat() rows = [ { "snapshot_date": snapshot_date, "data_date": row["date"], "site_url": site_url, "query": row.get("query", ""), "page": row.get("page", ""), "country": "", "device": "", "search_type": "web", "clicks": row.get("clicks"), "impressions": row.get("impressions"), "ctr": row.get("ctr"), "position": row.get("position"), } for row in raw ] # (7) Upsert idempotente en DuckDB (la verdad acumulada). duckdb_res = duckdb_upsert( duckdb_path, "gsc_search_analytics", rows, key_cols=_KEY_COLS, ) # (8) Espejo completo a PostgreSQL (regenerado cada vez). pg_res = duckdb_to_postgres( duckdb_path, "gsc_search_analytics", pg_dsn, pg_table="gsc_search_analytics", mode="replace", ) return { "status": "ok", "site_url": site_url, "start_date": start_date, "end_date": end_date, "rows_pulled": len(raw), "duckdb": duckdb_res, "postgres": pg_res, } except Exception as e: # noqa: BLE001 # Pipeline impuro de borde: nunca propagamos el crash, lo reportamos. return {"status": "error", "error": str(e)} if __name__ == "__main__": import json print(json.dumps(ingest_gsc_search_analytics(), indent=2, default=str))