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

275 lines
9.6 KiB
Python

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