feat(browser): auto-commit con 178 cambios

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-20 18:22:23 +02:00
parent 7d100e7f3e
commit 763e06c127
178 changed files with 19917 additions and 317 deletions
@@ -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))