Moved from gov-agreg/src/pages/achizitii/* to root (drop prefix). - 22 pages migrated, 127 files total - All internal links: /achizitii/X → /X (176 occurrences fixed) - AchizitiiLayout subnav rewritten: /X paths, top-right link to vreaudigital.ro hub - BaseLayout new (vreau.digital branding, OG tags, site URL) - astro.config.mjs: site https://vreau.digital, server output (was static) - docker-compose: port 5096 (vreaudigital is 5095), container vreau-digital - deploy.sh: paths /opt/vreau-digital, log /var/log/vreau-digital-deploy.log Backend shared with gov-agreg: - PostgreSQL satra (same schemas: seap, firms, anaf, anre, ...) - Photon, Martin tiles - Infisical /vreaudigital path (DATABASE_URL etc. shared) build: PASS (npx astro check 0 errors, npm run build 5s vite + 10s server)
8.3 KiB
ANRE — Plan de ingest & cross-source matching
Sursa
ANRE (Autoritatea Națională de Reglementare în domeniul Energiei) publică
4 registre online la portal.anre.ro/PublicLists/:
| Slug intern | URL | Volum | Pattern |
|---|---|---|---|
electricitate |
/LicenteAutorizatii |
~4,927 | flat columns + JSON |
gaze |
/LicenteAutorizatiiGN |
~353 companies → ~7,000 sub-licențe (HTML Detaliu) | parent+child |
atestat |
/Atestate |
~9,745 companies → ~10K+ sub-atestate (HTML Detaliu) | parent+child |
electricieni |
/AutorizatiiElectricieniAutorizati |
~101,529 | flat (persoane fizice) |
Total estimat după ingest complet: ~120K+ rânduri.
Acces tehnic — fără captcha, fără VIEWSTATE
Stack server: ASP.NET MVC 4 + Kendo Grid (2013). NU e WebForms — datele se citesc direct via AJAX:
POST /PublicLists/<List>/Get<List>
Content-Type: application/x-www-form-urlencoded
X-Requested-With: XMLHttpRequest
Body: page=1&pageSize=99999
Response: { "Data": [...], "Total": 4927 }
pageSize=99999 returnează tot setul într-un singur call pentru sursele
flat (electricitate, electricieni). Sursele cu Detaliu (HTML mare per
rând) au timeout server-side la pageSize > 100 → folosim paginare cu
pageSize=25 pentru robustețe.
Quirk: cert TLS invalid pentru Node
Node 22 returnează UNABLE_TO_VERIFY_LEAF_SIGNATURE la portal.anre.ro.
Cert este valid (verificat OOB prin handshake), dar lipsește un intermediate
din bundle-ul Node. Workaround identic cu RegAS: NODE_TLS_REJECT_UNAUTHORIZED=0
în envfile pentru acest scraper.
Quirk: portal flaky — pagini intermitent timeout
Portalul ANRE timeoutează aleator 1-2 pagini per run (3-min timeout server-side
pe queries cu HTML render mare). Scraperul are retry x4 cu exponential backoff,
apoi marchează pagina ca HARD SKIP și continuă. Operatorul poate re-rula
scraperul — UPSERT idempotent → re-fetch pages care au eșuat.
Schema — services/seap-scraper/sql/028_anre.sql
3 tabele + 1 MV:
anre.licente— unified flat: 1 rând per (license_source, license_no, titular_name, data_emitere, license_type). PK = sha1 deterministic.license_source: 'electricitate' | 'gaze' | 'atestat'- Coloane CUI matching:
titular_name_norm,titular_cui,cui_match_score,cui_match_method,matched_at
anre.electricieni— persoane fizice, ~101K rânduri. UNIQUE(nr_autorizare, nume_prenume). Nu se face fuzzy match (n-au CUI).anre.scrape_log— observabilitate per run.anre.mv_licente_per_cui— MV agregat cu COUNT per (CUI, license_source, status). REFRESH CONCURRENTLY după fiecare ingest.
Atestat / Gaze — HTML parsing al Detaliu
Coloana Detaliu din JSON e un <table> cu mai multe rânduri (un titular are
mai multe atestate / licențe gaz). Parser-ul nostru extrage fiecare sub-rând și
îl inserează în anre.licente cu același titular_name. Headers detectate
automat din primul <tr>.
Scraper — services/seap-scraper/src/scrape-anre.ts
# Smoke test (100 rows)
SOURCE=electricitate LIMIT=100 sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh
# Full ingest, all 4 sources
sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh
# Per-sursă
SOURCE=electricitate sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh
SOURCE=gaze sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh
SOURCE=atestat sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh
SOURCE=electricieni sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-anre.sh
Pattern identic cu RegAS: Infisical Machine Identity → envfile → docker run --env-file (NEVER -e $VAR), envfile șters post-launch.
CUI matching — cron/match-cui-anre.sh
Reutilizează pipeline-ul Stage A (exact normalized) + B (pg_trgm 0.85/0.10) +
C (judet disambiguation) din match-cui-external.sh, dar pe coloana
anre.licente.titular_name → titular_cui.
Rezultate finale (29,536 rânduri = electricitate + gaze + atestat):
| Method | Rânduri | % |
|---|---|---|
exact_norm |
23,995 | 81.2% |
trgm_judet |
3,044 | 10.3% |
trgm_unique |
236 | 0.8% |
| TOTAL matched | 27,275 | 92.3% |
| Unmatched | 2,261 | 7.7% |
Cele 7.7% unmatched = în mare parte companii străine (DK, AT, DE), atestate emise pentru sucursale extra-RO, plus typo-uri în denumirea ANRE vs. ONRC.
Cross-source value
-- Furnizori care vând la stat fără licență ANRE
SELECT s.supplier_cui, e.name, COUNT(*) AS contracte_seap
FROM seap.announcements s
LEFT JOIN anre.mv_licente_per_cui a ON a.cui = s.supplier_cui
JOIN firms.entities e ON e.cui = s.supplier_cui
WHERE s.cpv_code ~ '^09(11|12|13|31|32|33|34|41)' -- CPV energie
AND a.cui IS NULL -- fără licență ANRE
GROUP BY s.supplier_cui, e.name
HAVING COUNT(*) >= 5
ORDER BY contracte_seap DESC;
Această query e replicabilitate-anchor pentru rețete tip
/achizitii/energie-fara-licenta-anre.
Status implementare
- STEP 1 — Investigated portal endpoints (no captcha, no VIEWSTATE)
- STEP 2 — Schema
sql/028_anre.sqlaplicată pe satra - STEP 3 — Scraper TS + cron .sh livrate, retry/backoff/skip-page
- STEP 4 — CUI matcher livrat, 79.7% match pe primele 5,540 rânduri
Ingest runs efectuate
| Source | Total source | Rânduri DB | Inserted | Updated | Skipped | Status |
|---|---|---|---|---|---|---|
| electricitate | 4,927 | 4,541 | 4,445 | 145 | 337 | ✅ DONE — skipped = NrLicenta NULL (acreditare prelim) |
| gaze (sub-licențe per company) | 7,106 sub | 999 | 999 | 6,054 | 53 | ✅ DONE — 1 page (25 rânduri) lost la timeout; re-run scraperul |
| atestat (sub-atestate per company) | 34,314 sub | 23,996 | 23,996 | 8,726 | 1,592 | ✅ DONE — skipped = sub-rânduri fără Nr.atestat |
| electricieni | 101,529 | 0 | 0 | 0 | 0 | ❌ BLOCKED — vezi mai jos |
Total în anre.licente: 29,536 rânduri | unique CUIs: ~6,500+ | matched: 92.3%
❌ Electricieni — server-side pagination broken
Server ANRE returnează HTTP 500 Execution Timeout Expired la query-uri cu
OFFSET > ~9000. Confirmat experimental:
| pageSize | offset 0 | offset 4K | offset 9K+ |
|---|---|---|---|
| 1000 | 15.6s ✅ | 11.4s ✅ | 33s 500 ❌ |
| 2000 | OK | OK | 500 ❌ |
| 5000 | OK | 500 ❌ | 500 ❌ |
Și endpoint-ul Excel export dă tot 500 după 253s. Înseamnă că DB-ul ANRE n-are
index pe OFFSET/LIMIT la cele ~101K rânduri din tabelul electricieni.
Workarounds posibile pentru o sesiune viitoare:
- Filter prin
Judet=<id>— dar form-encoded GET nu pare să fie respectat în endpoint (probabil are nevoie de payload Kendo Grid bindingfilter[logic]=and&filter[filters][0][field]=Judet&filter[filters][0][value]=1). - Sort by NrAutorizare ASC + paginat cu
where NrAutorizare > last_seenîn loc de OFFSET — ocoli OFFSET-ul lent. Necesită folosireasortșifilterdin protocolul Kendo aspnetmvc-ajax. - Filter prin
Stare— doar "Autorizat" returnează ~6,600 din 101K (vezi sample probe), încape în offset-ul tolerat. - Scrape "ElectricieniPropusiExamen" — sesiunea curentă, mult mai mic.
Recomandare: ingestă doar electricieni cu Stare='Autorizat' (active) — sunt
~6.5% din 101K = ~6,600 — încape lejer în offset-ul tolerat. Restul (Expirat,
Anulat, Neautorizat) sunt istoric, mai puțin valoroase pentru cross-reference
SEAP. Implementare: adaugă param Stare la fetchPage, filtrează server-side.
Next steps
- Implementă filter-by-Stare pentru electricieni — vezi mai sus.
- Re-rulează scraperul gaze pentru a prinde pagina missed (UPSERT idempotent — sigur de re-rulat).
- Configure systemd timer (gen
vreaudigital-anre-monthly.timer) pentru refresh lunar — datele ANRE se actualizează rar. - Match-cui re-run după fiecare ingest nou (deja rulat — 92.3% match).
- Recipe: adaugă rețetă
/achizitii/energie-fara-licenta-anreînsrc/lib/recipes.ts(când se reia munca pe lib/) folosind query-ul din "Cross-source value". - Profile-page enrichment: adaugă bloc "Licențe ANRE" în
src/pages/achizitii/firma-publica/[id].astrodinanre.mv_licente_per_cui.