RFC 008 — Precios de competencia por producto (observación para analítica)¶
Estado: Implementado — el camino crítico está construido y verificado (§12).
Quedan fuera de esa base dos piezas opcionales: el paso en el modal de alta (§7.2.1) y la
carga masiva por CSV.
Fecha: 2026-08-24 (diseño) · 2026-08-26 (implementación)
Autores: TI Compulandia + Claude
Relacionado con: docs/analisis/precios-competencia-requisitos.md (definición de
requisitos, aprobada 2026-08-25), ADR-0005 (decisiones estructurales del módulo,
extraídas de este RFC), ADR-0001 (migraciones en subdirectorios numerados),
PageScrapingService y productos_compras_paraguai (scraping preexistente, §2.2)
1. Objetivo¶
Registrar, por producto, qué precio tiene ese mismo producto en la competencia, para alimentar el análisis de determinación del precio de venta y evitar precios estáticos.
El módulo es de observación, no de cálculo: no decide ni sugiere precios. Guarda lo que se observó, dónde y cuándo, y lo expone para que se analice fuera del sistema.
Explícitamente fuera de alcance: el precio observado no interviene en el cálculo
del precio de venta interno. ProductPriceService, PriceScale y la tabla prices no
se tocan.
2. Estado actual (verificado en código, 2026-08-24)¶
2.1 "Lista de precios" ya es un concepto ocupado¶
Existen price_lists y prices (App\Models\PriceList, App\Models\Price), que
modelan precios propios: una fila por supplier_product × price_list × partner.
Nombrar "lista de precios de la competencia" a lo nuevo genera colisión conceptual directa con esa estructura. Ver D8.
2.2 Ya existe scraping de precios ajenos, con otro propósito¶
App\Services\Webscraping\PageScrapingService scrapea comprasparaguai.com.br y
persiste en productos_compras_paraguai (App\Models\ProductoCompraParaguai).
No sirve como base conceptual, porque su finalidad es distinta: alimenta la creación
de supplier_products bajo un proveedor sintético (Supplier::find(999) en
SyncComprasParaguaiProducts) — es decir, descubrimiento de productos para comprar,
no comparación de precio de venta. Además:
- Pisa el precio en cada corrida; no hay historial.
- Guarda un rango (
precio_desde,precio_hasta,control_precio), no un precio puntual. - Se ata a
supplier_product_id, no aproduct_item.
Sí sirve como infraestructura reutilizable: proxy Playwright propio
(services.playwright.endpoint), ScrapeOps, Symfony\DomCrawler, y conversión de
moneda vía App\Services\CambiosChaco\ExchangeService.
2.3 Cobertura de identificadores comparables¶
product_items es la entidad con identificadores externos (ean, upc, part_number,
sku_sap). Medición sobre la base de desarrollo:
| Métrica | Cantidad | % del total |
|---|---|---|
product_items totales |
17.561 | 100% |
Activos (active = 1) |
16.430 | 94% |
Con ean no vacío |
3.069 | 17% |
Con part_number no vacío |
364 | 2% |
3. Problema¶
El precio de venta se determina hoy sin una referencia sistemática y actualizada del mercado. No hay dónde consultar a cuánto vende la competencia un producto dado, ni cómo evolucionó ese precio. La consecuencia es precio estático: se fija una vez y no se revisa, porque revisarlo exige relevamiento manual caso por caso.
4. Hallazgos que condicionan el diseño¶
4.1 El match automático por identificador no es viable¶
Con 17% de cobertura de EAN y 2% de part number (§2.3), y considerando que los sitios de retail local rara vez publican EAN, cruzar nuestro catálogo contra el del competidor por identificador cubriría una porción marginal del catálogo.
Las alternativas eran match difuso por nombre (mete precios equivocados en la analítica sin que nadie se entere) o curación humana. Se optó por curación (D3).
4.2 Esto invierte el sentido del scraping respecto de §2.2¶
El scraping existente va del catálogo ajeno hacia nuestro (scrapea todo y después intenta matchear). Este módulo va de nuestro producto hacia la página ajena: se registra la URL del producto en el competidor, y el scraper solo visita esa URL.
Consecuencias: el match se resuelve una sola vez, al cargar la URL; el scraping es determinístico y de volumen acotado; y no hay ambigüedad de a qué producto corresponde cada precio.
4.3 El universo relevado se define solo¶
No hace falta un flag "producto seleccionado para monitoreo": tener una URL cargada es
la selección. Un product_item sin URLs no participa. Esto elimina una entidad y un
proceso de mantenimiento.
5. Alcance¶
Dentro:
- Alta y administración de competidores.
- Carga manual de la URL del producto en cada competidor, sobre
product_itemsactivos. - Relevamiento automático periódico de esas URLs, con frecuencia propia por competidor.
- Persistencia del último precio observado, precio de lista y disponibilidad.
- Dejar los datos consultables en la base para las herramientas externas. Sin superficie de salida propia.
Fuera:
- Cualquier efecto sobre el precio de venta interno (§1).
- Descubrimiento de productos o alta de
supplier_products(eso es §2.2). - Match automático o asistido: sin URL cargada, no hay observación.
- Historial de precios: se guarda el último estado, no la evolución (D5).
- La analítica en sí misma (D24). El módulo deja los datos en la base y los expone; qué se hace con ellos es de la herramienta externa.
- Alertas o reglas automáticas ante cambios de precio de la competencia.
- Cualquier superficie de salida propia: vistas SQL, endpoints, exports o pantallas de consulta. El consumo externo lee las tablas directamente (D13).
6. Modelo conceptual¶
Esto describe las entidades y su razón de ser; el esquema concreto está en §8.
6.1 Competidor¶
Entidad dada de alta desde el sistema. Además de identificación (nombre, sitio) lleva su propia frecuencia de relevamiento y su estado activo/inactivo.
Que la frecuencia sea por competidor y no global permite relevar seguido a un sitio tolerante y espaciar a uno frágil o con anti-bot agresivo, sin frenar a los demás.
6.2 Precio de competencia¶
Una fila por product_item × competidor. El vínculo y la observación viven en la misma
fila — al guardarse solo el último valor (D5), separar "URL curada" de "precio
observado" en dos tablas no aporta nada.
Contiene: la URL curada, su estado, el precio final de venta, el precio de lista (tachado, cuando el sitio lo muestra), la disponibilidad, el título leído de la página como control de identidad, y las dos fechas de relevamiento —último intento y último éxito— y nada más (D18, D19). Ni el motivo del fallo ni un contador de errores: el motivo vive en el log (D22) y el estado se deduce de las dos fechas (§8.3).
Todos los importes se guardan normalizados a PYG (D6).
6.3 Salida: las tablas mismas¶
No hay superficie de salida: ni vista SQL, ni endpoints, ni pantallas de consulta. Las herramientas externas leen las tablas directamente; todo lo que necesitan saber para interpretarlas (estados derivados de las fechas, §8.3) queda documentado, no implementado.
6.4 Flujo¶
- Se da de alta el competidor con su frecuencia.
- Un operador pega la URL de ese producto en el competidor — al confirmar el alta del
producto, o desde la pestaña "Competencia" del
product_item(§7.2). Eso lo incorpora al universo relevado. - Cada competidor corre según su propia frecuencia: visita sus URLs, extrae precio, precio de lista y disponibilidad, y actualiza la fila junto con la fecha de scrapeo.
- Ante fallo (404, producto discontinuado, cambio de layout), se conserva el último precio conocido; la divergencia entre las dos fechas delata que falló y cuán viejo es el precio (D7, D18).
- Las herramientas externas leen las tablas directamente.
7. Flujos operativos¶
7.1 ABM de competidor¶
El competidor no es solo una etiqueta: concentra toda la configuración operativa y de extracción del sitio, de modo que adaptarse a un cambio en la página del competidor sea una edición de datos y no un despliegue de código.
Datos del competidor
| Grupo | Campos | Para qué |
|---|---|---|
| Identidad | nombre, código, dominio base, activo | Identificación y validación de URLs (§7.2) |
| Relevamiento | frecuencia en días, opciones de ScrapeOps (render_js y extras) |
Gobierno del proceso diario y del costo (§7.3) |
| Formato | moneda del sitio, separador decimal, separador de miles | Interpretar Gs. 3.500.000 o US$ 450,00 sin ambigüedad |
| Extracción | reglas por campo (abajo) | Obtener los datos de la página |
Configuración de extracción: cadena de estrategias por campo
Para cada dato a obtener se define una lista ordenada de reglas. El extractor las prueba en orden y gana la primera que devuelve un valor válido. Esto es lo que da tolerancia: si el sitio cambia el HTML pero mantiene sus metadatos, la primera regla sigue funcionando; si rompe todo, se agrega una regla nueva sin tocar código.
Tipos de regla, del más estable al más frágil:
| Tipo | Qué lee | Estabilidad |
|---|---|---|
json_ld |
Ruta dentro del <script type="application/ld+json"> de schema.org (Product → offers → price, priceCurrency, availability) |
Alta — es metadato para buscadores, sobrevive rediseños |
meta |
Etiquetas og:price:amount, product:price:amount |
Alta |
css |
Selector CSS + de dónde tomar el valor (texto del nodo o un atributo como content / data-price) |
Media — se rompe con cada cambio de tema |
regex |
Expresión sobre el HTML crudo | Baja — último recurso |
Recomendación: que json_ld sea siempre la primera regla de la cadena. La mayoría de
las plataformas de e-commerce lo emiten, y es el único punto de la página pensado para
ser leído por máquinas.
Pero no se asume que el sitio esté normalizado. Si el competidor no publica datos
estructurados, la prueba de esa regla simplemente falla y la cadena cae al selector
parametrizado. Ese es el propósito de la cadena: json_ld es el camino barato y estable
cuando existe, y la parametrización por selector es la red que garantiza cobertura
cuando no.
Campos a extraer
precio_venta— obligatorio. El precio que paga el cliente hoy.precio_lista— opcional. El precio tachado, cuando el sitio lo muestra.disponibilidad— requiere además un mapeo a nuestro vocabulario: los valores de schema.org (InStock/OutOfStock) o los textos del sitio ("Agotado", "Sin stock", "Disponible") se traducen aen_stock/sin_stock. Admite también la forma "existe este elemento en la página ⇒ hay stock".titulo— no es un dato analítico: es el control de identidad. Sirve para que el operador confirme al cargar la URL (§7.2) y para detectar más adelante que la página pasó a mostrar otro producto.
Guardarraíles
La configuración dinámica sin validación es un arma: un selector mal apuntado toma el
12 de "12 cuotas" y lo guarda como precio, sin que nadie se entere. Por eso:
- Rango plausible. El precio debe ser mayor a cero y no apartarse del último valor conocido más allá de un porcentaje configurable (§7.4). Fuera de rango ⇒ no se escribe.
- Sin valor válido ⇒ no se pisa el precio. Si ninguna regla de la cadena devuelve un valor aceptable, se registra el intento fallido con su motivo y el precio anterior queda como estaba (§7.3).
- Botón "Probar" en el ABM. Se pega una URL de muestra, corre la configuración actual
y muestra qué extrajo cada campo y con qué regla de la cadena lo obtuvo. Ese último
dato es el que responde, al dar de alta el competidor, la pregunta que importa: si ganó
json_ld, el sitio está normalizado y el mantenimiento futuro será mínimo; si ganó el selector CSS, ya se sabe que ese competidor va a requerir atención cada vez que rediseñe. Sin el probador el dinamismo no sirve: editar selectores a ciegas falla en silencio, que es peor que tenerlos en código. - Auditoría. Toda edición de la configuración queda registrada (el proyecto ya usa activity log), para poder responder "esto se rompió cuando alguien tocó la regla".
Baja. El competidor no se elimina: se desactiva. Borrarlo arrastraría las URLs curadas, que son trabajo humano acumulado. Desactivado deja de relevarse y sus datos quedan visibles con su fecha.
7.2 Asignación de URL del producto en el competidor¶
Es el flujo que produce el universo relevado (§4.3). Tiene dos puntos de entrada, con exigencias distintas, porque uno ocurre dentro de un flujo operativo de alto volumen y el otro es una gestión dedicada.
7.2.1 En el alta del producto — modal de confirmación de productos pendientes¶
El lugar natural para capturar la URL es donde el producto nace: el modal multipaso de
App\Livewire\PendingProducts\ConfirmationModal, con un paso extra que lista los
competidores activos y ofrece un campo para pegar la URL de cada uno.
Aplica cuando el flujo crea un product_item nuevo, y cuando el product_item
destino aún no tiene URL para ese competidor.
Se valida en el acto, pero no se bloquea la confirmación. Al pegar la URL se corre la extracción y se muestra lo obtenido —título, precio, disponibilidad— para que el operador confirme que es el producto correcto, igual que en la pestaña (§7.2.2). La diferencia está en qué pasa cuando eso no sale bien:
- Los campos son opcionales: se puede confirmar el producto sin cargar ninguna URL.
- Si la extracción falla, tarda demasiado o el operador prefiere no esperar, la URL se
guarda como
pendiente_verificaciony el producto se confirma igual. - Lo que siempre se valida, por barato y local, es formato de URL, pertenencia al dominio del competidor y unicidad (§7.2.3).
El motivo de esa concesión es concreto: cada verificación es un ida y vuelta a ScrapeOps de varios segundos, y confirmar productos pendientes es una tarea de volumen. Si el sitio de un competidor está caído, el alta de productos no puede quedar tomada de rehén.
El estado pendiente_verificacion no es un cabo suelto: es una bandeja de trabajo. Las
URLs que quedaron sin verificar se listan para resolverlas desde la pestaña, que es donde
la revisión tiene sentido.
7.2.2 En el producto — pestaña "Competencia" en el detalle del product_item¶
Es la gestión dedicada, y cubre lo que el modal no puede: actualizar URLs y cargar las de los productos que ya existen sin URL — que hoy son todos.
Se agrega como pestaña nueva en resources/views/components/product-items/tabs.blade.php,
junto a Variantes, Producto, Etiquetas, Galería e Historial. Sin ícono decorativo, según
docs/estandares/ui-design-standards.md (las pestañas existentes los tienen por
antigüedad, no por criterio vigente).
Muestra una fila por competidor activo, con su URL, último precio, disponibilidad, fechas y estado. Y acá la verificación sí es bloqueante:
- El operador pega la URL.
- Se valida formato, pertenencia al dominio y unicidad (§7.2.3).
- Se corre la extracción en el acto y se muestra qué se obtuvo: título, precio, precio de lista, disponibilidad.
- El operador confirma que es el producto correcto. Recién ahí se persiste como
activa.
El paso 4 es el que convierte el riesgo "la URL apunta a otro producto" (§10) de silencioso en imposible de ignorar: quien curó la URL ve el título extraído antes de confirmar. Y si la extracción falla, es señal temprana de que la configuración del competidor no cubre ese tipo de página — se detecta al cargar una URL, no meses después en la analítica.
Desde esta pestaña también se verifican y confirman las URLs que llegaron en estado
pendiente_verificacion desde el modal de alta.
7.2.3 Reglas comunes a ambos puntos de entrada¶
Validación de pertenencia: el dominio de la URL debe corresponder al del competidor. Evita el error trivial de pegar la URL de un competidor en la fila de otro.
Unicidad, en dos sentidos:
(competidor, url)única — una misma página no puede estar asignada a dos productos nuestros; si pasa, es un error de carga.(product_item, competidor)única — un producto tiene a lo sumo una URL por competidor.
Edición: cambiar la URL repite la verificación y descarta el precio anterior — es otra página, y probablemente otro producto.
Estados de la asignación:
| Estado | Significado | Cómo se llega |
|---|---|---|
pendiente_verificacion |
URL cargada pero no confirmada contra la página | Alta desde el modal de productos pendientes (§7.2.1) |
activa |
Participa del relevamiento diario | Alta verificada y confirmada (§7.2.2) |
pausada |
Se conserva la URL curada, no se releva | Decisión manual |
Son los tres estados que alguien decide, y por eso se almacenan. Que una URL esté rota no lo decide nadie: se deduce de las fechas (§8.3).
Quitar un producto del universo es pausar, no borrar: conserva la URL, que costó trabajo curar. El borrado existe pero es una acción aparte y explícita.
Carga masiva. Para el alta inicial de volumen, importación por CSV
(sku, competidor, url) que deja las filas en pendiente_verificacion y devuelve el
reporte de cuáles no pudieron verificarse.
7.3 Proceso diario de relevamiento¶
Corre todos los días. El lote del día no es "todo el universo", sino lo que está vencido y es relevante — con lo cual la carga se reparte entre días distintos.
Elegibilidad. Una URL entra al lote del día si se cumple todo:
- el competidor está activo;
- la asignación está
activa; - el
product_itemestá activo; - pasa la política de stock: si el relevamiento sin stock está desactivado, el producto debe tener stock (abajo);
- su último relevamiento es más viejo que la frecuencia del competidor, o nunca se relevó.
Orden: por fecha de último relevamiento ascendente — lo más rancio primero, así nada queda postergado indefinidamente.
Política de stock
El stock del producto se resuelve por product_item_view.stock > 0, que es donde el
sistema ya encapsula "producto vendible" (ver ADR-0002 antes de tocar esa vista).
Un único parámetro global (§7.4) decide si los productos sin stock propio entran al lote o no. Activado, se relevan igual que los demás; desactivado, quedan fuera hasta que vuelvan a tener stock.
Se evaluó espaciar el relevamiento según cuánto tiempo lleva agotado un producto, y se descartó: obliga a llevar la fecha de cuándo se quedó sin stock —que el sistema no registra— a cambio de un ahorro que todavía nadie midió. Si el gasto de créditos lo justifica, se retoma.
Volumen por corrida
La frecuencia por competidor alcanza como mecanismo de reparto. Queda previsto pero no implementado un límite máximo de URLs por ráfaga de corrida (§7.4), como techo duro de pedidos contra el sitio.
Vale registrar el efecto que ese límite corregiría, para no descubrirlo después: la frecuencia sola no desincroniza cohortes. Si N URLs se relevan el mismo día, con frecuencia de 7 días vuelven a vencer juntas siete días después, indefinidamente. En la práctica el riesgo es acotado, porque el alta de URLs ocurre en el flujo de confirmación de productos (§7.2.1), que sucede de a poco y deja las fechas naturalmente repartidas. El caso que sí concentraría carga es la carga masiva por CSV, donde todas las filas nacen el mismo día — y es exactamente ahí donde el límite por ráfaga se vuelve útil.
Obtención de la página: ScrapeOps
Las páginas no se piden directamente: se piden a través de ScrapeOps
(https://proxy.scrapeops.io/v1/), que ya está integrado y configurado en el proyecto
(services.scraperops.token, desde SCRAPEOPS_API_KEY). El servicio se ocupa de la
rotación de IP y del renderizado, con lo cual el módulo no necesita el proxy Playwright
propio — ese queda para el scraping preexistente de §2.2.
El costo es el parámetro de diseño. ScrapeOps cobra por créditos y el código actual ya
dejó medida la diferencia: con premium=level_1 y render_js=false un pedido cuesta
1,5 créditos; activando render_js pasa a 15. Diez veces más por página.
Eso tiene tres consecuencias directas:
render_jses una decisión por competidor, no un default. Se activa solo si sin él la página no entrega los datos, y esa pregunta la responde el probador del ABM (§7.1).- Refuerza la preferencia por
json_ld. Los datos estructurados de schema.org viven casi siempre en el HTML que devuelve el servidor, sin necesidad de ejecutar JavaScript. Elegir esa regla primero no es solo más estable (§7.1): es diez veces más barato. - El volumen del lote diario es presupuesto. La frecuencia por competidor, la política de productos sin stock (D20) y el límite por ráfaga (D16) dejan de ser solo perillas de cortesía contra el sitio y pasan a ser también control de gasto. D16 gana con esto un segundo motivo para existir.
El premium=level_1 se usa solo en producción, siguiendo el criterio ya aplicado en el
servicio existente.
Ejecución: un comando, no una cola
El proceso corre como un comando diario que atiende la corrida completa de principio a fin, con concurrencia acotada — no despachando un job por URL a una cola.
Es una desviación deliberada del patrón que usan las integraciones de salida del proyecto, y las razones son tres:
- La corrida necesita un principio y un fin. La cabecera y el resumen del log (abajo)
no tienen dónde emitirse si el comando despacha jobs y termina. Resolverlo con la cola
exigiría
Bus::batchy su callback final, un patrón que el proyecto no usa hoy y que arrastra la tablajob_batches. - El lote diario es chico por diseño. La frecuencia por competidor reparte el universo entre días, así que una corrida son decenas de URLs, no miles.
- Nada compite por esta ventana. Es un proceso diario, aislado, que no publica nada hacia afuera. No hay riesgo de atascar las colas de salida — que fue la lección del incidente de junio 2026 (ADR-0003).
Lo que se resigna es el reintento por job con backoff de la cola; los reintentos se hacen inline dentro del comando, con timeout explícito por pedido.
Detalles:
- Corre por scheduler, con opciones para depurar: un solo competidor, una sola URL, modo simulación.
- Concurrencia acotada al plan de ScrapeOps: el techo de pedidos simultáneos es el de la cuenta, no el de cada competidor, y se fija en un parámetro global (§7.4).
- Los contadores de la corrida viven en memoria del propio comando — no hacen falta contadores efímeros en Redis ni estado compartido entre jobs.
Traza: cabecera, una línea por producto, resumen
El archivo de log del módulo es la traza de auditoría, y tiene tres partes:
| Parte | Contenido |
|---|---|
| Cabecera | Inicio de la corrida: fecha, competidores incluidos, cuántas URLs entraron al lote y por qué criterio |
| Una línea por producto relevado | Formato obligatorio [{CANAL}:{product_id}:{sku}], con desenlace, precio obtenido y datos variables en el contexto (D22) |
| Resumen | Cierre: relevadas, exitosas, fallos separados por tipo, y el desglose por competidor |
El resumen es la alerta. Ahí es donde se publica la proporción de fallos de extracción por competidor, que es la señal de cambio de layout (abajo). Una línea como "Nissei: 30 relevadas, 5 exitosas, 25 fallos de extracción (83%)" dice sola lo que pasó, sin infraestructura de alertas.
Desenlaces por URL. Distinguir estos tres casos es lo que hace confiable el dato:
| Desenlace | Ejemplos | Qué pasa con el precio | Qué se registra |
|---|---|---|---|
| Éxito | Extracción válida y dentro de rango | Se actualiza | Fecha de intento y de éxito |
| Fallo transitorio | Timeout, 429, 5xx | No se toca | Fecha de intento; la de éxito no avanza. Motivo al log |
| Fallo permanente o de extracción | 404/410, ninguna regla matchea, valor fuera de rango | No se toca | Fecha de intento; la de éxito no avanza. Motivo al log |
rota es una etiqueta, no una regla de exclusión. Una URL sin relevamiento exitoso
desde hace más de X días (§7.4) se muestra como rota y aparece en la bandeja de revisión,
pero se sigue intentando a la cadencia normal. No hace falta sacarla del ciclo: como
la elegibilidad se mide por la fecha de último intento, una página muerta se toca cada N
días, no todos los días — que era el único problema que había que evitar. Y así, si el
sitio vuelve, la URL se recupera sola sin que nadie la rehabilite.
El estado de error no se guarda: se deduce. Con las dos fechas —último intento y último relevamiento exitoso— alcanza: si no coinciden, la última corrida falló, y la distancia entre ellas es la antigüedad real del precio. No hace falta ni un campo "con error" ni un contador de fallos: ambos pueden quedar desincronizados del hecho, las fechas no (§8.3).
El motivo del fallo tampoco se almacena: se loguea. Una línea por URL relevada en un
canal propio del servicio, con el formato obligatorio [{CANAL}:{product_id}:{sku}] y los
datos variables en el contexto. Eso alcanza para auditar por producto, SKU, competidor o
fecha, sin sumar columnas que solo sirven para mirar hacia atrás. Sigue el precedente de
la traza de edición masiva en storage/logs/jobs/.
Que sea una línea por URL no contradice la convención de "un log por acción significativa, no por iteración": cada URL es un pedido HTTP externo con un desenlace de negocio propio, no una vuelta de bucle sobre datos en memoria.
En la base queda solo lo que el proceso necesita para decidir: las dos fechas.
Detección de cambio de layout
Es el rédito del diseño dinámico. Si en una corrida muchas URLs del mismo competidor fallan por extracción — no por red — no se cayeron doscientas páginas: el sitio cambió el HTML. Superado el porcentaje configurado (§7.4) se dispara el aviso, y esa es la señal para ir a editar la cadena de reglas y verificarla con el botón "Probar" (§7.1).
Como el motivo no se guarda en la base (D22), el conteo se lleva en memoria durante la corrida y se publica en el resumen del log al cerrar. Es estado de ejecución, no esquema — se descarta cuando la corrida termina, y la traza fina queda línea por línea.
Sin esa distinción entre fallo de red y fallo de extracción, el módulo se degrada en silencio: sigue corriendo, no actualiza nada, y nadie se entera hasta que alguien nota que todos los precios tienen la misma fecha vieja.
7.4 Parámetros¶
Dos niveles, según qué tan específico es el valor:
Por competidor (en su registro, editable desde el ABM): frecuencia en días, opciones
de obtención (render_js y extras de ScrapeOps), moneda y formato numérico, y la
configuración de extracción.
Global (en .env, expuesto por un archivo de configuración propio, al estilo de
config/bulk_edit.php):
| Parámetro | Para qué | § |
|---|---|---|
| Relevar productos sin stock | Si los productos propios agotados entran al lote diario | 7.3 |
| Variación máxima de precio aceptada | Rango plausible; fuera de eso no se escribe | 7.1 |
Días sin relevamiento exitoso para marcar rota |
Cuándo señalar una URL para revisión | 7.3 |
| Porcentaje de fallos de extracción para alertar | Umbral de sospecha de cambio de layout | 7.3 |
| Pedidos simultáneos permitidos | Concurrencia del comando; se ajusta al plan contratado de ScrapeOps | 7.3 |
| Límite de URLs por ráfaga de corrida | Techo de pedidos —y de gasto— por corrida — previsto, no implementado | 7.3 |
Los valores globales son iguales para todos los competidores. Si en la práctica alguno necesita un umbral propio, el parámetro se muda a su registro; empezar global evita inventar configuración por competidor antes de saber si hace falta.
Recordatorio operativo: al tocar
.envo config en el servidor de desarrollo,php artisan config:clear— nuncaconfig:cache(ver CLAUDE.md y runbook).
8. Modelo de datos¶
Dos tablas, un archivo de configuración y un canal de log. Nombres en inglés
según la convención del repo; se evita deliberadamente el término price_list (D8).
8.1 competitors¶
| Columna | Tipo | Notas |
|---|---|---|
id |
bigint PK |
|
name |
string |
Nombre visible |
code |
string único |
Identificador corto para CSV y logs |
base_domain |
string |
Validación de pertenencia de la URL (§7.2.3) |
currency |
enum('PYG','USD') |
Moneda en que publica el sitio (D23) |
decimal_separator |
char(1) |
Por defecto , |
thousands_separator |
char(1) |
Por defecto . |
render_js |
boolean |
Por defecto false. Activa el renderizado en ScrapeOps — decuplica el costo (§7.3) |
fetch_options |
json nullable |
Extras de ScrapeOps para ese sitio (país, tipo de proxy, esperas) |
frequency_days |
unsignedSmallInteger |
Cada cuántos días se releva cada URL |
extraction_config |
json |
Cadena de reglas por campo (§8.4) |
active |
boolean |
Por defecto true. La baja es desactivación, no borrado |
created_at / updated_at |
Modelo Competitor. La edición de extraction_config se audita con activity log (§7.1).
8.2 competitor_prices¶
Una fila por product_item × competidor: el vínculo curado y la última observación en la
misma fila (§6.2).
| Columna | Tipo | Notas |
|---|---|---|
id |
bigint PK |
|
competitor_id |
FK → competitors |
restrictOnDelete — el competidor no se borra |
product_item_id |
FK → product_items |
cascadeOnDelete |
url |
string(500) |
URL de la página del producto en el competidor |
url_hash |
char(64) |
SHA-256 de url, para poder indexar la unicidad |
status |
enum('pending_verification','active','paused') |
Los tres estados que alguien decide (§7.2.3) |
title |
string nullable |
Título leído de la página — control de identidad (§7.1) |
sale_price |
decimal(12,0) nullable |
En PYG (D6). Sin decimales — ver D36 |
list_price |
decimal(12,0) nullable |
En PYG, cuando el sitio muestra precio tachado |
availability |
enum('in_stock','out_of_stock','unknown') nullable |
Disponibilidad en el competidor |
last_attempt_at |
datetime nullable |
Último intento de relevamiento — gobierna la elegibilidad |
last_success_at |
datetime nullable |
Último relevamiento exitoso — gobierna la frescura |
created_at / updated_at |
Modelo CompetitorPrice.
Índices
| Índice | Para qué |
|---|---|
único (competitor_id, url_hash) |
Una misma página no puede estar en dos productos (§7.2.3) |
único (product_item_id, competitor_id) |
Un producto tiene a lo sumo una URL por competidor |
(competitor_id, status, last_attempt_at) |
Query de elegibilidad del lote diario (§7.3) |
(last_success_at) |
Estado derivado y consultas externas (§8.3, §8.5) |
Por qué url_hash. La unicidad tiene que ser sobre la URL completa, y una URL de
producto supera cómodamente el largo indexable en MySQL con utf8mb4. Se indexa el hash
y se conserva la URL legible en su columna.
Por qué la elegibilidad usa last_attempt_at y no last_success_at. Porque así una
URL que falla se reintenta en su próximo vencimiento normal —cada N días— y no todos los
días. Es lo que permite que rota sea solo una etiqueta y no una regla de exclusión
(§7.3).
8.3 Estados derivados: ninguna columna¶
| Estado | Se deduce de |
|---|---|
| Relevada con éxito la última vez | last_attempt_at = last_success_at |
| Con error en el último intento | last_attempt_at ≠ last_success_at |
| Rota | last_success_at es NULL, o más vieja que el umbral de días (§7.4) |
| Antigüedad del precio | Distancia entre last_success_at y hoy |
No hay campo de estado ni contador de fallos: los tres primeros salen de comparar dos fechas, y una fecha no puede quedar desincronizada del hecho que representa (D18).
8.4 Formato de extraction_config¶
Un objeto por campo a extraer, con la lista ordenada de reglas (D14). El extractor las prueba en orden y gana la primera que devuelve un valor válido.
{
"sale_price": [
{ "type": "json_ld", "path": "offers.price" },
{ "type": "meta", "property": "product:price:amount" },
{ "type": "css", "selector": ".summary .price ins .amount" }
],
"list_price": [
{ "type": "css", "selector": ".summary .price del .amount" }
],
"availability": [
{ "type": "json_ld", "path": "offers.availability",
"map": { "InStock": "in_stock", "OutOfStock": "out_of_stock" } },
{ "type": "css", "selector": ".stock",
"map": { "Agotado": "out_of_stock", "*": "in_stock" } }
],
"title": [
{ "type": "json_ld", "path": "name" },
{ "type": "css", "selector": "h1.product_title" }
]
}
json_ld— ruta dentro del<script type="application/ld+json">de schema.org.meta—propertyonamede la etiqueta.css—selector, conattropcional para leer un atributo en vez del texto.regex—patternsobre el HTML crudo, último recurso.map— solo paraavailability: traduce el valor leído al vocabulario propio, con*como comodín.
Es un JSON validado contra un esquema al guardar, no texto libre: un tipo desconocido o una regla sin su campo obligatorio se rechaza en el ABM, antes de llegar a una corrida.
8.5 Consumo externo: sin superficie de salida¶
No se construye vista, endpoint ni export (D13): las herramientas externas consultan
competitor_prices y competitors directamente. Lo que necesita saber quien consulte:
- Considerar solo filas en estado
active: una URL sin verificar o pausada no es una observación publicable. - Los precios están en PYG (D6).
- El estado del relevamiento se deriva de las fechas según §8.3; no existe columna de estado de error.
- La clave para cruzar con el resto del sistema es
product_item_id.
El módulo no entrega nuestro precio de venta ni ningún cálculo comparativo: la comparación es enteramente del lado de quien consume (D24).
8.6 Configuración y logging¶
config/competitor_prices.php, leyendo de.env, con los parámetros globales de §7.4 (política de stock, variación máxima aceptada, días pararota, umbral de alerta por cambio de layout, y el límite por ráfaga previsto en D16). Sigue el patrón deconfig/bulk_edit.php.- Canal de log propio en
config/logging.phpmás su constante enApp\Constants\LogChannels, con una línea por URL relevada en el formato obligatorio[{CANAL}:{product_id}:{sku}]y los datos variables en el contexto (D22). - Sin conexión de cola: el proceso es un comando único (§7.3), así que no agrega colas ni workers. La concurrencia se controla dentro del propio comando.
- Credencial de ScrapeOps: se reusa la existente (
SCRAPEOPS_API_KEYen.env, leída porservices.scraperops.token). No se agrega configuración nueva ni se versiona el valor.
8.7 Migración¶
Va en un subdirectorio numerado nuevo de database/migrations/ (ADR-0001). El número
se asigna al implementar, no ahora: hay ramas en curso que ya tomaron los siguientes, y
elegirlo por anticipado garantiza el choque.
No toca product_item_view — solo la consulta —, así que no aplica la auditoría de
equivalencia del ADR-0002.
9. Decisiones tomadas¶
| # | Decisión | Valor | Razón |
|---|---|---|---|
| D1 | Efecto sobre el pricing interno | Ninguno | El objetivo es analítica; acoplarlo al cálculo multiplicaría el riesgo sin beneficio inmediato |
| D2 | Origen de los datos | Scraping automático | Hay infraestructura montada (§2.2) |
| D3 | Resolución del match | URL curada a mano por producto | El match automático no es viable (§4.1); el difuso contamina en silencio |
| D4 | Entidad de vínculo | product_item, match obligatorio |
Es la entidad con identificadores y con precio de venta asociado |
| D5 | Historial | Solo último precio | El módulo guarda estado, no evolución; llevar serie temporal es del lado de quien consume (D24) |
| D6 | Moneda | Normalizado a PYG | Comparación directa sin conversión del lado de quien consume |
| D7 | Ante fallo de scrapeo | Conservar último precio; no pisar nunca con un fallo | No perder el dato por fallas transitorias |
| D8 | Nomenclatura | Evitar "price list" | Colisión con PriceList (§2.1) |
| D9 | Universo de productos | Activos con URL cargada | Se define solo (§4.3) |
| D10 | Cantidad de competidores | 2-3 sitios propios definidos | Acotado y predecible de mantener |
| D11 | Frecuencia | En días, parametrizada por competidor | §6.1 |
| D12 | Datos capturados | Precio de venta, precio de lista, disponibilidad, título | El título es control de identidad, no dato analítico (§7.1) |
| D13 | Consumo | Directo de las tablas. Sin vista SQL, endpoints ni pantallas: nada de la salida se implementa aquí | Todo lo relacionado a analítica queda fuera del módulo, incluida su superficie de lectura; lo que el consumidor necesita saber se documenta (§8.5) |
| D14 | Configuración de extracción | Dinámica, por competidor, como cadena ordenada de reglas con json_ld primero y selector parametrizado de respaldo |
Adaptarse a un cambio de la página debe ser edición de datos, no despliegue; y no se asume que el sitio publique datos estructurados (§7.1) |
| D15 | Reparto de carga del proceso diario | Solo por frecuencia y fecha de último relevamiento. Sin cupo diario | Suficiente en la práctica, porque las altas de URL ocurren distribuidas en el tiempo (§7.3) |
| D16 | Límite por ráfaga de corrida | Parámetro previsto, no implementado en esta versión | Techo duro contra el sitio; se vuelve útil recién con carga masiva por CSV (§7.3) |
| D17 | Alta de URL: dos puntos de entrada | Paso extra en el modal de productos pendientes, y pestaña "Competencia" del product_item |
Capturar la URL donde el producto nace, y tener una gestión dedicada para actualizar y para los productos que ya existen (§7.2). El comportamiento ante fallo de verificación difiere: ver D28 |
| D18 | Estado de error y estado rota |
Derivados, no almacenados. Sin contador de fallos: alcanza con comparar las dos fechas | Un campo de estado o un contador pueden desincronizarse del hecho; las fechas no (§8.3) |
| D19 | Fechas | Dos: último intento y último relevamiento exitoso | Es lo que permite conocer la antigüedad real del precio (§7.3). Reemplaza la reserva sobre D7 |
| D20 | Productos sin stock | Un parámetro global de encendido/apagado. Sin decaimiento por tiempo agotado ni fecha de "sin stock desde" | Es el control de volumen más simple que resuelve el caso; el decaimiento exigía registrar un dato que el sistema no lleva, sin ahorro medido (§7.3) |
| D21 | Umbrales | Parametrizados globalmente en .env vía archivo de configuración propio |
Se ajustan sin desplegar; se mudan al competidor solo si alguno lo necesita (§7.4) |
| D22 | Motivo del fallo | No se almacena: una línea de log por URL relevada, en canal propio del servicio | El log es auditable por producto, SKU, competidor y fecha; una columna de motivo solo sirve para mirar hacia atrás y hay que mantenerla (§7.3) |
| D23 | Moneda de origen | Dato del competidor (publica en PYG o en USD); no se almacena la cotización usada | La conversión a guaraníes es el resultado buscado; reconstruir el importe original no hace falta |
| D24 | Analítica | Fuera de alcance por completo. El módulo deja los datos en la base; no analiza, no compara, no alerta sobre precios | Mantiene el módulo chico y con una sola responsabilidad: observar y persistir |
| D25 | Servicio de scraping | Nuevo, no se reusa PageScrapingService |
Está acoplado a comprasparaguai en constructor y base URL, y responde a otro propósito (§2.2); el extractor nuevo se guía por la configuración de D14 |
| D26 | Obtención de las páginas | ScrapeOps, ya integrado en el proyecto. Sin proxy Playwright propio para este módulo | Resuelve rotación de IP y renderizado; una dependencia en vez de dos. render_js queda como decisión por competidor por su costo (§7.3) |
| D27 | Ejecución del proceso diario | Comando único con concurrencia acotada, sin cola ni jobs por URL | La corrida necesita principio y fin para la cabecera y el resumen; el lote diario es chico y no compite con las colas de salida (§7.3) |
| D28 | Verificación en el modal de alta | Se valida in situ y se muestra el resultado, pero no bloquea la confirmación del producto | Da la garantía de identidad donde se carga la URL, sin que una caída del sitio ajeno frene el alta de productos (§7.2.1) |
10. Riesgos y mitigaciones¶
| Riesgo | Impacto | Mitigación |
|---|---|---|
| Cambio de layout del competidor rompe la extracción en silencio | El precio queda congelado y se analiza como vigente | Cadena de reglas con json_ld primero (§7.1); alerta por umbral de fallos de extracción contados durante la corrida (§7.3); las dos fechas hacen visible la antigüedad |
| Selector mal apuntado guarda un número que no es el precio | Dato falso, indistinguible de uno bueno | Validación de rango plausible y botón "Probar" antes de guardar la config (§7.1) |
| Anti-bot o bloqueo por IP | Se pierde la cobertura de un competidor | Deja de ser problema propio: la rotación la resuelve ScrapeOps (D26); la frecuencia por competidor modera la carga igual |
| Costo de créditos mayor al previsto | Gasto que crece con el universo relevado | render_js desactivado por defecto y preferencia por json_ld (§7.3); frecuencia por competidor y exclusión de productos sin stock como control de volumen; límite por ráfaga (D16) |
| URL cargada en el modal de alta queda sin verificar | Se releva una página que quizá no es el producto | Solo las active entran al relevamiento y al consumo externo (§8.5); las pendientes quedan en bandeja visible desde la pestaña (§7.2) |
| Página muerta relevada indefinidamente | Ruido y pedidos inútiles al sitio | La cadencia por competidor ya la espacia; se señala como rota para revisión humana (§7.3) |
| Carga masiva por CSV concentra todo el universo en un mismo día de vencimiento | Picos periódicos de pedidos contra el sitio | Documentado en §7.3; el límite por ráfaga (D16) es la corrección si aparece |
| Costo de curación de URLs | El universo relevado no crece | El alta queda incorporada al flujo donde el producto nace (§7.2.1), que es donde el costo marginal es menor |
11. Decisiones abiertas¶
Quedan dos, y ninguna bloquea el modelo de datos.
- Cuáles son los 2-3 competidores concretos. No hace falta para diseñar: todo lo que
varía entre sitios ya está parametrizado — modo de obtención, formato numérico, cadena
de reglas. Hace falta para el piloto: recién con páginas reales se sabe si la
extracción funciona, cuánta cobertura de
json_ldhay y qué valores tomar en el punto 2. - Valores iniciales de los parámetros de §7.4 — variación máxima aceptada, días para
marcar
rotay umbral de alerta por cambio de layout. Salen de mirar los sitios reales, no de decidirlos en abstracto.
12. Estado de la implementación¶
Actualizado 2026-08-26. El plan de implementación por bloques y su checklist viven fuera del repositorio, en
docs/_borradores/precios-competencia/(carpeta de trabajo no versionada).
12.1 Construido y verificado¶
| Bloque | Qué quedó | Verificación |
|---|---|---|
| Fundaciones | config/competitor_prices.php (10 parámetros), canal de log COMPETITOR_PRICES |
Config y canal probados en ejecución |
| Modelo de datos | competitors + competitor_prices en database/migrations/74, modelos Competitor y CompetitorPrice |
Esquema verificado en MariaDB; los dos índices únicos rechazan los duplicados |
| Motor de extracción | ExtractionEngine + 4 clases de regla + ExtractionConfigValidator + PageDocument |
3 fixtures en resources/fixtures/competitors/; validador probado contra 8 errores de configuración |
| Obtención | PageFetcher + FetchResult, ScrapeOps con concurrencia acotada |
5 desenlaces probados con handler simulado; concurrencia medida (8 URLs: 8 s → 2 s) |
| Normalización | ValueNormalizer + PlausibilityGuard |
Tabla de casos de parseo, conversión y rechazo |
| ABM + probador | CompetitorController, Livewire\Competitors\CompetitorForm |
Vistas compilan; ida y vuelta formulario ↔ extraction_config |
| Vinculación | UrlLinker, pestaña «Competencia» del product_item |
6 casos de pertenencia de dominio, incluido el ataque nissei.com.ajeno.io |
| Relevamiento diario | SurveyRunner + competitor:survey, en el scheduler a las 05:00 |
Invariante verificado: en los 5 desenlaces de fallo el precio queda intacto |
| Consumo externo | docs/integraciones/precios-competencia.md |
— |
Comandos nuevos: competitor:survey (corrida diaria, con --dry-run, --competitor,
--url, --limit) y competitor:test-extraction (probador por CLI sobre fixture o URL).
12.2 Decisiones que se cerraron al implementar¶
Ninguna contradice el diseño; todas lo precisan.
| # | Decisión | Resolución |
|---|---|---|
| D29 | Cotización para USD→PYG | Cotización cruda (ExchangeService::getExchangeRates()['salePrice']), no USDToPYG(): ese método suma un ajuste comercial fijo de +50 Gs que, aplicado a un precio ajeno, introduce un sesgo sistemático de ~0,8%. ExchangeService no se modifica |
| D30 | Cómo se mantiene url_hash |
Columna generada STORED por la base (SHA2(url,256), charset ascii). Verificado: un UPDATE por SQL crudo recalcula el hash igual. Imposible de desincronizar |
| D31 | El formato numérico depende de la regla ganadora | schema.org publica el precio en forma canónica aunque el sitio muestre otra: 215.50 en JSON-LD y US$ 215,50 en pantalla. Aplicar los separadores del competidor al valor de json_ld lo multiplicaría por 100. Por eso ValueNormalizer recibe el ExtractionResult, no un string |
| D32 | Desenlaces del relevamiento | Cinco, no tres. Se separan fallo_red_permanente, fallo_extraccion y valor_implausible: juntarlos destruye la señal de cambio de layout, que es lo único que distingue «el sitio se cayó» de «el sitio cambió» |
| D33 | Reintentos de la corrida | Por rondas, no por URL. Los transitorios de una ronda se reintentan en la siguiente; los permanentes (404/410) no se reintentan |
| D34 | Mecanismo de concurrencia | GuzzleHttp\Pool, no Http::pool: PageFetcher ya usa Guzzle y así reusa cliente, manejo de códigos y contador de créditos |
| D36 | Precisión de los importes | Enteros, sin decimales, a diferencia del resto de las columnas de precio del sistema (decimal(12,2)). El guaraní no tiene subunidad, y los sitios Magento publican el precio sin redondear —Nissei emite 4621999.998001 para un producto que muestra como 4.622.000—: conservar decimales guardaría un número que no coincide con el que exhibe la página. Se redondea después de convertir de USD, nunca antes |
| D35 | Resolución del stock | Una consulta aparte a product_item_view (channel = 'Compulandia'), no un join en la consulta principal ni forProduct() por ítem. La vista es pesada (ADR-0002) |
12.3 Pendiente¶
- Piloto. Falta definir los 2-3 competidores concretos (§11.1) y, con páginas reales, fijar los valores de los parámetros de §7.4. Hoy corren con defaults razonables.
- Paso «Competencia» en el modal de productos pendientes (§7.2.1). Fuera del camino
crítico: el módulo funciona con la pestaña del producto. Es el bloque de mayor riesgo de
implementación —
ConfirmationModaltiene 3.156 líneas y una máquina de pasos pormatch— y su ausencia es lo que limita el crecimiento del universo relevado. - Carga masiva por CSV (§7.2.3). Fuera del camino crítico.
- Tests unitarios. La regresión hoy la cubre
competitor:test-extraction --fixture=…, que sí viaja en el PR;tests/está en.gitignore.
13. Referencias¶
app/Services/Webscraping/PageScrapingService.php— scraping existente; referencia del uso de ScrapeOps y sus costos por créditoconfig/services.php—scraperops.token, desdeSCRAPEOPS_API_KEYapp/Console/Commands/SyncComprasParaguaiProducts.php— consumo del scraping existenteapp/Models/ProductoCompraParaguai.php— persistencia del scraping existenteapp/Services/CambiosChaco/ExchangeService.php— cotizaciónapp/Models/Price.php,app/Models/PriceList.php— precios propios (colisión de nombre)app/Models/ProductItemView.php— resolución de stock del producto (§7.3)app/Livewire/PendingProducts/ConfirmationModal.php— modal de alta de productos (§7.2.1)resources/views/components/product-items/tabs.blade.php— pestañas del producto (§7.2.2)docs/analisis/pending-product-confirmation-flow.md— flujo de confirmación de pendientesconfig/logging.phpyapp/Constants/LogChannels.php— canal de log propio (§7.3)docs/estandares/job-implementation-standards.md— estándar de jobsdocs/estandares/ui-design-standards.md— estándar de UI- ADR-0001 — migraciones en subdirectorios numerados
- ADR-0002 — lookup scopeado sobre
product_item_view - ADR-0003 — política de reintentos de jobs