Files
fn_registry/python/functions/browser/scrape_aliexpress_cdp.py
T
egutierrez 763e06c127 feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-20 18:22:23 +02:00

312 lines
13 KiB
Python

"""Scrapea productos de AliExpress por Chrome DevTools Protocol (CDP).
Via para captar señales de dropshipping (que importar de China) sin chocar con el
captcha que bloquea el scraper HTTP `scrape_aliexpress_trending_py_datascience`.
Opera el navegador diario logueado (chromium-personal con remote debugging en el
puerto 9222, IP residencial): navega a la pagina de busqueda, hace scroll para
disparar el lazy-load de las cards y extrae cada producto con coste en EUR y numero
de pedidos (demanda real).
Reutiliza la primitiva de transport CDP `cdp_eval_py_browser`: navega via
`location.href = url` y evalua el JS de extraccion sobre la misma pestana, igual
patron que `extract_cmp_tcf_py_browser`.
"""
import json
import os
import re
import sys
import time
from datetime import datetime, timezone
# Permite importar funciones del registry tanto si se ejecuta desde la raiz del
# repo (cwd) como si se invoca el modulo directamente. Deriva la raiz dinamica
# desde la ubicacion de este archivo (nunca hardcodear paths de usuario).
_FN_ROOT = os.path.join(os.path.dirname(__file__), "..")
if _FN_ROOT not in sys.path:
sys.path.insert(0, _FN_ROOT)
from browser.cdp_eval import cdp_eval # noqa: E402
# JS de extraccion de las cards de la galeria de busqueda. Devuelve un string JSON
# con la lista de productos crudos (campos sin parsear, como los ve el navegador).
# Usa textContent NORMALIZADO (no innerText): innerText devuelve "" para las cards
# fuera del viewport (respeta layout/visibilidad), mientras que textContent siempre
# trae el texto aunque la card no este pintada en pantalla. El texto viene todo
# pegado sin saltos de linea, por eso los regex no dependen de "\n".
#
# Forma real del texto de una card (es.aliexpress.com, validado):
# "<titulo> 14,49€32,2€ -55%4.610.000+ vendidos12,42€ por Ud..."
# - precio: primer \d+[.,]\d+ seguido de € -> "14,49€"
# - price_orig: segundo \d+[.,]\d+ seguido de € -> "32,2€"
# - rating+ord: \d\.\d (rating) PEGADO a [\d.,]+\+? vendidos -> "4.6" + "10.000+"
# Cards promocionales "GRATIScon una compra" no tienen precio EUR -> price None.
_JS_EXTRACT = r"""
JSON.stringify(Array.from(document.querySelectorAll('.search-item-card-wrapper-gallery')).map(card => {
const a = card.querySelector('a[href*="/item/"]');
const href = a ? a.href.split('?')[0] : null;
const id = href ? ((href.match(/item\/(\d+)\.html/)||[])[1]) : null;
const txt = (card.textContent || '').replace(/\s+/g, ' ').trim();
const all_eur = (txt.match(/(\d+(?:[.,]\d+)?)\s*€/g) || []);
const price = all_eur.length ? all_eur[0].replace('','').trim() : null;
const price_orig = all_eur.length>1 ? all_eur[1].replace('','').trim() : null;
// rating (\d\.\d) pegado a las unidades vendidas: "4.610.000+ vendidos".
const ro = txt.match(/(\d\.\d)([\d.,]+\+?)\s*vendidos/);
const rating = ro ? ro[1] : null;
const orders = ro ? (ro[2] + ' vendidos') : ((txt.match(/([\d.,]+\+?\s*vendidos)/)||[])[1] || null);
const ship = (txt.match(/(Env[ií]o[^·]*?)(?:·|$)/)||[])[1] || null;
const img = a ? a.querySelector('img') : null;
const title = (img && img.alt) ? img.alt.trim() : (txt.split('')[0]||'').trim();
return {item_id:id, url:href, title, price, price_orig, rating, orders, ship_from:ship};
}))
"""
# JS de deteccion de captcha / muro anti-bot (slider "nc", punish page, etc.).
_JS_CAPTCHA = r"""
(function(){
var t=(document.body && document.body.innerText || '').toLowerCase();
var hasSlider=!!document.querySelector('.nc_iconfont, .nc-container, #nc_1_wrapper, [id*="nocaptcha"]');
var punish=/punish|verify to continue|slide to verify|desliza para|arrastra el control/.test(t);
var title=(document.title||'').toLowerCase();
return JSON.stringify({captcha: hasSlider || punish, title: title, has_cards: document.querySelectorAll('.search-item-card-wrapper-gallery').length});
})()
"""
def _slugify_query(query: str) -> str:
"""Convierte la query en el slug que usa la URL de busqueda (espacios -> guiones)."""
q = (query or "").strip().lower()
q = re.sub(r"\s+", "-", q)
return q
def _parse_eur(raw) -> float:
"""Parsea un precio EU ('12,34' o '1.234,56') a float. None si no es valido."""
if raw is None:
return None
s = str(raw).strip()
if not s:
return None
# Quitar separador de millar (.) y usar . como decimal (la coma EU).
if "," in s:
s = s.replace(".", "").replace(",", ".")
try:
return round(float(s), 2)
except (ValueError, TypeError):
return None
def _parse_float(raw) -> float:
"""Parsea un float simple (rating). None si no es valido."""
if raw is None:
return None
try:
return float(str(raw).strip())
except (ValueError, TypeError):
return None
def _parse_orders_num(raw) -> int:
"""Aproxima el numero de pedidos a int.
'10.000+ vendidos' -> 10000, '5.000+' -> 5000, '1.234' -> 1234, '500+' -> 500.
Quita puntos de millar, el '+' y el texto. None si no hay digitos.
"""
if raw is None:
return None
s = str(raw)
m = re.search(r"([\d.,]+)\s*\+?", s)
if not m:
return None
num = m.group(1).replace(".", "").replace(",", "")
if not num.isdigit():
return None
try:
return int(num)
except (ValueError, TypeError):
return None
def _coerce_products(raw_list, query: str) -> list:
"""Normaliza la lista cruda de cards a la forma de salida (parsea precios/pedidos)."""
scraped_at = datetime.now(timezone.utc).isoformat()
out = []
for c in raw_list:
if not isinstance(c, dict):
continue
if not c.get("item_id") and not c.get("url"):
continue
out.append({
"item_id": c.get("item_id"),
"url": c.get("url"),
"title": (c.get("title") or "").strip(),
"price": _parse_eur(c.get("price")),
"price_orig": _parse_eur(c.get("price_orig")),
"rating": _parse_float(c.get("rating")),
"orders": c.get("orders"),
"orders_num": _parse_orders_num(c.get("orders")),
"ship_from": (c.get("ship_from") or None),
"scraped_at": scraped_at,
})
return out
def _parse_json_value(value) -> object:
"""Convierte el string JSON devuelto por cdp_eval en objeto Python; None si falla."""
if isinstance(value, (list, dict)):
return value
if not isinstance(value, str):
return None
try:
return json.loads(value)
except (ValueError, TypeError):
return None
def scrape_aliexpress_cdp(
query: str,
sort: str = "total_tranpro_desc",
limit: int = 40,
port: int = 9222,
timeout_s: float = 25.0,
) -> dict:
"""Scrapea la pagina de busqueda de AliExpress por CDP y devuelve los productos.
Navega el navegador diario (Chrome con remote debugging) a la URL de busqueda
ordenada por `sort`, hace scroll para disparar el lazy-load hasta acercarse a
`limit` cards, extrae cada producto y parsea precios (EUR) y numero de pedidos.
Args:
query: Termino de busqueda (ej. "organizador maletero coche"). Los espacios
se convierten en guiones para la URL.
sort: Orden de resultados. "total_tranpro_desc" = por numero de pedidos
(demanda real, el default util para dropshipping). Otros: "default",
"price_asc", "price_desc".
limit: Numero objetivo de productos a recolectar. El scroll itera hasta
acercarse a este valor (cap de seguridad en el numero de scrolls).
port: Puerto de remote debugging de Chrome. Default 9222.
timeout_s: Timeout (segundos) para cada evaluacion CDP.
Returns:
dict autosuficiente:
{"status": "ok"|"error"|"captcha",
"source": "aliexpress",
"query": str,
"url": str, # URL navegada
"count": int,
"products": [
{"item_id", "url", "title", "price"(float EUR|None),
"price_orig"(float|None), "rating"(float|None),
"orders"(str crudo|None), "orders_num"(int|None),
"ship_from"(str|None), "scraped_at"(iso)}
],
"error": str # solo presente si status=="error"
}
Nunca inventa datos: sin cards -> status="error" products=[]; captcha
detectado -> status="captcha" products=[]. Nunca lanza.
"""
slug = _slugify_query(query)
url = f"https://es.aliexpress.com/w/wholesale-{slug}.html?SortType={sort}"
base = {"status": "error", "source": "aliexpress", "query": query, "url": url,
"count": 0, "products": []}
try:
# 1. Navegar la pestana activa a la URL de busqueda (reutiliza transport CDP).
# Se prioriza una pestana cuya URL ya contenga "aliexpress" para no pisar
# otra pestana del navegador diario; si no hay, cae al primer target page.
nav_expr = "location.href=" + json.dumps(url) + "; true"
nav = cdp_eval(nav_expr, port=port, target_url_substr="aliexpress", timeout_s=timeout_s)
if not nav.get("ok"):
nav = cdp_eval(nav_expr, port=port, timeout_s=timeout_s)
if not nav.get("ok"):
base["error"] = "navigate failed: " + str(nav.get("error", ""))
return base
# 2. Esperar la carga inicial de la SPA + primeras cards.
time.sleep(8.0)
# 3. Deteccion temprana de captcha / muro anti-bot.
cap = cdp_eval(_JS_CAPTCHA, port=port, target_url_substr="aliexpress", timeout_s=timeout_s)
cap_data = _parse_json_value(cap.get("value")) or {}
if isinstance(cap_data, dict) and cap_data.get("captcha"):
res = dict(base)
res["status"] = "captcha"
return res
# 4. Scroll para disparar el lazy-load. AliExpress carga ~15 cards iniciales
# y va anadiendo mas al bajar. Iteramos hasta acercarnos a `limit` o
# hasta que el conteo deje de crecer (cap de seguridad de 8 scrolls).
last_count = 0
stable_rounds = 0
max_scrolls = 8
for _ in range(max_scrolls):
cnt = cdp_eval(
"document.querySelectorAll('.search-item-card-wrapper-gallery').length",
port=port, target_url_substr="aliexpress", timeout_s=timeout_s,
)
current = cnt.get("value") if cnt.get("ok") else 0
if not isinstance(current, int):
current = 0
if current >= limit:
break
if current <= last_count:
stable_rounds += 1
if stable_rounds >= 2:
break
else:
stable_rounds = 0
last_count = current
cdp_eval(
"window.scrollTo(0, document.body.scrollHeight); true",
port=port, target_url_substr="aliexpress", timeout_s=timeout_s,
)
time.sleep(1.2)
# 5. Extraer las cards con el JS validado.
ext = cdp_eval(_JS_EXTRACT, port=port, target_url_substr="aliexpress", timeout_s=timeout_s)
if not ext.get("ok"):
base["error"] = "extract eval failed: " + str(ext.get("error", ""))
return base
raw_list = _parse_json_value(ext.get("value"))
if not isinstance(raw_list, list):
base["error"] = "extract returned non-list value"
return base
products = _coerce_products(raw_list, query)
# Sin cards: re-comprobar captcha por si el muro aparecio tras el scroll.
if not products:
cap2 = cdp_eval(_JS_CAPTCHA, port=port, target_url_substr="aliexpress", timeout_s=timeout_s)
cap2_data = _parse_json_value(cap2.get("value")) or {}
if isinstance(cap2_data, dict) and cap2_data.get("captcha"):
res = dict(base)
res["status"] = "captcha"
return res
base["error"] = "no product cards found"
return base
# Respetar el limite (la galeria puede traer mas que `limit`).
products = products[:limit]
return {
"status": "ok",
"source": "aliexpress",
"query": query,
"url": url,
"count": len(products),
"products": products,
}
except Exception as e: # noqa: BLE001 — nunca relanzar, devolver status error
base["error"] = str(e)
return base
if __name__ == "__main__":
q = sys.argv[1] if len(sys.argv) > 1 else "organizador maletero coche"
srt = sys.argv[2] if len(sys.argv) > 2 else "total_tranpro_desc"
lim = int(sys.argv[3]) if len(sys.argv) > 3 else 40
out = scrape_aliexpress_cdp(q, sort=srt, limit=lim)
print(json.dumps(out, ensure_ascii=False, indent=2))