feat(shell): auto-commit con 31 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,274 @@
|
||||
"""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/<ID>.html, o promo (?productIds=<ID>:...
|
||||
// o x_object_id=<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 <a href*="/item/"> 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 <a href*="/item/"> 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))
|
||||
Reference in New Issue
Block a user