763e06c127
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
312 lines
13 KiB
Python
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))
|