a6c03a091e
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)
433 lines
29 KiB
Markdown
433 lines
29 KiB
Markdown
# ANI Declarații de Avere și Interese — Ingest Plan
|
||
|
||
**Mission:** ingestăm 1.3M+ declarații PDF ale demnitarilor și înalților funcționari publici din România (2008–2022 + e-DAI 2022→) ca să cross-referențiem **politicieni × firme deținute × contracte SEAP** — flagship feature pentru vreaudigital.ro.
|
||
|
||
**Cadru legal:** Legea 176/2010 (publicarea declarațiilor e mandate-by-law, GDPR-safe). CNP-ul **nu e public**; tot restul (nume, funcție, instituție, valori, locații imobile, asocieri firme) **este**.
|
||
|
||
**Status la 2026-05-09:** arhitectură + schemă DB + scraper skeleton. **Full ingest = 15 zile efort focalizat**, nu se face în această sesiune. Acest document e foaia de drum pentru a continua "cold" în următoarea sesiune.
|
||
|
||
---
|
||
|
||
## 1. Pipeline (high-level)
|
||
|
||
```
|
||
┌──────────────────────────────────────────────────────────────────────────┐
|
||
│ SURSE (3 portaluri ANI, fiecare cu mecanică diferită) │
|
||
│ │
|
||
│ ▸ old-declaratii.integritate.eu JSF/IceFaces, search + CSV export │
|
||
│ (2008–2022 archive, ~12M docs, ~1.3M declaratii distincte) │
|
||
│ → /search.html?... POST forms, /DownloadServlet?fileName=…&… │
|
||
│ │
|
||
│ ▸ declaratii.integritate.eu Angular SPA + Spring Boot REST API │
|
||
│ (e-DAI 2022→, declarații electronice native) │
|
||
│ → /api/<form-id>/submission JSON cu data.bucket + data.filename │
|
||
│ │
|
||
│ ▸ depozitar.integritate.eu depozit raw, mirror partial │
|
||
│ (folosit ca fallback dacă portalul principal e down) │
|
||
└────────────────────────────────┬─────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────────────────────────────────────────────────────────┐
|
||
│ STAGE 1 — Listing scraper (cron/scrape-ani-listings.sh) │
|
||
│ Walk results pages, populate ani.declaratii (URL + metadata only) │
|
||
│ Idempotent. Dedupe pe (official_name, year, declaration_type, source) │
|
||
│ Output: ~1.3M rows, ~120 MB postgres │
|
||
└────────────────────────────────┬─────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────────────────────────────────────────────────────────┐
|
||
│ STAGE 2 — PDF download (cron/download-ani-pdfs.sh) │
|
||
│ Fetch PDFs sequential, store on satra disk │
|
||
│ Path: /opt/vreaudigital-data/ani/{year}/{sha256[:2]}/{sha256}.pdf │
|
||
│ Update ani.declaratii.pdf_path + raw_sha256 │
|
||
│ Estimat: 1.3M × ~300 KB avg = ~400 GB raw │
|
||
│ Throttled: 2 req/s → ~1 săpt 24/7 sau ~3 săpt @ 8h/zi │
|
||
└────────────────────────────────┬─────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────────────────────────────────────────────────────────┐
|
||
│ STAGE 3 — PDF parser (src/parse-ani-pdf.ts) │
|
||
│ Two pipelines: │
|
||
│ (a) e-DAI (2022→): native text PDFs, generate de Form.io. │
|
||
│ → pdftotext -layout, regex pe câmpuri stabile. │
|
||
│ (b) Old (2008–2021): scanned + native mix. │
|
||
│ → pdftotext întâi; dacă < 50 caractere "vizibile" → OCR (tesseract │
|
||
│ cu lang=ron, ~5–15s/pagină pe satra). │
|
||
│ Template-detection: 3 generații de template-uri (2008–2010, 2011–2016, │
|
||
│ 2017+). Diferite în text labels dar structuri tabelare comune: │
|
||
│ I. Bunuri imobile, II. Bunuri mobile, III. Active financiare, │
|
||
│ IV. Datorii, V. Donații, VI. Conturi/depozite, VII. Plasamente, │
|
||
│ VIII. Funcții, IX. Asociații/firme deținute, X. Venituri. │
|
||
│ Output: structured rows în ani.bunuri, ani.shareholdings, ani.functii, │
|
||
│ ani.donatii. │
|
||
└────────────────────────────────┬─────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────────────────────────────────────────────────────────┐
|
||
│ STAGE 4 — Entity resolution │
|
||
│ (a) Officials: dedupe across years pe (normalized_name + first │
|
||
│ institution + first year-of-birth slice). CNP-hash neavailable — │
|
||
│ omonimii rezolvate manual prin UI dacă apar conflicte SEAP. │
|
||
│ (b) Shareholdings: parsed firm_name (raw text din PDF) → CUI match │
|
||
│ via firms.match_company_name() (deja deployed în 019_cui_matcher). │
|
||
│ Tier 1: exact name match → 70% acoperire. │
|
||
│ Tier 2: pg_trgm similarity > 0.8 → +20%. │
|
||
│ Tier 3: manual review queue → 10% rest. │
|
||
└────────────────────────────────┬─────────────────────────────────────────┘
|
||
│
|
||
▼
|
||
┌──────────────────────────────────────────────────────────────────────────┐
|
||
│ STAGE 5 — UI surfacing │
|
||
│ ▸ /achizitii/politician/[slug] — profil demnitar (toate │
|
||
│ declaratiile, evolutie netto worth, firme deținute, contracte SEAP) │
|
||
│ ▸ /achizitii/firma/[cui] — adăugăm card "deținută de │
|
||
│ politicianul X" în profilul firmei existente │
|
||
│ ▸ /achizitii/retete/ │
|
||
│ politician-cu-firma-furnizor-stat │
|
||
│ (top 50 politicieni a căror firmă a încasat contracte SEAP) │
|
||
│ politician-uat-controleaza-furnizorul │
|
||
│ (primar/consilier × firma furnizor în UAT-ul lui) │
|
||
│ evolutie-avere-functie │
|
||
│ (politicieni cu cea mai mare creștere netto worth în mandat) │
|
||
└──────────────────────────────────────────────────────────────────────────┘
|
||
```
|
||
|
||
---
|
||
|
||
## 2. Schema DB (sql/030_ani_schema.sql)
|
||
|
||
5 tabele, schemă `ani.*`. Toate au `(*)_at` pentru audit + `source_url` ca să fim verifiable.
|
||
|
||
### `ani.officials` — demnitari/funcționari publici
|
||
|
||
| col | type | note |
|
||
|---|---|---|
|
||
| `id` | bigserial PK | |
|
||
| `normalized_name` | text NOT NULL | lowercase + unaccent + collapse whitespace |
|
||
| `display_name` | text NOT NULL | "Popescu Ioan-Vasile" în casing original |
|
||
| `cnp_hash` | char(64) | SHA-256 al CNP dacă l-am extras (RAR — ANI maschează majoritar). Permite linkare across years fără a expune CNP. |
|
||
| `first_seen_year` | smallint | min(declaration year) |
|
||
| `last_seen_year` | smallint | max(declaration year) |
|
||
| `slug` | text UNIQUE | URL-friendly: "popescu-ioan-vasile" + suffix dacă collision |
|
||
| `created_at` | timestamptz default now() | |
|
||
|
||
Index:
|
||
- `idx_officials_norm_name` btree pe normalized_name
|
||
- `idx_officials_norm_name_trgm` gin pe normalized_name (trgm)
|
||
- `idx_officials_slug` unique
|
||
|
||
### `ani.declaratii` — un PDF = un row
|
||
|
||
| col | type | note |
|
||
|---|---|---|
|
||
| `id` | bigserial PK | |
|
||
| `official_id` | bigint REFERENCES ani.officials(id) | nullable înainte de Stage 4 entity-resolution |
|
||
| `raw_official_name` | text NOT NULL | numele exact cum apare în portal (înainte de normalization) |
|
||
| `raw_institution` | text | "Ministerul X" / "Primaria Cluj-Napoca" / "Curtea de Apel Brasov" |
|
||
| `raw_function` | text | "Ministru" / "Consilier local" / "Judecator" |
|
||
| `raw_localitate` | text | localitatea declarată |
|
||
| `raw_judet` | text | județul |
|
||
| `year` | smallint NOT NULL | anul declarației (din date completare) |
|
||
| `declaration_type` | text NOT NULL CHECK (...) | 'avere' \| 'interese' \| 'avere+interese' |
|
||
| `submission_kind` | text | 'anuala' \| 'numire-functie' \| 'incetare-functie' \| 'rectificativa' |
|
||
| `data_completare` | date | data completării declarate de demnitar |
|
||
| `source_portal` | text NOT NULL | 'old' \| 'new' \| 'depozitar' |
|
||
| `source_url` | text NOT NULL | URL public (dacă e old: DownloadServlet…; dacă e new: API submission ID) |
|
||
| `source_id` | text | ID intern al portalului (uniqueIdentifier la old, _id la new) |
|
||
| `pdf_path` | text | path relativ sub /opt/vreaudigital-data/ani/, NULL până la Stage 2 |
|
||
| `pdf_sha256` | char(64) | hash conținut, dedupe |
|
||
| `pdf_size_bytes` | integer | |
|
||
| `fetched_at` | timestamptz | when PDF was downloaded |
|
||
| `parsed_at` | timestamptz | when parser finished |
|
||
| `parse_status` | text | 'pending' \| 'ok' \| 'ocr_required' \| 'parse_failed' \| 'template_unknown' |
|
||
| `parse_error` | text | last error message |
|
||
| `inserted_at` | timestamptz default now() | |
|
||
|
||
Index:
|
||
- `idx_declaratii_official` (official_id, year DESC) WHERE official_id IS NOT NULL
|
||
- `idx_declaratii_year` (year DESC, declaration_type)
|
||
- `idx_declaratii_sha` UNIQUE (pdf_sha256) WHERE pdf_sha256 IS NOT NULL
|
||
- `idx_declaratii_source` UNIQUE (source_portal, source_id)
|
||
- `idx_declaratii_pending` (parse_status) WHERE parse_status IN ('pending','ocr_required')
|
||
- `idx_declaratii_raw_name_trgm` gin pe raw_official_name
|
||
|
||
### `ani.bunuri` — secțiunile I (imobile) + II (mobile)
|
||
|
||
| col | type | note |
|
||
|---|---|---|
|
||
| `id` | bigserial PK | |
|
||
| `declaration_id` | bigint NOT NULL REFERENCES ani.declaratii(id) ON DELETE CASCADE | |
|
||
| `category` | text NOT NULL CHECK (...) | 'imobil-teren' \| 'imobil-cladire' \| 'mobil-vehicul' \| 'mobil-bijuterii' \| 'mobil-altele' |
|
||
| `subcategory` | text | "agricol" / "intravilan" / "apartament" / "casa" / "auto" |
|
||
| `localitate` | text | judet/țara/localitate text |
|
||
| `judet` | text | județ-normalizat unde aplicabil |
|
||
| `tara` | text | implicit "România" |
|
||
| `year_acquired` | smallint | anul dobândirii |
|
||
| `mode_acquired` | text | "cumparare" \| "mostenire" \| "donatie" \| "constructie" |
|
||
| `area_sqm` | numeric | suprafață în m² (terenuri/clădiri) |
|
||
| `share_pct` | numeric | cota-parte (1.0 = integrală) |
|
||
| `co_owner` | text | numele co-proprietarului dacă declarat |
|
||
| `value_lei` | numeric | valoarea declarată |
|
||
| `value_currency` | text default 'RON' | uneori EUR/USD |
|
||
| `raw_row_text` | text | textul brut din PDF, ca audit trail |
|
||
|
||
Index: `idx_bunuri_decl` (declaration_id), `idx_bunuri_judet` (judet) WHERE judet IS NOT NULL.
|
||
|
||
### `ani.shareholdings` — secțiunea IX (firme deținute) + secțiunea VIII partial (asociat) — **flagship table**
|
||
|
||
| col | type | note |
|
||
|---|---|---|
|
||
| `id` | bigserial PK | |
|
||
| `declaration_id` | bigint NOT NULL REFERENCES ani.declaratii(id) ON DELETE CASCADE | |
|
||
| `firm_name_raw` | text NOT NULL | textul brut din PDF |
|
||
| `firm_cui` | text | rezolvat în Stage 4, NULL în primă fază |
|
||
| `firm_match_score` | real | similarity la match |
|
||
| `firm_match_method` | text | 'exact_name' \| 'trgm' \| 'manual' \| 'unmatched' |
|
||
| `role` | text | "actionar" \| "asociat" \| "membru CA" \| "administrator" \| "cenzor" \| "membru AGA" |
|
||
| `share_pct` | numeric | cota deținută (dacă declarată) |
|
||
| `value_lei` | numeric | valoarea participațiunii |
|
||
| `category` | text | 'societate' \| 'asociatie' \| 'fundatie' \| 'cooperativa' \| 'altele' |
|
||
| `raw_row_text` | text | audit |
|
||
|
||
Index:
|
||
- `idx_share_decl` (declaration_id)
|
||
- `idx_share_cui` (firm_cui) WHERE firm_cui IS NOT NULL
|
||
- `idx_share_name_trgm` gin pe firm_name_raw
|
||
|
||
### `ani.functii` — secțiunea VIII (funcții deținute, public + privat)
|
||
|
||
| col | type | note |
|
||
|---|---|---|
|
||
| `id` | bigserial PK | |
|
||
| `declaration_id` | bigint NOT NULL REFERENCES ani.declaratii(id) ON DELETE CASCADE | |
|
||
| `is_public` | boolean | TRUE = funcție în instituție publică |
|
||
| `function_name` | text NOT NULL | "Consilier", "Ministru", "Director general" |
|
||
| `institution_name` | text NOT NULL | numele instituției / firmei |
|
||
| `institution_cui` | text | rezolvat în Stage 4 (joinable cu firms.entities sau seap.cui_authority) |
|
||
| `start_year` | smallint | |
|
||
| `end_year` | smallint | NULL dacă activă |
|
||
| `salary_lei` | numeric | venit anual din această funcție (când declarat) |
|
||
| `raw_row_text` | text | |
|
||
|
||
Index: `idx_functii_decl` (declaration_id), `idx_functii_inst_cui` (institution_cui) WHERE institution_cui IS NOT NULL.
|
||
|
||
### `ani.donatii` — secțiunea V (donații primite)
|
||
|
||
| col | type | note |
|
||
|---|---|---|
|
||
| `id` | bigserial PK | |
|
||
| `declaration_id` | bigint NOT NULL REFERENCES ani.declaratii(id) ON DELETE CASCADE | |
|
||
| `donor_name` | text | cine a făcut donația |
|
||
| `donation_type` | text | 'bani' \| 'imobil' \| 'mobil' \| 'servicii' |
|
||
| `value_lei` | numeric | |
|
||
| `currency` | text default 'RON' | |
|
||
| `year_received` | smallint | |
|
||
| `raw_row_text` | text | |
|
||
|
||
Index: `idx_donatii_decl` (declaration_id).
|
||
|
||
---
|
||
|
||
## 3. Estimări de volum
|
||
|
||
| Stage | Estimat | Notă |
|
||
|---|---|---|
|
||
| officials (distinct) | ~150K | demnitari + magistrati + înalți funcționari activi în 2008–2025 |
|
||
| declaratii (rows) | ~1.3M | 8–10 declarații/persoană în medie pe carieră |
|
||
| pdf storage | ~400 GB | 300 KB avg × 1.3M |
|
||
| bunuri (rows) | ~6M | 4–5 bunuri/declarație medie |
|
||
| shareholdings (rows) | ~800K | doar 30–40% au firme declarate |
|
||
| functii (rows) | ~3M | 2–3 funcții/declarație |
|
||
| donatii (rows) | ~250K | rare (10–20% au donații) |
|
||
|
||
**DB size estimat:** ~12 GB (fără PDF-uri, doar metadata + parsed).
|
||
|
||
**Cross-source magic queries posibile după ingest:**
|
||
1. `ani.shareholdings JOIN firms.entities ON cui JOIN seap.announcements ON supplier_cui` → politicianul X are firma Y care a câștigat 50M lei contracte.
|
||
2. `ani.functii(institutie publica) JOIN seap.announcements(authority) ON cui` → consilier local × autoritatea unde votează.
|
||
3. Year-over-year diff pe `ani.declaratii.bunuri` → creștere bruscă de avere în mandat.
|
||
|
||
---
|
||
|
||
## 4. Plan de execuție 15 zile
|
||
|
||
### Faza 1 — Listing & metadata (Days 1–2)
|
||
- **Day 1:** Scraper pentru **old portal** (JSF/IceFaces). Reverse-engineer formul `/search.html` cu pagination prin "Cautare avansata" + date range slicing (lună de lună 2008–2022 ca să nu lovim limita de rezultate). Output: ani.declaratii cu source_url + metadata, fără PDF-uri. Test: 1000 rows pe februarie 2020.
|
||
- **Day 2:** Scraper pentru **new portal** (Angular SPA → Spring Boot REST). Reverse-engineer endpoint-ul `/api/<form-id>/submission` cu request real captured din DevTools (TODO: necesită browser session pentru a observa traffic). Test: 100 rows e-DAI 2024.
|
||
|
||
**Deliverable Day 2:** ~50K rows în ani.declaratii (sample), 0 PDF-uri downloaded.
|
||
|
||
### Faza 2 — PDF download (Days 3–4)
|
||
- **Day 3:** `cron/download-ani-pdfs.sh` cu rate limit 2 req/s + retry exponential. Storage la `/opt/vreaudigital-data/ani/{yyyy}/{sha256[:2]}/{sha256}.pdf`. Update declaratii.pdf_path + sha + size + fetched_at. Run pe 1000 PDFs pilot.
|
||
- **Day 4:** Scale-up. Background detached docker container, log la `/var/log/vreaudigital-ani-pdfs.log`. Lasă să meargă în paralel cu munca pe parser.
|
||
|
||
### Faza 3 — Parser PDF (Days 5–7)
|
||
- **Day 5:** Setup `pdftotext` în container + helper Node `src/parse-ani-pdf.ts`. Detect template (2008-2010 / 2011-2016 / 2017+ / e-DAI). Parse secțiunea I (imobile) ca proof-of-concept. Test pe 10 PDFs din fiecare era.
|
||
- **Day 6:** Parser secțiunile II (mobile) + IX (shareholdings). Acestea sunt cheia. Output în ani.bunuri și ani.shareholdings. Test pe 100 PDFs.
|
||
- **Day 7:** Secțiunile VIII (functii) + V (donatii). OCR fallback (tesseract ron) pentru PDF-uri scanate (estimat 15-25% din 2008-2014). Marcăm `parse_status='ocr_required'` și rulăm OCR într-un cron separat.
|
||
|
||
**Deliverable Day 7:** parser care procesează ~70% din PDF-uri auto, ~25% cu OCR, ~5% template-unknown (manual review).
|
||
|
||
### Faza 4 — Entity resolution (Days 8–10)
|
||
- **Day 8:** Officials dedup. SQL function `ani.dedup_officials()` care grupează ani.declaratii pe (normalized_name + raw_judet + first-year). Manual review pentru top 1000 ambiguous (UI viewer simplu).
|
||
- **Day 9:** CUI matching pentru shareholdings. Refolosim `firms.match_company_name()` din 019_cui_matcher. Tier 1 exact + Tier 2 trgm > 0.8. Restul → tabel ani.shareholdings_unmatched_queue pentru review.
|
||
- **Day 10:** CUI matching pentru functii.institution_cui. Authority-side: lookup în `seap.cui_authority`. Private-side: lookup în firms.entities.
|
||
|
||
**Deliverable Day 10:** ~85% din shareholdings au CUI rezolvat → joinable cu seap și firms.
|
||
|
||
### Faza 5 — UI (Days 11–13)
|
||
- **Day 11:** `/achizitii/politician/[slug]` — pagină profil. Cards: declarații (timeline), evoluție avere, top firme deținute, contracte câștigate de firmele lui prin SEAP. Endpoint API la `/api/politician/[slug]`.
|
||
- **Day 12:** Cross-link în pagina existentă `/achizitii/firma/[cui]`: section "Asociat cu politicieni (declarații ANI)" — list de officials cu link la profil.
|
||
- **Day 13:** Recipe page `politician-cu-firma-furnizor-stat`. Top 50 politicieni unde COALESCE(firma.contracte_seap_total) > 0. Plus 2 recipe variants (evoluție avere, primar × furnizor UAT).
|
||
|
||
### Faza 6 — Polish (Days 14–15)
|
||
- **Day 14:** Materialized views pentru perf: `mv_official_seap_exposure` (politician → total contracte SEAP firme proprii), refresh nightly. Indexes finali. Analyze.
|
||
- **Day 15:** Testing. Edge cases: persoane omonime (Popescu Ion × 50), firme cu nume identice cu funcții ("ASOCIAȚIA"), declarații fără PDF rezolvabil, OCR errors (CUI = "S.C. SRL" → garbage). Documentare pentru următoarea sesiune. Disclaimer GDPR în pagina /despre.
|
||
|
||
---
|
||
|
||
## 5. Risk register
|
||
|
||
| Risc | Probabilitate | Impact | Mitigare |
|
||
|---|---|---|---|
|
||
| Anti-scraping (rate limits, IP block) | Medie | Mare | User-Agent identifier ("gov-agreg/1.0 vreaudigital.ro contact:..."), 2 req/s max, retry exponential, fallback la depozitar.integritate.eu. ANI nu are istoric de a bloca scraperi (briatte/integritate a mers fără probleme 2017-2019). |
|
||
| PDF template change mid-corpus | Mare | Medie | Detector explicit per-template (regex pe header text); marker `parse_status='template_unknown'` pentru manual review. Quarterly check. |
|
||
| OCR errors → CUI invalid | Mare | Medie | Validare CUI cu checksum oficial (algoritm pe ultima cifră). Multe vor pica; tier 3 manual queue. |
|
||
| Name disambiguation (omonimii) | Mare | Mare | Default conservativ: NU merge officials cu nume identic dacă funcție/judeţ diferă. UI marker "posibil aceeași persoană" cu disclaimer. |
|
||
| GDPR challenges | Mică | Mare | Tot ce publicăm are basis legal (Legea 176/2010). Disclaimer prominent. NIMIC din CNP/data nașterii nu apare în UI. Privacy policy explicit. Right-to-rectify accesibil prin /contact. |
|
||
| Old portal sunset | Mare (anunțat 2025) | Mare | **Prioritate:** ingestăm rapid old portal înainte de takedown. Cache local PDF-uri ca single source of truth. New portal e SPA fragil → backup. |
|
||
| Volum PDF (400 GB) | Medie | Medie | Storage pe satra: avem ~2 TB free. Compress PDFs (zstd -19) la cold storage după parsing → ~120 GB. |
|
||
| Effort > 15 zile | Mare | Medie | MVP shippable la Day 13 (UI + recipe), zilele 14-15 sunt polish. Faza 4 (entity resolution) e cea mai imprevizibilă; dacă pică, ship cu shareholdings unmatched + UI care arată "candidat firmă declarată: X (nu am putut face matching automat)". |
|
||
|
||
---
|
||
|
||
## 6. Decizii de arhitectură (locked-in)
|
||
|
||
1. **Storage PDFs:** filesystem pe satra, NU în Postgres bytea. Path templated pe sha256. Permite rsync/backup separat.
|
||
2. **Officials sunt dedupliated DUPĂ ce avem PDFs parsed**, nu înainte. ani.declaratii.official_id e nullable înainte de Stage 4.
|
||
3. **CNP nu se stochează în clear**, doar hash dacă e parsed (rar — ANI maschează în majoritatea cazurilor). Folosim doar pentru disambiguation, nu pentru afișare.
|
||
4. **Două scrapers separate** (old + new), nu unul unificat. Mecanicile sunt prea diferite (JSF vs REST). Schema DB unificată via source_portal column.
|
||
5. **Parser e batch**, nu online. Rulează nightly via cron. Nu blocăm scraper-ul de listing.
|
||
6. **Recipe registration:** slot `politician-cu-firma-furnizor-stat` adăugat acum în RECIPES (returnează empty rows până avem date) — keeps URL stabil pentru SEO și menționabil în comunicare publică ("vine în curând").
|
||
|
||
---
|
||
|
||
## 7. Open questions (de rezolvat în sesiune următoare)
|
||
|
||
1. **e-DAI API endpoint exact:** trebuie capturat din DevTools într-o sesiune browser reală (Selenium / Playwright). Bundle-ul SPA îl construiește runtime din config necunoscut. Plan: rulăm un browser headless 1x să capturăm 2-3 cereri și să reverse-engineerăm.
|
||
2. **Old portal CSV export:** există un buton "Exporta resultate" — dacă funcționează prin POST simplu, sărim peste paginare HTML și luăm CSV bulk. Trebuie verificat manual.
|
||
3. **Tesseract pe satra:** confirma că modelul `ron` e instalat. Estimat 5-15s/pagină pe CPU; pentru 200K PDFs OCR-required = 2-3 zile la concurrency 8.
|
||
4. **Slug uniqueness pentru politicieni cu nume identice:** Popescu Ion poate fi 50 de oameni. Strategy: `nume-prenume-judet-functie-prima-aparitie`? Vezi după dedupe.
|
||
|
||
---
|
||
|
||
## 8. API endpoints discovered (live verification 2026-05-09)
|
||
|
||
### Old portal (PRIMARY ingestion target)
|
||
|
||
`https://old-declaratii.integritate.eu/search.html`
|
||
|
||
JSF/IceFaces, POST cu form data:
|
||
```
|
||
form=form
|
||
form:searchKey_input=<query> # nume sau institutie
|
||
form:searchField_input=numePrenume # | "institutia"
|
||
form:submitButtonSS=cauta
|
||
javax.faces.ViewState=<grabbed-from-GET-search.html>
|
||
```
|
||
Response: HTML cu `<table>` rezultate, fiecare rând conține `DownloadServlet?fileName=<X>.pdf&uniqueIdentifier=NTNTARTLNE_<NUM>`.
|
||
|
||
Pattern fileName: `<unique_id>_<persona_id>_<seq><suffix>.pdf` unde suffix `_a` = avere, `_b` = interese (probabil; de validat pe corpus mai mare).
|
||
|
||
Coloane în tabel: Nume Prenume / Institutie / Functie / Localitate / Judet / Data completare / Tip declaratie / Vezi declaratie / Distribuie.
|
||
|
||
Pagination: `form:resultsTable_pageInput`, `form:resultsTable_pageButton` — JSF AJAX. Soluție: date range slicing (lună de lună) ca să nu lovim limita de pagini.
|
||
|
||
**No auth, no captcha, no rate limit explicit.** Confirmed working 2026-05-09.
|
||
|
||
### New portal (e-DAI 2022→) — captcha protected
|
||
|
||
`https://depozitar.integritate.eu/api/formio/grid/documente/submission`
|
||
|
||
JSON REST API. Filtre cunoscute (Form.io syntax):
|
||
- `data.numePrenume__regex=<text>`
|
||
- `data.institutie__regex=<text>`
|
||
- `data.judet__regex=<JUDET-uppercase>`
|
||
- `data.functie__regex=<text>`
|
||
- `data.tipDeclaratie__regex=<text>`
|
||
- `data.dataCompletarii__gte=<ISO>`, `data.dataCompletarii__lte=<ISO>`
|
||
- `data.show__regex=1` (filtru de bază pentru declarații publicate)
|
||
- `sort=-created`, `limit=N`, `skip=N`
|
||
|
||
**Returnează 401 fără token Cloudflare Turnstile** (`x-jwt-token` header). Necesită browser headless (Playwright) sau solver. Punem în Phase 2 zile 8-9 dacă merită.
|
||
|
||
Per-document: `data.bucket` + `data.filename` → API download via separate endpoint (TBD, capturat din browser session).
|
||
|
||
### Depozitar.integritate.eu
|
||
|
||
Mirror al new portal-ului, aceeași API + Turnstile. Folosit ca fallback când portalul principal e down.
|
||
|
||
## 9. Sample PDFs analizate (Task 2)
|
||
|
||
5 PDFs descărcate de pe old-declaratii (stocate în `satra:/tmp/ani_samples/`):
|
||
|
||
| # | Persoană | An | Tip | Producer | Pages | Bytes | OCR? |
|
||
|---|---|---|---|---|---|---|---|
|
||
| 1 | KLAUS WERNER IOHANNIS (Președintele României) | 2024 | avere | iText 5.5.13.2 | ~5 | 60 KB | nu |
|
||
| 2 | KLAUS WERNER IOHANNIS | 2017 | avere | iText (similar) | ~5 | 60 KB | nu |
|
||
| 3 | KLAUS WERNER IOHANNIS | 2014 | avere | (no producer) | scanned | 293 KB | **DA** |
|
||
| 4 | EMIL BOCA (politist penitenciare Gherla — homonim cu Boc) | 2024 | avere | Kodak Capture | scanned | 58 KB | **DA** |
|
||
| 5 | CATALIN PREDOIU (Vice prim-ministru) | 2024 | avere | Alaris Capture | scanned | 112 KB | **DA** |
|
||
|
||
### Observații cheie
|
||
|
||
1. **Native vs scanned NU e funcție de an** — depinde de cum a încărcat funcționarul. Iohannis 2024 e iText nativ (probabil generat din formular electronic intern), Predoiu 2024 e Alaris scanat (a printat → semnat → scanat). În practică: ~30-50% din PDF-uri necesită OCR independent de an.
|
||
|
||
2. **Toate PDF-urile native au structură IDENTICĂ** — același template iText cu secțiunile I-X marcate cu litere romane. Layout tabular cu 6-7 coloane pentru fiecare secțiune. `pdftotext -layout` păstrează structura suficient cât regexes per-section funcționează.
|
||
|
||
3. **CNP e mascat în native PDFs** (`*************`) → nu vom putea extrage CNP-uri pentru disambiguation. Ne bazăm pe `(name + institutie + judet + first_year)`.
|
||
|
||
4. **Localitate / Adresa sunt parțial mascate** (`***********`) pentru proprietăți → confirmă conformitatea ANI cu GDPR (adresă completă nu e public). Avem judeţul. Suficient pentru cross-check.
|
||
|
||
5. **Sample text extras** (Iohannis 2024 secțiune I.2 Clădiri):
|
||
```
|
||
Tara: ROMANIA
|
||
Judet: Sibiu
|
||
Localitate: Sibiu
|
||
Adresa: ***********
|
||
Categorie: Apartament
|
||
Anul dobândirii: 1997
|
||
Suprafata: 84.60 m2
|
||
Cota-parte: 1/1
|
||
Modul de dobândire: Contract de vânzare cumpărare
|
||
Titularul: IOHANNIS CARMEN, IOHANNIS KLAUS
|
||
```
|
||
→ toate câmpurile pentru `ani.bunuri` rezolvabile cu regex per-tabel.
|
||
|
||
6. **Filename suffix decoding (preliminary):**
|
||
- `_a.pdf` la sfârșit → declarație avere (confirmed pe Iohannis 2024 + 2017)
|
||
- `_b.pdf` la sfârșit → declarație interese (de validat)
|
||
- `_NNN.pdf` (3 cifre) la sfârșit → variantă numerotată (rectificative? batch upload?)
|
||
|
||
### Recomandare parser
|
||
|
||
**Strategie pe 3 nivele:**
|
||
|
||
1. **Tier 1: pdftotext -layout + regex per-secțiune** (rapid, ~50 ms/PDF). Se aplică tuturor PDFs.
|
||
- Dacă output > 500 chars vizibili (nu doar headere) → procesăm.
|
||
- Folosim markeri "I. Bunuri imobile", "II. Bunuri mobile", "VIII. ", "IX. " ca anchor pentru extragerea blocurilor de text.
|
||
|
||
2. **Tier 2: detect scanned + OCR** (lent, ~5-15s/PDF). Aplicat când Tier 1 returnează < 500 chars.
|
||
- `tesseract <pdf-img> - -l ron` în container. PDF → imagine via `pdftoppm -r 200` întâi.
|
||
- Output mai zgomotos: regex relaxat, mai mulți falși pozitivi.
|
||
|
||
3. **Tier 3: template_unknown** (ratele PDF-uri parsate nu match niciun template). Coadă manuală review în UI admin.
|
||
|
||
**Tools:**
|
||
- **pdftotext (poppler-utils)** + Node.js — nu Python (`pdfplumber` ar fi mai elegant dar adaugă dependency Python într-un repo TS).
|
||
- **tesseract-ocr-ron** — în container alpine cu `apk add tesseract-ocr tesseract-ocr-data-ron`. Estimat 5-15s/PDF pe satra CPU. 200K PDFs scanate × 8s = 18 zile single-thread → cu concurrency 8 = ~3 zile.
|
||
- **NO Apache Tika** — overkill, mai bine pdftotext direct.
|
||
|
||
**Effort:** parser MVP la 70% acuratețe e ~3 zile (Day 5-7). Restul de 30% (template-uri vechi 2008-2010, edge cases) ajunge la 90% în următoarea iterație.
|
||
|