--- name: scrape_competitor_prices kind: function lang: py domain: datascience version: "1.0.0" purity: impure signature: "def scrape_competitor_prices(targets: list[dict]) -> list[dict]" description: "Vigila precios de la competencia: dada una lista de objetivos (URL de producto + competidor), hace GET con headers realistas (timeout + 1 reintento) y extrae el precio actual de cada pagina con una cascada de estrategias (CSS selector, JSON-LD offers, meta tags, heuristica de clases). Normaliza a float (tolera coma/punto, simbolos, miles) y detecta in_stock. Devuelve una fila por target con claves 1:1 de la tabla Postgres competitor_prices; si falla un target devuelve price=None sin abortar los demas." tags: [competitor, pricing, scraping, market-intel, datascience, recon] params: - name: targets desc: "Lista de dicts, uno por producto a vigilar. Cada dict: competitor (str, nombre/id del competidor), product_key (str, clave interna estable), product_name (str, nombre legible), url (str, URL de la pagina del producto), price_selector (str, opcional, selector CSS que apunta al nodo del precio — lo mas robusto), currency (str, opcional, codigo de moneda a estampar, default 'EUR')." output: "Lista de dicts, una fila por target, con EXACTAMENTE estas claves (casan 1:1 con la tabla Postgres competitor_prices, sin id/snapshot_date/scraped_at): competitor (str), product_key (str), product_name (str), url (str), price (float | None), currency (str), in_stock (bool | None). price=None si no se pudo extraer; in_stock=None si la pagina fallo." uses_functions: [] uses_types: [] returns: [] returns_optional: false error_type: "error_go_core" imports: [requests, beautifulsoup4, lxml] tested: false tests: [] test_file_path: "" file_path: "python/functions/datascience/scrape_competitor_prices.py" --- ## Ejemplo ```python import sys, os sys.path.insert(0, os.path.join("python", "functions")) from datascience.scrape_competitor_prices import scrape_competitor_prices targets = [ { "competitor": "books-to-scrape", "product_key": "light-in-the-attic", "product_name": "A Light in the Attic", "url": "http://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html", "price_selector": "p.price_color", # el selector por target es lo mas fiable "currency": "GBP", }, { "competitor": "competidor_b", "product_key": "SKU-4242", "product_name": "Filtro de aceite XYZ", "url": "https://www.ejemplo-tienda.com/producto/4242", # sin price_selector -> autodeteccion JSON-LD / meta / heuristica de clases "currency": "EUR", }, ] rows = scrape_competitor_prices(targets) # rows[0] -> {"competitor": "books-to-scrape", "product_key": "light-in-the-attic", # "product_name": "A Light in the Attic", "url": "...", # "price": 51.77, "currency": "GBP", "in_stock": True} # Listo para INSERT en la tabla competitor_prices (anade tu snapshot_date/scraped_at). ``` ## Cuando usarla Cuando necesites un snapshot puntual del precio de uno o varios productos de la competencia para alimentar una tabla de market intelligence (`competitor_prices`). Util en un cron/pipeline que lee una lista de objetivos, scrapea, y persiste una fila por producto. Pasa `price_selector` por target siempre que conozcas el sitio: es la via mas robusta. Si no lo pasas, la funcion intenta autodetectar (JSON-LD `offers.price`, meta tags de precio, clases comunes de e-commerce). Las filas salen con las claves exactas de la tabla destino, asi que el caller solo anade `snapshot_date`/`scraped_at` antes del INSERT. ## Gotchas - **Funcion impura**: hace I/O de red (HTTP GET). Depende del HTML real de cada sitio en el momento de la llamada. - **El scraping de precios es muy especifico por sitio.** Sin `price_selector`, la autodeteccion acierta en muchos e-commerce estandar (los que exponen JSON-LD `Product/Offer`, meta `og:price:amount`/`itemprop=price`, o clases tipicas `.price`), pero **falla en SPAs / paginas JS-rendered** (React/Vue/Angular que pintan el precio tras cargar) y en sitios con **anti-bot** (Cloudflare, captchas, fingerprinting). Para esos casos el GET devuelve un HTML sin el precio o un challenge, y la fila sale con `price=None`. - **Para sitios JS-rendered o con anti-bot usa el navegador del ecosistema** (browser MCP / CDP: `page_perceive`, `cdp_get_text`, `cdp_perceive_outline`) para renderizar la pagina y extraer el precio del DOM ya pintado, en lugar de esta funcion de HTTP puro. Esta funcion es para HTML servidor-renderizado. - **`price_selector` por target es lo mas fiable**: evita depender de la heuristica y sobrevive mejor a cambios de plantilla. Define uno por competidor en tu lista de objetivos. - **Normalizacion de precio**: tolera `1.299,99 €` (europeo: punto miles, coma decimal), `$1,299.99` (US), `29,90`, `1299.99`. Heuristica: el separador mas a la derecha es el decimal cuando hay ambos; con solo coma, se trata como decimal si quedan 2 digitos detras, si no como miles. Casos exoticos (3 decimales, formatos regionales raros) pueden malinterpretarse — verifica con `price_selector` apuntando al nodo limpio. - **`in_stock` es heuristico**: `True` salvo que el texto de la pagina contenga marcadores de agotado (`agotado`, `sin stock`, `out of stock`, `sold out`, etc.). Falsos positivos/negativos posibles si el sitio usa otra redaccion o muestra esos terminos en contexto no relacionado. `None` si la pagina fallo al cargar. - **Tolerancia a fallos por target**: si un target peta (red, timeout, HTML invalido), su fila sale con `price=None`/`in_stock=None` y **el resto del batch continua**. Nunca aborta toda la lista por un fallo individual. - **Reintento unico**: cada GET reintenta una vez ante error de transporte. No hay backoff exponencial ni rotacion de proxies/User-Agent; para scraping a escala o contra anti-bot fuerte, eso queda fuera del alcance de esta funcion.