joel taylor pedrós
blog

supermercapy: comparar preus de 14 supermercats amb python

diagrama d'una crida a search_all que es reparteix en catorze línies, una per supermercat, cadascuna amb la tecnologia de la seva web.

plusfresc i condis tenien la mateixa oferta a la llauna d'estrella damm de 33 cl, la segona al 50 %. plusfresc la publicava a 0,67 €, i condis a 0,89 €.

plusfresc dona com a preu la mitjana de les dues llaunes, (0,89 + 0,445) / 2 = 0,6675, que mostra com a 0,67, amb 0,89 com a preu anterior. condis dona el preu d'una sola llauna i escriu l'oferta al costat, "segunda unidad 50%". si en compres dues, surten igual a les dues botigues. si en vols una, la més barata era a 0,75 €, a carrefour, eroski, caprabo i bonpreu.

són preus del 6 d'octubre per al codi postal 08013, i surten de supermercapy, una llibreria de python que llegeix les botigues en línia de 14 supermercats.

de mercapy a supermercapy

el maig de 2024 vaig publicar mercapy, un client per a la botiga en línia de mercadona. al setembre de 2026 el vaig reescriure sencer, i ara és la base del programa que llegeix cada dia els preus de mercadona a 174 zones. supermercapy fa el mateix amb 14 cadenes: mercadona, consum, plusfresc, bonàrea, carrefour, lidl, bonpreu, dia, eroski, caprabo, aldi, ahorramás, alcampo i condis. el client de mercadona és un port de mercapy. s'instal·la amb pip install supermercapy, i el codi és a github.com/jtayped/supermercapy.

cada cadena té una classe que hereta d'un client base. cercar, llegir un producte i llegir les categories funcionen a totes. el catàleg sencer, les ofertes, els codis de barres o la informació nutricional depenen de la botiga, i cada client declara què té. si demanes una cosa que una botiga no publica, la llibreria falla abans de fer cap petició.

com es llegeixen les webs de 14 supermercats

cap d'aquestes botigues té una api documentada, i cadascuna va a la seva:

botigad'on surten les dadesde què depèn el preu
mercadonaapi json i un índex d'algoliael magatzem
aldiun índex d'algolia per regiópenínsula, balears o canàries
carrefouríndex d'empathy i l'estat de la pàginael punt de venda
condisíndex d'empathy i pàgines next.jsel centre en canvia l'assortiment, no el preu
bonpreuocado smart platformde res, hi ha una sola regió
alcampoocado smart platformla regió
eroski, caprabohtml d'apache tapestryla botiga que fixa la sessió anònima
ahorramáshtml de salesforce commerce cloudde res
bonàreapeticions de formularide res
plusfrescapi json amb un token de convidatel centre de preparació en canvia l'assortiment
consumapi json amb dues capçaleresla zona en canvia l'assortiment, no el preu
lidlquatre apisla regió
diaapi json amb una cookie de sessióel codi postal, gairebé mai

a dia, de 871 productes que es venien a madrid i a barcelona, només un tenia un preu diferent.

el pitjor cas és condis. per saber quin centre serveix un codi postal, la web crida una server action de next.js, i l'identificador d'aquesta acció canvia a cada desplegament i només surt als scripts de la pàgina. el client llegeix la portada, passant per un inici de sessió anònim, i després els scripts per ordre fins que un nomena l'acció. a l'octubre van ser 12 scripts i uns 1,4 mb. en total, 18 peticions per resoldre un codi postal. per això la documentació recomana resoldre'l un cop i guardar el centre.

bonpreu i alcampo tenen un tallafoc d'aws amb un pressupost per adreça ip. a bonpreu, a l'octubre, eren de 8 a 13 peticions des de zero; a alcampo, 7 o 8 cada mitja hora al servei de productes. després responen 202 amb el cos buit durant desenes de minuts. aquests dos clients esperen 2 segons entre petició i petició i serveixen per a consultes puntuals, no per recórrer el catàleg.

a eroski, cada producte de la llista porta un atribut d'analítica amb el preu en coma flotant, que perd els zeros finals. el client llegeix el preu que es veu imprès i només fa servir el de l'analítica si no n'hi ha.

un sol model de preu

cada client ho tradueix a les mateixes classes, immutables i amb Decimal, mai float. cada producte té l'import, el preu anterior, el preu de referència i les promocions.

la part difícil és el preu de referència. mercadona envia les unitats com a "L" i "dc", consum com a "1 L" i "100 Gr", bonpreu com a "PER_1KG". aldi envia g i 100-g en preus que en realitat són per quilo: 1,59 € per 200 g, amb un preu de referència de 7,95. price.reference ho torna a dir tot per quilo, litre, peça, dosi, metre o metre quadrat, i None vol dir que no se sap, no que valgui zero.

una crida, catorze fils

search_all() fa la mateixa cerca a totes les botigues, cadascuna en el seu fil i amb el seu client. amb un codi postal, les que en necessiten un el resolen abans de cercar. el resultat surt sempre en el mateix ordre, no en el que han acabat, i cada botiga diu com ha anat: ha respost, no s'ha preguntat, no serveix aquell codi o ha fallat. una botiga que falla no amaga el que han trobat les altres.

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 cerca de la llauna va costar 45 peticions i 9 segons, 19 d'elles de condis, i van respondre les 14 botigues.

codi de python amb supermercapy que crida search_all i find_same, i la sortida al terminal: la llauna d'estrella damm de 33 cl a deu cadenes. plusfresc 0,67, abans 0,89, segona al 50 %. carrefour, eroski, caprabo i bonpreu 0,75. alcampo 0,86. mercadona, consum, dia i condis 0,89, condis amb segunda unidad 50 %. 14 de 14 botigues han respost.
execució del 6 d'octubre de 2026 a les 19:04, codi postal 08013.

trobar el mateix producte a cada supermercat

només mercadona, consum, carrefour i lidl publiquen codis de barres, i mercadona només a la pàgina de cada producte. per saber si una llauna de carrefour és la mateixa que una de bonpreu, gairebé sempre cal mirar el nom, la marca i la mida.

si els dos codis de barres coincideixen, la puntuació és 1. si no, és la meitat de la semblança dels noms, més un quart si la marca coincideix i un quart si la mida coincideix. una marca, una mida o un envàs que es contradiuen la divideixen per dos. els noms es comparen sense la marca, la mida ni les paraules d'envàs, en castellà i en català, i només compten les diferències: un descriptor com "refresco" costa 0,15, qualsevol altra paraula 0,7, i un número o una negació com "sin lactosa", 1. la semblança és 1 / (1 + cost).

amb la marca i la mida bé, una sola paraula de diferència deixa la semblança a 0,59 i la puntuació a 0,79, just per sota del llindar per defecte, 0,8. bonpreu i condis van puntuar exactament així: marca i mida bé, nom a 0,59, total 0,79. per això vaig fer servir 0,75. i vaig partir de la llauna de carrefour, que porta codi de barres, perquè partint de la d'eroski, amb 0,75, s'hi colava la cervesa amb llimona de consum. així find_same() troba la llauna encara que consum l'anomeni "cerveza lata" i bonpreu "cervesa especial en llauna".

dos gràfics de línies amb la precisió i l'exhaustivitat de score_same segons el llindar. a les 10 cerques de desenvolupament, amb llindar 0,75 la precisió és 0,75 i amb 0,80 és 1,00, mentre l'exhaustivitat baixa de 0,81 a 0,59 entre 0,75 i 0,90. a les 4 cerques reservades, la precisió passa de 0,76 a 0,996 i l'exhaustivitat de 0,81 a 0,64.
0,8 és just a la vora: a 0,75 ja s'hi cola una paraula de diferència.

per triar el llindar vaig etiquetar a mà 1.343 resultats de 14 cerques reals del 4 d'octubre. a 0,8, score_same() no s'equivoca mai a les 10 cerques amb què vaig escriure les regles, i troba el 73 % dels parells. a les 4 que vaig etiquetar abans d'executar-lo, la primera passada va donar una precisió de 0,96 i una exhaustivitat de 0,55. per a una categoria que les regles no han vist mai, l'estimació honesta és aquesta.

a la cerca de la llauna, va sortir a 10 de les 14 botigues. lidl va tornar productes que no hi tenien res a veure, com làmpades i una estrella de nadal, aldi no va tornar res, ahorramás va tornar estrella galicia i mahou, i bonàrea només la ven en packs de 6 i de 12.

preus abans d'hora

lidl i aldi publiquen preus abans que entrin en vigor. diumenge 4 d'octubre, 113 productes d'aldi a la península ja portaven el preu de dilluns, i 74 en portaven un segon, per quan s'acabés el primer. a lidl, get_offers(week="next") llegeix les ofertes de la setmana següent dies abans que comencin.

quan una web canvia

cada client té tests en viu que criden tots els mètodes públics i comparen el json de cada resposta amb una còpia desada al repositori, perquè un camp reanomenat faci fallar un test en lloc de convertir-se en None en silenci. corren cada setmana i obren una issue per cada botiga que falla.

l'auditoria del 4 d'octubre, abans de la primera versió, ja va trobar canvis a diverses webs. la cerca de lidl responia 406 a qui demanava application/json, el tallafoc de bonpreu bloquejava qualsevol client que no semblés un navegador, i mercadona havia deixat d'enviar el nivell de les categories. carrefour va perdre una funció sencera. el seu únic llistat complet eren els sitemaps, i des d'octubre responen amb la pàgina de verificació de cloudflare. per això supermercapy pot llegir el catàleg sencer d'11 cadenes, i no de 14.