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

29 KiB
Raw Blame History

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.