joel taylor pedrós
blog

supermercapy: comparar precios de 14 supermercados con python

captura de pantalla de un terminal con supermercapy compare "estrella damm lata" --postal-code 08013: la misma lata de estrella damm en carrefour a 0,82 €, en alcampo a 0,86 € y en eroski y caprabo a 0,89 €, un pack en mercadona a 8,28 € y en plusfresc a 10,44 €, y 9,268 segundos en total.

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)
rows = [can] + [(m.right.store, m.right.product) for m in same]

for store, item in sorted(rows, key=lambda r: r[1].price.amount):
    price = item.price
    was = f"was {price.previous}" if price.previous else ""
    deal = ", ".join(p.description for p in item.promotions)
    print(f"{store:<10} {price.amount:>5} {was:<9} {deal:<18} {item.name}")

ok = [s for s in result.stores if s.status == "ok"]
print(f"\n{len(ok)} of {len(result.stores)} stores answered")

lo ejecuté el 6 de octubre de 2026 a las 19:04, con el código postal 08013. la búsqueda de la lata costó 45 peticiones en 9,1 segundos, 19 de ellas de condis, y respondieron las 14 tiendas. estos son los precios que imprimió, en euros y tal como los publica cada tienda:

tienda precio antes oferta
plusfresc 0,67 € 0,89 € segona al 50 %
carrefour 0,75 €
eroski 0,75 € 0,89 €
caprabo 0,75 € 0,89 €
bonpreu 0,75 € 0,89 € abans 0,89€
alcampo 0,86 €
mercadona 0,89 €
consum 0,89 €
dia 0,89 €
condis 0,89 € segunda unidad 50%

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.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. 1.343 resultados de 14 búsquedas reales en el código postal 08013, el 4 de octubre de 2026, agrupados a mano.

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.