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