---
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.
