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,393 @@
|
||||
"""Capta productos populares de AliExpress como señal de e-commerce/dropshipping.
|
||||
|
||||
Extrae el JSON que AliExpress embebe en el HTML de su página de búsqueda/listado
|
||||
(``window.runParams`` / ``_dida_config`` / scripts ``data``) en lugar de parsear
|
||||
el DOM renderizado por JS. AliExpress es anti-bot fuerte (captcha, 403, fingerprint
|
||||
sobre headless/datacenter), por lo que esta función es best-effort: cuando el fetch
|
||||
real es bloqueado lanza ``RuntimeError`` con un mensaje claro. NUNCA inventa datos.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
|
||||
_BASE = "https://www.aliexpress.com"
|
||||
_WHOLESALE = f"{_BASE}/wholesale"
|
||||
|
||||
# Headers realistas de un navegador desktop. AliExpress fingerprint-ea agresivamente,
|
||||
# así que enviamos un perfil coherente (Chrome estable + Accept-Language acorde a region).
|
||||
_DESKTOP_HEADERS = {
|
||||
"User-Agent": (
|
||||
"Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) "
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
),
|
||||
"Accept": (
|
||||
"text/html,application/xhtml+xml,application/xml;q=0.9,"
|
||||
"image/avif,image/webp,image/apng,*/*;q=0.8"
|
||||
),
|
||||
"Accept-Encoding": "gzip, deflate, br",
|
||||
"Connection": "keep-alive",
|
||||
"Upgrade-Insecure-Requests": "1",
|
||||
"Sec-Fetch-Dest": "document",
|
||||
"Sec-Fetch-Mode": "navigate",
|
||||
"Sec-Fetch-Site": "none",
|
||||
"Sec-Fetch-User": "?1",
|
||||
"Cache-Control": "max-age=0",
|
||||
}
|
||||
|
||||
# AliExpress decide moneda/region por estas cookies. Mapa ship_to -> (region, locale, currency).
|
||||
_REGION_MAP: dict[str, tuple[str, str, str]] = {
|
||||
"ES": ("ES", "es_ES", "EUR"),
|
||||
"US": ("US", "en_US", "USD"),
|
||||
"GB": ("GB", "en_GB", "GBP"),
|
||||
"FR": ("FR", "fr_FR", "EUR"),
|
||||
"DE": ("DE", "de_DE", "EUR"),
|
||||
"IT": ("IT", "it_IT", "EUR"),
|
||||
"PT": ("PT", "pt_PT", "EUR"),
|
||||
"MX": ("MX", "es_MX", "USD"),
|
||||
"BR": ("BR", "pt_BR", "BRL"),
|
||||
}
|
||||
|
||||
# Señales de bloqueo anti-bot en la respuesta.
|
||||
_BLOCK_MARKERS = (
|
||||
"punish", # /_____tmd_____/punish — captcha slider de AliExpress
|
||||
"nc_token", # NoCaptcha de Alibaba
|
||||
"captcha",
|
||||
"Access Denied",
|
||||
"baxia-dialog", # widget de verificacion
|
||||
)
|
||||
|
||||
|
||||
def _region_cookies(ship_to: str) -> dict[str, str]:
|
||||
region, locale, currency = _REGION_MAP.get(
|
||||
ship_to.upper(), _REGION_MAP["ES"]
|
||||
)
|
||||
return {
|
||||
"aep_usuc_f": f"site=glo&c_tp={currency}®ion={region}&b_locale={locale}",
|
||||
"intl_locale": locale,
|
||||
"xman_us_f": f"x_l=0&no_popup_today=n&zero_order=n&x_locale={locale}",
|
||||
}
|
||||
|
||||
|
||||
def _build_url(query: str | None, category: str | None) -> str:
|
||||
if query:
|
||||
# /wholesale?SearchText=... es el listado de búsqueda con runParams embebido.
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
return f"{_WHOLESALE}?SearchText={quote_plus(query)}&SortType=total_tranpro_desc"
|
||||
if category:
|
||||
# Categorías numéricas: /category/<id>/x.html. Si llega un slug, lo usamos como texto.
|
||||
if category.isdigit():
|
||||
return f"{_BASE}/category/{category}/x.html?SortType=total_tranpro_desc"
|
||||
from urllib.parse import quote_plus
|
||||
|
||||
return f"{_WHOLESALE}?SearchText={quote_plus(category)}&SortType=total_tranpro_desc"
|
||||
# Sin query ni categoría: listado de best-selling genérico.
|
||||
return f"{_WHOLESALE}?SearchText=hot+products&SortType=total_tranpro_desc"
|
||||
|
||||
|
||||
def _looks_blocked(html: str, status_code: int) -> bool:
|
||||
if status_code in (403, 429, 503):
|
||||
return True
|
||||
head = html[:6000].lower()
|
||||
return any(marker.lower() in head for marker in _BLOCK_MARKERS)
|
||||
|
||||
|
||||
def _extract_embedded_json(html: str) -> dict[str, Any] | None:
|
||||
"""Intenta varios patrones de JSON embebido que AliExpress ha usado a lo largo del tiempo.
|
||||
|
||||
El nombre/forma cambia con frecuencia, así que probamos en orden y nos quedamos
|
||||
con el primero que parsee y contenga algo con pinta de items.
|
||||
"""
|
||||
patterns = (
|
||||
r"window\.runParams\s*=\s*({.*?})\s*;\s*</script>",
|
||||
r"window\._dida_config_\s*=\s*({.*?})\s*;",
|
||||
r"_init_data_\s*=\s*{\s*data:\s*({.*?})\s*}\s*</script>",
|
||||
r"window\.runParams\s*=\s*({.*?});",
|
||||
)
|
||||
for pat in patterns:
|
||||
m = re.search(pat, html, re.DOTALL)
|
||||
if not m:
|
||||
continue
|
||||
blob = m.group(1)
|
||||
try:
|
||||
data = json.loads(blob)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if isinstance(data, dict):
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def _dig_items(data: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Localiza la lista de productos dentro del JSON embebido, sea cual sea su anidación.
|
||||
|
||||
Las claves han variado entre 'mods.itemList.content', 'items', 'result.items'...
|
||||
así que hacemos un walk genérico buscando la primera lista de dicts con pinta de
|
||||
producto (tienen productId/title/trade).
|
||||
"""
|
||||
found: list[dict[str, Any]] = []
|
||||
|
||||
def _is_product(d: dict[str, Any]) -> bool:
|
||||
keys = set(d.keys())
|
||||
id_keys = {"productId", "product_id", "productid", "id"}
|
||||
title_keys = {"title", "subject", "name"}
|
||||
return bool(keys & id_keys) and bool(keys & title_keys)
|
||||
|
||||
def _walk(node: Any) -> None:
|
||||
if found:
|
||||
return
|
||||
if isinstance(node, list):
|
||||
product_like = [x for x in node if isinstance(x, dict) and _is_product(x)]
|
||||
if len(product_like) >= 2:
|
||||
found.extend(product_like)
|
||||
return
|
||||
for x in node:
|
||||
_walk(x)
|
||||
elif isinstance(node, dict):
|
||||
for v in node.values():
|
||||
_walk(v)
|
||||
|
||||
_walk(data)
|
||||
return found
|
||||
|
||||
|
||||
def _to_float(value: Any) -> float | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, (int, float)):
|
||||
return float(value)
|
||||
s = str(value)
|
||||
# Quita símbolos de moneda y separadores de miles; deja el primer número decimal.
|
||||
m = re.search(r"\d[\d.,]*", s.replace(" ", " "))
|
||||
if not m:
|
||||
return None
|
||||
num = m.group(0)
|
||||
# Heurística: si hay coma y punto, asume coma = miles. Si solo coma, coma = decimal.
|
||||
if "," in num and "." in num:
|
||||
num = num.replace(",", "")
|
||||
elif "," in num:
|
||||
num = num.replace(",", ".")
|
||||
try:
|
||||
return float(num)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _to_orders(value: Any) -> int | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
s = str(value).lower()
|
||||
# Formatos: "1,234 sold", "2.3k sold", "10000+ orders".
|
||||
mult = 1
|
||||
if "k" in s:
|
||||
mult = 1000
|
||||
m = re.search(r"\d[\d.,]*", s)
|
||||
if not m:
|
||||
return None
|
||||
num = m.group(0).replace(",", "")
|
||||
try:
|
||||
base = float(num)
|
||||
except ValueError:
|
||||
return None
|
||||
return int(base * mult)
|
||||
|
||||
|
||||
def _normalize_item(
|
||||
raw: dict[str, Any], category: str | None
|
||||
) -> dict[str, Any] | None:
|
||||
pid = (
|
||||
raw.get("productId")
|
||||
or raw.get("product_id")
|
||||
or raw.get("productid")
|
||||
or raw.get("id")
|
||||
)
|
||||
if pid is None:
|
||||
return None
|
||||
product_id = str(pid)
|
||||
|
||||
title = raw.get("title") or raw.get("subject") or raw.get("name")
|
||||
if isinstance(title, dict):
|
||||
title = title.get("displayTitle") or title.get("seoTitle")
|
||||
title = str(title).strip() if title else None
|
||||
|
||||
# Precio: AliExpress lo mete en 'prices.salePrice.minPrice' o variantes planas.
|
||||
price_node = (
|
||||
raw.get("prices", {}).get("salePrice", {})
|
||||
if isinstance(raw.get("prices"), dict)
|
||||
else {}
|
||||
)
|
||||
price = _to_float(
|
||||
(price_node.get("minPrice") if isinstance(price_node, dict) else None)
|
||||
or raw.get("salePrice")
|
||||
or raw.get("price")
|
||||
or raw.get("minPrice")
|
||||
)
|
||||
|
||||
currency = None
|
||||
if isinstance(price_node, dict):
|
||||
currency = price_node.get("currencyCode")
|
||||
currency = currency or raw.get("currency") or raw.get("currencyCode")
|
||||
currency = str(currency) if currency else None
|
||||
|
||||
orders = _to_orders(
|
||||
raw.get("trade", {}).get("tradeDesc")
|
||||
if isinstance(raw.get("trade"), dict)
|
||||
else None
|
||||
)
|
||||
if orders is None:
|
||||
orders = _to_orders(
|
||||
raw.get("orders") or raw.get("tradeCount") or raw.get("sales")
|
||||
)
|
||||
|
||||
rating = _to_float(
|
||||
(
|
||||
raw.get("evaluation", {}).get("starRating")
|
||||
if isinstance(raw.get("evaluation"), dict)
|
||||
else None
|
||||
)
|
||||
or raw.get("rating")
|
||||
or raw.get("averageStar")
|
||||
or raw.get("starRating")
|
||||
)
|
||||
|
||||
url = raw.get("productDetailUrl") or raw.get("url") or raw.get("detail_url")
|
||||
if url:
|
||||
url = str(url)
|
||||
if url.startswith("//"):
|
||||
url = "https:" + url
|
||||
else:
|
||||
url = f"{_BASE}/item/{product_id}.html"
|
||||
|
||||
return {
|
||||
"category": category,
|
||||
"product_id": product_id,
|
||||
"title": title,
|
||||
"price": price,
|
||||
"currency": currency,
|
||||
"orders": orders,
|
||||
"rating": rating,
|
||||
"url": url,
|
||||
}
|
||||
|
||||
|
||||
def scrape_aliexpress_trending(
|
||||
query: str | None = None,
|
||||
category: str | None = None,
|
||||
limit: int = 40,
|
||||
ship_to: str = "ES",
|
||||
) -> list[dict]:
|
||||
"""Capta productos populares de AliExpress (señal e-commerce/dropshipping).
|
||||
|
||||
Hace UNA request HTTP a la página de listado de AliExpress ordenada por número
|
||||
de pedidos (``total_tranpro_desc``) y extrae el JSON embebido en el HTML. Es
|
||||
best-effort: AliExpress bloquea agresivamente headless/datacenter, por lo que
|
||||
ante un bloqueo (403/429/captcha) lanza ``RuntimeError`` con un mensaje claro y
|
||||
ante un HTML sin JSON parseable devuelve ``[]``. NUNCA inventa datos.
|
||||
|
||||
Args:
|
||||
query: Texto de búsqueda (ej. "kitchen gadgets"). Si se da, manda en la URL.
|
||||
category: ID numérico de categoría AliExpress o slug. Ignorado si hay ``query``.
|
||||
limit: Número máximo de productos a devolver. Default 40.
|
||||
ship_to: Código de país ISO-2 para fijar región/moneda via cookies. Default "ES".
|
||||
|
||||
Returns:
|
||||
Lista de dicts con claves exactas:
|
||||
``category, product_id, title, price, currency, orders, rating, url``.
|
||||
``price``/``rating`` son ``float | None``, ``orders`` es ``int | None``.
|
||||
Lista vacía si el HTML no traía JSON parseable.
|
||||
|
||||
Raises:
|
||||
RuntimeError: Si AliExpress bloquea la request (captcha/403/429) o la red falla.
|
||||
"""
|
||||
import requests
|
||||
|
||||
url = _build_url(query, category)
|
||||
cookies = _region_cookies(ship_to)
|
||||
headers = dict(_DESKTOP_HEADERS)
|
||||
_, locale, _ = _REGION_MAP.get(ship_to.upper(), _REGION_MAP["ES"])
|
||||
headers["Accept-Language"] = f"{locale.replace('_', '-')},en;q=0.8"
|
||||
|
||||
try:
|
||||
resp = requests.get(
|
||||
url,
|
||||
headers=headers,
|
||||
cookies=cookies,
|
||||
timeout=20,
|
||||
allow_redirects=True,
|
||||
)
|
||||
except requests.RequestException as exc:
|
||||
raise RuntimeError(
|
||||
f"scrape_aliexpress_trending: fallo de red contra {url}: {exc}"
|
||||
) from exc
|
||||
|
||||
html = resp.text or ""
|
||||
|
||||
if _looks_blocked(html, resp.status_code):
|
||||
raise RuntimeError(
|
||||
f"scrape_aliexpress_trending: AliExpress bloqueó la request "
|
||||
f"(status={resp.status_code}, captcha/anti-bot). "
|
||||
f"Usa el browser MCP/CDP con sesión real para esta fuente."
|
||||
)
|
||||
|
||||
data = _extract_embedded_json(html)
|
||||
if data is None:
|
||||
# HTML sin el JSON esperado: layout cambió o respondió un shell vacío.
|
||||
# Devolvemos [] honesto en vez de inventar.
|
||||
return []
|
||||
|
||||
raw_items = _dig_items(data)
|
||||
cat_label = category if (category and not query) else (query or category)
|
||||
|
||||
out: list[dict] = []
|
||||
seen: set[str] = set()
|
||||
for raw in raw_items:
|
||||
norm = _normalize_item(raw, cat_label)
|
||||
if norm is None:
|
||||
continue
|
||||
if norm["product_id"] in seen:
|
||||
continue
|
||||
seen.add(norm["product_id"])
|
||||
out.append(norm)
|
||||
if len(out) >= limit:
|
||||
break
|
||||
|
||||
return out
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Self-test honesto: import OK obligatorio + UN fetch real en try/except.
|
||||
# NUNCA falla la build por la red.
|
||||
print("import OK: scrape_aliexpress_trending")
|
||||
expected_keys = {
|
||||
"category",
|
||||
"product_id",
|
||||
"title",
|
||||
"price",
|
||||
"currency",
|
||||
"orders",
|
||||
"rating",
|
||||
"url",
|
||||
}
|
||||
try:
|
||||
rows = scrape_aliexpress_trending(query="phone holder", limit=5, ship_to="ES")
|
||||
if rows:
|
||||
got_keys = set(rows[0].keys())
|
||||
keys_ok = got_keys == expected_keys
|
||||
print(
|
||||
f"fetch real: {len(rows)} filas obtenidas | "
|
||||
f"claves correctas={keys_ok}"
|
||||
)
|
||||
print(f" muestra: {rows[0]}")
|
||||
else:
|
||||
print(
|
||||
"fetch real: 0 filas (HTML sin JSON embebido parseable "
|
||||
"— layout cambió o shell vacío). NO se inventan datos."
|
||||
)
|
||||
except RuntimeError as exc:
|
||||
print(f"fetch real: BLOQUEADO/ERROR honesto -> {exc}")
|
||||
Reference in New Issue
Block a user