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)
11 KiB
CNSC — Consiliul Național de Soluționare a Contestațiilor
Status: Stage 1 ingest live. Stage 2 (PDF parse) is the next step.
Sursa: http://portal.cnsc.ro/decizii.html — registru oficial al deciziilor pe contestații depuse împotriva procedurilor SEAP. Bază legală: Legea 101/2016.
1. Ce s-a livrat (Stage 1)
| Artifact | Path |
|---|---|
| Schema migration | services/seap-scraper/sql/033_cnsc.sql |
| Scraper TS | services/seap-scraper/src/scrape-cnsc.ts |
| Cron wrapper | services/seap-scraper/cron/scrape-cnsc.sh |
| Plan / handoff | services/seap-scraper/CNSC-PLAN.md (this file) |
DB obiecte (schema cnsc):
cnsc.decizii— tabel principal, PK natural(decision_no, decision_year)cnsc.scrape_log— istoric run-uri scrapercnsc.mv_per_authority_cui— rollup per autoritate contractantăcnsc.mv_per_contestator_cui— rollup per contestator (firmă)
Smoke test (3 pagini, run 2026-05-10):
- 150 decizii ingerate, 100% cu PDF URL
- 53% au CUI autoritate, 91% au CUI contestator (în listing-ul CNSC)
- Cross-join cu
seap.announcements: 26,046 hits via authority_cui, 6,260 via contestator_cui.
2. Cum funcționează scraping-ul
Portalul CNSC e ASP.NET WebForms cu un quirk: paginarea e stateful pe sesiune. AJAX-ul nu acceptă pagina în body — server-ul citește pagina curentă din state-ul de sesiune, setat de un GET prealabil pe /decizii.html?page=N.
Flow per pagină (sesiune partajată cu ASP.NET_SessionId cookie):
GET /decizii.html?a=search®:registrationDate=-&page=N— setează state-ulPOST /Default.aspx/CallWebMethodcu body{sender, methodName:'get', senderParams, isBuletin:'0'}- Răspunsul e JSON
{"d":"<html><table>...</table></html>"}— 50 rânduri / pagină
Total: ~617 pagini × 50 rânduri ≈ 30,800 decizii, datate 2016 → prezent. Pagina 617 are doar 13 rânduri (rest 2016).
Listing-ul oferă DEJA, fără să descarci PDF-ul:
- numărul deciziei + anul + data înregistrării
- numele și CUI-ul contestatorului (uneori multiplii — asociere)
- numele și CUI-ul autorității contractante
- numărul de înregistrare CNSC
- URL-ul PDF (
sivadoc/download.aspx?docUID=...&filename=...)
Asta e 80% din valoare — joinabil direct cu seap.announcements (CUI ↔ CUI), cu firms.entities, etc.
Idempotență
ON CONFLICT (decision_no, decision_year) DO UPDATE — re-run-uri zilnice sunt fără efecte secundare. Decizii noi: INSERT. Decizii existente: UPDATE doar fetched_at.
Run
# Smoke test (2 pagini ≈ 100 rânduri, ~15s)
sudo MAX_PAGES=2 /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh
# Full crawl (estimat: 7-10 min, ~617 pagini × 250ms politețe + ~7s/pagină)
sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh
# Resume după întrerupere parțială
sudo START_PAGE=400 /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh
Cron sugerat (zilnic, prinde decizii noi):
30 5 * * * /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh
3. Recipe-uri cross-source posibile (LIVE acum, Stage 1)
3.1. Top autorități contestate
Câte contestații a primit fiecare autoritate contractantă, în trecut. Indicator de risc procedural.
SELECT
ac AS authority_cui,
e.name AS authority_name,
COUNT(*) AS contestations_count,
COUNT(DISTINCT cc) AS distinct_challengers,
MIN(d.registration_date) AS first_seen,
MAX(d.registration_date) AS last_seen
FROM cnsc.decizii d,
unnest(d.authority_cuis) ac,
unnest(d.contestator_cuis) cc
LEFT JOIN firms.entities e ON e.cui = ac
GROUP BY ac, e.name
HAVING COUNT(*) >= 5
ORDER BY contestations_count DESC
LIMIT 50;
3.2. Cei mai litigioși ofertanți
Firme care contestă cel mai mult. La ANAF poate fi un semnal de "vexatious bidder" sau, invers, de actor care apără concurența contra abuzurilor.
SELECT
cc AS contestator_cui,
e.name,
COUNT(*) AS contestations_filed,
COUNT(DISTINCT ac) AS distinct_targets
FROM cnsc.decizii d,
unnest(d.contestator_cuis) cc,
unnest(d.authority_cuis) ac
LEFT JOIN firms.entities e ON e.cui = cc
GROUP BY cc, e.name
HAVING COUNT(*) >= 3
ORDER BY contestations_filed DESC
LIMIT 50;
3.3. Contestator vs SEAP-supplier overlap
Câte din contestațiile depuse de o firmă sunt împotriva unei proceduri pe care a câștigat-o ulterior cineva din vecinătate.
SELECT
d.decision_no, d.decision_year, d.registration_date,
d.contestator_name_raw,
d.authority_name,
COUNT(s.id) AS seap_announcements_with_same_supplier,
SUM(s.awarded_value) AS total_won_by_contestator_at_same_authority
FROM cnsc.decizii d,
unnest(d.contestator_cuis) cc,
unnest(d.authority_cuis) ac
JOIN seap.announcements s
ON s.supplier_cui = cc AND s.authority_cui = ac
GROUP BY d.id
ORDER BY total_won_by_contestator_at_same_authority DESC NULLS LAST
LIMIT 25;
4. Killer queries (UNLOCKED de Stage 2 — PDF parse)
Aceste rapoarte cer decision_type (admis/respins) extras din PDF.
4.1. Autoritățile cu cea mai mare RATĂ DE CONTESTAȚII PIERDUTE
Semnal puternic de procedură vicioasă: autoritatea pierde la CNSC mai des decât media → fie scrie caiete de sarcini deficitare, fie evaluează vădit părtinitor.
SELECT
ac AS cui,
e.name,
COUNT(*) FILTER (WHERE decision_type IN ('admis','admis_in_parte')) AS lost,
COUNT(*) FILTER (WHERE decision_type = 'respins') AS won,
COUNT(*) FILTER (WHERE decision_type IS NOT NULL) AS resolved,
ROUND(
100.0 * COUNT(*) FILTER (WHERE decision_type IN ('admis','admis_in_parte'))
/ NULLIF(COUNT(*) FILTER (WHERE decision_type IS NOT NULL), 0)
, 1) AS pct_lost
FROM cnsc.decizii d, unnest(d.authority_cuis) ac
LEFT JOIN firms.entities e ON e.cui = ac
WHERE d.decision_type IS NOT NULL
GROUP BY ac, e.name
HAVING COUNT(*) FILTER (WHERE decision_type IS NOT NULL) >= 5
ORDER BY pct_lost DESC, resolved DESC
LIMIT 50;
4.2. SEAP procedure → CNSC outcome → award
SELECT
s.ref_number, s.title, s.authority_name,
s.awarded_value, s.supplier_name,
d.decision_no, d.decision_type, d.contestator_name_raw
FROM seap.announcements s
JOIN cnsc.decizii d ON d.seap_procedure_ref = s.ref_number
WHERE s.awarded_value > 1000000
AND d.decision_type = 'admis'
ORDER BY s.awarded_value DESC
LIMIT 100;
→ "Tendere mari unde contestația A FOST admisă (procedura era vicioasă) DAR procedura totuși s-a finalizat cu un câștigător." Multe au fost adjudecate exact acelorași firme atacate inițial — pattern de captură.
5. Stage 2 — Estimare PDF parse (15-25h)
Ce trebuie extras din fiecare PDF
seap_procedure_ref— pattern variabil în text liber:- "în cadrul procedurii simplificată...nr. CN1234567"
- "anunț de participare nr. ADV2024XXXXX"
- "concurs de soluții...SCN2023..."
- Uneori e absent (decizii pe contestații de clarificări — ~15-20%)
decision_type— căutat în zonă "DISPUNE / DISPOZITIV / DECIDE":- "admite contestația" →
admis - "admite în parte" →
admis_in_parte - "respinge contestația" →
respins - "redirecționează" →
redirectionat - "arhivează" →
arhivat - "constată inadmisibilitatea" →
respins(subtype)
- "admite contestația" →
decision_date— data deciziei (≠ data înregistrării; e mai târziu)decision_summary— primele 500 chars după "DECIDE"
Parser pseudocode
import { execFile } from 'child_process';
async function pdfText(pdfUrl: string): Promise<string> {
// Fetch PDF, save to temp, run pdftotext -layout, return text
// Cache by sha1 of bytes; idempotent.
}
function parseDecision(text: string) {
const seapRefMatch = text.match(/\b(CN[0-9]{6,}|SCN[0-9]+|ADV[0-9]+|RFQ[0-9]+)\b/i);
// Decision type — search after dispositive heading
const dispoIdx = Math.max(text.indexOf('DISPUNE'), text.indexOf('DISPOZITIV'), text.indexOf('Decide'));
const dispo = dispoIdx > 0 ? text.slice(dispoIdx, dispoIdx + 1500).toLowerCase() : '';
let decisionType: string | null = null;
if (/admite[^a-zăîâșț]+\s*(în parte|in parte)/.test(dispo)) decisionType = 'admis_in_parte';
else if (/admite\b/.test(dispo)) decisionType = 'admis';
else if (/respinge\b/.test(dispo)) decisionType = 'respins';
else if (/redirec[țt]ion/.test(dispo)) decisionType = 'redirectionat';
else if (/arhiv/.test(dispo)) decisionType = 'arhivat';
const dateMatch = text.match(/Data:?\s*(\d{1,2})[./](\d{1,2})[./](\d{4})/);
return { seapRef: seapRefMatch?.[0] ?? null, decisionType, decisionDate: dateMatch ? `${dateMatch[3]}-${dateMatch[2].padStart(2,'0')}-${dateMatch[1].padStart(2,'0')}` : null };
}
Effort breakdown (15-25h)
| Task | h |
|---|---|
Set up pdftotext invocation + tempfile cleanup, retry on transient HTTP errors |
1.5 |
| Download throttling (1 PDF/s polite) + resumable per-doc state | 1 |
| First-pass parser (regex above) on 500-PDF eval set + measure coverage | 3 |
| Iterate on edge cases (admite parțial, multi-procedure decisions, scanned PDFs that need OCR) | 4-6 |
OCR fallback (~5-10% of older PDFs are images) — tesseract -l ron |
3-5 |
| Concurrency runner with rate limit, persistent skip log, MV refresh | 2 |
| Productionize cron + monitoring | 1 |
| Documentation + recipe pages on UI | 1-2 |
Total descărcare: ~30K PDF × ~100 KB = ~3 GB → trivial pe satra.
6. Riscuri și ce să nu facem
- NU îmbunătățim Stage 2 fără să avem un eval set adnotat manual. Pe 30K PDF-uri o regexă poate avea 20% fals-pozitivi pe
decision_type— aproape inutilizabil pentru recipe-ul "rate de contestații pierdute" (semnalul e zgomotos). Investește 2h să adnotezi 200 PDF-uri pe mână, apoi măsoară. - Scrape rate: serverul portal.cnsc.ro pare modest (vechi); 250ms / pagină politețe e setat în scraper, NU coborî sub 100ms.
- Schema cnsc.decizii NU stochează PDF-ul (doar URL + docuid_b64). PDF-urile rămân la sursă; refeed e oricând posibil. Asta evită 3 GB în DB.
- CUI-uri în listing au prefix uneori (RO123), alteori cifre pure. Normalizat la cifre-only în array, raw păstrat în
*_raw. Joinabil cufirms.entities.cui(care e la fel cifre-only). - Listing-ul are inconsistențe:
1378/2025poate apărea pe pagină 2 (între numerele 2026), pentru că numerotarea e per-comisie (Cx), nu strict cronologică. UNIQUE pe(decision_no, decision_year)previne duplicarea.
7. Plan imediat / next steps
- Run full Stage 1 (~10 min):
sudo /opt/vreaudigital/services/seap-scraper/cron/scrape-cnsc.sh→ ~30K rânduri încnsc.decizii. - Adaugă cron daily (5:30 AM) — capturează decizii noi.
- Schiță 2 recipe-uri pe
src/lib/recipes.ts(de către agent UI):cnscTopAutoritatiContestate(3.1)cnscTopContestatori(3.2)
- Stage 2 PDF parse — programează după ce avem o sesiune dedicată ~25h.
- (Opțional) verifică dacă portal.cnsc.ro publică un buletin oficial structurat (am văzut
/buletinoficial.html— 1.1MB de CPV-uri; n-am explorat) care ar putea oferi mai mult metadata per-decizie.