"""Captación de interés de búsqueda de Google Trends vía pytrends. Google Trends NUNCA devuelve volúmenes absolutos de búsqueda: todo el interés es relativo y está normalizado en una escala 0-100 dentro del payload consultado (keywords + geo + timeframe). Esta función aplana el resultado de pytrends en una lista de dicts con un schema fijo que casa 1:1 con la tabla Postgres `google_trends`. """ import time # Sentinel numérico para related_queries "rising" que Google marca como "Breakout". # pytrends entrega la cadena literal "Breakout" cuando el crecimiento es tan alto # que no cabe en un porcentaje (>5000%). Lo representamos como este entero para # mantener la columna `value` numérica en Postgres sin perder la señal. BREAKOUT_SENTINEL = 999999 def _to_iso(value) -> str: """Convierte una fecha/timestamp de pandas a ISO YYYY-MM-DD.""" # pandas Timestamp y datetime.date/datetime exponen strftime. if hasattr(value, "strftime"): return value.strftime("%Y-%m-%d") # Fallback: ya viene como string ISO o similar; recorta a 10 chars (fecha). return str(value)[:10] def _coerce_value(raw): """Normaliza el valor de una related_query rising/top a int o sentinel. pytrends devuelve enteros para top y la mayoría de rising, pero rising puede traer la cadena "Breakout". Cualquier valor no numérico se mapea al sentinel. """ if isinstance(raw, str): if raw.strip().lower() == "breakout": return BREAKOUT_SENTINEL try: return int(float(raw)) except (ValueError, TypeError): return BREAKOUT_SENTINEL try: return int(raw) except (ValueError, TypeError): return None def scrape_google_trends( keywords: list[str], geo: str = "ES", timeframe: str = "now 7-d", include_related: bool = True, ) -> list[dict]: """Capta interés de búsqueda de Google Trends para una lista de keywords. Construye un único payload de pytrends (keywords + geo + timeframe) y aplana interest_over_time y, opcionalmente, related_queries (rising + top) en filas homogéneas. El interés es relativo 0-100, nunca volumen absoluto. Args: keywords: lista de términos/nichos a consultar (máx. 5 por payload — límite de Google Trends). Cada elemento es una keyword. geo: código de país ISO-3166 (ej. "ES", "US", "" para mundial). timeframe: ventana temporal en sintaxis pytrends (ej. "now 7-d", "today 3-m", "today 12-m", "2024-01-01 2024-12-31"). include_related: si True, añade filas metric="rising" y metric="top" de related_queries por keyword. Si False, solo interest_over_time. Returns: Lista de dicts con EXACTAMENTE estas claves (sin id/snapshot_date/scraped_at, que los añade el ingest): geo, timeframe, keyword, metric, point_date, value, related_query Tres familias de fila según `metric`: - "interest_over_time": una por (keyword, punto temporal). point_date=fecha ISO, value=interés 0-100, related_query=None. - "rising": related_queries rising (si include_related). related_query=query, value=valor rising (Breakout→BREAKOUT_SENTINEL), point_date=None. - "top": related_queries top (si include_related). related_query=query, value=valor 0-100, point_date=None. Raises: RuntimeError: si Google rate-limitea (429) tras agotar los reintentos, o si pytrends falla de forma no recuperable. """ # Import dentro de la función: pytrends es dependencia impura/externa. from pytrends.request import TrendReq if not keywords: return [] pytrends = TrendReq(hl="es-ES", tz=60) # ---- build_payload con backoff ante 429 ---- backoff = [5, 15, 30] last_err = None for attempt in range(len(backoff) + 1): try: pytrends.build_payload(keywords, geo=geo, timeframe=timeframe) last_err = None break except Exception as exc: # pragma: no cover - depende de la red last_err = exc msg = str(exc).lower() is_rate_limit = "429" in msg or "too many requests" in msg or "rate" in msg if attempt < len(backoff) and is_rate_limit: time.sleep(backoff[attempt]) continue if is_rate_limit: raise RuntimeError( "Google Trends rate-limited (429): se agotaron los reintentos " f"({len(backoff)} backoffs {backoff}s). pytrends usa una API no " "oficial y Google la limita agresivamente. Reintenta más tarde." ) from exc raise RuntimeError( f"build_payload falló de forma no recuperable: {exc}" ) from exc if last_err is not None: raise RuntimeError(f"build_payload no completó: {last_err}") rows: list[dict] = [] # ---- interest_over_time ---- try: iot = pytrends.interest_over_time() except Exception as exc: # pragma: no cover - depende de la red raise RuntimeError(f"interest_over_time falló: {exc}") from exc if iot is not None and not iot.empty: # El índice es la fecha; cada columna es una keyword + 'isPartial' (ignorar). for idx, record in iot.iterrows(): point_date = _to_iso(idx) for kw in keywords: if kw not in record: continue rows.append( { "geo": geo, "timeframe": timeframe, "keyword": kw, "metric": "interest_over_time", "point_date": point_date, "value": int(record[kw]), "related_query": None, } ) # ---- related_queries (rising + top) ---- if include_related: try: related = pytrends.related_queries() except Exception as exc: # pragma: no cover - depende de la red raise RuntimeError(f"related_queries falló: {exc}") from exc related = related or {} for kw in keywords: entry = related.get(kw) or {} for metric in ("rising", "top"): df = entry.get(metric) if df is None or getattr(df, "empty", True): continue for _, qrow in df.iterrows(): rows.append( { "geo": geo, "timeframe": timeframe, "keyword": kw, "metric": metric, "point_date": None, "value": _coerce_value(qrow.get("value")), "related_query": qrow.get("query"), } ) return rows if __name__ == "__main__": # Self-test: el import siempre debe funcionar. Una llamada real a Google puede # dar 429 en este entorno; la capturamos y reportamos sin fallar. print("import OK") try: out = scrape_google_trends(["python", "rust"], geo="ES", timeframe="now 7-d") n_iot = sum(1 for r in out if r["metric"] == "interest_over_time") n_rising = sum(1 for r in out if r["metric"] == "rising") n_top = sum(1 for r in out if r["metric"] == "top") print( f"ok: {len(out)} filas " f"(interest_over_time={n_iot}, rising={n_rising}, top={n_top})" ) if out: print("muestra:", out[0]) except RuntimeError as exc: print(f"rate-limited o error de red (esperado en este entorno): {exc}")