FarmaAzi: un comparator de prețuri pentru cinci lanțuri de farmacii, construit pe potrivire după codul de bare
Cinci lanțuri de farmacii, 109.014 listări colectate și 55.734 de produse unice, dintre care 15.385 comparabile între cel puțin două farmacii. Cum am ales potrivirea pe codul de bare în locul potrivirii după denumire, cum arată istoricul de preț pe intervale și trei erori de date care arătau exact ca date bune: prețuri de o sută de ori mai mari, un atribut „OTC: Nu" citit greșit și un cache care a memorat pagina de blocare.
Cuprins
- Contextul
- Ce înseamnă „același produs" în cinci cataloage diferite
- Colectarea: cinci lanțuri, cinci comportamente diferite
- Istoricul de preț se scrie o singură dată
- Trei erori de date care arătau exact ca date bune
- Substanța activă și limita pe care nu o putem trece singuri
- Site-ul: 60.000 de pagini care trebuie să existe la prima cerere
- Cum se susține, declarat pe față
- Unde suntem acum
Contextul
În România, același medicament fără prescripție are cinci prețuri publice diferite, câte unul pentru fiecare lanț mare de farmacii, și niciun loc unde să le vezi pe toate odată. Cine voia să compare deschidea cinci site-uri, căuta de cinci ori, și de obicei se oprea după al doilea, pentru că denumirile comerciale nu se scriu la fel nicăieri.
Am construit comparatorul de prețuri pentru farmacii FarmaAzi ca răspuns la exact această frecare. Acoperă cinci lanțuri, Farmacia Tei, Catena, Dr.Max, HelpNet și Dona, și pune prețurile lor publicate unul lângă altul pe aceeași pagină de produs. Este produsul-soră al PromoAzi în rețeaua „Azi", pe aceeași stivă și cu aceleași tipare de colectare a datelor. A intrat public în septembrie 2026.
Un lucru pe care îl spunem din prima pagină și îl repetăm aici: FarmaAzi compară prețuri publicate de farmacii. Nu vinde, nu recomandă tratamente și nu înlocuiește discuția cu medicul sau cu farmacistul.
Ce înseamnă „același produs" în cinci cataloage diferite
Toată valoarea unui comparator stă într-o singură întrebare: două listări din două farmacii sunt același produs sau nu. Dacă răspunzi greșit, arăți două prețuri pentru lucruri diferite, ceea ce e mai rău decât să nu arăți nimic.
Am refuzat de la început potrivirea după denumire. Aceeași cutie apare ca „Nurofen 200mg 24 compr.", „NUROFEN 200 mg x 24 comprimate filmate" și „Nurofen, 200 mg, 24 comprimate", iar potrivirea aproximativă pe text produce exact genul de eroare pe care utilizatorul o observă imediat. Am construit potrivirea pe codul de bare, EAN, care este singurul identificator pe care toate cele cinci lanțuri îl publică și care înseamnă fizic aceeași cutie.
Cifrele de la data publicării acestui text: 109.014 listări brute colectate din cele cinci lanțuri se consolidează în 55.734 de produse unice, cu 55.711 coduri EAN atașate. Dintre ele, 15.385 apar la cel puțin două farmacii, deci au o comparație reală de afișat. Restul există într-un singur loc și primesc o pagină cu un singur preț, marcată ca atare.
Potrivirea rulează în trei treceri, în loturi, peste tabelul de staging: identifică EAN-urile cunoscute, creează produsele noi apărute de la rularea anterioară, apoi sincronizează prețurile. Este idempotentă, adică o rulare repetată pe aceleași date nu produce duplicate, iar o rulare completă peste cele 109.014 rânduri durează aproximativ 37 de minute.
Colectarea: cinci lanțuri, cinci comportamente diferite
Niciunul dintre cele cinci lanțuri nu oferă un API public de catalog, deci datele se iau de pe paginile lor, prin scraping. Patru dintre ele publică date structurate JSON-LD în pagina de produs și sunt citite de patru parsere care împart același cod de extragere. Dr.Max nu publică JSON-LD utilizabil, dar are un API GraphQL deschis, deci pentru el am scris un colector separat care interoghează direct.
Colectarea rulează pe un cluster de patru instanțe, fiecare cu propriul tunel VPN cu ieșire în România. O trecere completă se face în trei faze, ca să nu apăsăm două ori pe același furnizor simultan: întâi Catena singură pe toate cele 16 fire de execuție, apoi Dona și HelpNet în paralel, opt și opt, apoi Farmacia Tei și Dr.Max, tot opt și opt. O trecere completă peste toate cele cinci lanțuri durează în jur de două ore și jumătate.
Fiecare răspuns de tip 403, 429, 503 sau expirare de timp trece printr-o strategie de reîncercare cu pauză, rotire de ieșire VPN și re-punere a adresei în coadă, o singură dată. O adresă care pică de două ori nu se mai reîncearcă, pentru că a doua eroare identică aproape niciodată nu e tranzitorie, iar reîncercările la nesfârșit doar întârzie momentul în care se uită cineva la problemă.
Istoricul de preț se scrie o singură dată
Un comparator fără istoric arată doar prezentul, iar prezentul singur nu spune dacă un preț e bun. Am ales să nu stocăm un rând pe zi pe produs pe farmacie, pentru că majoritatea prețurilor nu se schimbă între două rulări și ar fi însemnat milioane de rânduri identice.
În locul acestui model, fiecare preț este un interval cu început și, dacă s-a schimbat, cu sfârșit. Un preț care rămâne la fel două luni ocupă un singur rând, iar o modificare închide intervalul curent și deschide unul nou. Rezultatul: 78.838 de intervale acoperă tot istoricul celor cinci lanțuri, iar întrebarea „cât costa acum trei săptămâni la Dona" se răspunde cu o singură interogare, fără agregare peste zile.
Trei erori de date care arătau exact ca date bune
Le scriem pentru că partea utilă dintr-o lucrare nu e lista de funcționalități, ci ce s-a stricat și cum ne-am dat seama.
Prima: prețurile de la două lanțuri erau de o sută de ori mai mari. Datele structurate din paginile Farmacia Tei și Dona scriau prețul cu separator zecimal, iar codul de curățare, scris să scoată simbolurile de monedă și spațiile, scotea și separatorul. 23,50 lei devenea 2350. Eroarea nu a sărit în ochi la validare, pentru că 2350 este un număr perfect valid pentru un preț. A sărit la comparație, unde același produs apărea cu 23,50 la o farmacie și 2350 la alta. Corectura a fost un singur convertor de preț comun tuturor parserelor, care tratează separatorul zecimal și spațiul neîntrerupt explicit, plus o verificare de plajă care refuză un preț cu două ordine de mărime peste mediana celorlalte farmacii pentru același EAN.
A doua: „OTC: Nu" nu înseamnă „cu prescripție". Unul dintre lanțuri are în tabelul de atribute un câmp OTC, iar prima citire a fost cea evidentă: dacă nu e OTC, e medicament cu prescripție. Am marcat 20.458 de produse ca fiind cu prescripție. Verificarea manuală a arătat că sub același „OTC: Nu" stau parfumuri, suplimente și cosmetice, adică tot ce nu e clasificat ca medicament fără prescripție, nu doar ce are nevoie de rețetă. Semnalul corect este cu totul altundeva: produsele cu prescripție stau într-o categorie separată, iar paginile lor nu conțin deloc date structurate, deci nu au fost niciodată colectate. Steagul greșit a fost anulat, iar regula acum se citește din categorie, nu din atribut.
A treia: cache-ul a memorat pagina de blocare ca și cum ar fi fost date. Scriptul care aduce sitemap-urile pe gazdă scria în cache orice răspuns cu cod 200. Furnizorul de protecție al unuia dintre lanțuri răspunde la o adresă necunoscută cu o pagină de verificare, tot cu 200, deci cache-ul a păstrat pagina de verificare în locul listei de adrese. Rularea următoare a pornit cu zero adrese și a raportat succes, ceea ce este cel mai neplăcut fel de eșec. Scriptul verifică acum că fișierul chiar este XML de sitemap înainte să îl scrie și, dacă nu este, păstrează ultima variantă bună în loc să o suprascrie.
Substanța activă și limita pe care nu o putem trece singuri
Peste potrivirea pe EAN am construit un al doilea nivel: gruparea produselor cu aceeași substanță activă, aceeași concentrație și aceeași formă farmaceutică. Cheia de grup se construiește din aceste trei elemente, iar rezultatul sunt 145 de grupuri, peste un vocabular de 1.738 de substanțe active și 61 de forme farmaceutice normalizate din denumirile în limba română folosite de lanțuri.
Aici trebuie spus limpede unde se oprește: doar 3.915 din cele 55.734 de produse au o substanță activă atașată, pentru că doar două dintre cele cinci lanțuri publică informația pe pagina de produs. Celelalte trei nu o publică deloc, iar noi nu o deducem, pentru că o substanță activă ghicită dintr-o denumire comercială este exact genul de dată pe care nu ai voie să o inventezi într-un context farmaceutic. Acoperirea crește când crește ce publică sursele, nu când scriem noi mai mult cod.
Aceeași disciplină se aplică și categoriilor. Cele opt categorii sunt atribuite printr-o listă de reguli deterministe peste denumire și traseul din site-ul sursei, nu printr-un clasificator probabilistic. Rezultatul este că 444 de produse din 55.734, adică 0,8%, rămân neîncadrate, iar acelea rămân vizibile ca neîncadrate în loc să fie împinse într-o categorie plauzibilă.
Site-ul: 60.000 de pagini care trebuie să existe la prima cerere
Partea publică este Angular cu randare pe server, peste un backend Spring Boot pe Java 21 și PostgreSQL, cu schema gestionată prin migrări versionate. Randarea pe server nu e o preferință de stil aici: un catalog de zeci de mii de pagini care se randează doar în browser rămâne luni întregi în starea „descoperită, dar neindexată".
Harta site-ului se generează din baza de date și se împarte în fișiere de câte 40.000 de adrese: 55.593 de pagini de produs, 3.169 de pagini de brand, 1.738 de pagini de substanță activă, plus categoriile și farmaciile. Fiecare pagină de produs emite date structurate cu oferta agregată din farmaciile care îl au, iar paginile de căutare filtrată sunt marcate să nu fie indexate, ca să nu concureze cu paginile reale.
Imaginile au fost partea cu cel mai bun raport între efort și efect. Sunt aduse o singură dată per produs, cu politețe per gazdă și cu limită de dimensiune, apoi convertite în trei lățimi în format WebP și servite de pe un subdomeniu propriu, prin cache de margine. 55.730 de imagini aduse o dată înseamnă zero cereri către site-urile farmaciilor la fiecare afișare de pagină, ceea ce este și mai rapid, și mai corect față de sursă.
Cum se susține, declarat pe față
FarmaAzi nu vinde nimic. Butonul de pe fiecare rând de preț trimite vizitatorul către farmacia respectivă printr-o redirecționare proprie, care numără clicul și, unde există un program de afiliere, atașează identificatorul de partener. Redirecționarea are limitare de rată și identifică vizitatorul printr-un hash, nu prin datele lui.
Modelul de venit este scris într-o pagină dedicată, legată din subsolul fiecărei pagini, împreună cu termenii, politica de confidențialitate și politica de cookie-uri. Consimțământul pentru analiză este cerut înainte, nu după, iar scripturile de măsurare nu se încarcă deloc până nu există acordul. E o alegere care ne costă niște date, dar e singura variantă pe care o putem apăra.
Unde suntem acum
farmaazi.ro este public din septembrie 2026, cu 55.734 de produse, 15.385 dintre ele comparabile între cel puțin două farmacii, și 78.838 de intervale de preț în istoric. Colectarea, potrivirea și publicarea rulează pe aceeași infrastructură.
Ce urmează, în ordinea în care le facem: trecerea colectării pe un program automat de noapte, care astăzi se declanșează la cerere și care va repara în aceeași rulare și datele rămase din trecerile vechi; extinderea acoperirii pe substanță activă acolo unde lanțurile o publică; și al șaselea și al șaptelea lanț, care sunt deja în schemă, dar încă fără colector. Raportarea rămâne aceeași, inclusiv în lunile în care un număr scade. Livrarea e 40% din treabă; restul e ca lucrurile să meargă curat luni la rând (A.06).
Întrebări frecvente
Ce face FarmaAzi?
Compară prețurile publicate de cinci lanțuri de farmacii din România pentru același produs, pe aceeași pagină. Acoperă Farmacia Tei, Catena, Dr.Max, HelpNet și Dona, cu 55.734 de produse în catalog, dintre care 15.385 apar la cel puțin două farmacii și au deci o comparație reală de afișat. Site-ul nu vinde nimic, nu recomandă tratamente și nu înlocuiește discuția cu medicul sau cu farmacistul.
Cum știți că două listări din farmacii diferite sunt același produs?
După codul de bare EAN, nu după denumire. Aceeași cutie este scrisă diferit de fiecare lanț, iar potrivirea aproximativă pe text produce erori pe care utilizatorul le vede imediat. EAN-ul este singurul identificator publicat de toate cele cinci lanțuri care înseamnă fizic aceeași cutie. Potrivirea rulează în trei treceri peste tabelul de staging, este idempotentă, iar o rulare completă peste 109.014 listări durează în jur de 37 de minute.
Cât de des se actualizează prețurile?
La fiecare trecere completă de colectare, care durează în jur de două ore și jumătate pentru toate cele cinci lanțuri. Colectarea rulează pe un cluster de patru instanțe cu tuneluri VPN separate, în trei faze, ca să nu apăsăm două ori pe același furnizor simultan. Trecerea pe un program automat de noapte este pasul următor; astăzi rularea se declanșează la cerere. Fiecare preț este stocat ca interval cu început și sfârșit, deci istoricul se poate citi înapoi fără să stocăm un rând pe zi.
De ce nu au toate produsele substanța activă afișată?
Pentru că doar două dintre cele cinci lanțuri o publică pe pagina de produs. Din 55.734 de produse, 3.915 au o substanță activă atașată, peste un vocabular de 1.738 de substanțe și 61 de forme farmaceutice. Nu deducem substanța activă din denumirea comercială, pentru că o valoare ghicită într-un context farmaceutic este exact genul de dată pe care nu ai voie să o inventezi. Acoperirea crește când crește ce publică sursele.
Cum se susține financiar un comparator care nu vinde nimic?
Prin afiliere, declarată pe față într-o pagină dedicată legată din subsolul fiecărei pagini. Butonul de pe fiecare rând de preț trece printr-o redirecționare proprie care numără clicul și, unde există un program de afiliere, atașează identificatorul de partener. Redirecționarea are limitare de rată și identifică vizitatorul printr-un hash, nu prin datele lui, iar scripturile de măsurare nu se încarcă până nu există consimțământ.