supermercapy: comparar precios de 14 supermercados con python

plusfresc y condis tenían la misma oferta en la lata de estrella damm de 33 cl, la segunda al 50 %. plusfresc la publicaba a 0,67 €, y condis a 0,89 €.
plusfresc da como precio la media de las dos latas, (0,89 + 0,445) / 2 = 0,6675, que muestra como 0,67, con 0,89 como precio anterior. condis da el precio de una sola lata y escribe la oferta al lado, "segunda unidad 50%". si compras dos, salen igual en las dos tiendas. si quieres una, la más barata estaba a 0,75 €, en carrefour, eroski, caprabo y bonpreu.
son precios del 6 de octubre para el código postal 08013, y salen de supermercapy, una librería de python para comparar precios de supermercados: lee las tiendas online de 14 cadenas.
de mercapy a supermercapy
en mayo de 2024 publiqué mercapy, un cliente para la tienda online de mercadona. en septiembre de 2026 lo reescribí entero, y ahora es la base del programa que lee cada día los precios de mercadona en 174 zonas. supermercapy hace lo mismo con 14 cadenas: mercadona, consum, plusfresc, bonàrea, carrefour, lidl, bonpreu, dia, eroski, caprabo, aldi, ahorramás, alcampo y condis. el cliente de mercadona es un port de mercapy. se instala con pip install supermercapy, y el código está en github.com/jtayped/supermercapy.
cada cadena tiene una clase que hereda de un cliente base. buscar, leer un producto y leer las categorías funcionan en todas. el catálogo entero, las ofertas, los códigos de barras o la información nutricional dependen de la tienda, y cada cliente declara qué tiene. si pides algo que una tienda no publica, la librería falla antes de hacer ninguna petición.
cómo se leen las webs de 14 supermercados
ninguna de estas tiendas tiene una api documentada, y cada una va a lo suyo:
| tienda | de dónde salen los datos | de qué depende el precio |
|---|---|---|
| mercadona | api json y un índice de algolia | el almacén |
| aldi | un índice de algolia por región | península, baleares o canarias |
| carrefour | índice de empathy y el estado de la página | el punto de venta |
| condis | índice de empathy y páginas next.js | el centro cambia el surtido, no el precio |
| bonpreu | ocado smart platform | de nada, hay una sola región |
| alcampo | ocado smart platform | la región |
| eroski, caprabo | html de apache tapestry | la tienda que fija la sesión anónima |
| ahorramás | html de salesforce commerce cloud | de nada |
| bonàrea | peticiones de formulario | de nada |
| plusfresc | api json con un token de invitado | el centro de preparación cambia el surtido |
| consum | api json con dos cabeceras | la zona cambia el surtido, no el precio |
| lidl | cuatro apis | la región |
| dia | api json con una cookie de sesión | el código postal, casi nunca |
en dia, de 871 productos que se vendían en madrid y en barcelona, solo uno tenía un precio distinto.
el peor caso es condis. para saber qué centro sirve un código postal, la web llama a una server action de next.js, y el identificador de esa acción cambia en cada despliegue y solo aparece en los scripts de la página. el cliente lee la portada, pasando por un inicio de sesión anónimo, y luego los scripts por orden hasta que uno nombra la acción. en octubre fueron 12 scripts y unos 1,4 mb. en total, 18 peticiones para resolver un código postal. por eso la documentación recomienda resolverlo una vez y guardar el centro.
bonpreu y alcampo tienen un cortafuegos de aws con un presupuesto por dirección ip. en bonpreu, en octubre, eran de 8 a 13 peticiones desde cero; en alcampo, 7 u 8 cada media hora en el servicio de productos. después responden 202 con el cuerpo vacío durante decenas de minutos. estos dos clientes esperan 2 segundos entre petición y petición y sirven para consultas puntuales, no para recorrer el catálogo.
en eroski, cada producto de la lista lleva un atributo de analítica con el precio en coma flotante, que pierde los ceros finales. el cliente lee el precio que se ve impreso y solo usa el de la analítica si no lo hay.
un solo modelo de precio
cada cliente lo traduce a las mismas clases, inmutables y con Decimal, nunca float. cada producto tiene el importe, el precio anterior, el precio de referencia y las promociones.
la parte difícil es el precio de referencia. mercadona envía las unidades como "L" y "dc", consum como "1 L" y "100 Gr", bonpreu como "PER_1KG". aldi envía g y 100-g en precios que en realidad son por kilo: 1,59 € por 200 g, con un precio de referencia de 7,95. price.reference lo vuelve a expresar todo por kilo, litro, pieza, dosis, metro o metro cuadrado, y None quiere decir que no se sabe, no que valga cero.
una llamada, catorce hilos
search_all() hace la misma búsqueda en todas las tiendas, cada una en su hilo y con su cliente. con un código postal, las que lo necesitan lo resuelven antes de buscar. el resultado sale siempre en el mismo orden, no en el que han terminado, y cada tienda dice cómo ha ido: ha respondido, no se le ha preguntado, no sirve ese código o ha fallado. una tienda que falla no oculta lo que han encontrado las demás.
from supermercapy import find_same, search_all
result = search_all("estrella damm", postal_code="08013", page_size=10)
can = next((s, p) for s, p in result.products
if s == "carrefour" and "lata 33 cl" in p.name)
same = find_same(can, result.products, threshold=0.75)
la búsqueda de la lata costó 45 peticiones y 9 segundos, 19 de ellas de condis, y respondieron las 14 tiendas.

encontrar el mismo producto en cada supermercado
solo mercadona, consum, carrefour y lidl publican códigos de barras, y mercadona solo en la página de cada producto. para saber si una lata de carrefour es la misma que una de bonpreu, casi siempre hay que mirar el nombre, la marca y el tamaño.
si los dos códigos de barras coinciden, la puntuación es 1. si no, es la mitad de la similitud de los nombres, más un cuarto si la marca coincide y un cuarto si el tamaño coincide. una marca, un tamaño o un envase que se contradicen la dividen por dos. los nombres se comparan sin la marca, el tamaño ni las palabras de envase, en castellano y en catalán, y solo cuentan las diferencias: un descriptor como "refresco" cuesta 0,15, cualquier otra palabra 0,7, y un número o una negación como "sin lactosa", 1. la similitud es 1 / (1 + cost).
con la marca y el tamaño bien, una sola palabra de diferencia deja la similitud en 0,59 y la puntuación en 0,79, justo por debajo del umbral por defecto, 0,8. bonpreu y condis puntuaron exactamente así: marca y tamaño bien, nombre a 0,59, total 0,79. por eso usé 0,75. y partí de la lata de carrefour, que lleva código de barras, porque partiendo de la de eroski, con 0,75, se colaba la cerveza con limón de consum. así find_same() encuentra la lata aunque consum la llame "cerveza lata" y bonpreu "cervesa especial en llauna".

para elegir el umbral etiqueté a mano 1.343 resultados de 14 búsquedas reales del 4 de octubre. a 0,8, score_same() no se equivoca nunca en las 10 búsquedas con las que escribí las reglas, y encuentra el 73 % de los pares. en las 4 que etiqueté antes de ejecutarlo, la primera pasada dio una precisión de 0,96 y una exhaustividad de 0,55. para una categoría que las reglas no han visto nunca, la estimación honesta es esa.
en la búsqueda de la lata, salió en 10 de las 14 tiendas. lidl devolvió productos que no tenían nada que ver, como lámparas y una estrella de navidad, aldi no devolvió nada, ahorramás devolvió estrella galicia y mahou, y bonàrea solo la vende en packs de 6 y de 12.
precios antes de tiempo
lidl y aldi publican precios antes de que entren en vigor. el domingo 4 de octubre, 113 productos de aldi en la península ya llevaban el precio del lunes, y 74 llevaban un segundo, para cuando se acabara el primero. en lidl, get_offers(week="next") lee las ofertas de la semana siguiente días antes de que empiecen.
cuando una web cambia
cada cliente tiene tests en vivo que llaman a todos los métodos públicos y comparan el json de cada respuesta con una copia guardada en el repositorio, para que un campo renombrado haga fallar un test en lugar de convertirse en None en silencio. se ejecutan cada semana y abren una issue por cada tienda que falla.
la auditoría del 4 de octubre, antes de la primera versión, ya encontró cambios en varias webs. la búsqueda de lidl respondía 406 a quien pedía application/json, el cortafuegos de bonpreu bloqueaba cualquier cliente que no pareciera un navegador, y mercadona había dejado de enviar el nivel de las categorías. carrefour perdió una función entera. su único listado completo eran los sitemaps, y desde octubre responden con la página de verificación de cloudflare. por eso supermercapy puede leer el catálogo entero de 11 cadenas, y no de 14.