feat(browser): auto-commit con 178 cambios
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,340 @@
|
||||
"""Scraper de Amazon Movers & Shakers via Chrome DevTools Protocol (CDP).
|
||||
|
||||
Funcion IMPURA: la pagina ``/gp/movers-and-shakers/`` de Amazon monta sus cards
|
||||
por JavaScript (el GET HTTP puro devuelve 0 productos), asi que esta funcion
|
||||
renderiza la pagina en un Chrome con remote debugging, espera a que el grid de
|
||||
ranking monte async, extrae el ``outerHTML`` renderizado y se lo pasa al parser
|
||||
PURO del registry (``parse_amazon_ranking_html``) — el mismo que usa el scraper
|
||||
HTTP de bestsellers, sin reescribir el parsing.
|
||||
|
||||
Movers & Shakers = productos cuyo ranking de ventas mas sube en las ultimas 24h
|
||||
= la mejor senal publica de demanda emergente (clave para dropshipping). Aporta
|
||||
el PRECIO DE VENTA en el marketplace (ej. amazon.es en EUR) y el % de subida en
|
||||
ranking por producto.
|
||||
|
||||
Compone DOS funciones del registry (no reescribe transporte CDP ni parsing):
|
||||
1. ``cdp_open_url_and_wait`` (pipeline) — crea tab nuevo en el Chrome remoto,
|
||||
navega a la URL de listado y espera ``Page.loadEventFired``.
|
||||
2. ``cdp_eval`` (browser) — evalua JS en la pestana cuyo URL contiene un
|
||||
substring (polling de cards + extraccion del ``outerHTML`` del grid).
|
||||
|
||||
Devuelve SIEMPRE un dict autosuficiente (estilo del grupo market-intel): nunca
|
||||
lanza. NUNCA inventa datos: si no hay cards tras el timeout devuelve
|
||||
``status="error"``; si Amazon sirve un captcha, ``status="captcha"``.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), ".."))
|
||||
|
||||
from browser.cdp_eval import cdp_eval
|
||||
from datascience.parse_amazon_ranking_html import parse_amazon_ranking_html
|
||||
from pipelines.cdp_open_url_and_wait import cdp_open_url_and_wait
|
||||
|
||||
# Marcadores de un interstitial anti-bot / captcha de Amazon.
|
||||
_CAPTCHA_MARKERS = (
|
||||
"enter the characters you see below",
|
||||
"to discuss automated access",
|
||||
"api-services-support@amazon",
|
||||
"robot check",
|
||||
"/errors/validatecaptcha",
|
||||
)
|
||||
|
||||
# Selectores de los cards del grid de ranking (movers comparte plantilla con
|
||||
# bestsellers). Se usan en el JS de polling para contar cards montados.
|
||||
_CARD_COUNT_JS = (
|
||||
"(document.querySelectorAll('div[id=\"gridItemRoot\"]').length || "
|
||||
"document.querySelectorAll('li.zg-item-immersion').length || "
|
||||
"document.querySelectorAll('.p13n-desktop-grid div[data-asin]').length)"
|
||||
)
|
||||
|
||||
|
||||
def _build_url(marketplace: str, category: str | None) -> str:
|
||||
"""URL de Movers & Shakers para un marketplace y slug de categoria.
|
||||
|
||||
Base: ``https://www.<marketplace>/gp/movers-and-shakers``. Si ``category``
|
||||
es None se usa la portada general; si no, se anade ``/<slug>``.
|
||||
"""
|
||||
url = f"https://www.{marketplace}/gp/movers-and-shakers"
|
||||
if category:
|
||||
url = f"{url}/{category.strip('/')}"
|
||||
return url
|
||||
|
||||
|
||||
def _detect_captcha(port: int, target_substr: str) -> bool:
|
||||
"""True si la pagina renderizada parece un interstitial anti-bot/captcha."""
|
||||
r = cdp_eval(
|
||||
"document.body ? document.body.innerText.slice(0, 4000) : ''",
|
||||
port=port,
|
||||
target_url_substr=target_substr,
|
||||
timeout_s=10.0,
|
||||
)
|
||||
if not r.get("ok"):
|
||||
return False
|
||||
lowered = (r.get("value") or "").lower()
|
||||
return any(m in lowered for m in _CAPTCHA_MARKERS)
|
||||
|
||||
|
||||
def _wait_for_cards(port: int, target_substr: str, deadline: float) -> int:
|
||||
"""Polling de ``document.querySelectorAll`` hasta >0 cards o deadline.
|
||||
|
||||
El grid monta async tras la hidratacion, asi que el load event NO garantiza
|
||||
que las cards esten en el DOM. Devuelve el numero de cards (0 si se agota).
|
||||
"""
|
||||
while time.time() < deadline:
|
||||
r = cdp_eval(
|
||||
_CARD_COUNT_JS,
|
||||
port=port,
|
||||
target_url_substr=target_substr,
|
||||
timeout_s=10.0,
|
||||
)
|
||||
if r.get("ok"):
|
||||
try:
|
||||
n = int(r.get("value") or 0)
|
||||
except (TypeError, ValueError):
|
||||
n = 0
|
||||
if n > 0:
|
||||
return n
|
||||
time.sleep(1.0)
|
||||
return 0
|
||||
|
||||
|
||||
def _grab_grid_html(port: int, target_substr: str, timeout_s: float) -> str:
|
||||
"""Extrae el ``outerHTML`` del grid de ranking renderizado (o del body)."""
|
||||
expr = (
|
||||
"(() => { const g = document.querySelector('.p13n-desktop-grid'); "
|
||||
"return g ? g.outerHTML : (document.body ? document.body.outerHTML : ''); })()"
|
||||
)
|
||||
r = cdp_eval(
|
||||
expr,
|
||||
port=port,
|
||||
target_url_substr=target_substr,
|
||||
timeout_s=max(15.0, timeout_s),
|
||||
)
|
||||
if not r.get("ok"):
|
||||
return ""
|
||||
return r.get("value") or ""
|
||||
|
||||
|
||||
def _scrape_one_category(
|
||||
marketplace: str,
|
||||
category: str | None,
|
||||
port: int,
|
||||
max_items: int,
|
||||
timeout_s: float,
|
||||
scraped_at: str,
|
||||
) -> dict:
|
||||
"""Navega a una categoria de movers, espera cards y extrae los productos.
|
||||
|
||||
Devuelve ``{"ok": bool, "products": [...], "error": str, "captcha": bool}``.
|
||||
Cada product lleva ya ``marketplace``, ``category``, ``source`` y
|
||||
``scraped_at``. Filtra filas sin asin ni title.
|
||||
"""
|
||||
url = _build_url(marketplace, category)
|
||||
target_substr = "movers-and-shakers"
|
||||
|
||||
# 1. Navegar: crea tab nuevo en el Chrome remoto y espera el load event.
|
||||
try:
|
||||
cdp_open_url_and_wait(port, url, int(timeout_s))
|
||||
except Exception as e: # noqa: BLE001 — RuntimeError de cdp_open_url_and_wait
|
||||
msg = str(e)
|
||||
if (
|
||||
"no se pudo crear tab" in msg
|
||||
or "URLError" in msg
|
||||
or "Connection refused" in msg
|
||||
or "timeout" in msg.lower()
|
||||
):
|
||||
msg = (
|
||||
f"no hay Chrome usable en el puerto {port} "
|
||||
f"(¿remote debugging activo?): {e}"
|
||||
)
|
||||
return {"ok": False, "products": [], "error": msg, "captcha": False}
|
||||
|
||||
# 2. Detectar captcha lo antes posible.
|
||||
if _detect_captcha(port, target_substr):
|
||||
return {
|
||||
"ok": False,
|
||||
"products": [],
|
||||
"error": "Amazon sirvio un captcha / interstitial anti-bot",
|
||||
"captcha": True,
|
||||
}
|
||||
|
||||
# 3. Polling hasta que los cards monten (render async tras hidratacion).
|
||||
deadline = time.time() + timeout_s
|
||||
n_cards = _wait_for_cards(port, target_substr, deadline)
|
||||
if n_cards == 0:
|
||||
# Re-chequear captcha (puede haber aparecido tras la hidratacion).
|
||||
if _detect_captcha(port, target_substr):
|
||||
return {
|
||||
"ok": False,
|
||||
"products": [],
|
||||
"error": "Amazon sirvio un captcha / interstitial anti-bot",
|
||||
"captcha": True,
|
||||
}
|
||||
return {
|
||||
"ok": False,
|
||||
"products": [],
|
||||
"error": (
|
||||
"no hay cards de ranking (la categoria puede no tener movers ahora "
|
||||
"—Amazon muestra 'no movers and shakers available'— o el chromium "
|
||||
"del puerto sirvio una pagina degradada / no logueada)"
|
||||
),
|
||||
"captcha": False,
|
||||
}
|
||||
|
||||
# 4. Extraer el outerHTML del grid y parsearlo con el parser PURO.
|
||||
html = _grab_grid_html(port, target_substr, timeout_s)
|
||||
rows = parse_amazon_ranking_html(
|
||||
html,
|
||||
marketplace=marketplace,
|
||||
list_type="movers_shakers",
|
||||
max_items=max_items,
|
||||
)
|
||||
|
||||
# 5. Enriquecer: category + source + scraped_at; filtrar filas vacias.
|
||||
products = []
|
||||
for row in rows:
|
||||
if not row.get("asin") and not row.get("title"):
|
||||
continue
|
||||
row["category"] = category
|
||||
row["source"] = "amazon_movers"
|
||||
row["scraped_at"] = scraped_at
|
||||
products.append(row)
|
||||
|
||||
if not products:
|
||||
return {
|
||||
"ok": False,
|
||||
"products": [],
|
||||
"error": (
|
||||
f"se montaron {n_cards} cards pero el parser no extrajo productos "
|
||||
"(¿Amazon roto la plantilla del DOM?)"
|
||||
),
|
||||
"captcha": False,
|
||||
}
|
||||
|
||||
return {"ok": True, "products": products, "error": "", "captcha": False}
|
||||
|
||||
|
||||
def scrape_amazon_movers_cdp(
|
||||
marketplace: str = "amazon.es",
|
||||
categories: list[str] | None = None,
|
||||
port: int = 9222,
|
||||
max_items: int = 30,
|
||||
timeout_s: float = 25.0,
|
||||
) -> dict:
|
||||
"""Scrapea Amazon Movers & Shakers renderizando la pagina via CDP.
|
||||
|
||||
Funcion IMPURA: necesita un Chrome con remote debugging escuchando en
|
||||
``port`` (el navegador diario residential en 9222 pasa el anti-bot mejor que
|
||||
``requests``). Por cada categoria navega a la URL de movers, espera a que el
|
||||
grid (montado por JS) aparezca, extrae el ``outerHTML`` renderizado y lo pasa
|
||||
al parser PURO ``parse_amazon_ranking_html``. Nunca lanza: cualquier fallo
|
||||
devuelve ``{"status": "error"|"captcha", ...}`` con ``products: []``. NUNCA
|
||||
inventa datos.
|
||||
|
||||
Args:
|
||||
marketplace: Dominio Amazon objetivo (``"amazon.es"``, ``"amazon.com"``,
|
||||
...). Determina la URL y la moneda fallback del parser.
|
||||
categories: Lista de slugs de categoria de movers (ej. ``"automotive"``,
|
||||
``"pet-supplies"``). Si es None, scrapea la portada general de movers.
|
||||
Cada slug navega a ``/gp/movers-and-shakers/<slug>``.
|
||||
port: Puerto de remote debugging del Chrome a usar. Default 9222 (el
|
||||
chromium-personal residential de produccion). Para un Chrome aislado
|
||||
apunta a 9333 (el del browser_mcp).
|
||||
max_items: Numero maximo de productos recolectados por categoria.
|
||||
timeout_s: Timeout (segundos) por categoria, tanto para la navegacion como
|
||||
para el polling de aparicion de cards. Default 25.0.
|
||||
|
||||
Returns:
|
||||
dict autosuficiente. En exito::
|
||||
|
||||
{
|
||||
"status": "ok",
|
||||
"source": "amazon_movers",
|
||||
"count": <N productos>,
|
||||
"products": [ {product_dict}, ... ],
|
||||
}
|
||||
|
||||
donde cada product_dict tiene las claves: marketplace, list_type
|
||||
("movers_shakers"), category, rank (int), asin, title, price (float EUR),
|
||||
currency, rating (float|None), reviews (int|None), pct_change (float|None),
|
||||
url, source ("amazon_movers"), scraped_at (ISO8601 UTC).
|
||||
|
||||
En error::
|
||||
|
||||
{"status": "error", "error": <msg>, "source": "amazon_movers", "products": []}
|
||||
|
||||
Si Amazon sirve captcha::
|
||||
|
||||
{"status": "captcha", "error": <msg>, "source": "amazon_movers", "products": []}
|
||||
"""
|
||||
scraped_at = datetime.now(timezone.utc).isoformat()
|
||||
cats: list[str | None] = list(categories) if categories else [None]
|
||||
|
||||
all_products: list[dict] = []
|
||||
last_error = ""
|
||||
saw_captcha = False
|
||||
|
||||
for category in cats:
|
||||
res = _scrape_one_category(
|
||||
marketplace=marketplace,
|
||||
category=category,
|
||||
port=port,
|
||||
max_items=max_items,
|
||||
timeout_s=timeout_s,
|
||||
scraped_at=scraped_at,
|
||||
)
|
||||
if res["ok"]:
|
||||
all_products.extend(res["products"])
|
||||
else:
|
||||
last_error = res["error"]
|
||||
if res.get("captcha"):
|
||||
saw_captcha = True
|
||||
|
||||
if all_products:
|
||||
return {
|
||||
"status": "ok",
|
||||
"source": "amazon_movers",
|
||||
"count": len(all_products),
|
||||
"products": all_products,
|
||||
}
|
||||
|
||||
# Sin productos en ninguna categoria: error o captcha.
|
||||
return {
|
||||
"status": "captcha" if saw_captcha else "error",
|
||||
"error": last_error or "no se extrajo ningun producto",
|
||||
"source": "amazon_movers",
|
||||
"products": [],
|
||||
}
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Scraper de Amazon Movers & Shakers via CDP."
|
||||
)
|
||||
parser.add_argument("--marketplace", default="amazon.es")
|
||||
parser.add_argument(
|
||||
"--categories",
|
||||
default="",
|
||||
help="slugs separados por coma (ej. automotive,pet-supplies). Vacio = portada.",
|
||||
)
|
||||
parser.add_argument("--port", type=int, default=9222)
|
||||
parser.add_argument("--max-items", type=int, default=30)
|
||||
parser.add_argument("--timeout-s", type=float, default=25.0)
|
||||
args = parser.parse_args()
|
||||
|
||||
cats = [c.strip() for c in args.categories.split(",") if c.strip()] or None
|
||||
out = scrape_amazon_movers_cdp(
|
||||
marketplace=args.marketplace,
|
||||
categories=cats,
|
||||
port=args.port,
|
||||
max_items=args.max_items,
|
||||
timeout_s=args.timeout_s,
|
||||
)
|
||||
print(json.dumps(out, ensure_ascii=False, indent=2))
|
||||
Reference in New Issue
Block a user