feat(shell): auto-commit con 31 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-14 23:55:16 +02:00
parent 1430039688
commit e1e9bb7499
31 changed files with 3917 additions and 0 deletions
+10
View File
@@ -10,8 +10,18 @@ from .datascience import (
autocorrelation,
linspace,
)
from .scrape_amazon_bestsellers import scrape_amazon_bestsellers
from .scrape_google_trends import scrape_google_trends
from .scrape_competitor_prices import scrape_competitor_prices
from .scrape_tiktok_creative import scrape_tiktok_creative
from .scrape_aliexpress_trending import scrape_aliexpress_trending
__all__ = [
"scrape_amazon_bestsellers",
"scrape_google_trends",
"scrape_competitor_prices",
"scrape_tiktok_creative",
"scrape_aliexpress_trending",
"pearson",
"standardize",
"min_max_scale",
@@ -0,0 +1,274 @@
"""Scrapea productos trending de AliExpress conduciendo un Chrome real por CDP.
Variante que SI funciona frente al bloqueo por captcha: en vez de pedir el HTML
por HTTP (que devuelve un challenge/captcha para la busqueda de AliExpress),
abre una pestana en un Chrome con perfil real (puerto de remote debugging) que
ejecuta el JavaScript de la SPA y renderiza los productos. La extraccion se hace
con `cdp_eval` del registry, scrolleando para forzar el lazy-load de tarjetas.
Devuelve dicts con claves 1:1 con la tabla Postgres `aliexpress_trends`
(sin id/snapshot_date/scraped_at), listos para insertar.
"""
import json
import re
import time
import urllib.parse
import requests
from browser.cdp_eval import cdp_eval
# Expresion JS de extraccion. Se evalua una sola vez tras el scroll y devuelve
# JSON.stringify de la lista de filas. Tolerante: campos ausentes -> null, nunca
# aborta una tarjeta. Deduplica por product_id dentro del propio JS.
_EXTRACT_JS = r"""
(function () {
// 1. product_id desde el href: /item/<ID>.html, o promo (?productIds=<ID>:...
// o x_object_id=<ID>).
function productIdFromHref(href) {
if (!href) return null;
var m = href.match(/\/item\/(\d+)\.html/);
if (m) return m[1];
m = href.match(/[?&]productIds=(\d+)/);
if (m) return m[1];
m = href.match(/x_object_id(?:%3A|:|=)(\d+)/);
if (m) return m[1];
return null;
}
// 2. href absoluto al producto. Prefiere un <a href*="/item/"> dentro del card;
// si no, el href del propio anchor de la tarjeta.
function absUrl(href) {
if (!href) return null;
if (href.indexOf("//") === 0) return "https:" + href;
if (href.indexOf("http") === 0) return href;
return "https://www.aliexpress.com" + href;
}
// 3. precio EUR -> float (coma decimal ES). "0,33€" -> 0.33. "GRATIS" -> null.
function parsePrice(txt) {
if (!txt) return null;
// primer token monetario con € o EUR
var m = txt.match(/([\d.]+,\d+)\s*(?:€|EUR)/);
if (!m) m = txt.match(/(?:€|EUR)\s*([\d.]+,\d+)/);
if (!m) m = txt.match(/([\d.]+)\s*(?:€|EUR)/);
if (!m) return null;
var raw = m[1].replace(/\./g, "").replace(",", ".");
var v = parseFloat(raw);
return isFinite(v) ? v : null;
}
// 4. pedidos: "100K+ vendidos", "50.000+ vendidos", "1.000+ sold", "234 sold".
function parseOrders(txt) {
if (!txt) return null;
var m = txt.match(/([\d.,]+)\s*([KkMm])?\s*\+?\s*(?:vendidos|sold|orders|pedidos)/);
if (!m) return null;
var num = m[1].replace(/\./g, "").replace(/,/g, ".");
var val = parseFloat(num);
if (!isFinite(val)) return null;
var suf = (m[2] || "").toLowerCase();
if (suf === "k") val *= 1000;
else if (suf === "m") val *= 1000000;
return Math.round(val);
}
// 5. rating: primer "4.9" / "4,9" tras el bloque de precio (0-5).
function parseRating(txt) {
if (!txt) return null;
var matches = txt.match(/\b([0-5][.,]\d)\b/g);
if (!matches) return null;
for (var i = 0; i < matches.length; i++) {
var v = parseFloat(matches[i].replace(",", "."));
if (v >= 0 && v <= 5) return v;
}
return null;
}
var anchors = Array.prototype.slice.call(
document.querySelectorAll("a.search-card-item")
);
var seen = {};
var rows = [];
for (var i = 0; i < anchors.length; i++) {
var a = anchors[i];
var card = a.closest(".search-item-card-wrapper-gallery") || a;
// href al producto: primero un <a href*="/item/"> dentro del card.
var href = null;
var inner = card.querySelectorAll("a");
for (var j = 0; j < inner.length; j++) {
var h = inner[j].getAttribute("href") || "";
if (/\/item\/\d+\.html/.test(h)) { href = h; break; }
}
if (!href) href = a.getAttribute("href") || "";
var pid = productIdFromHref(href);
if (!pid || seen[pid]) continue;
seen[pid] = true;
var img = card.querySelector("img");
var title = img ? (img.getAttribute("alt") || "") : "";
if (!title) title = (a.innerText || "").trim();
title = (title || "").trim() || null;
var text = card.innerText || "";
rows.push({
product_id: pid,
title: title,
price: parsePrice(text),
currency: "EUR",
orders: parseOrders(text),
rating: parseRating(text),
url: absUrl(href)
});
}
return JSON.stringify(rows);
})()
"""
def cdp_scrape_aliexpress_trending(
query: str = "gadgets",
limit: int = 40,
ship_to: str = "ES",
port: int = 9222,
) -> list[dict]:
"""Scrapea productos trending de AliExpress via CDP sobre un Chrome real.
Abre una pestana en la busqueda de AliExpress ordenada por popularidad
(numero de pedidos), espera al render, scrollea para disparar el lazy-load
de tarjetas y extrae los productos con un unico `cdp_eval`.
Args:
query: Termino de busqueda. Tambien se usa como `category` en cada fila.
limit: Maximo de productos a devolver tras deduplicar por product_id.
ship_to: Codigo de pais de envio (afecta precios/moneda mostrados).
port: Puerto de remote debugging del Chrome con perfil real. Default 9222.
Returns:
Lista de dicts con claves exactas (1:1 con la tabla `aliexpress_trends`):
category, product_id, title, price, currency, orders, rating, url.
price es float|None, orders int|None, rating float|None; el resto str.
Raises:
RuntimeError: si no se puede abrir la pestana, si CDP devuelve un error
de evaluacion, o si el JSON de extraccion no se puede parsear.
"""
base = "http://localhost:%d" % port
target_url = (
"https://www.aliexpress.com/w/wholesale-%s.html"
"?SortType=total_tranpro_desc&shipCountry=%s"
% (urllib.parse.quote(query), urllib.parse.quote(ship_to))
)
# 1. Abrir pestana via DevTools HTTP API (esta build exige PUT en /json/new).
tab_id = ""
try:
new_url = "%s/json/new?%s" % (base, urllib.parse.quote(target_url, safe=""))
resp = requests.put(new_url, timeout=10)
if resp.status_code != 200:
# Fallback a POST por compatibilidad con builds antiguas.
resp = requests.post(new_url, timeout=10)
resp.raise_for_status()
tab = resp.json()
tab_id = tab.get("id", "")
if not tab_id:
raise RuntimeError("DevTools /json/new no devolvio id de pestana")
except Exception as exc: # noqa: BLE001 — red/HTTP/JSON
raise RuntimeError("no se pudo abrir pestana en %s: %s" % (base, exc))
substr = "aliexpress.com/w/wholesale-%s" % urllib.parse.quote(query)
try:
# 2. Esperar render inicial.
time.sleep(6.0)
# 3. Scroll en bucle para forzar lazy-load hasta tener >= limit tarjetas
# o hasta que el conteo deje de crecer (estabilizado).
count_js = (
'document.querySelectorAll("a.search-card-item").length'
)
prev = -1
stable = 0
for _ in range(15):
cdp_eval(
"window.scrollBy(0, 2500)",
port=port,
target_url_substr=substr,
)
time.sleep(1.2)
res = cdp_eval(count_js, port=port, target_url_substr=substr)
n = res.get("value") if res.get("ok") else None
n = int(n) if isinstance(n, (int, float)) else 0
if n >= limit:
break
if n <= prev:
stable += 1
if stable >= 2:
break
else:
stable = 0
prev = n
# 4. Extraer con un unico cdp_eval (devuelve JSON.stringify de las filas).
res = cdp_eval(_EXTRACT_JS, port=port, target_url_substr=substr)
if not res.get("ok"):
raise RuntimeError(
"cdp_eval fallo en la extraccion: %s" % res.get("error", "")
)
raw = res.get("value")
if not raw:
return []
try:
rows = json.loads(raw)
except Exception as exc: # noqa: BLE001 — JSON malformado
raise RuntimeError("JSON de extraccion invalido: %s" % exc)
# 5. Anadir category y truncar a limit. Saneo defensivo de tipos.
out: list[dict] = []
seen: set[str] = set()
for r in rows:
pid = r.get("product_id")
if not pid or pid in seen:
continue
seen.add(pid)
price = r.get("price")
orders = r.get("orders")
rating = r.get("rating")
out.append(
{
"category": query,
"product_id": str(pid),
"title": r.get("title"),
"price": float(price) if isinstance(price, (int, float)) else None,
"currency": r.get("currency") or "EUR",
"orders": int(orders) if isinstance(orders, (int, float)) else None,
"rating": float(rating) if isinstance(rating, (int, float)) else None,
"url": r.get("url"),
}
)
if len(out) >= limit:
break
return out
finally:
# 6. Cerrar la pestana siempre (best-effort).
if tab_id:
try:
requests.get("%s/json/close/%s" % (base, tab_id), timeout=5)
except Exception: # noqa: BLE001 — cierre best-effort
pass
if __name__ == "__main__":
import sys
q = sys.argv[1] if len(sys.argv) > 1 else "gadgets"
lim = int(sys.argv[2]) if len(sys.argv) > 2 else 40
products = cdp_scrape_aliexpress_trending(query=q, limit=lim, port=9222)
print("%d productos" % len(products))
print(json.dumps(products[:5], ensure_ascii=False, indent=2))
@@ -0,0 +1,81 @@
---
name: scrape_aliexpress_trending
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def scrape_aliexpress_trending(query: str | None = None, category: str | None = None, limit: int = 40, ship_to: str = 'ES') -> list[dict]"
description: "Capta productos populares de AliExpress como señal de e-commerce/dropshipping (orders, rating, precio). Hace una request HTTP a la página de listado ordenada por número de pedidos y extrae el JSON embebido en el HTML (window.runParams / _dida_config). Best-effort: ante anti-bot lanza RuntimeError, ante HTML sin JSON devuelve []. NUNCA inventa datos."
tags: [aliexpress, ecommerce, dropshipping, trends, market-intel, datascience]
params:
- name: query
desc: "Texto de búsqueda (ej. 'kitchen gadgets'). Si se da, manda en la URL sobre category."
- name: category
desc: "ID numérico de categoría AliExpress o slug. Ignorado si hay query. None usa un listado 'hot products' genérico."
- name: limit
desc: "Número máximo de productos a devolver. Default 40."
- name: ship_to
desc: "Código de país ISO-2 (ES, US, GB, DE, ...) que fija región y moneda via cookies de AliExpress. Default 'ES'."
output: "Lista de dicts con claves exactas (casan 1:1 con la tabla Postgres aliexpress_trends, sin id/snapshot_date/scraped_at): category (str|None), product_id (str), title (str|None), price (float|None), currency (str|None), orders (int|None), rating (float|None), url (str). Lista vacía si el HTML no traía JSON parseable."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/scrape_aliexpress_trending.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.scrape_aliexpress_trending import scrape_aliexpress_trending
# Top productos por número de pedidos para una búsqueda concreta, enviando a España.
rows = scrape_aliexpress_trending(query="phone holder", limit=20, ship_to="ES")
for r in rows[:3]:
print(r["title"], "->", r["orders"], "pedidos |", r["price"], r["currency"])
# Cada dict (las 8 claves casan con la tabla aliexpress_trends):
# {"category": "phone holder", "product_id": "100500...", "title": "...",
# "price": 3.21, "currency": "EUR", "orders": 12000, "rating": 4.8,
# "url": "https://www.aliexpress.com/item/100500....html"}
```
## Cuando usarla
Cuando necesites una señal de qué productos están vendiendo bien en AliExpress para
research de dropshipping o market-intel: detectar tendencias, sourcing de productos
ganadores, o alimentar un histórico (tabla `aliexpress_trends`) que cruce orders /
rating / precio por categoría. Úsala antes de decidir un nicho o para vigilar
periódicamente una keyword. El output va directo a un `INSERT` Postgres (las 8 claves
coinciden con las columnas no autogeneradas).
## Gotchas
- **Anti-bot fuerte (CRÍTICO):** AliExpress bloquea agresivamente headless/datacenter
con captcha (`/_____tmd_____/punish`), 403/429 y fingerprinting. Desde una IP de
datacenter o un patrón de scraping evidente, esta función **lanzará `RuntimeError`**
con frecuencia. Para extracción fiable y sostenida, la alternativa robusta es el
**browser MCP/CDP con sesión real** (Chrome del usuario, cookies legítimas), no
`requests`. Esta función es la vía barata; si falla repetidamente, sube de nivel.
- **JSON embebido volátil:** el nombre/estructura del blob (`window.runParams`,
`_dida_config_`, `_init_data_`) cambia con frecuencia. Se prueban varios patrones y
un walk genérico, pero si AliExpress cambia el layout la función devuelve `[]`
(HTML válido sin JSON parseable) — **NO inventa datos**. Diferencia clave:
`RuntimeError` = bloqueado; `[]` = layout cambiado o shell vacío.
- **Región/moneda dependen de `ship_to`:** se setean por cookies (`aep_usuc_f`,
`intl_locale`). Un `ship_to` no mapeado cae a `ES`/`EUR`. El `currency` devuelto
depende de lo que AliExpress decida servir, no se fuerza tras el fetch.
- **`orders`/`price`/`rating` pueden venir `None`** si el item no expone ese campo en
el JSON (productos nuevos sin ventas, listados sin rating). No asumir no-null.
- **Una sola página:** devuelve hasta `limit` items de la primera página de resultados;
no pagina. Para más volumen, llamar con queries/categorías distintas.
- **Sin reintentos ni rotación de proxy/UA:** es una request única con headers fijos.
Para uso periódico, orquestar reintentos y backoff fuera de la función.
@@ -0,0 +1,393 @@
"""Capta productos populares de AliExpress como señal de e-commerce/dropshipping.
Extrae el JSON que AliExpress embebe en el HTML de su página de búsqueda/listado
(``window.runParams`` / ``_dida_config`` / scripts ``data``) en lugar de parsear
el DOM renderizado por JS. AliExpress es anti-bot fuerte (captcha, 403, fingerprint
sobre headless/datacenter), por lo que esta función es best-effort: cuando el fetch
real es bloqueado lanza ``RuntimeError`` con un mensaje claro. NUNCA inventa datos.
"""
from __future__ import annotations
import json
import re
from typing import Any
_BASE = "https://www.aliexpress.com"
_WHOLESALE = f"{_BASE}/wholesale"
# Headers realistas de un navegador desktop. AliExpress fingerprint-ea agresivamente,
# así que enviamos un perfil coherente (Chrome estable + Accept-Language acorde a region).
_DESKTOP_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/124.0.0.0 Safari/537.36"
),
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,image/apng,*/*;q=0.8"
),
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Cache-Control": "max-age=0",
}
# AliExpress decide moneda/region por estas cookies. Mapa ship_to -> (region, locale, currency).
_REGION_MAP: dict[str, tuple[str, str, str]] = {
"ES": ("ES", "es_ES", "EUR"),
"US": ("US", "en_US", "USD"),
"GB": ("GB", "en_GB", "GBP"),
"FR": ("FR", "fr_FR", "EUR"),
"DE": ("DE", "de_DE", "EUR"),
"IT": ("IT", "it_IT", "EUR"),
"PT": ("PT", "pt_PT", "EUR"),
"MX": ("MX", "es_MX", "USD"),
"BR": ("BR", "pt_BR", "BRL"),
}
# Señales de bloqueo anti-bot en la respuesta.
_BLOCK_MARKERS = (
"punish", # /_____tmd_____/punish — captcha slider de AliExpress
"nc_token", # NoCaptcha de Alibaba
"captcha",
"Access Denied",
"baxia-dialog", # widget de verificacion
)
def _region_cookies(ship_to: str) -> dict[str, str]:
region, locale, currency = _REGION_MAP.get(
ship_to.upper(), _REGION_MAP["ES"]
)
return {
"aep_usuc_f": f"site=glo&c_tp={currency}&region={region}&b_locale={locale}",
"intl_locale": locale,
"xman_us_f": f"x_l=0&no_popup_today=n&zero_order=n&x_locale={locale}",
}
def _build_url(query: str | None, category: str | None) -> str:
if query:
# /wholesale?SearchText=... es el listado de búsqueda con runParams embebido.
from urllib.parse import quote_plus
return f"{_WHOLESALE}?SearchText={quote_plus(query)}&SortType=total_tranpro_desc"
if category:
# Categorías numéricas: /category/<id>/x.html. Si llega un slug, lo usamos como texto.
if category.isdigit():
return f"{_BASE}/category/{category}/x.html?SortType=total_tranpro_desc"
from urllib.parse import quote_plus
return f"{_WHOLESALE}?SearchText={quote_plus(category)}&SortType=total_tranpro_desc"
# Sin query ni categoría: listado de best-selling genérico.
return f"{_WHOLESALE}?SearchText=hot+products&SortType=total_tranpro_desc"
def _looks_blocked(html: str, status_code: int) -> bool:
if status_code in (403, 429, 503):
return True
head = html[:6000].lower()
return any(marker.lower() in head for marker in _BLOCK_MARKERS)
def _extract_embedded_json(html: str) -> dict[str, Any] | None:
"""Intenta varios patrones de JSON embebido que AliExpress ha usado a lo largo del tiempo.
El nombre/forma cambia con frecuencia, así que probamos en orden y nos quedamos
con el primero que parsee y contenga algo con pinta de items.
"""
patterns = (
r"window\.runParams\s*=\s*({.*?})\s*;\s*</script>",
r"window\._dida_config_\s*=\s*({.*?})\s*;",
r"_init_data_\s*=\s*{\s*data:\s*({.*?})\s*}\s*</script>",
r"window\.runParams\s*=\s*({.*?});",
)
for pat in patterns:
m = re.search(pat, html, re.DOTALL)
if not m:
continue
blob = m.group(1)
try:
data = json.loads(blob)
except (json.JSONDecodeError, ValueError):
continue
if isinstance(data, dict):
return data
return None
def _dig_items(data: dict[str, Any]) -> list[dict[str, Any]]:
"""Localiza la lista de productos dentro del JSON embebido, sea cual sea su anidación.
Las claves han variado entre 'mods.itemList.content', 'items', 'result.items'...
así que hacemos un walk genérico buscando la primera lista de dicts con pinta de
producto (tienen productId/title/trade).
"""
found: list[dict[str, Any]] = []
def _is_product(d: dict[str, Any]) -> bool:
keys = set(d.keys())
id_keys = {"productId", "product_id", "productid", "id"}
title_keys = {"title", "subject", "name"}
return bool(keys & id_keys) and bool(keys & title_keys)
def _walk(node: Any) -> None:
if found:
return
if isinstance(node, list):
product_like = [x for x in node if isinstance(x, dict) and _is_product(x)]
if len(product_like) >= 2:
found.extend(product_like)
return
for x in node:
_walk(x)
elif isinstance(node, dict):
for v in node.values():
_walk(v)
_walk(data)
return found
def _to_float(value: Any) -> float | None:
if value is None:
return None
if isinstance(value, (int, float)):
return float(value)
s = str(value)
# Quita símbolos de moneda y separadores de miles; deja el primer número decimal.
m = re.search(r"\d[\d.,]*", s.replace(" ", " "))
if not m:
return None
num = m.group(0)
# Heurística: si hay coma y punto, asume coma = miles. Si solo coma, coma = decimal.
if "," in num and "." in num:
num = num.replace(",", "")
elif "," in num:
num = num.replace(",", ".")
try:
return float(num)
except ValueError:
return None
def _to_orders(value: Any) -> int | None:
if value is None:
return None
if isinstance(value, int):
return value
s = str(value).lower()
# Formatos: "1,234 sold", "2.3k sold", "10000+ orders".
mult = 1
if "k" in s:
mult = 1000
m = re.search(r"\d[\d.,]*", s)
if not m:
return None
num = m.group(0).replace(",", "")
try:
base = float(num)
except ValueError:
return None
return int(base * mult)
def _normalize_item(
raw: dict[str, Any], category: str | None
) -> dict[str, Any] | None:
pid = (
raw.get("productId")
or raw.get("product_id")
or raw.get("productid")
or raw.get("id")
)
if pid is None:
return None
product_id = str(pid)
title = raw.get("title") or raw.get("subject") or raw.get("name")
if isinstance(title, dict):
title = title.get("displayTitle") or title.get("seoTitle")
title = str(title).strip() if title else None
# Precio: AliExpress lo mete en 'prices.salePrice.minPrice' o variantes planas.
price_node = (
raw.get("prices", {}).get("salePrice", {})
if isinstance(raw.get("prices"), dict)
else {}
)
price = _to_float(
(price_node.get("minPrice") if isinstance(price_node, dict) else None)
or raw.get("salePrice")
or raw.get("price")
or raw.get("minPrice")
)
currency = None
if isinstance(price_node, dict):
currency = price_node.get("currencyCode")
currency = currency or raw.get("currency") or raw.get("currencyCode")
currency = str(currency) if currency else None
orders = _to_orders(
raw.get("trade", {}).get("tradeDesc")
if isinstance(raw.get("trade"), dict)
else None
)
if orders is None:
orders = _to_orders(
raw.get("orders") or raw.get("tradeCount") or raw.get("sales")
)
rating = _to_float(
(
raw.get("evaluation", {}).get("starRating")
if isinstance(raw.get("evaluation"), dict)
else None
)
or raw.get("rating")
or raw.get("averageStar")
or raw.get("starRating")
)
url = raw.get("productDetailUrl") or raw.get("url") or raw.get("detail_url")
if url:
url = str(url)
if url.startswith("//"):
url = "https:" + url
else:
url = f"{_BASE}/item/{product_id}.html"
return {
"category": category,
"product_id": product_id,
"title": title,
"price": price,
"currency": currency,
"orders": orders,
"rating": rating,
"url": url,
}
def scrape_aliexpress_trending(
query: str | None = None,
category: str | None = None,
limit: int = 40,
ship_to: str = "ES",
) -> list[dict]:
"""Capta productos populares de AliExpress (señal e-commerce/dropshipping).
Hace UNA request HTTP a la página de listado de AliExpress ordenada por número
de pedidos (``total_tranpro_desc``) y extrae el JSON embebido en el HTML. Es
best-effort: AliExpress bloquea agresivamente headless/datacenter, por lo que
ante un bloqueo (403/429/captcha) lanza ``RuntimeError`` con un mensaje claro y
ante un HTML sin JSON parseable devuelve ``[]``. NUNCA inventa datos.
Args:
query: Texto de búsqueda (ej. "kitchen gadgets"). Si se da, manda en la URL.
category: ID numérico de categoría AliExpress o slug. Ignorado si hay ``query``.
limit: Número máximo de productos a devolver. Default 40.
ship_to: Código de país ISO-2 para fijar región/moneda via cookies. Default "ES".
Returns:
Lista de dicts con claves exactas:
``category, product_id, title, price, currency, orders, rating, url``.
``price``/``rating`` son ``float | None``, ``orders`` es ``int | None``.
Lista vacía si el HTML no traía JSON parseable.
Raises:
RuntimeError: Si AliExpress bloquea la request (captcha/403/429) o la red falla.
"""
import requests
url = _build_url(query, category)
cookies = _region_cookies(ship_to)
headers = dict(_DESKTOP_HEADERS)
_, locale, _ = _REGION_MAP.get(ship_to.upper(), _REGION_MAP["ES"])
headers["Accept-Language"] = f"{locale.replace('_', '-')},en;q=0.8"
try:
resp = requests.get(
url,
headers=headers,
cookies=cookies,
timeout=20,
allow_redirects=True,
)
except requests.RequestException as exc:
raise RuntimeError(
f"scrape_aliexpress_trending: fallo de red contra {url}: {exc}"
) from exc
html = resp.text or ""
if _looks_blocked(html, resp.status_code):
raise RuntimeError(
f"scrape_aliexpress_trending: AliExpress bloqueó la request "
f"(status={resp.status_code}, captcha/anti-bot). "
f"Usa el browser MCP/CDP con sesión real para esta fuente."
)
data = _extract_embedded_json(html)
if data is None:
# HTML sin el JSON esperado: layout cambió o respondió un shell vacío.
# Devolvemos [] honesto en vez de inventar.
return []
raw_items = _dig_items(data)
cat_label = category if (category and not query) else (query or category)
out: list[dict] = []
seen: set[str] = set()
for raw in raw_items:
norm = _normalize_item(raw, cat_label)
if norm is None:
continue
if norm["product_id"] in seen:
continue
seen.add(norm["product_id"])
out.append(norm)
if len(out) >= limit:
break
return out
if __name__ == "__main__":
# Self-test honesto: import OK obligatorio + UN fetch real en try/except.
# NUNCA falla la build por la red.
print("import OK: scrape_aliexpress_trending")
expected_keys = {
"category",
"product_id",
"title",
"price",
"currency",
"orders",
"rating",
"url",
}
try:
rows = scrape_aliexpress_trending(query="phone holder", limit=5, ship_to="ES")
if rows:
got_keys = set(rows[0].keys())
keys_ok = got_keys == expected_keys
print(
f"fetch real: {len(rows)} filas obtenidas | "
f"claves correctas={keys_ok}"
)
print(f" muestra: {rows[0]}")
else:
print(
"fetch real: 0 filas (HTML sin JSON embebido parseable "
"— layout cambió o shell vacío). NO se inventan datos."
)
except RuntimeError as exc:
print(f"fetch real: BLOQUEADO/ERROR honesto -> {exc}")
@@ -0,0 +1,72 @@
---
name: scrape_amazon_bestsellers
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def scrape_amazon_bestsellers(marketplace: str = 'amazon.es', categories: list[str] | None = None, list_type: str = 'bestsellers', max_items: int = 50) -> list[dict]"
description: "Scrapea los rankings de Amazon (Best Sellers y Movers & Shakers) de un marketplace para captar señales de demanda de productos: rank, ASIN, titulo, precio, rating, reseñas y, en movers, el cambio porcentual."
tags: [amazon, scraping, trends, market-intel, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests, bs4]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/scrape_amazon_bestsellers.py"
params:
- name: marketplace
desc: "Dominio Amazon objetivo (amazon.es, amazon.com, amazon.co.uk, amazon.de, ...). Determina la URL, el Accept-Language enviado y la moneda fallback."
- name: categories
desc: "Lista de slugs de categoria a scrapear (ej. 'electronics', 'videogames'). Si es None, scrapea la portada general del ranking elegido. Cada slug genera una pagina/peticion."
- name: list_type
desc: "Tipo de ranking: 'bestsellers' (URL /gp/bestsellers/<cat>) o 'movers_shakers' (URL /gp/movers-and-shakers/<cat>). Cualquier otro valor lanza ValueError."
- name: max_items
desc: "Numero maximo de productos recolectados por categoria. Default 50 (una pagina de ranking suele tener ~50 items)."
output: "Lista de dicts, uno por producto, con exactamente estas claves: marketplace, list_type, category, rank, asin, title, price, currency, rating, reviews, pct_change, url. None donde no haya dato. price/rating/pct_change son float; rank/reviews son int. pct_change solo se rellena en movers_shakers. Casa 1:1 con la tabla Postgres amazon_bestsellers (el ingest añade id/snapshot_date/scraped_at)."
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.scrape_amazon_bestsellers import scrape_amazon_bestsellers
# Best Sellers de electronica y videojuegos en Amazon.es
rows = scrape_amazon_bestsellers(
marketplace="amazon.es",
categories=["electronics", "videogames"],
list_type="bestsellers",
max_items=50,
)
print(len(rows), "items")
print(rows[0])
# {'marketplace': 'amazon.es', 'list_type': 'bestsellers', 'category': 'electronics',
# 'rank': 1, 'asin': 'B0...', 'title': '...', 'price': 29.99, 'currency': 'EUR',
# 'rating': 4.5, 'reviews': 1234, 'pct_change': None, 'url': 'https://www.amazon.es/dp/B0...'}
# Movers & Shakers (productos que mas suben) — incluye pct_change
movers = scrape_amazon_bestsellers(
marketplace="amazon.com",
list_type="movers_shakers",
max_items=30,
)
```
## Cuando usarla
Usala cuando necesites captar señales de demanda de mercado desde Amazon: que se esta vendiendo mas (Best Sellers) o que esta subiendo de golpe en ventas (Movers & Shakers), por marketplace y categoria. Util como fuente de un pipeline de market intelligence / trend detection que luego ingesta a la tabla `amazon_bestsellers` y cruza snapshots diarios para detectar productos al alza. Llamala antes de cualquier analisis de tendencias de catalogo; el dict devuelto esta listo para insertar tras añadir `snapshot_date`/`scraped_at`.
## Gotchas
- **Anti-bot fuerte**: Amazon detecta scraping HTTP puro y puede devolver captcha, `503` o `429`. La funcion detecta el bloqueo (status 429/503 o markers de captcha en el HTML) y, tras agotar reintentos, lanza `RuntimeError` con el status. **Si HTTP puro falla repetidamente, la alternativa es el navegador del ecosistema (browser MCP / CDP)** sobre una pestaña real de Chrome, que pasa el anti-bot mejor que `requests`.
- **HTML fragil**: Amazon cambia las plantillas del DOM con frecuencia y sirve varias a la vez segun A/B test. Los selectores estan escritos defensivamente (varios fallbacks por campo) pero **pueden necesitar mantenimiento** cuando Amazon rota plantillas. Si un campo no aparece en ninguna plantilla conocida, se devuelve `None` en vez de petar.
- **Campos opcionales = None**: no todos los items traen precio/rating/reviews/pct_change. `pct_change` solo se rellena en `list_type="movers_shakers"`; en bestsellers siempre es `None`.
- **rank fallback posicional**: si Amazon no renderiza el badge de rank, se usa la posición (1-indexada) del item en la pagina como rank.
- **Una peticion por categoria**: cada slug en `categories` dispara una peticion HTTP independiente (con 2 reintentos + backoff). Listas largas de categorias multiplican el riesgo de throttling — espacia las llamadas si scrapeas muchas.
- **Moneda best-effort**: `currency` se infiere del simbolo en el precio (€, $, £, R$) y, si no hay simbolo reconocible, del TLD del marketplace. Puede ser `None` si no se pudo determinar.
@@ -0,0 +1,425 @@
"""Scrape Amazon Best Sellers and Movers & Shakers ranking pages for product demand signals."""
from __future__ import annotations
import re
import time
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
# Accept-Language hint per marketplace TLD. Falls back to a generic value.
_ACCEPT_LANGUAGE = {
"amazon.es": "es-ES,es;q=0.9,en;q=0.6",
"amazon.com": "en-US,en;q=0.9",
"amazon.co.uk": "en-GB,en;q=0.9",
"amazon.de": "de-DE,de;q=0.9,en;q=0.6",
"amazon.fr": "fr-FR,fr;q=0.9,en;q=0.6",
"amazon.it": "it-IT,it;q=0.9,en;q=0.6",
"amazon.com.mx": "es-MX,es;q=0.9,en;q=0.6",
"amazon.com.br": "pt-BR,pt;q=0.9,en;q=0.6",
}
# Currency guessed from the marketplace TLD (used only as a fallback when the
# price string has no recognisable symbol).
_CURRENCY_BY_MARKET = {
"amazon.es": "EUR",
"amazon.com": "USD",
"amazon.co.uk": "GBP",
"amazon.de": "EUR",
"amazon.fr": "EUR",
"amazon.it": "EUR",
"amazon.com.mx": "MXN",
"amazon.com.br": "BRL",
}
# Map common currency symbols to ISO codes.
_SYMBOL_TO_CURRENCY = {
"": "EUR",
"$": "USD",
"£": "GBP",
"R$": "BRL",
"US$": "USD",
}
_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
# Signals that Amazon served an anti-bot / captcha / throttling page instead of
# the ranking content.
_BLOCK_MARKERS = (
"api-services-support@amazon",
"captcha",
"to discuss automated access",
"enter the characters you see below",
"robot check",
)
def _build_headers(marketplace: str) -> dict:
"""Realistic browser-ish headers for the given marketplace."""
return {
"User-Agent": _USER_AGENT,
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8"
),
"Accept-Language": _ACCEPT_LANGUAGE.get(marketplace, "en-US,en;q=0.9"),
"Accept-Encoding": "gzip, deflate, br",
"Connection": "keep-alive",
"Upgrade-Insecure-Requests": "1",
"Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
"Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
}
def _build_url(marketplace: str, list_type: str, category: str | None) -> str:
"""Compose the ranking URL for a marketplace / list type / category slug."""
base = "movers-and-shakers" if list_type == "movers_shakers" else "bestsellers"
url = f"https://www.{marketplace}/gp/{base}"
if category:
url = f"{url}/{category.strip('/')}"
return url
def _looks_blocked(status_code: int, html: str) -> bool:
"""Heuristic: did Amazon serve an anti-bot / throttling page?"""
if status_code in (429, 503):
return True
lowered = html.lower()
return any(marker in lowered for marker in _BLOCK_MARKERS)
def _fetch(url: str, headers: dict, timeout: int, retries: int) -> requests.Response:
"""GET with small retry + backoff. Raises on persistent failure / block."""
last_exc: Exception | None = None
for attempt in range(retries + 1):
try:
resp = requests.get(url, headers=headers, timeout=timeout)
except requests.RequestException as exc: # network / timeout
last_exc = exc
if attempt < retries:
time.sleep(1.5 * (attempt + 1))
continue
raise RuntimeError(f"request to {url} failed: {exc}") from exc
if _looks_blocked(resp.status_code, resp.text):
if attempt < retries:
time.sleep(2.0 * (attempt + 1))
continue
raise RuntimeError(
f"Amazon anti-bot block on {url} (HTTP {resp.status_code}). "
"HTTP scraping is being throttled/captcha'd; fall back to the "
"browser MCP/CDP path of the ecosystem."
)
if resp.status_code != 200:
last_exc = RuntimeError(
f"unexpected HTTP {resp.status_code} for {url}"
)
if attempt < retries:
time.sleep(1.5 * (attempt + 1))
continue
raise last_exc
return resp
# Should not reach here, but be defensive.
raise RuntimeError(f"could not fetch {url}: {last_exc}")
_ASIN_RE = re.compile(r"/(?:dp|gp/product)/([A-Z0-9]{10})(?:[/?]|$)")
_RANK_RE = re.compile(r"#?\s*(\d+)")
_PRICE_NUM_RE = re.compile(r"[-+]?\d[\d.,]*")
_REVIEWS_RE = re.compile(r"[\d.,]+")
_RATING_RE = re.compile(r"([\d.,]+)\s*(?:out of|de|von|su|sur|de um total de)")
_PCT_RE = re.compile(r"([\d.,]+)\s*%")
def _text(node) -> str:
return node.get_text(" ", strip=True) if node is not None else ""
def _parse_asin(card) -> str | None:
"""ASIN from a data-asin attribute or any /dp/<ASIN>/ link inside the card."""
asin = card.get("data-asin")
if asin and re.fullmatch(r"[A-Z0-9]{10}", asin):
return asin
for a in card.find_all("a", href=True):
m = _ASIN_RE.search(a["href"])
if m:
return m.group(1)
return None
def _parse_url(card, marketplace: str) -> str | None:
"""Absolute product URL from the first /dp/ link in the card."""
base = f"https://www.{marketplace}"
for a in card.find_all("a", href=True):
if _ASIN_RE.search(a["href"]):
return urljoin(base, a["href"].split("?")[0])
# Fall back to the first link at all.
first = card.find("a", href=True)
if first is not None:
return urljoin(base, first["href"].split("?")[0])
return None
def _parse_rank(card) -> int | None:
"""Rank badge. Amazon renders it as '#1', '1', etc."""
badge = card.select_one(".zg-bdg-text, .zg-badge-text, [class*='badge']")
txt = _text(badge)
if not txt:
# Sometimes the rank is in a class like a11y .zg-bdg-text sibling.
for sel in (".a-badge-text", "[class*='rank']"):
node = card.select_one(sel)
txt = _text(node)
if txt:
break
m = _RANK_RE.search(txt)
return int(m.group(1)) if m else None
def _parse_title(card) -> str | None:
"""Product title — several templates over the years."""
for sel in (
"._cDEzb_p13n-sc-css-line-clamp-3_g3dy1",
"._cDEzb_p13n-sc-css-line-clamp-2_EWgCb",
"[class*='line-clamp']",
".p13n-sc-truncate",
".p13n-sc-truncated",
"a.a-link-normal[title]",
"img[alt]",
):
node = card.select_one(sel)
if node is None:
continue
if node.name == "img":
alt = node.get("alt")
if alt:
return alt.strip()
continue
if node.has_attr("title") and node["title"].strip():
return node["title"].strip()
txt = _text(node)
if txt:
return txt
return None
def _parse_price(card, marketplace: str) -> tuple[float | None, str | None]:
"""Price value (float) and ISO currency, best-effort across templates."""
for sel in (
"._cDEzb_p13n-sc-price_3mJ9Z",
".p13n-sc-price",
"span.a-price > span.a-offscreen",
".a-price .a-offscreen",
"[class*='price']",
):
node = card.select_one(sel)
txt = _text(node)
if not txt:
continue
currency = None
for sym, iso in _SYMBOL_TO_CURRENCY.items():
if sym in txt:
currency = iso
break
if currency is None:
currency = _CURRENCY_BY_MARKET.get(marketplace)
m = _PRICE_NUM_RE.search(txt)
if not m:
continue
raw = m.group(0)
value = _to_float(raw)
if value is not None:
return value, currency
return None, None
def _parse_rating(card) -> float | None:
"""Star rating, e.g. '4,5 de 5 estrellas' / '4.5 out of 5 stars'."""
for sel in ("[class*='review-stars']", ".a-icon-alt", "[title*='star']", "[aria-label*='star']"):
node = card.select_one(sel)
txt = _text(node) or (node.get("title", "") if node is not None else "") or (
node.get("aria-label", "") if node is not None else ""
)
if not txt:
continue
m = _RATING_RE.search(txt)
if m:
return _to_float(m.group(1))
# Some templates only render the number ('4,5').
m2 = _PRICE_NUM_RE.search(txt)
if m2 and ("star" in txt.lower() or "estrella" in txt.lower()):
return _to_float(m2.group(0))
return None
def _parse_reviews(card) -> int | None:
"""Number of ratings/reviews shown next to the stars."""
for sel in (
"a.a-size-small.a-link-normal",
".a-size-small.a-link-normal",
"[class*='review-count']",
"span.a-size-small",
):
for node in card.select(sel):
txt = _text(node)
if not txt:
continue
m = _REVIEWS_RE.search(txt)
if not m:
continue
digits = m.group(0).replace(".", "").replace(",", "")
if digits.isdigit() and len(digits) >= 1:
# Avoid catching rank/price by requiring a plausible count token.
return int(digits)
return None
def _parse_pct_change(card) -> float | None:
"""Movers & Shakers percentage change ('+150%')."""
for sel in (".zg-percent-change", "[class*='percent']", "[class*='sales-movement']"):
node = card.select_one(sel)
txt = _text(node)
if not txt:
continue
m = _PCT_RE.search(txt)
if m:
value = _to_float(m.group(1))
if value is None:
continue
return -value if txt.strip().startswith("-") else value
return None
def _to_float(raw: str) -> float | None:
"""Parse a numeric string with EU or US decimal/grouping conventions."""
if raw is None:
return None
s = raw.strip().replace("\xa0", "").replace(" ", "")
if not s:
return None
if "," in s and "." in s:
# The rightmost separator is the decimal one.
if s.rfind(",") > s.rfind("."):
s = s.replace(".", "").replace(",", ".")
else:
s = s.replace(",", "")
elif "," in s:
# Treat a single comma as decimal separator (EU markets).
s = s.replace(",", ".")
try:
return float(s)
except ValueError:
return None
def _select_cards(soup: BeautifulSoup) -> list:
"""Locate the list-item cards across known Amazon templates."""
selectors = (
"div.p13n-sc-uncoverable-faceout",
"div[id^='gridItemRoot']",
"div.zg-grid-general-faceout",
"li.zg-item-immersion",
"div.a-cardui[data-asin]",
"div[data-asin]",
)
for sel in selectors:
cards = soup.select(sel)
if cards:
return cards
return []
def scrape_amazon_bestsellers(
marketplace: str = "amazon.es",
categories: list[str] | None = None,
list_type: str = "bestsellers",
max_items: int = 50,
) -> list[dict]:
"""Scrape Amazon Best Sellers / Movers & Shakers ranking pages.
Captures demand signals (rank, title, price, rating, reviews and — for
Movers & Shakers — percentage change) from one or more category ranking
pages of a given Amazon marketplace.
Args:
marketplace: Amazon domain, e.g. ``"amazon.es"``, ``"amazon.com"``.
categories: Category slugs (e.g. ``"electronics"``, ``"videogames"``).
If ``None`` the general front page of the chosen list is scraped.
list_type: ``"bestsellers"`` (URL ``/gp/bestsellers/<cat>``) or
``"movers_shakers"`` (URL ``/gp/movers-and-shakers/<cat>``).
max_items: Maximum number of items collected per category.
Returns:
A list of dicts, one per product, with exactly these keys:
``marketplace, list_type, category, rank, asin, title, price,
currency, rating, reviews, pct_change, url``. Missing values are
``None``. ``price``/``rating``/``pct_change`` are floats,
``rank``/``reviews`` are ints.
Raises:
ValueError: If ``list_type`` is not one of the allowed values.
RuntimeError: On network failure or when Amazon serves an anti-bot /
captcha / throttling page.
"""
if list_type not in ("bestsellers", "movers_shakers"):
raise ValueError(
f"list_type must be 'bestsellers' or 'movers_shakers', got {list_type!r}"
)
cats: list[str | None] = list(categories) if categories else [None]
headers = _build_headers(marketplace)
results: list[dict] = []
for category in cats:
url = _build_url(marketplace, list_type, category)
resp = _fetch(url, headers, timeout=20, retries=2)
soup = BeautifulSoup(resp.text, "lxml")
cards = _select_cards(soup)
count = 0
for idx, card in enumerate(cards):
if count >= max_items:
break
asin = _parse_asin(card)
title = _parse_title(card)
# Skip empty / non-product wrappers.
if asin is None and title is None:
continue
rank = _parse_rank(card)
if rank is None:
rank = idx + 1 # positional fallback when no badge is rendered
price, currency = _parse_price(card, marketplace)
results.append(
{
"marketplace": marketplace,
"list_type": list_type,
"category": category,
"rank": rank,
"asin": asin,
"title": title,
"price": price,
"currency": currency,
"rating": _parse_rating(card),
"reviews": _parse_reviews(card),
"pct_change": _parse_pct_change(card)
if list_type == "movers_shakers"
else None,
"url": _parse_url(card, marketplace),
}
)
count += 1
return results
@@ -0,0 +1,73 @@
---
name: scrape_competitor_prices
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def scrape_competitor_prices(targets: list[dict]) -> list[dict]"
description: "Vigila precios de la competencia: dada una lista de objetivos (URL de producto + competidor), hace GET con headers realistas (timeout + 1 reintento) y extrae el precio actual de cada pagina con una cascada de estrategias (CSS selector, JSON-LD offers, meta tags, heuristica de clases). Normaliza a float (tolera coma/punto, simbolos, miles) y detecta in_stock. Devuelve una fila por target con claves 1:1 de la tabla Postgres competitor_prices; si falla un target devuelve price=None sin abortar los demas."
tags: [competitor, pricing, scraping, market-intel, datascience, recon]
params:
- name: targets
desc: "Lista de dicts, uno por producto a vigilar. Cada dict: competitor (str, nombre/id del competidor), product_key (str, clave interna estable), product_name (str, nombre legible), url (str, URL de la pagina del producto), price_selector (str, opcional, selector CSS que apunta al nodo del precio — lo mas robusto), currency (str, opcional, codigo de moneda a estampar, default 'EUR')."
output: "Lista de dicts, una fila por target, con EXACTAMENTE estas claves (casan 1:1 con la tabla Postgres competitor_prices, sin id/snapshot_date/scraped_at): competitor (str), product_key (str), product_name (str), url (str), price (float | None), currency (str), in_stock (bool | None). price=None si no se pudo extraer; in_stock=None si la pagina fallo."
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests, beautifulsoup4, lxml]
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/scrape_competitor_prices.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.scrape_competitor_prices import scrape_competitor_prices
targets = [
{
"competitor": "books-to-scrape",
"product_key": "light-in-the-attic",
"product_name": "A Light in the Attic",
"url": "http://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
"price_selector": "p.price_color", # el selector por target es lo mas fiable
"currency": "GBP",
},
{
"competitor": "competidor_b",
"product_key": "SKU-4242",
"product_name": "Filtro de aceite XYZ",
"url": "https://www.ejemplo-tienda.com/producto/4242",
# sin price_selector -> autodeteccion JSON-LD / meta / heuristica de clases
"currency": "EUR",
},
]
rows = scrape_competitor_prices(targets)
# rows[0] -> {"competitor": "books-to-scrape", "product_key": "light-in-the-attic",
# "product_name": "A Light in the Attic", "url": "...",
# "price": 51.77, "currency": "GBP", "in_stock": True}
# Listo para INSERT en la tabla competitor_prices (anade tu snapshot_date/scraped_at).
```
## Cuando usarla
Cuando necesites un snapshot puntual del precio de uno o varios productos de la competencia para alimentar una tabla de market intelligence (`competitor_prices`). Util en un cron/pipeline que lee una lista de objetivos, scrapea, y persiste una fila por producto. Pasa `price_selector` por target siempre que conozcas el sitio: es la via mas robusta. Si no lo pasas, la funcion intenta autodetectar (JSON-LD `offers.price`, meta tags de precio, clases comunes de e-commerce). Las filas salen con las claves exactas de la tabla destino, asi que el caller solo anade `snapshot_date`/`scraped_at` antes del INSERT.
## Gotchas
- **Funcion impura**: hace I/O de red (HTTP GET). Depende del HTML real de cada sitio en el momento de la llamada.
- **El scraping de precios es muy especifico por sitio.** Sin `price_selector`, la autodeteccion acierta en muchos e-commerce estandar (los que exponen JSON-LD `Product/Offer`, meta `og:price:amount`/`itemprop=price`, o clases tipicas `.price`), pero **falla en SPAs / paginas JS-rendered** (React/Vue/Angular que pintan el precio tras cargar) y en sitios con **anti-bot** (Cloudflare, captchas, fingerprinting). Para esos casos el GET devuelve un HTML sin el precio o un challenge, y la fila sale con `price=None`.
- **Para sitios JS-rendered o con anti-bot usa el navegador del ecosistema** (browser MCP / CDP: `page_perceive`, `cdp_get_text`, `cdp_perceive_outline`) para renderizar la pagina y extraer el precio del DOM ya pintado, en lugar de esta funcion de HTTP puro. Esta funcion es para HTML servidor-renderizado.
- **`price_selector` por target es lo mas fiable**: evita depender de la heuristica y sobrevive mejor a cambios de plantilla. Define uno por competidor en tu lista de objetivos.
- **Normalizacion de precio**: tolera `1.299,99 €` (europeo: punto miles, coma decimal), `$1,299.99` (US), `29,90`, `1299.99`. Heuristica: el separador mas a la derecha es el decimal cuando hay ambos; con solo coma, se trata como decimal si quedan 2 digitos detras, si no como miles. Casos exoticos (3 decimales, formatos regionales raros) pueden malinterpretarse — verifica con `price_selector` apuntando al nodo limpio.
- **`in_stock` es heuristico**: `True` salvo que el texto de la pagina contenga marcadores de agotado (`agotado`, `sin stock`, `out of stock`, `sold out`, etc.). Falsos positivos/negativos posibles si el sitio usa otra redaccion o muestra esos terminos en contexto no relacionado. `None` si la pagina fallo al cargar.
- **Tolerancia a fallos por target**: si un target peta (red, timeout, HTML invalido), su fila sale con `price=None`/`in_stock=None` y **el resto del batch continua**. Nunca aborta toda la lista por un fallo individual.
- **Reintento unico**: cada GET reintenta una vez ante error de transporte. No hay backoff exponencial ni rotacion de proxies/User-Agent; para scraping a escala o contra anti-bot fuerte, eso queda fuera del alcance de esta funcion.
@@ -0,0 +1,389 @@
"""Scrape current prices for a list of competitor product pages.
Watches competitor pricing: given a list of targets (product URL + competitor),
fetches each page and extracts the current price using a cascade of strategies
(CSS selector, JSON-LD offers, meta tags, common-class heuristics). Output rows
map 1:1 to the Postgres `competitor_prices` table (minus the autogenerated
id/snapshot_date/scraped_at columns).
"""
import json
import re
import urllib.request
import urllib.error
from bs4 import BeautifulSoup
_USER_AGENT = (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
)
_REQUEST_HEADERS = {
"User-Agent": _USER_AGENT,
"Accept": (
"text/html,application/xhtml+xml,application/xml;q=0.9,"
"image/avif,image/webp,*/*;q=0.8"
),
"Accept-Language": "es-ES,es;q=0.9,en;q=0.8",
"Accept-Encoding": "identity",
"Connection": "close",
}
# Substrings that, when present, signal the product is NOT available.
_OUT_OF_STOCK_MARKERS = (
"agotado",
"sin stock",
"sin existencias",
"no disponible",
"out of stock",
"sold out",
"unavailable",
"currently unavailable",
)
# Common class/attribute patterns used by mainstream e-commerce templates.
_PRICE_HEURISTIC_SELECTORS = (
"[itemprop=price]",
"[data-price]",
"[data-product-price]",
".price",
".product-price",
".price--current",
".current-price",
".sale-price",
".a-price .a-offscreen",
"[class*=price]",
)
# A token that looks like a price: optional currency symbol, digits with
# thousands/decimal separators. Captured group is the numeric part.
# First alternative requires >=1 explicit thousands group (e.g. 1.299,99);
# second alternative covers plain contiguous digits with optional decimals
# (e.g. 1299.99, 29,90). Ordering the thousands branch first avoids the
# plain-digit branch greedily eating "1299" out of "1299.99".
_PRICE_NUMBER_RE = re.compile(
r"(?:[€$£]|EUR|USD|GBP)?\s*"
r"(\d{1,3}(?:[.,\s]\d{3})+(?:[.,]\d{1,2})?|\d+(?:[.,]\d{1,2})?)"
r"\s*(?:[€$£]|EUR|USD|GBP)?",
re.IGNORECASE,
)
def _fetch_html(url: str, timeout: float = 15.0) -> str:
"""GET a URL with realistic headers, one retry on failure.
Raises the last urllib error if both attempts fail.
"""
last_err: Exception | None = None
for attempt in range(2):
try:
req = urllib.request.Request(url, headers=_REQUEST_HEADERS)
with urllib.request.urlopen(req, timeout=timeout) as resp:
raw = resp.read()
charset = resp.headers.get_content_charset() or "utf-8"
try:
return raw.decode(charset, errors="replace")
except (LookupError, UnicodeDecodeError):
return raw.decode("utf-8", errors="replace")
except Exception as err: # noqa: BLE001 - retry on any transport error
last_err = err
continue
raise last_err if last_err is not None else RuntimeError("fetch failed")
def _normalize_price(raw) -> float | None:
"""Normalize a price token to float, tolerating comma/dot and symbols.
Handles "1.299,99 €", "$1,299.99", "1299.99", "29,90" etc.
Returns None if no numeric value can be parsed.
"""
if raw is None:
return None
if isinstance(raw, (int, float)):
try:
return float(raw)
except (ValueError, TypeError):
return None
text = str(raw).strip()
if not text:
return None
match = _PRICE_NUMBER_RE.search(text)
if not match:
return None
num = match.group(1).strip().replace(" ", "")
last_comma = num.rfind(",")
last_dot = num.rfind(".")
if last_comma != -1 and last_dot != -1:
# The right-most separator is the decimal separator.
if last_comma > last_dot:
# European: 1.299,99 -> dots are thousands, comma is decimal.
num = num.replace(".", "").replace(",", ".")
else:
# US: 1,299.99 -> commas are thousands, dot is decimal.
num = num.replace(",", "")
elif last_comma != -1:
# Only commas present. Decimal if it looks like "29,90"; else thousands.
if len(num) - last_comma - 1 == 2:
num = num.replace(",", ".")
else:
num = num.replace(",", "")
# Only dots (or none): assume dot is already decimal / no separators.
try:
return float(num)
except ValueError:
return None
def _extract_from_selector(soup: BeautifulSoup, selector: str) -> float | None:
"""Try a single CSS selector and normalize the matched node."""
try:
node = soup.select_one(selector)
except Exception: # noqa: BLE001 - invalid selector should not abort
return None
if node is None:
return None
# Prefer common price-bearing attributes, fall back to text.
for attr in ("content", "data-price", "data-product-price", "value"):
if node.has_attr(attr):
price = _normalize_price(node.get(attr))
if price is not None:
return price
return _normalize_price(node.get_text(" ", strip=True))
def _iter_json_ld_prices(soup: BeautifulSoup):
"""Yield candidate prices found inside ld+json offers blocks."""
for tag in soup.find_all("script", attrs={"type": "application/ld+json"}):
payload = tag.string or tag.get_text()
if not payload:
continue
try:
data = json.loads(payload)
except (ValueError, TypeError):
continue
for node in _walk_json(data):
if not isinstance(node, dict):
continue
offers = node.get("offers")
for offer in _as_list(offers):
if isinstance(offer, dict) and "price" in offer:
yield offer.get("price")
# Some schemas place price directly on the node.
if "price" in node and not isinstance(node.get("offers"), (dict, list)):
yield node.get("price")
def _walk_json(node):
"""Depth-first walk over arbitrarily nested JSON structures."""
if isinstance(node, dict):
yield node
for value in node.values():
yield from _walk_json(value)
elif isinstance(node, list):
for item in node:
yield from _walk_json(item)
def _as_list(value):
"""Wrap a value in a list unless it already is one."""
if value is None:
return []
return value if isinstance(value, list) else [value]
def _extract_from_meta(soup: BeautifulSoup) -> float | None:
"""Try common price meta tags in priority order."""
candidates = (
{"itemprop": "price"},
{"property": "og:price:amount"},
{"property": "product:price:amount"},
{"name": "twitter:data1"},
)
for attrs in candidates:
tag = soup.find("meta", attrs=attrs)
if tag is not None:
price = _normalize_price(tag.get("content"))
if price is not None:
return price
return None
def _detect_in_stock(soup: BeautifulSoup) -> bool | None:
"""Heuristic stock detection: True unless an out-of-stock marker appears."""
text = soup.get_text(" ", strip=True).lower()
if not text:
return None
for marker in _OUT_OF_STOCK_MARKERS:
if marker in text:
return False
return True
def _extract_price(soup: BeautifulSoup, price_selector) -> float | None:
"""Run the extraction cascade and return the first price found."""
# 1. Caller-supplied CSS selector (most robust).
if price_selector:
price = _extract_from_selector(soup, str(price_selector))
if price is not None:
return price
# 2. JSON-LD offers.
for candidate in _iter_json_ld_prices(soup):
price = _normalize_price(candidate)
if price is not None:
return price
# 3. Meta tags.
price = _extract_from_meta(soup)
if price is not None:
return price
# 4. Common-class heuristics.
for selector in _PRICE_HEURISTIC_SELECTORS:
price = _extract_from_selector(soup, selector)
if price is not None:
return price
return None
def scrape_competitor_prices(targets: list[dict]) -> list[dict]:
"""Scrape current prices for a list of competitor product pages.
For each target performs a GET with realistic headers (timeout + 1 retry)
and extracts the price using a cascade of strategies. Extraction failures
of a single target never abort the others: that row is returned with
price=None (and in_stock=None) so the caller still gets one row per target.
Args:
targets: list of dicts, each with keys:
- competitor (str): competitor name/id.
- product_key (str): stable internal product key.
- product_name (str): human-readable product name.
- url (str): product page URL to scrape.
- price_selector (str, optional): CSS selector pinpointing the
price node. Most robust when provided.
- currency (str, optional): currency code to stamp on the row
(e.g. "EUR"). Defaults to "EUR".
Returns:
list of dicts, one per target, with EXACTLY these keys (1:1 with the
Postgres `competitor_prices` table, minus id/snapshot_date/scraped_at):
- competitor (str)
- product_key (str)
- product_name (str)
- url (str)
- price (float | None)
- currency (str)
- in_stock (bool | None)
"""
rows: list[dict] = []
for target in targets:
competitor = target.get("competitor")
product_key = target.get("product_key")
product_name = target.get("product_name")
url = target.get("url")
price_selector = target.get("price_selector")
currency = target.get("currency") or "EUR"
price: float | None = None
in_stock: bool | None = None
if url:
try:
html = _fetch_html(url)
soup = BeautifulSoup(html, "lxml")
price = _extract_price(soup, price_selector)
in_stock = _detect_in_stock(soup)
except Exception: # noqa: BLE001 - never abort the whole batch
price = None
in_stock = None
rows.append(
{
"competitor": competitor,
"product_key": product_key,
"product_name": product_name,
"url": url,
"price": price,
"currency": currency,
"in_stock": in_stock,
}
)
return rows
if __name__ == "__main__":
# Self-test: import is implicitly OK if we reach this point.
print("self-test: import OK")
# Pure-logic checks that need no network.
assert _normalize_price("1.299,99 €") == 1299.99, "EU thousands+decimal"
assert _normalize_price("$1,299.99") == 1299.99, "US thousands+decimal"
assert _normalize_price("29,90") == 29.90, "EU decimal only"
assert _normalize_price("1,299") == 1299.0, "US thousands only"
assert _normalize_price("1299.99") == 1299.99, "plain dot decimal"
assert _normalize_price("Precio: 49,95 EUR hoy") == 49.95, "embedded"
assert _normalize_price("no price here") is None, "no number"
assert _normalize_price(None) is None, "none in -> none out"
print("self-test: price normalization OK")
# Shape check: one row per target, exact keys, failed target -> price None.
sample = scrape_competitor_prices(
[
{
"competitor": "demo",
"product_key": "SKU-1",
"product_name": "Demo product",
"url": "http://invalid.localhost.invalid/nope",
"currency": "EUR",
}
]
)
expected_keys = {
"competitor",
"product_key",
"product_name",
"url",
"price",
"currency",
"in_stock",
}
assert len(sample) == 1, "one row per target"
assert set(sample[0].keys()) == expected_keys, "exact keys"
assert sample[0]["price"] is None, "failed target -> price None, no abort"
assert sample[0]["currency"] == "EUR", "currency default"
print("self-test: row shape + graceful-failure OK")
# Optional: best-effort real fetch against a public URL (never fails build).
try:
live = scrape_competitor_prices(
[
{
"competitor": "books-to-scrape",
"product_key": "light-in-the-attic",
"product_name": "A Light in the Attic",
"url": (
"http://books.toscrape.com/catalogue/"
"a-light-in-the-attic_1000/index.html"
),
"price_selector": "p.price_color",
"currency": "GBP",
}
]
)
print(f"self-test: live fetch -> price={live[0]['price']} "
f"in_stock={live[0]['in_stock']}")
except Exception as err: # noqa: BLE001 - network optional
print(f"self-test: live fetch skipped ({type(err).__name__})")
print("self-test: ALL OK")
@@ -0,0 +1,77 @@
---
name: scrape_google_trends
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def scrape_google_trends(keywords: list[str], geo: str = \"ES\", timeframe: str = \"now 7-d\", include_related: bool = True) -> list[dict]"
description: "Capta interes de busqueda de Google Trends por keyword/nicho via pytrends. El interes es relativo 0-100, NUNCA volumen absoluto. Aplana interest_over_time + related_queries (rising/top) en filas con schema fijo que casa 1:1 con la tabla Postgres google_trends. Backoff/retry ante 429."
tags: [google-trends, pytrends, trends, market-intel, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [pytrends, time]
params:
- name: keywords
desc: "lista de terminos/nichos a consultar (max 5 por payload, limite de Google Trends). Cada elemento es una keyword string."
- name: geo
desc: "codigo de pais ISO-3166 (ej. 'ES', 'US', '' para mundial). Default 'ES'."
- name: timeframe
desc: "ventana temporal en sintaxis pytrends (ej. 'now 7-d', 'today 3-m', 'today 12-m', '2024-01-01 2024-12-31'). Default 'now 7-d'."
- name: include_related
desc: "si True anade filas metric='rising' y metric='top' de related_queries por keyword. Si False solo interest_over_time. Default True."
output: "lista de dicts con claves EXACTAS {geo, timeframe, keyword, metric, point_date, value, related_query}. Tres tipos de fila segun metric: 'interest_over_time' (point_date=fecha ISO, value=0-100, related_query=None), 'rising' (related_query=query, value=valor rising o BREAKOUT_SENTINEL, point_date=None), 'top' (related_query=query, value=0-100, point_date=None). No incluye id/snapshot_date/scraped_at (los anade el ingest)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/scrape_google_trends.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join("python", "functions"))
from datascience.scrape_google_trends import scrape_google_trends
# Interes de busqueda en Espana, ultimos 7 dias, con related queries
rows = scrape_google_trends(
["coche electrico", "panel solar"],
geo="ES",
timeframe="now 7-d",
include_related=True,
)
# Cada fila tiene el mismo schema, listo para insertar en Postgres google_trends:
# {"geo": "ES", "timeframe": "now 7-d", "keyword": "coche electrico",
# "metric": "interest_over_time", "point_date": "2026-06-12", "value": 73,
# "related_query": None}
#
# {"geo": "ES", "timeframe": "now 7-d", "keyword": "coche electrico",
# "metric": "rising", "point_date": None, "value": 999999, # "Breakout"
# "related_query": "ayudas coche electrico 2026"}
#
# {"geo": "ES", "timeframe": "now 7-d", "keyword": "panel solar",
# "metric": "top", "point_date": None, "value": 100,
# "related_query": "placas solares precio"}
interes = [r for r in rows if r["metric"] == "interest_over_time"]
print(len(interes), "puntos de interes temporal")
```
## Cuando usarla
Cuando necesites medir el interes/momentum de un nicho o keyword en el tiempo (market intelligence, deteccion de tendencias, validacion de demanda de producto) y vayas a persistirlo en la tabla Postgres `google_trends`. Usala antes del ingest: devuelve filas crudas con el schema exacto de la tabla, sin los campos que pone el ingest (id, snapshot_date, scraped_at). Pon `include_related=False` si solo te interesa la serie temporal y quieres minimizar la superficie de rate-limit.
## Gotchas
- **API no oficial + rate-limit (429).** pytrends scrapea una API interna de Google que NO es publica. Google la limita agresivamente: rafagas de llamadas devuelven HTTP 429. La funcion reintenta con backoff incremental (5s, 15s, 30s) ante 429; si tras esos reintentos sigue limitada, lanza `RuntimeError` mencionando explicitamente el rate-limit. En entornos de CI/headless es habitual recibir 429 a la primera — no es un bug de la funcion.
- **Puede romperse sin aviso.** Al depender de un endpoint interno, Google puede cambiarlo y romper pytrends en cualquier momento. Trata los fallos como esperados y cachea resultados aguas arriba.
- **Interes relativo, NO volumen absoluto.** Los valores 0-100 estan normalizados DENTRO del payload consultado (mismo geo + timeframe + conjunto de keywords). 100 = el pico del conjunto, no "100 busquedas". No son comparables entre payloads distintos. Cambiar el set de keywords reescala todos los valores.
- **"Breakout" en rising.** Google marca como la cadena literal `"Breakout"` (en vez de un %) las related_queries rising cuyo crecimiento supera ~5000%. Para mantener la columna `value` numerica en Postgres se mapea al sentinel `BREAKOUT_SENTINEL = 999999`. Si necesitas distinguir un breakout real de un valor 999999 legitimo (imposible en la practica para %), filtra por ese sentinel.
- **Maximo 5 keywords por payload.** Limite de Google Trends. Pasar mas keywords hace que pytrends falle o ignore las extra. Trocea en lotes de <=5 y llama varias veces (espaciando para no disparar el 429).
- **DataFrames vacios.** `interest_over_time()` puede volver vacio (keyword sin datos en la ventana) y `related_queries()` devuelve un dict `{keyword: {'top': df|None, 'rising': df|None}}` con valores None. La funcion maneja ambos casos sin petar: simplemente no genera filas para esas combinaciones.
- **Columna `isPartial`.** `interest_over_time()` incluye una columna `isPartial` que marca el ultimo punto como provisional. Se ignora por completo (solo se leen las columnas que coinciden con las keywords).
@@ -0,0 +1,193 @@
"""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}")
@@ -0,0 +1,99 @@
---
name: scrape_tiktok_creative
kind: function
lang: py
domain: datascience
version: "1.0.0"
purity: impure
signature: "def scrape_tiktok_creative(country: str = 'ES', kind: str = 'hashtag', limit: int = 50, period: int = 7) -> list[dict]"
description: "Capta tendencias del TikTok Creative Center (hashtags, canciones, creadores y videos virales con metricas reales) via su API JSON interna creative_radar_api. Headers realistas con requests, paginacion, parseo tolerante a cambios de schema. Devuelve filas 1:1 con la tabla Postgres tiktok_trends. Impure: hace HTTP a un endpoint interno no publico que puede romperse o exigir anti-bot."
tags: [tiktok, social, trends, market-intel, datascience]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [requests]
params:
- name: country
desc: "Codigo ISO de pais del ranking (ej. 'ES', 'US', 'MX'). El Creative Center segmenta las tendencias por mercado. Default 'ES'."
- name: kind
desc: "Tipo de tendencia: 'hashtag' (default, el mas estable), 'song', 'creator' o 'video'. Cada uno usa un endpoint interno distinto. Empieza por hashtag si no estas seguro."
- name: limit
desc: "Numero maximo de filas a devolver. El endpoint pagina de 50 en 50; la funcion concatena paginas hasta alcanzar limit o agotar resultados. Default 50."
- name: period
desc: "Ventana temporal en dias. Solo acepta 7 (default), 30 o 120 — el endpoint rechaza otros valores con error de validacion."
output: "Lista de dicts con EXACTAMENTE las claves: country (str), kind (str), name (str|None), rank (int|None), views (int|None, BIGINT), growth_pct (float|None), industry (str|None), url (str|None). Mapea 1:1 con la tabla Postgres tiktok_trends (sin id/snapshot_date/scraped_at). Devuelve [] si el endpoint responde OK pero sin items para el segmento. Lanza ValueError (kind/period invalidos) o RuntimeError (403 anti-bot, HTTP de error, JSON invalido, code de error logico)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/datascience/scrape_tiktok_creative.py"
notes: |
ESTRATEGIA: el Creative Center (ads.tiktok.com/business/creativecenter) es una
SPA JS-rendered, pero alimenta sus rankings desde una API interna de facto bajo
https://ads.tiktok.com/creative_radar_api/v1/popular_trend/... Esta funcion habla
directamente con ese endpoint con requests (mucho mas barato que un navegador
headless CUANDO responde). El parseo tolera variaciones del schema (data.list,
data.hashtags, data.items...) y nombres de campo distintos por kind.
REALISMO: en pruebas reales desde un entorno headless/datacenter el endpoint
respondio con code=40101 ("no permission") — rechazo anti-bot por falta de los
tokens de sesion firmados (anonymous-user-id, user-sign, timestamp) que la SPA
genera en cliente y que no se pueden falsear fuera del navegador. La funcion NO
inventa datos: en ese caso lanza RuntimeError con un mensaje claro. Se considera
el comportamiento esperado, no un bug de la funcion.
---
## Ejemplo
```python
from datascience.scrape_tiktok_creative import scrape_tiktok_creative
# Top 50 hashtags virales en Espana, ultimos 7 dias.
rows = scrape_tiktok_creative(country="ES", kind="hashtag", limit=50, period=7)
# rows[0] -> {
# "country": "ES", "kind": "hashtag", "name": "fyp", "rank": 1,
# "views": 12450000, "growth_pct": 42.0, "industry": "Entertainment",
# "url": "https://ads.tiktok.com/business/creativecenter/hashtag/fyp/pc/en"
# }
# Canciones en tendencia en US, ventana de 30 dias.
songs = scrape_tiktok_creative(country="US", kind="song", limit=20, period=30)
# Las filas casan 1:1 con un INSERT en la tabla Postgres tiktok_trends
# (sin id/snapshot_date/scraped_at, que los pone la BD).
```
## Cuando usarla
Usala cuando necesites market intelligence de TikTok: detectar hashtags, canciones,
creadores o productos virales por pais con metricas reales (views, ranking,
crecimiento) para alimentar la tabla `tiktok_trends`, un dashboard de tendencias o
un analisis de oportunidad de contenido. Empieza por `kind="hashtag"` (el endpoint
mas estable) antes de probar song/creator/video. Si el fetch HTTP devuelve
RuntimeError por anti-bot, baja al browser MCP/CDP del ecosistema.
## Gotchas
- **El endpoint interno NO es una API publica versionada.** `creative_radar_api/v1/popular_trend`
es un contrato de facto que TikTok cambia sin aviso: ruta, parametros, schema del
JSON y claves de campo pueden romperse en cualquier deploy. El parseo es tolerante
pero no inmune; si TikTok mueve la lista a otra ruta, la funcion devuelve [] o
lanza RuntimeError.
- **Anti-bot real y frecuente.** Desde IPs de datacenter o entornos headless el
endpoint suele responder `403` o `code=40101 (no permission)`. Los rankings se
sirven solo a clientes con los tokens de sesion firmados que la SPA genera en
navegador (`anonymous-user-id`, `user-sign`, `timestamp`). Esos tokens NO se
pueden falsear con requests. **Verificado en self-test: respondio code=40101.**
- **Alternativa robusta cuando el HTTP esta bloqueado:** usar el browser MCP/CDP del
ecosistema (regla `flow_replay.md`) navegando el Creative Center con una sesion de
chrome real, dejando que el cliente genere los tokens, y leyendo el JSON de la
respuesta XHR o el DOM renderizado. Es mas caro pero pasa el anti-bot.
- **No inventa datos.** Si no puede extraer de verdad, lanza una excepcion clara con
el codigo HTTP / code logico para diagnostico, en vez de devolver filas falsas.
- **growth_pct heuristico:** el Creative Center expresa el crecimiento como ratio
(0.42) o como porcentaje (42) segun campo/version; la funcion normaliza ratios en
[-1, 1] a porcentaje (*100). Si TikTok cambia la convencion, revisar `_row_from_item`.
- **Rate limiting:** la paginacion hace una request por pagina de 50. Para `limit`
altos puedes encadenar varias requests rapidas — anade backoff propio si scrapeas
muchos paises seguidos para no acelerar el bloqueo.
@@ -0,0 +1,287 @@
"""Scrape de tendencias del TikTok Creative Center via su API JSON interna.
El TikTok Creative Center (https://ads.tiktok.com/business/creativecenter/) es una
SPA JS-rendered, pero alimenta sus rankings desde una API interna documentada de
facto bajo `https://ads.tiktok.com/creative_radar_api/v1/popular_trend/...`.
Esta funcion habla DIRECTAMENTE con ese endpoint usando `requests` con headers
realistas, evitando el coste de un navegador headless cuando el endpoint responde.
ADVERTENCIA: el endpoint interno cambia sin aviso, puede exigir token anti-bot y
desde IPs de datacenter/headless suele devolver 403 o listas vacias. La funcion
falla con una excepcion clara cuando el endpoint no responde como se espera. La
alternativa robusta para entornos bloqueados es el browser MCP/CDP del ecosistema
navegando el Creative Center con una sesion real (ver `## Gotchas` del .md).
"""
from __future__ import annotations
import requests
# Endpoints internos del Creative Center por tipo de tendencia. Son APIs de facto
# (no publicas ni versionadas como contrato) y pueden romperse en cualquier deploy
# de TikTok. Se mantienen aqui en un solo sitio para facilitar el parcheo.
_BASE = "https://ads.tiktok.com/creative_radar_api/v1/popular_trend"
_ENDPOINTS: dict[str, str] = {
"hashtag": f"{_BASE}/hashtag/list",
"song": f"{_BASE}/song/list",
"creator": f"{_BASE}/creator/list",
"video": f"{_BASE}/list",
}
# Periodos validos del Creative Center (en dias). El endpoint rechaza otros valores.
_VALID_PERIODS = {7, 30, 120}
_HEADERS = {
"User-Agent": (
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 "
"(KHTML, like Gecko) Chrome/124.0.0.0 Safari/537.36"
),
"Accept": "application/json, text/plain, */*",
"Accept-Language": "en-US,en;q=0.9,es;q=0.8",
"Referer": "https://ads.tiktok.com/business/creativecenter/inspiration/popular/hashtag/pc/en",
"Origin": "https://ads.tiktok.com",
# El Creative Center exige este header para servir JSON; sin el devuelve HTML.
"anonymous-user-id": "",
"timestamp": "",
"user-sign": "",
}
def _to_int(value: object) -> int | None:
"""Convierte un valor numerico del payload a int, o None si no es parseable."""
if value is None:
return None
try:
# Algunos campos vienen como string ("1234567") o float (1234567.0).
return int(float(value))
except (TypeError, ValueError):
return None
def _to_float(value: object) -> float | None:
"""Convierte un valor numerico del payload a float, o None si no es parseable."""
if value is None:
return None
try:
return float(value)
except (TypeError, ValueError):
return None
def _extract_items(payload: dict) -> list[dict]:
"""Localiza la lista de items dentro del JSON, tolerando variaciones del schema.
El Creative Center ha servido la lista bajo distintas rutas a lo largo del
tiempo (`data.list`, `data.hashtags`, `data.items`, ...). Se prueban en orden.
"""
data = payload.get("data")
if not isinstance(data, dict):
return []
for key in ("list", "hashtags", "songs", "creators", "videos", "items"):
candidate = data.get(key)
if isinstance(candidate, list):
return candidate
# Fallback: la primera lista no vacia que aparezca dentro de data.
for value in data.values():
if isinstance(value, list) and value:
return value
return []
def _row_from_item(item: dict, country: str, kind: str, fallback_rank: int) -> dict:
"""Normaliza un item crudo del payload a la fila canonica de `tiktok_trends`.
Claves de salida (1:1 con la tabla Postgres): country, kind, name, rank, views,
growth_pct, industry, url. Tolera nombres de campo distintos por tipo de kind.
"""
name = (
item.get("hashtag_name")
or item.get("title")
or item.get("name")
or item.get("nickname")
or item.get("song_title")
or item.get("music_name")
or item.get("keyword")
)
rank = _to_int(item.get("rank")) or _to_int(item.get("trend_rank"))
if rank is None:
rank = fallback_rank
# Volumen de visualizaciones / publicaciones segun el tipo de tendencia.
views = (
_to_int(item.get("video_views"))
or _to_int(item.get("views"))
or _to_int(item.get("publish_cnt"))
or _to_int(item.get("post_count"))
or _to_int(item.get("play_count"))
)
# El Creative Center expresa el crecimiento como ratio (0.42) o porcentaje (42).
growth_raw = item.get("trend") or item.get("rank_diff") or item.get("growth")
growth_pct = _to_float(growth_raw)
if growth_pct is not None and -1.0 <= growth_pct <= 1.0:
# Heuristica: si viene como ratio en [-1,1], normalizar a porcentaje.
growth_pct = round(growth_pct * 100.0, 2)
industry = None
industries = item.get("industry_info") or item.get("industry")
if isinstance(industries, dict):
industry = industries.get("value") or industries.get("label")
elif isinstance(industries, list) and industries:
first = industries[0]
industry = first.get("value") if isinstance(first, dict) else str(first)
elif isinstance(industries, str):
industry = industries
url = item.get("url") or item.get("link")
if not url and kind == "hashtag" and name:
slug = str(name).lstrip("#")
url = (
"https://ads.tiktok.com/business/creativecenter/hashtag/"
f"{slug}/pc/en"
)
return {
"country": country,
"kind": kind,
"name": str(name) if name is not None else None,
"rank": rank,
"views": views,
"growth_pct": growth_pct,
"industry": industry,
"url": url,
}
def scrape_tiktok_creative(
country: str = "ES",
kind: str = "hashtag",
limit: int = 50,
period: int = 7,
) -> list[dict]:
"""Capta tendencias del TikTok Creative Center via su API JSON interna.
Args:
country: codigo ISO de pais del ranking (ej. "ES", "US", "MX"). El Creative
Center segmenta las tendencias por mercado.
kind: tipo de tendencia. Uno de: "hashtag" (default, el mas estable),
"song", "creator", "video".
limit: numero maximo de filas a devolver (el endpoint pagina de 50 en 50).
period: ventana temporal en dias. Validos: 7 (default), 30, 120.
Returns:
Lista de dicts con EXACTAMENTE las claves: country, kind, name, rank, views,
growth_pct, industry, url. Mapea 1:1 con la tabla Postgres `tiktok_trends`
(sin id/snapshot_date/scraped_at). `views` es int|None, `growth_pct` es
float|None, `rank` es int|None. Devuelve [] si el endpoint responde OK pero
sin items para el segmento solicitado.
Raises:
ValueError: si `kind` o `period` no son validos.
RuntimeError: si el endpoint interno no responde como JSON util (HTTP de
error, anti-bot, cambio de schema, bloqueo desde datacenter/headless).
El mensaje indica el codigo HTTP o la causa para diagnostico.
"""
if kind not in _ENDPOINTS:
raise ValueError(
f"kind invalido: {kind!r}. Validos: {sorted(_ENDPOINTS)}"
)
if period not in _VALID_PERIODS:
raise ValueError(
f"period invalido: {period}. Validos: {sorted(_VALID_PERIODS)}"
)
endpoint = _ENDPOINTS[kind]
rows: list[dict] = []
page = 1
page_size = 50
session = requests.Session()
session.headers.update(_HEADERS)
while len(rows) < limit:
params = {
"page": page,
"limit": page_size,
"period": period,
"country_code": country,
"sort_by": "popular",
}
try:
resp = session.get(endpoint, params=params, timeout=15)
except requests.RequestException as exc:
raise RuntimeError(
"TikTok Creative Center: fallo de red contactando el endpoint "
f"interno {endpoint!r}: {exc}. Alternativa: usar el browser "
"MCP/CDP del ecosistema con sesion real (ver .md ## Gotchas)."
) from exc
if resp.status_code == 403:
raise RuntimeError(
"TikTok Creative Center devolvio 403 (anti-bot / IP de "
"datacenter bloqueada). El endpoint JSON interno requiere "
"tokens de sesion (anonymous-user-id/user-sign) que no se "
"pueden falsear desde headless. Alternativa robusta: browser "
"MCP/CDP navegando el Creative Center con sesion real."
)
if resp.status_code != 200:
raise RuntimeError(
f"TikTok Creative Center devolvio HTTP {resp.status_code} para "
f"{endpoint!r}. El endpoint interno pudo cambiar de ruta o de "
"contrato (no es una API publica versionada)."
)
try:
payload = resp.json()
except ValueError as exc:
raise RuntimeError(
"TikTok Creative Center no devolvio JSON (probable HTML de "
"challenge o pagina de login). El endpoint interno cambio o "
"exige sesion real. Alternativa: browser MCP/CDP."
) from exc
# TikTok envuelve la respuesta en {code, msg, data}. code != 0 = error logico.
code = payload.get("code")
if code not in (0, None):
raise RuntimeError(
f"TikTok Creative Center respondio code={code} "
f"({payload.get('msg', 'sin mensaje')}). El endpoint interno "
"rechazo la peticion (parametros o anti-bot)."
)
items = _extract_items(payload)
if not items:
break
for offset, item in enumerate(items):
if not isinstance(item, dict):
continue
rank_fallback = (page - 1) * page_size + offset + 1
rows.append(_row_from_item(item, country, kind, rank_fallback))
if len(rows) >= limit:
break
# Si la pagina vino incompleta, no hay mas resultados.
if len(items) < page_size:
break
page += 1
return rows[:limit]
if __name__ == "__main__":
# Self-test honesto: import OK obligatorio + UN intento de fetch real que NO
# falla la build por la red. Reporta si TikTok respondio o bloqueo/cambio.
print("import OK: scrape_tiktok_creative cargado")
try:
sample = scrape_tiktok_creative(country="ES", kind="hashtag", limit=10, period=7)
if sample:
print(f"FETCH REAL OK: {len(sample)} filas. Primera: {sample[0]}")
else:
print(
"FETCH REAL: el endpoint respondio pero sin items "
"(segmento vacio o anti-bot silencioso)."
)
except Exception as exc: # noqa: BLE001 -- self-test honesto, no propaga
print(f"FETCH REAL FALLO (esperable desde headless/datacenter): {exc}")
+4
View File
@@ -18,8 +18,12 @@ from .caldav_put_event import caldav_put_event
from .dav_list_resources import dav_list_resources
from .dav_get_resource import dav_get_resource
from .dav_delete_resource import dav_delete_resource
from .pg_insert_rows import pg_insert_rows
from .pg_apply_sql import pg_apply_sql
__all__ = [
"pg_insert_rows",
"pg_apply_sql",
"setup_logger",
"get_logger",
"generate_app_icon",
+59
View File
@@ -0,0 +1,59 @@
---
name: pg_apply_sql
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def pg_apply_sql(dsn: str, sql_path: str) -> int"
description: "Lee un archivo .sql y ejecuta su contenido completo contra PostgreSQL en un solo cursor.execute via psycopg2. Multi-statement en una transaccion (sin parametros). Pensado para migraciones idempotentes (el SQL usa IF NOT EXISTS). Commit al exito. Retorna el numero de statements aplicados (split por ;), minimo 1 si el script no esta vacio."
tags: [postgres, market-intel, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [psycopg2]
params:
- name: dsn
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname."
- name: sql_path
desc: "Ruta al archivo .sql a aplicar (ej. db/migrations/001_init.sql)."
output: "Numero entero de statements no vacios aplicados (split por ;), minimo 1 si el script no esta vacio; 0 si el archivo esta vacio."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/pg_apply_sql.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from infra.pg_apply_sql import pg_apply_sql
dsn = "postgresql://scraper:secret@localhost:5432/captacion"
# db/migrations/001_init.sql con:
# CREATE TABLE IF NOT EXISTS leads_raw (
# id SERIAL PRIMARY KEY, name TEXT, city TEXT, score INT,
# snapshot_date DATE
# );
n = pg_apply_sql(dsn, "db/migrations/001_init.sql")
print(f"aplicados {n} statements") # aplicados 1 statements
```
## Cuando usarla
Cuando necesitas aplicar un archivo de migracion `.sql` (crear tablas, indices, columnas)
a Postgres antes de escribir datos. Usala al arrancar el pipeline de captacion_clientes
para garantizar el schema, y para iterar sobre `db/migrations/*.sql` en orden.
## Gotchas
- Idempotencia depende del SQL: el archivo DEBE usar `IF NOT EXISTS` / `ON CONFLICT` para poder re-aplicarse sin error. Esta funcion no lleva control de versiones de migracion — el caller decide que archivos aplica y en que orden.
- Todo el script va en UNA transaccion: si cualquier statement falla, se hace rollback de todo el archivo y se lanza RuntimeError.
- El conteo de statements (`split(";")`) es informativo y aproximado: un `;` dentro de un string literal o de un cuerpo de funcion PL/pgSQL infla la cuenta. No lo uses como verdad exacta, solo como indicador.
- NO pasa parametros: el contenido del `.sql` se ejecuta tal cual. No metas datos no confiables en el archivo — es para DDL/migraciones controladas, no para input de usuario.
- Requiere `psycopg2` instalado en el venv (import perezoso: el modulo importa sin la dependencia, pero la llamada falla con RuntimeError claro si falta).
- Archivo inexistente o ilegible lanza RuntimeError con la ruta.
+68
View File
@@ -0,0 +1,68 @@
"""Apply a .sql script file against a PostgreSQL database via psycopg2."""
from __future__ import annotations
from pathlib import Path
def pg_apply_sql(dsn: str, sql_path: str) -> int:
"""Read a .sql file and execute its full contents against PostgreSQL.
The whole script is sent in a single cursor.execute call. psycopg2 runs
multi-statement scripts in one execute when there are no bound parameters,
so DDL files with several statements separated by ";" apply atomically in
one transaction. Designed for idempotent migrations (the SQL itself uses
"IF NOT EXISTS"). Commits on success.
Args:
dsn: Connection string, e.g. "postgresql://user:pass@host:port/dbname".
sql_path: Path to the .sql file to apply (e.g. db/migrations/001_init.sql).
Returns:
Number of non-empty statements applied (counted by splitting on ";").
At minimum 1 when the script is non-empty.
Raises:
RuntimeError: If the file cannot be read, or the connection / execution
fails. The original exception is chained for debugging.
"""
path = Path(sql_path)
try:
script = path.read_text(encoding="utf-8")
except OSError as exc:
raise RuntimeError(
f"pg_apply_sql could not read {sql_path!r}: {exc}"
) from exc
if not script.strip():
return 0
# Lazy import so the module loads even without psycopg2 installed.
try:
import psycopg2
except ImportError as exc: # pragma: no cover - exercised only without dep
raise RuntimeError(
"psycopg2 is required for pg_apply_sql; install psycopg2-binary"
) from exc
# Best-effort statement count (informational return value only). Strip
# blank fragments produced by a trailing semicolon.
statement_count = sum(1 for part in script.split(";") if part.strip())
statement_count = max(statement_count, 1)
conn = None
try:
conn = psycopg2.connect(dsn)
with conn.cursor() as cur:
cur.execute(script)
conn.commit()
return statement_count
except Exception as exc:
if conn is not None:
conn.rollback()
raise RuntimeError(
f"pg_apply_sql failed applying {sql_path!r}: {exc}"
) from exc
finally:
if conn is not None:
conn.close()
+63
View File
@@ -0,0 +1,63 @@
---
name: pg_insert_rows
kind: function
lang: py
domain: infra
version: "1.0.0"
purity: impure
signature: "def pg_insert_rows(dsn: str, table: str, rows: list[dict], add_snapshot_date: bool = True) -> int"
description: "Inserta filas (lista de dicts) en una tabla PostgreSQL de forma append-only via psycopg2.extras.execute_values. Deriva columnas de las claves del dict (union si difieren, rellena con None). Opcionalmente inyecta snapshot_date = date.today(). Insercion parametrizada (sin format de strings, evita inyeccion SQL). Commit y cierre de conexion. Retorna el numero de filas insertadas."
tags: [postgres, market-intel, infra]
uses_functions: []
uses_types: []
returns: []
returns_optional: false
error_type: "error_go_core"
imports: [psycopg2]
params:
- name: dsn
desc: "Cadena de conexion PostgreSQL en formato postgresql://user:pass@host:port/dbname."
- name: table
desc: "Nombre de la tabla destino (debe existir previamente)."
- name: rows
desc: "Lista de dicts; cada dict es una fila, sus claves son nombres de columna. Si los esquemas difieren se usa la union de claves y se rellena con None."
- name: add_snapshot_date
desc: "Si True y una fila no trae snapshot_date, inyecta snapshot_date = date.today() antes de insertar. Default True."
output: "Numero entero de filas insertadas (0 si rows esta vacio)."
tested: false
tests: []
test_file_path: ""
file_path: "python/functions/infra/pg_insert_rows.py"
---
## Ejemplo
```python
import sys, os
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "python", "functions"))
from infra.pg_insert_rows import pg_insert_rows
dsn = "postgresql://scraper:secret@localhost:5432/captacion"
rows = [
{"name": "Cliente A", "city": "Madrid", "score": 87},
{"name": "Cliente B", "city": "Sevilla"}, # sin score -> NULL
]
# snapshot_date = hoy se inyecta en cada fila automaticamente
n = pg_insert_rows(dsn, "leads_raw", rows)
print(f"insertadas {n} filas") # insertadas 2 filas
```
## Cuando usarla
Cuando escribes datos scrapeados a Postgres en lote append-only y quieres la columna
`snapshot_date` poblada sin codigo extra. Usala antes de cualquier dashboard/consulta de
market-intel sobre el dato bruto. Cada llamada acumula una nueva foto historica.
## Gotchas
- La tabla debe existir antes de llamar — esta funcion NO crea schema (usa `pg_apply_sql` para eso).
- Es append-only: NO hace upsert ni deduplica. Llamadas repetidas duplican filas (por diseno, para historico).
- El esquema efectivo es la UNION de las claves de todas las filas; columnas ausentes en una fila se insertan como NULL. Si una clave no existe como columna en la tabla, Postgres lanza error y la transaccion entera hace rollback.
- `add_snapshot_date=True` solo rellena filas que NO traen ya `snapshot_date`; si tu dict ya la incluye, se respeta.
- Requiere `psycopg2` instalado en el venv (import perezoso: el modulo se importa sin la dependencia, pero la llamada falla con RuntimeError claro si falta).
- Conexion nueva por llamada (sin pool). Para muchas inserciones pequenas en bucle, agrupa las filas en una sola llamada.
+92
View File
@@ -0,0 +1,92 @@
"""Append-only batch insert of dict rows into a PostgreSQL table via psycopg2."""
from __future__ import annotations
from datetime import date
def pg_insert_rows(
dsn: str,
table: str,
rows: list[dict],
add_snapshot_date: bool = True,
) -> int:
"""Insert rows (list of dicts) into a PostgreSQL table, append-only.
Columns are derived from the dict keys. If rows have heterogeneous schemas,
the union of all keys is used and missing values are filled with None, so a
single parameterized statement covers every row. Insertion uses
psycopg2.extras.execute_values (no string formatting) to avoid SQL injection.
Args:
dsn: Connection string, e.g. "postgresql://user:pass@host:port/dbname".
table: Target table name (must already exist).
rows: List of dicts; each dict is one row, keys are column names.
add_snapshot_date: If True and a row lacks "snapshot_date", inject
snapshot_date = date.today() before inserting.
Returns:
Number of rows inserted.
Raises:
RuntimeError: If the connection or the insert fails. The original
psycopg2 exception is chained for debugging.
"""
if not rows:
return 0
# psycopg2 is imported lazily so the module imports without the dependency
# present (self-test / introspection) and fails clearly only when invoked.
try:
import psycopg2
from psycopg2 import extras as pg_extras
from psycopg2 import sql as pg_sql
except ImportError as exc: # pragma: no cover - exercised only without dep
raise RuntimeError(
"psycopg2 is required for pg_insert_rows; install psycopg2-binary"
) from exc
# Work on copies so we never mutate the caller's dicts.
prepared: list[dict] = [dict(row) for row in rows]
if add_snapshot_date:
today = date.today()
for row in prepared:
row.setdefault("snapshot_date", today)
# Stable union of columns across all rows (first-seen order).
columns: list[str] = []
seen: set[str] = set()
for row in prepared:
for key in row:
if key not in seen:
seen.add(key)
columns.append(key)
if not columns:
return 0
# Build the value tuples in column order, filling absent keys with None.
values = [tuple(row.get(col) for col in columns) for row in prepared]
insert_stmt = pg_sql.SQL("INSERT INTO {table} ({cols}) VALUES %s").format(
table=pg_sql.Identifier(table),
cols=pg_sql.SQL(", ").join(pg_sql.Identifier(c) for c in columns),
)
conn = None
try:
conn = psycopg2.connect(dsn)
with conn.cursor() as cur:
pg_extras.execute_values(cur, insert_stmt, values)
conn.commit()
return len(values)
except Exception as exc:
if conn is not None:
conn.rollback()
raise RuntimeError(
f"pg_insert_rows failed inserting into {table!r}: {exc}"
) from exc
finally:
if conn is not None:
conn.close()
@@ -0,0 +1,72 @@
---
name: ingest_market_trends
kind: pipeline
lang: py
domain: pipelines
version: 1.0.0
purity: impure
signature: "ingest_market_trends(source)"
error_type: error_go_core
description: "Scrapea una fuente de tendencias de mercado (Amazon, Google Trends, TikTok, AliExpress o precios de competencia) y aterriza la foto del día en su tabla de la base de datos Postgres `trends`. Dispatcher one-shot pensado para invocarse desde dag_engine (un step por fuente). Proyecto captacion_clientes."
tags: [market-intel, scraping, trends, postgres, ingest, launcher]
uses_functions:
- scrape_amazon_bestsellers_py_datascience
- scrape_google_trends_py_datascience
- scrape_tiktok_creative_py_datascience
- scrape_aliexpress_trending_py_datascience
- scrape_competitor_prices_py_datascience
- pg_insert_rows_py_infra
uses_types: []
returns: []
returns_optional: false
file_path: python/functions/pipelines/ingest_market_trends.py
params:
- name: source
desc: "Fuente a scrapear: amazon | google_trends | tiktok | aliexpress | competitor. Una por invocación."
- name: config
desc: "Ruta del JSON de configuración (keywords, categorías, países). Default: projects/captacion_clientes/config/sources.json."
- name: dsn
desc: "DSN Postgres override. Si se omite, se resuelve por CAPTACION_DSN env -> .env del proyecto -> pass captacion/postgres."
output: "JSON por stdout con {source, scraped, inserted} (filas scrapeadas e insertadas en Postgres)."
---
Pipeline dispatcher que compone un scraper del registry con `pg_insert_rows` para
insertar la foto diaria de una fuente de tendencias en la base de datos `trends`.
Resuelve el DSN de Postgres por precedencia: `--dsn` → env `CAPTACION_DSN`
`projects/captacion_clientes/.env``pass captacion/postgres`. La configuración de cada
fuente (keywords, categorías, países) vive en `config/sources.json` del proyecto, sin
secretos.
## Ejemplo
```bash
# Amazon Best Sellers + Movers & Shakers de las categorías del config
fn run ingest_market_trends_py_pipelines --source amazon
# -> {"source": "amazon", "scraped": 420, "inserted": 420}
# Google Trends de las keywords del config
fn run ingest_market_trends_py_pipelines --source google_trends
# Precios de la competencia (lee competitor_targets de la propia DB)
fn run ingest_market_trends_py_pipelines --source competitor
```
## Cuando usarla
Cuando quieras capturar la foto del día de una fuente de tendencias en Postgres para
analizarla en Metabase. Es el step canónico que invoca dag_engine (un `function:` por
fuente) para el scraping programado diario/horario del proyecto captacion_clientes.
## Gotchas
- **TikTok y AliExpress** bloquean el scraping HTTP-directo desde datacenter/headless
(anti-bot, captcha). Esos `--source` lanzarán error (visible en el run de dag_engine)
hasta que se reimplementen vía browser MCP/CDP con sesión real (doctrina `flow_replay.md`).
Amazon y Google Trends sí funcionan por HTTP.
- **`--source competitor`** no hace nada si `competitor_targets` está vacía: hay que
insertar primero los objetivos (competidor + URL + product_key) a vigilar.
- Append-only: cada corrida inserta una foto nueva (no actualiza), de modo que Metabase
puede graficar la evolución temporal. No correr en bucle apretado o inflarás la tabla.
- Google Trends (pytrends) se rate-limitea (429); el scraper reintenta con backoff pero
con muchas keywords puede tardar o fallar.
@@ -0,0 +1,185 @@
"""ingest_market_trends — scrapea una fuente de tendencias y la aterriza en Postgres `trends`.
Pipeline dispatcher del proyecto captacion_clientes. Compone un scraper del registry
(según `--source`) con `pg_insert_rows` para insertar la foto (snapshot) del día en la
tabla correspondiente de la base de datos `trends`.
Pensado para invocarse desde dag_engine con un step `function:` (un step por fuente),
o a mano: `fn run ingest_market_trends_py_pipelines --source amazon`.
Resolución del DSN de Postgres (en este orden):
1. --dsn <dsn>
2. env CAPTACION_DSN
3. projects/captacion_clientes/.env (clave CAPTACION_DSN, gitignored)
4. pass captacion/postgres (construye el DSN; requiere gpg-agent desbloqueado)
Configuración de fuentes (keywords, categorías, ...) en
projects/captacion_clientes/config/sources.json (sin secretos).
"""
import argparse
import json
import os
import subprocess
import sys
ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
sys.path.insert(0, os.path.join(ROOT, "python", "functions"))
from datascience import ( # noqa: E402
scrape_amazon_bestsellers,
scrape_google_trends,
scrape_tiktok_creative,
scrape_aliexpress_trending,
scrape_competitor_prices,
)
from infra import pg_insert_rows # noqa: E402
PROJECT_DIR = os.path.join(ROOT, "projects", "captacion_clientes")
DEFAULT_CONFIG = os.path.join(PROJECT_DIR, "config", "sources.json")
DEFAULT_ENV = os.path.join(PROJECT_DIR, ".env")
SOURCES = ("amazon", "google_trends", "tiktok", "aliexpress", "competitor")
def resolve_dsn(cli_dsn: str | None) -> str:
"""Resuelve el DSN de Postgres según la precedencia documentada."""
if cli_dsn:
return cli_dsn
if os.environ.get("CAPTACION_DSN"):
return os.environ["CAPTACION_DSN"]
if os.path.exists(DEFAULT_ENV):
with open(DEFAULT_ENV) as fh:
for line in fh:
line = line.strip()
if line.startswith("CAPTACION_DSN="):
return line.split("=", 1)[1].strip()
# Fallback: construir desde pass
try:
pw = subprocess.check_output(
["pass", "show", "captacion/postgres"], text=True
).splitlines()[0].strip()
return f"postgresql://captacion:{pw}@localhost:5433/trends"
except Exception as exc: # noqa: BLE001
raise RuntimeError(
"No se pudo resolver el DSN de Postgres (--dsn / CAPTACION_DSN / .env / pass)."
) from exc
def load_config(path: str) -> dict:
with open(path) as fh:
return json.load(fh)
def _read_competitor_targets(dsn: str) -> list[dict]:
"""Lee los objetivos activos de la tabla competitor_targets."""
import psycopg2
cols = ["competitor", "product_key", "product_name", "url", "price_selector", "currency"]
conn = psycopg2.connect(dsn)
try:
cur = conn.cursor()
cur.execute(
"SELECT competitor, product_key, product_name, url, price_selector, currency "
"FROM competitor_targets WHERE active = TRUE"
)
return [dict(zip(cols, row)) for row in cur.fetchall()]
finally:
conn.close()
def _dispatch(source: str, config: dict, dsn: str) -> dict:
"""Scrapea la fuente indicada y aterriza las filas en su tabla. Devuelve un resumen."""
if source == "amazon":
cfg = config.get("amazon", {})
rows: list[dict] = []
for category in cfg.get("categories", [None]):
for list_type in cfg.get("list_types", ["bestsellers"]):
batch = scrape_amazon_bestsellers(
marketplace=cfg.get("marketplace", "amazon.es"),
categories=[category] if category else None,
list_type=list_type,
max_items=cfg.get("max_items", 50),
)
for r in batch:
if not r.get("category"):
r["category"] = category or "general"
rows += batch
inserted = pg_insert_rows(dsn, "amazon_bestsellers", rows)
return {"source": source, "scraped": len(rows), "inserted": inserted}
if source == "google_trends":
cfg = config.get("google_trends", {})
rows = scrape_google_trends(
keywords=cfg.get("keywords", []),
geo=cfg.get("geo", "ES"),
timeframe=cfg.get("timeframe", "now 7-d"),
include_related=cfg.get("include_related", True),
)
inserted = pg_insert_rows(dsn, "google_trends", rows)
return {"source": source, "scraped": len(rows), "inserted": inserted}
if source == "tiktok":
cfg = config.get("tiktok", {})
rows = []
for kind in cfg.get("kinds", ["hashtag"]):
rows += scrape_tiktok_creative(
country=cfg.get("country", "ES"),
kind=kind,
limit=cfg.get("limit", 50),
period=cfg.get("period", 7),
)
inserted = pg_insert_rows(dsn, "tiktok_trends", rows)
return {"source": source, "scraped": len(rows), "inserted": inserted}
if source == "aliexpress":
cfg = config.get("aliexpress", {})
rows = []
for query in cfg.get("queries", [None]):
rows += scrape_aliexpress_trending(
query=query,
category=cfg.get("category"),
limit=cfg.get("limit", 40),
ship_to=cfg.get("ship_to", "ES"),
)
inserted = pg_insert_rows(dsn, "aliexpress_trends", rows)
return {"source": source, "scraped": len(rows), "inserted": inserted}
if source == "competitor":
targets = _read_competitor_targets(dsn)
if not targets:
return {"source": source, "scraped": 0, "inserted": 0,
"note": "competitor_targets vacía — inserta objetivos para vigilar precios"}
rows = scrape_competitor_prices(targets)
inserted = pg_insert_rows(dsn, "competitor_prices", rows)
return {"source": source, "scraped": len(rows), "inserted": inserted}
raise ValueError(f"source desconocida: {source!r}. Válidas: {', '.join(SOURCES)}")
def ingest_market_trends(source: str, config: str | None = None, dsn: str | None = None) -> dict:
"""Punto de entrada del pipeline (lo invoca `fn run` y dag_engine con `source` posicional).
Resuelve la configuración y el DSN internamente, scrapea la fuente y aterriza la foto
en Postgres. Imprime el resumen JSON por stdout y lo devuelve.
"""
config_data = load_config(config or DEFAULT_CONFIG)
resolved_dsn = resolve_dsn(dsn)
summary = _dispatch(source, config_data, resolved_dsn)
print(json.dumps(summary, ensure_ascii=False))
return summary
def main() -> int:
ap = argparse.ArgumentParser(description="Ingest de tendencias de mercado a Postgres trends")
ap.add_argument("--source", required=True, choices=SOURCES, help="Fuente a scrapear")
ap.add_argument("--config", default=DEFAULT_CONFIG, help="Ruta del JSON de configuración")
ap.add_argument("--dsn", default=None, help="DSN Postgres (override)")
args = ap.parse_args()
ingest_market_trends(args.source, config=args.config, dsn=args.dsn)
return 0
if __name__ == "__main__":
sys.exit(main())