Files
vreau-digital/services/seap-scraper/ANI-PLAN.md
T
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

433 lines
29 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 (20082022 + 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 │
│ (20082022 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 (20082021): scanned + native mix. │
│ → pdftotext întâi; dacă < 50 caractere "vizibile" → OCR (tesseract │
│ cu lang=ron, ~515s/pagină pe satra). │
│ Template-detection: 3 generații de template-uri (20082010, 20112016, │
│ 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 20082025 |
| declaratii (rows) | ~1.3M | 810 declarații/persoană în medie pe carieră |
| pdf storage | ~400 GB | 300 KB avg × 1.3M |
| bunuri (rows) | ~6M | 45 bunuri/declarație medie |
| shareholdings (rows) | ~800K | doar 3040% au firme declarate |
| functii (rows) | ~3M | 23 funcții/declarație |
| donatii (rows) | ~250K | rare (1020% 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 12)
- **Day 1:** Scraper pentru **old portal** (JSF/IceFaces). Reverse-engineer formul `/search.html` cu pagination prin "Cautare avansata" + date range slicing (lună de lună 20082022 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 34)
- **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 57)
- **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 810)
- **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 1113)
- **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 1415)
- **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.