Files
fn_registry/python/functions/datascience/scrape_google_trends.py
T
egutierrez e1e9bb7499 feat(shell): auto-commit con 31 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-14 23:55:16 +02:00

194 lines
7.7 KiB
Python

"""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}")