"""Extractor de Search Analytics de Google Search Console (GSC). Consulta la Search Analytics API de Google Search Console y devuelve las filas aplanadas (impresiones, clicks, CTR, posicion) por las dimensiones pedidas. Es el extractor principal de datos SEO para alimentar un pipeline hacia DuckDB/Postgres. """ from typing import Any def pull_gsc_search_analytics( service: object, site_url: str, start_date: str, end_date: str, dimensions: list = None, row_limit: int = 25000, max_total_rows: int = 0, search_type: str = "web", ) -> list: """Extrae datos de Search Analytics de Google Search Console. Llama a ``service.searchanalytics().query(...).execute()`` paginando los resultados (la API devuelve como maximo ``row_limit`` filas por request, con tope duro de 25000) y aplana cada fila a un dict donde el array ``keys`` se mapea posicionalmente a los nombres de ``dimensions``. Args: service: objeto service autenticado de la API de Search Console (el que devuelve ``gsc_auth`` del registry). Se inyecta ya construido; esta funcion NO lo crea. site_url: propiedad de Search Console. ``sc-domain:ejemplo.com`` para propiedad de dominio, o la URL completa ``https://ejemplo.com/`` para propiedad de prefijo. start_date: fecha inicial inclusiva en formato ``YYYY-MM-DD``. end_date: fecha final inclusiva en formato ``YYYY-MM-DD``. La API tiene ~2-3 dias de lag; el caller deberia pedir hasta hoy-3. dimensions: lista de dimensiones a desglosar. Por defecto ``["query", "page"]``. Otras validas: ``date``, ``country``, ``device``, ``searchAppearance``. row_limit: filas por request (1..25000). Tambien el tamaƱo de paso de la paginacion. Por defecto 25000. max_total_rows: tope total de filas acumuladas. ``0`` = sin tope (trae todas las paginas disponibles). search_type: tipo de busqueda. ``"web"`` | ``"image"`` | ``"video"`` | ``"news"`` | ``"discover"`` | ``"googleNews"``. Va en el body como ``"type"``. Returns: Lista de dicts aplanados. Cada dict tiene una clave por cada dimension (con su nombre real, ej. ``query``, ``page``) mas ``clicks``, ``impressions``, ``ctr`` y ``position``. Lista vacia si la API no devuelve filas. Raises: Exception: cualquier error de la API HTTP de Google se propaga (autenticacion, permisos sobre la propiedad, rate limit, etc.). """ dims = list(dimensions) if dimensions else ["query", "page"] # Clamp del row_limit al rango valido de la API (1..25000). page_size = max(1, min(int(row_limit), 25000)) results: list = [] start_row = 0 while True: body: dict[str, Any] = { "startDate": start_date, "endDate": end_date, "dimensions": dims, "type": search_type, "rowLimit": page_size, "startRow": start_row, } response = ( service.searchanalytics().query(siteUrl=site_url, body=body).execute() ) rows = response.get("rows") if isinstance(response, dict) else None if not rows: # rows ausente o vacio => no hay mas datos. break for row in rows: keys = row.get("keys", []) flat: dict[str, Any] = {} for i, dim in enumerate(dims): flat[dim] = keys[i] if i < len(keys) else None flat["clicks"] = row.get("clicks") flat["impressions"] = row.get("impressions") flat["ctr"] = row.get("ctr") flat["position"] = row.get("position") results.append(flat) if max_total_rows > 0 and len(results) >= max_total_rows: return results[:max_total_rows] # Si la pagina trajo menos filas que el tope, no hay mas paginas. if len(rows) < page_size: break start_row += page_size return results