feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,311 @@
|
||||
"""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))
|
||||
Reference in New Issue
Block a user