e1e9bb7499
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
194 lines
7.7 KiB
Python
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}")
|