joel taylor pedrós
blog

supermercapy: comparar precios de 14 supermercados con python

diagrama de una llamada a search_all que se reparte en catorce líneas, una por supermercado, cada una con la tecnología de su web.

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:

tiendade dónde salen los datosde qué depende el precio
mercadonaapi json y un índice de algoliael almacén
aldiun índice de algolia por regiónpenínsula, baleares o canarias
carrefouríndice de empathy y el estado de la páginael punto de venta
condisíndice de empathy y páginas next.jsel centro cambia el surtido, no el precio
bonpreuocado smart platformde nada, hay una sola región
alcampoocado smart platformla región
eroski, caprabohtml de apache tapestryla tienda que fija la sesión anónima
ahorramáshtml de salesforce commerce cloudde nada
bonàreapeticiones de formulariode nada
plusfrescapi json con un token de invitadoel centro de preparación cambia el surtido
consumapi json con dos cabecerasla zona cambia el surtido, no el precio
lidlcuatro apisla región
diaapi json con una cookie de sesiónel 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.

código de python con supermercapy que llama a search_all y find_same, y la salida en el terminal: la lata de estrella damm de 33 cl en diez cadenas. plusfresc 0,67, antes 0,89, segunda al 50 %. carrefour, eroski, caprabo y bonpreu 0,75. alcampo 0,86. mercadona, consum, dia y condis 0,89, condis con segunda unidad 50 %. 14 de 14 tiendas han respondido.
ejecución del 6 de octubre de 2026 a las 19:04, código postal 08013.

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

dos gráficos de líneas con la precisión y la exhaustividad de score_same según el umbral. en las 10 búsquedas de desarrollo, con umbral 0,75 la precisión es 0,75 y con 0,80 es 1,00, mientras la exhaustividad baja de 0,81 a 0,59 entre 0,75 y 0,90. en las 4 búsquedas reservadas, la precisión pasa de 0,76 a 0,996 y la exhaustividad de 0,81 a 0,64.
0,8 está justo en el límite: a 0,75 ya se cuela una palabra de diferencia.

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.