Files
Claude VM a6c03a091e initial: split from gov-agreg — vreau.digital standalone platform
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)
2026-05-13 00:10:32 +03:00

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.sql aplicată 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:

  1. Filter prin Judet=<id> — dar form-encoded GET nu pare să fie respectat în endpoint (probabil are nevoie de payload Kendo Grid binding filter[logic]=and&filter[filters][0][field]=Judet&filter[filters][0][value]=1).
  2. Sort by NrAutorizare ASC + paginat cu where NrAutorizare > last_seen în loc de OFFSET — ocoli OFFSET-ul lent. Necesită folosirea sort și filter din protocolul Kendo aspnetmvc-ajax.
  3. Filter prin Stare — doar "Autorizat" returnează ~6,600 din 101K (vezi sample probe), încape în offset-ul tolerat.
  4. 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

  1. Implementă filter-by-Stare pentru electricieni — vezi mai sus.
  2. Re-rulează scraperul gaze pentru a prinde pagina missed (UPSERT idempotent — sigur de re-rulat).
  3. Configure systemd timer (gen vreaudigital-anre-monthly.timer) pentru refresh lunar — datele ANRE se actualizează rar.
  4. Match-cui re-run după fiecare ingest nou (deja rulat — 92.3% match).
  5. Recipe: adaugă rețetă /achizitii/energie-fara-licenta-anre în src/lib/recipes.ts (când se reia munca pe lib/) folosind query-ul din "Cross-source value".
  6. Profile-page enrichment: adaugă bloc "Licențe ANRE" în src/pages/achizitii/firma-publica/[id].astro din anre.mv_licente_per_cui.