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)
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
# 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`
|
||||
|
||||
```bash
|
||||
# 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
|
||||
|
||||
```sql
|
||||
-- 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
|
||||
|
||||
- [x] STEP 1 — Investigated portal endpoints (no captcha, no VIEWSTATE)
|
||||
- [x] STEP 2 — Schema `sql/028_anre.sql` aplicată pe satra
|
||||
- [x] STEP 3 — Scraper TS + cron .sh livrate, retry/backoff/skip-page
|
||||
- [x] 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`.
|
||||
Reference in New Issue
Block a user