"""Scrapea productos trending de AliExpress conduciendo un Chrome real por CDP. Variante que SI funciona frente al bloqueo por captcha: en vez de pedir el HTML por HTTP (que devuelve un challenge/captcha para la busqueda de AliExpress), abre una pestana en un Chrome con perfil real (puerto de remote debugging) que ejecuta el JavaScript de la SPA y renderiza los productos. La extraccion se hace con `cdp_eval` del registry, scrolleando para forzar el lazy-load de tarjetas. Devuelve dicts con claves 1:1 con la tabla Postgres `aliexpress_trends` (sin id/snapshot_date/scraped_at), listos para insertar. """ import json import re import time import urllib.parse import requests from browser.cdp_eval import cdp_eval # Expresion JS de extraccion. Se evalua una sola vez tras el scroll y devuelve # JSON.stringify de la lista de filas. Tolerante: campos ausentes -> null, nunca # aborta una tarjeta. Deduplica por product_id dentro del propio JS. _EXTRACT_JS = r""" (function () { // 1. product_id desde el href: /item/.html, o promo (?productIds=:... // o x_object_id=). function productIdFromHref(href) { if (!href) return null; var m = href.match(/\/item\/(\d+)\.html/); if (m) return m[1]; m = href.match(/[?&]productIds=(\d+)/); if (m) return m[1]; m = href.match(/x_object_id(?:%3A|:|=)(\d+)/); if (m) return m[1]; return null; } // 2. href absoluto al producto. Prefiere un dentro del card; // si no, el href del propio anchor de la tarjeta. function absUrl(href) { if (!href) return null; if (href.indexOf("//") === 0) return "https:" + href; if (href.indexOf("http") === 0) return href; return "https://www.aliexpress.com" + href; } // 3. precio EUR -> float (coma decimal ES). "0,33€" -> 0.33. "GRATIS" -> null. function parsePrice(txt) { if (!txt) return null; // primer token monetario con € o EUR var m = txt.match(/([\d.]+,\d+)\s*(?:€|EUR)/); if (!m) m = txt.match(/(?:€|EUR)\s*([\d.]+,\d+)/); if (!m) m = txt.match(/([\d.]+)\s*(?:€|EUR)/); if (!m) return null; var raw = m[1].replace(/\./g, "").replace(",", "."); var v = parseFloat(raw); return isFinite(v) ? v : null; } // 4. pedidos: "100K+ vendidos", "50.000+ vendidos", "1.000+ sold", "234 sold". function parseOrders(txt) { if (!txt) return null; var m = txt.match(/([\d.,]+)\s*([KkMm])?\s*\+?\s*(?:vendidos|sold|orders|pedidos)/); if (!m) return null; var num = m[1].replace(/\./g, "").replace(/,/g, "."); var val = parseFloat(num); if (!isFinite(val)) return null; var suf = (m[2] || "").toLowerCase(); if (suf === "k") val *= 1000; else if (suf === "m") val *= 1000000; return Math.round(val); } // 5. rating: primer "4.9" / "4,9" tras el bloque de precio (0-5). function parseRating(txt) { if (!txt) return null; var matches = txt.match(/\b([0-5][.,]\d)\b/g); if (!matches) return null; for (var i = 0; i < matches.length; i++) { var v = parseFloat(matches[i].replace(",", ".")); if (v >= 0 && v <= 5) return v; } return null; } var anchors = Array.prototype.slice.call( document.querySelectorAll("a.search-card-item") ); var seen = {}; var rows = []; for (var i = 0; i < anchors.length; i++) { var a = anchors[i]; var card = a.closest(".search-item-card-wrapper-gallery") || a; // href al producto: primero un dentro del card. var href = null; var inner = card.querySelectorAll("a"); for (var j = 0; j < inner.length; j++) { var h = inner[j].getAttribute("href") || ""; if (/\/item\/\d+\.html/.test(h)) { href = h; break; } } if (!href) href = a.getAttribute("href") || ""; var pid = productIdFromHref(href); if (!pid || seen[pid]) continue; seen[pid] = true; var img = card.querySelector("img"); var title = img ? (img.getAttribute("alt") || "") : ""; if (!title) title = (a.innerText || "").trim(); title = (title || "").trim() || null; var text = card.innerText || ""; rows.push({ product_id: pid, title: title, price: parsePrice(text), currency: "EUR", orders: parseOrders(text), rating: parseRating(text), url: absUrl(href) }); } return JSON.stringify(rows); })() """ def cdp_scrape_aliexpress_trending( query: str = "gadgets", limit: int = 40, ship_to: str = "ES", port: int = 9222, ) -> list[dict]: """Scrapea productos trending de AliExpress via CDP sobre un Chrome real. Abre una pestana en la busqueda de AliExpress ordenada por popularidad (numero de pedidos), espera al render, scrollea para disparar el lazy-load de tarjetas y extrae los productos con un unico `cdp_eval`. Args: query: Termino de busqueda. Tambien se usa como `category` en cada fila. limit: Maximo de productos a devolver tras deduplicar por product_id. ship_to: Codigo de pais de envio (afecta precios/moneda mostrados). port: Puerto de remote debugging del Chrome con perfil real. Default 9222. Returns: Lista de dicts con claves exactas (1:1 con la tabla `aliexpress_trends`): category, product_id, title, price, currency, orders, rating, url. price es float|None, orders int|None, rating float|None; el resto str. Raises: RuntimeError: si no se puede abrir la pestana, si CDP devuelve un error de evaluacion, o si el JSON de extraccion no se puede parsear. """ base = "http://localhost:%d" % port target_url = ( "https://www.aliexpress.com/w/wholesale-%s.html" "?SortType=total_tranpro_desc&shipCountry=%s" % (urllib.parse.quote(query), urllib.parse.quote(ship_to)) ) # 1. Abrir pestana via DevTools HTTP API (esta build exige PUT en /json/new). tab_id = "" try: new_url = "%s/json/new?%s" % (base, urllib.parse.quote(target_url, safe="")) resp = requests.put(new_url, timeout=10) if resp.status_code != 200: # Fallback a POST por compatibilidad con builds antiguas. resp = requests.post(new_url, timeout=10) resp.raise_for_status() tab = resp.json() tab_id = tab.get("id", "") if not tab_id: raise RuntimeError("DevTools /json/new no devolvio id de pestana") except Exception as exc: # noqa: BLE001 — red/HTTP/JSON raise RuntimeError("no se pudo abrir pestana en %s: %s" % (base, exc)) substr = "aliexpress.com/w/wholesale-%s" % urllib.parse.quote(query) try: # 2. Esperar render inicial. time.sleep(6.0) # 3. Scroll en bucle para forzar lazy-load hasta tener >= limit tarjetas # o hasta que el conteo deje de crecer (estabilizado). count_js = ( 'document.querySelectorAll("a.search-card-item").length' ) prev = -1 stable = 0 for _ in range(15): cdp_eval( "window.scrollBy(0, 2500)", port=port, target_url_substr=substr, ) time.sleep(1.2) res = cdp_eval(count_js, port=port, target_url_substr=substr) n = res.get("value") if res.get("ok") else None n = int(n) if isinstance(n, (int, float)) else 0 if n >= limit: break if n <= prev: stable += 1 if stable >= 2: break else: stable = 0 prev = n # 4. Extraer con un unico cdp_eval (devuelve JSON.stringify de las filas). res = cdp_eval(_EXTRACT_JS, port=port, target_url_substr=substr) if not res.get("ok"): raise RuntimeError( "cdp_eval fallo en la extraccion: %s" % res.get("error", "") ) raw = res.get("value") if not raw: return [] try: rows = json.loads(raw) except Exception as exc: # noqa: BLE001 — JSON malformado raise RuntimeError("JSON de extraccion invalido: %s" % exc) # 5. Anadir category y truncar a limit. Saneo defensivo de tipos. out: list[dict] = [] seen: set[str] = set() for r in rows: pid = r.get("product_id") if not pid or pid in seen: continue seen.add(pid) price = r.get("price") orders = r.get("orders") rating = r.get("rating") out.append( { "category": query, "product_id": str(pid), "title": r.get("title"), "price": float(price) if isinstance(price, (int, float)) else None, "currency": r.get("currency") or "EUR", "orders": int(orders) if isinstance(orders, (int, float)) else None, "rating": float(rating) if isinstance(rating, (int, float)) else None, "url": r.get("url"), } ) if len(out) >= limit: break return out finally: # 6. Cerrar la pestana siempre (best-effort). if tab_id: try: requests.get("%s/json/close/%s" % (base, tab_id), timeout=5) except Exception: # noqa: BLE001 — cierre best-effort pass if __name__ == "__main__": import sys q = sys.argv[1] if len(sys.argv) > 1 else "gadgets" lim = int(sys.argv[2]) if len(sys.argv) > 2 else 40 products = cdp_scrape_aliexpress_trending(query=q, limit=lim, port=9222) print("%d productos" % len(products)) print(json.dumps(products[:5], ensure_ascii=False, indent=2))