# DOCUMENTO TECNICO — Gestionale Aste Giudiziarie (aste.regla.it / pvp-scraper)

> Stato verificato sul server **al 20/08/2026**. Tutti i numeri, i percorsi, i cron e gli
> schemi in questo documento sono stati letti dal server di produzione, non stimati.
> Destinatario: uno sviluppatore che non conosce il sistema. Leggilo dall'alto: la
> PANORAMICA e la MAPPA DEI MOTORI danno il quadro, poi ogni sezione scende nel dettaglio.

---

## 1. PANORAMICA

Il sistema raccoglie **tutte le aste immobiliari giudiziarie italiane** dal Portale delle
Vendite Pubbliche (PVP del Ministero della Giustizia), le arricchisce con dati di mercato,
recupera le **perizie** (relazioni di stima CTU) dai 12 portali privati che le pubblicano,
ne **estrae con AI** i dati strutturati (superfici commerciali, stato, abusi, stima) e calcola
**metriche di mercato** (OMI, comparabili, isolamento, prossimità hotel/annunci). Il risultato
è consultabile su **https://aste.regla.it** (mappa + tabella + scheda per lotto).

### Cosa fa, in una riga per fase

| Fase | In parole |
|---|---|
| **Scrape PVP** | Scarica lista + dettaglio di ogni asta dall'API della Giustizia |
| **Raggruppa** | Riconosce che più pubblicazioni = stesso immobile ribassato nel tempo |
| **Localizza** | Assegna lat/lon al lotto con una cascata (asta → bene → catasto → geocode) |
| **Popola** | Attacca al lotto immobiliare.it, OMI, demografia, e i "raggi" di prossimità |
| **Perizie** | 12 portali + resolver agganciano l'asta PVP e scaricano il PDF di perizia |
| **AI ingest** | Un worker legge il PDF e produce dati strutturati (superfici, abusi, stima) |
| **Metriche** | Comparabili €/mq, zona favorevole, isolamento, hotel/annunci vicini |
| **Archivio** | Le aste concluse passate vanno su S3 e il DB caldo si alleggerisce |

### Architettura a 2 server

```
 ┌──────────────────────────────────────────┐        ┌──────────────────────────────────┐
 │  HETZNER PRODUZIONE  167.233.25.108       │        │  UBUNTU "uff" — Bari  10.10.0.2  │
 │  (ubuntu-4gb-fsn1)                         │        │  (dietro WireGuard wg0)          │
 │                                            │        │                                  │
 │  - pvp.db (cuore) + tutti i DB derivati    │◄──wg0──┤  - idealista.db (annunci)        │
 │  - scraper PVP, 12 scraper portali         │  sync  │  - immobiliare.db (prezzi/poly)  │
 │  - ingest AI worker (Fleet API)            │  notte │  - booking.db (hotel)            │
 │  - localizza / popola / metriche           │        │  - omi.db (fonte AdE)            │
 │  - frontend Express :3210 (aste.regla.it)  │        │  - 2 chiavette LTE (mobileproxy) │
 │  - S3 Hetzner Object Storage (pvp-storage) │        │    dongle1=TIM, dongle2=WindTre  │
 └──────────────────────────────────────────┘        │    proxy 8080-8085, API rot :8000│
                                                       └──────────────────────────────────┘
```

- **Hetzner** (questo è il server di produzione, quello che tocchi via SSH) fa tutto il lavoro
  pesante: scraping PVP, i 12 portali, l'AI, l'arricchimento, il frontend. IP di uscita unico
  e **non ruotabile** `167.233.25.108`.
- **Ubuntu "uff" a Bari** raggiungibile solo via tunnel **WireGuard wg0** a `10.10.0.2`. Ospita
  gli scraper "lenti/rischiosi" (idealista, immobiliare, booking) e le **2 chiavette LTE**
  esposte da `mobileproxy` (proxy per-dongle sulle porte 8080-8085, Control API di rotazione
  sulla `:8000`). Le sue basi dati vengono **sincronizzate di notte** verso Hetzner.
- **S3 Hetzner Object Storage**, bucket `pvp-storage` (endpoint fsn1, SigV4 path-style): perizie
  PDF, foto lotti, backup notturni dei DB, archivio delle aste storiche. Credenziali in
  `/root/.s3_pvp.env` (mai in git). Prefissi `perizie_portali/` e `foto/` pubblici, resto privato.

### Numeri chiave (al 20/08/2026)

| Metrica | Valore |
|---|---|
| Lotti totali in pvp.db | **293.505** |
| Beni totali | 353.275 |
| Aste ATTIVE (attiva=1) | 187.082 |
| Aste future attive (data_vendita futura) | 15.501 |
| Aste storiche archiviate su S3 (archiviata_s3=1) | 277.780 |
| Lotti con coordinate | 256.323 |
| Perizie scaricate (perizia_dati) | **15.523** |
| Perizie AI estratte (perizia_estratta, identificato=1) | 4.414 |
| Future attive con perizia agganciata | 15.038 su 15.501 (**~97%**) |
| Copertura immobiliare.it (attive) | 15.453 |
| Copertura OMI (attive) | 13.276 |
| Copertura comparabili (attive) | 7.956 |

---

## 2. MAPPA DEI MOTORI

Legenda architettura: **HTTP** = requests/BeautifulSoup; **BROWSER** = playwright/patchright
headless; **API** = endpoint JSON; **CALC** = solo calcolo su DB locali.

| Motore | Server | Arch. | Trigger | Input | Output |
|---|---|---|---|---|---|
| **scrape PVP** (`scraper.run`+`enrich`+`group`) | Hetzner | HTTP/API | pm2 `pvp-scheduler` (04:30, `settings.json`) + `scrape.sh` | API Giustizia (lista+dettaglio) | `lotti`, `beni`, `categorie`, `scrape_runs` |
| **collega_tentativi** | Hetzner | CALC | dentro `scrape.sh`/incrementale | `lotti`, catasto | `lotti.gruppo_esteso` |
| **localizza** (cascata L0→geocode) | Hetzner | CALC+geocode | cron `17 * * * *` (budget 60/h) + hook post-scrape/post-AI | `lotti`,`beni`,`perizia_lotto2`,`catasto.db`,Nominatim | `lotti.lat/lon/has_coordinate/coord_fonte`, `geocode_stato` |
| **popola** (immo/zona/omi + raggi) | Hetzner | CALC | cron `47 3,9,15,21` + hook post-scrape/post-AI | `lotti` localizzati, DB esterni | `lotti.immo_*/zona_*/omi_*` + raggi |
| **12 portali** (scraper+resolver+sweep) | Hetzner | HTTP/API | `portali_scheduler` (finestra 21-08) + chain 6:15 | aste future senza perizia | `perizia_dati`, `allegati_portale`, `asta_foto`, `tentativi_storici`, `portale_scan_stato` |
| **ingest AI** (feeder→worker→persist→bridge) | Hetzner | API (Fleet) | pm2 `ingest-worker` (finestra 22-07) | `ai_ingest_queue`, PDF perizia | `perizia_lotto2/bene2`, `perizia_estratta` |
| **idealista HTTP** (primario) | Ubuntu | API mobile | timer gg **1 e 15** 04:00 | API mobile idealista | `esterni/idealista.db` |
| **idealista browser** (fallback) | Ubuntu | BROWSER | manuale (dashboard/one-shot) | idealista.it | `esterni/idealista.db` |
| **immobiliare** | Ubuntu | BROWSER (patchright) | timer gg **8 e 22** 04:00 | immobiliare.it | `esterni/immobiliare.db` (prezzi/poligoni) |
| **booking** | Ubuntu | BROWSER | timer giornaliero 04:30 (nuova sessione se >30gg) | booking.com | `esterni/booking.db` |
| **OMI** | Ubuntu→Hetzner | scrape AdE | timer `omi-aggiorna` (mensile) + weekly su sync domenicale | Agenzia Entrate quotazioni | `omi.db` |
| **sync_esterni + aggancio_chain** | Hetzner | CALC | cron `10 2 * * *` (weekly domenica) | DB esterni Ubuntu via wg0 | snapshot locali + raggi ricalcolati |
| **comparabili** | Hetzner | CALC | build da idealista | idealista annunci geolocalizzati | `comparabili.db` (archivio €/mq) |
| **zona_favorevole** | Hetzner | CALC | cron `37 4,16` | `lotti`, comparabili | `lotti.comp_*` |
| **isolamento** | Hetzner | CALC | dentro popola (path --ids) + dashboard | `lotti`, altre aste | `lotti.isolamento_*` |
| **hotel_vicini** | Hetzner | CALC | popola --ids + dashboard | `booking.db` | `lotti.hotel_vicini_*` |
| **idealista_vicini** | Hetzner | CALC | popola --ids + sync chain | `idealista_geo.db` | `lotti.idealista_vicini_*` |
| **immobiliare_vicini** | Hetzner | CALC | popola --ids + sync chain | `immobiliare.db` | `lotti.immobiliare_vicini_*` |
| **archiviazione storiche** | Hetzner | CALC+S3 | cron `0 5 2 * *` (mensile) | `lotti` conclusi passati | S3 `storico/` + snellimento locale |
| **watchdog_pipeline** | Hetzner | CALC | cron `*/15` + `@reboot` | tutto lo stato | ripara + `pipeline_salute` |
| **healthcheck_ubuntu** | Hetzner | CALC | cron `5,20,35,50 * * * *` | Ubuntu Bari via wg0 | `pipeline_salute(ubuntu_salute)` |
| **backup S3** | Hetzner | CALC+S3 | cron `20 3 * * *` | 10 DB caldi | S3 `backups/` (+monthly il g.1) |

---

## 3. PERCORSO DELLA SINGOLA ASTA

Un'asta segue **due canali** che scrivono sulle stesse righe di `lotti`: un canale
**deterministico immediato** (parte appena l'asta viene scoperta dallo scrape) e un canale
**a finestre notturne** (portali + AI, che di giorno tacciono per non farsi bannare).

```
                          PVP (API Giustizia)
                                 │
                    scraper.run  │  lista incrementale (stop al watermark pubblicazione)
                                 ▼
                    ┌───────────────────────────┐
                    │  UPSERT lotti + beni       │   colonne: id, prezzo_base, data_vendita,
                    │  data_acquisizione=oggi    │   citta, provincia, num_beni, siti(JSON)...
                    └───────────────────────────┘
                                 │
                    scraper.enrich (dettaglio)  →  lotti: rg_numero/anno, tribunale, url_inserzione,
                                 │                  prezzo_base, offerta_minima, contatti; beni ricreati;
                                 │                  L0 = coord a livello asta (coord_fonte='asta')
                                 ▼
                    scraper.group  →  attiva/conclusa/gruppo/tentativo/n_tentativi/ribasso_pct
                                 │      (riconosce le ripubblicazioni dello stesso immobile)
                                 ▼
      ┌──────────────────────── CANALE DETERMINISTICO (subito, in giornata) ───────────────┐
      │                                                                                     │
      │   Per gli ID NUOVI (diff pre/post con `comm`):                                      │
      │     localizza.py --no-geocode --ids <NEW>        (cascata offline, no Nominatim)     │
      │     popola.py --ids <NEW> --force                (immo, omi, + i 5 raggi)            │
      │       └─ colonne: lat/lon/coord_fonte, immo_*, omi_*, comp_*, hotel_*, idealista_*,  │
      │          immobiliare_*, isolamento_*                                                 │
      │   Il cron orario localizza (17') e popola (47' 4x/dì) recuperano ciò che il lock     │
      │   ha saltato e riprovano le orfane.                                                  │
      └─────────────────────────────────────────────────────────────────────────────────────┘
                                 │
      ┌──────────────────────── CANALE NOTTURNO (finestre 21:00–08:00) ─────────────────────┐
      │                                                                                     │
      │   portali_scheduler / chain 6:15:                                                   │
      │     12 portali cercano l'asta (resolver: id PVP / RG / prezzo±% / catasto)           │
      │       → perizia_dati (PDF), allegati_portale, asta_foto, tentativi_storici           │
      │                                                                                     │
      │   feeder → ai_ingest_queue → ingest_worker (30 thread, Fleet API sonnet):           │
      │       → persist_v2 (perizia_lotto2/bene2, coeff commerciali)                         │
      │       → bridge_gestionale (perizia_estratta, ciò che legge il frontend)             │
      │       → localizza+popola per QUELL'id (post-perizia ri-localizza col catasto AI)     │
      │                                                                                     │
      │   ── RAMO "DOCUMENTO SBAGLIATO" (blacklist + rientro in ricerca) ─────────────────   │
      │   Se l'AI dice che il PDF non è la perizia dell'asta (non è una perizia, oppure è    │
      │   di un ALTRO immobile/lotto), l'ingest_worker:                                      │
      │       1) mette l'url in blacklist (perizia_url_scartate) + l'url_orig del portale    │
      │       2) rimuove il pointer perizia_dati e pulisce perizia_lotto2/bene2/estratta     │
      │       3) rimuove la riga da ai_ingest_queue                                          │
      │   → l'asta torna "senza perizia" e RIENTRA da sola nel ciclo di ricerca (sweep/      │
      │     catena/retry-5-giorni), che al giro dopo promuove un documento DIVERSO (i motori │
      │     escludono gli url in blacklist). NON transitorio: rate-limit/rete NON scartano.  │
      └─────────────────────────────────────────────────────────────────────────────────────┘

  ── ANTICIPO PERIZIA per le ORFANE ──────────────────────────────────────────────────────
  Un'asta futura senza coordinate (has_coordinate 0/NULL) ma con perizia_dati.url disponibile
  viene messa in coda AI a PRIORITÀ MASSIMA (priorita=1e9, fonte_priorita='anticipo_coordinate').
  Motivo: la perizia contiene le triplette catastali (foglio/particella/comune) → dopo
  l'estrazione, localizza L4 (catasto da perizia AI) può darle le coordinate che nessuna
  fonte deterministica aveva. È il modo di localizzare le aste "cieche".
```

### Cascata di localizzazione (primo che risolve vince, offline prima del geocoding)

```
 L0  coord a livello ASTA        lotti.coord_fonte='asta' dal dettaglio PVP     fonte 'asta'   forza 95
 L1  coord del BENE              beni.latitudine/longitudine (bbox Italia)      fonte 'bene'   forza 95
 L2  CATASTO da asta             beni.foglio/particella → catasto.db            'catasto_asta' forza 100
 L3  CATASTO da perizia AI       perizia_lotto2.beni_formali (triplette) → cat  'catasto_perizia' forza 100
 L4  GEOCODE indirizzo           via/civico/cap → Nominatim (budget 60/h)       'indirizzo'    forza 40
                                                                                'geocodifica'  forza 30
```

Regola d'oro: una fonte **non degrada mai** una fonte più forte già presente (confronto `FORZA`).
Bbox Italia lat 35–48 / lon 6–19. Catasto: match particella esatta (prec='particella') o
centroide del foglio (prec='foglio'); gestisce la sezione censuaria ripiegata nel numero foglio
(`foglio % 100 == fg`, es. 107 vale come 7). Geocode Nominatim a 1 req/s (`sleep 1.05`).

**Distribuzione coord_fonte (attive, 20/08):** vuoto (coord dirette PVP) 151.057; pvp 7.046;
catasto 1.878; catasto_asta 1.754; geocodifica 1.017; catasto_perizia 831; bene 770; indirizzo 41.

---

## 4. DATABASE

Tutti SQLite in `/var/www/pvp-scraper` (WAL, `synchronous=NORMAL`, `busy_timeout=30000`).
Leggili **sempre in sola lettura**: `sqlite3 "file:pvp.db?mode=ro" "..."`.

### pvp.db — 1.1 GB — cuore del gestionale

| Tabella | Righe (20/08) | Ruolo / colonne chiave |
|---|---|---|
| **lotti** | 293.505 | PK `id` (id PVP stabile). Anagrafica asta + arricchimento (immo_*, omi_*, zona_*, comp_*, hotel_*, idealista_*, immobiliare_*, isolamento_*). Flag `attiva`, `conclusa`, `archiviata_s3`, `gruppo`, `gruppo_esteso`, `coord_fonte`, `ribasso_pct` |
| **beni** | 353.275 | FK `lotto_id`→lotti.id. Catasto `foglio/particella/subalterno`, superficie, `latitudine/longitudine`, `elevazione`, `dist_mare_km`, `dist_lago_km` |
| **perizia_dati** | 15.523 | PK `id_vendita`. Perizia scaricata: `fonte`, `url`, `testo`, `valore_stima`, classe verificata. **`lotto_id` spesso NULL (2.567 su 15.523)** |
| **perizia_estratta** | 4.616 | PK `id_vendita`. Output AI che il frontend legge: `comm_totale_mq`, `sup_comm_pesata`, `comm_perito`, `stato_voto/label`, `abuso_edilizio`, `stima_eur`, kp_* |
| **perizia_lotto2** | 2.309 | PK `id_vendita`. Estrazione AI grezza a livello lotto: `comm_mq`, `perito_mq`, `beni_formali` (JSON catasto), tipologia, diritto |
| **perizia_bene2** | 8.884 | PK `(id_vendita, idx)`. Un bene per riga: mq lorda/netta/commerciale, coeff, stato edile, abusi, altezza |
| **perizia_url_scartate** | (nuova) | PK `(id_vendita, url)`. BLACKLIST degli url di perizia SCARTATI perché DOCUMENTO SBAGLIATO (non-perizia / lotto assente / non pertinente). Colonne: `motivo`, `scartata_at`. Scritta dall'ingest_worker; letta da `perizia_select.scegli_perizia`, `perizie_pipeline.find_perizia`, `caccia_perizie_ai.insert_perizia_dati` per NON riproporre lo stesso documento. Modulo unico: `perizia_blacklist.py` |
| **tentativi_storici** | 5.870 | PK `(id_vendita, data_vendita)`. Storico ribassi da portali: prezzo/offerta per tentativo |
| **allegati_portale** | — | PK `(id_vendita, url_orig)`. Ogni allegato trovato: `tipo`, `local_path`, `scaricato` (0/1) |
| **asta_foto** | — | foto scaricate dai portali (poi su S3) |
| **portale_scan_stato** | 4.735 | PK `(id_vendita, portale)`. Stato scan per portale: pending/running/err/done |
| **resolver_stato** | 0 | (schema presente, di fatto vuoto: il flusso usa portale_scan_stato) |
| **ai_ingest_queue** | 3.153 | PK `id_vendita`. Coda AI: `stato` (pending 1.023 / done 1.999 / done_fail 127), `priorita`, `fonte_priorita`, `claim_tag` |
| **ai_ingest_control** | 4 righe | chiave/valore: **`paused`** (sovrano), `cooldown_until`, `cooldown_level`, `engine`=sonnet |
| **pipeline_salute** | 1.124 | health di ogni componente (watchdog, healthcheck, sync). retention ~200/componente |
| **pool_cooldown** | 1 | PK `pool`. Cooldown persistente per-portale: `cooldown_until`, `motivo`, `last_probe_code` |
| **scheduler_stato** | — | stato per-asta dello scheduler portali (done_perizia/done_fail/pending) |
| **geocode_stato** | — | esito geocoding per id_vendita |
| **scrape_runs** | — | ogni giro di scrape (fase lista/dettaglio, watermark, heartbeat) |
| **categorie** | — | conteggi categorie per (code, livello) |

**⚠ CHIAVE DI JOIN corretta perizia ↔ lotto:**

```sql
-- GIUSTO (id_vendita == id PVP del lotto):
SELECT l.*, pd.url, pe.comm_totale_mq
FROM lotti l
JOIN perizia_dati pd     ON pd.id_vendita = l.id
LEFT JOIN perizia_estratta pe ON pe.id_vendita = l.id;

-- SBAGLIATO: NON usare perizia_dati.lotto_id → è NULL in 2.567 righe su 15.523.
```

La chiave universale in tutte le tabelle perizia è **`id_vendita`** e vale **`= lotti.id`**.

### Altri database

| DB | Dim | Tabelle principali (righe) | Ruolo |
|---|---|---|---|
| **catasto.db** | 5.1 GB | `particelle` (86.753.058), `comuni` (7.904), `comuni_catasto` (7.592) | Catasto nazionale foglio/particella → coordinate. Fonte di L2/L3 |
| **omi.db** | 716 MB | `quotazione` (1.500.673), `zona` (269.584), `ntn` (128.520), viste `v_*_corrente` | Quotazioni OMI Agenzia Entrate + NTN (transato). Alimenta omi_* |
| **comparabili.db** | 227 MB | `comparabili` (1.392.576) | Annunci €/mq geolocalizzati, archivio interrogato on-the-fly da `/api/comparabili/:id` |
| **demografia.db** | 25 MB | `zone_demografia` (63.152), `demo_done` (7.894) | Reddito/popolazione/densità per zona → zona_* |
| **pvp_immobiliare.db** | 487 MB | `price_history` (2.325.156), `polygons` (12.275), `immo_stats` (24.292) | Prezzi e poligoni immobiliare.it usati da immo_match |
| **zone_history.db** | 122 MB | serie storiche zone | trend zona 1/3/5y |
| **caccia_perizie.db** | 4.1 MB | `doc_scan` (8.243) | classificazione documenti perizia (perizia vs avviso/ordinanza) |
| **geo_cache.db** | 76 KB | cache geocoding | cache Nominatim root |
| **esterni/idealista.db** | 485 MB | `annunci` (749.623), `runs` (18.110) | Annunci idealista (sync da Bari) |
| **esterni/idealista_geo.db** | 41 MB | `annunci_geo` (731.867), `geo_cache` (16.870) | idealista geolocalizzato → idealista_vicini |
| **esterni/idealista_geo_mobile.db** | 39 MB | `annunci_geo` (731.867) | variante da API mobile |
| **esterni/booking.db** | 782 MB | `properties` (347.978), `price_samples` (2.561.015), `review_snapshots` (254.729) | Hotel booking.com → hotel_vicini |
| **esterni/immobiliare.db** | 290 MB | `price_history` (1.578.112), `polygons` (12.275), `crawl_state` (14.665) | Prezzi immobiliare.it → immobiliare_vicini |

---

## 5. I 12 PORTALI

Base `/var/www/pvp-scraper/scraper_portali/`. IP di uscita unico Hetzner (non ruotabile).
Ogni portale ha uno **scraper** (scarica la perizia dato il deeplink) e nella maggior parte dei
casi un **resolver** (aggancia l'asta PVP al deeplink del portale). Strategie di aggancio in
ordine di forza: **id PVP** (match diretto) > **RG** (numero/anno procedura) > **catasto**
(foglio/particella) > **prezzo ±%**; il tribunale è conferma.

| Portale | Tecnologia | Endpoint di ricerca | Rate-limit / delay | Cosa dà | Strategia resolver | Note |
|---|---|---|---|---|---|---|
| **astegiudiziarie.it** | HTTP+BS4 (HTML SSR) | `POST webapi.astegiudiziarie.it/api/search/map` + `/Data` | Scraper 1.5s; **resolver ≥9.0s/req sequenziale**; scheduler conc=1 | perizia PDF, foto (max 8), storico ribassi | RG server-side + comune (tol 0.05); banda prezzo 0.02→0.05; catasto disambigua; tribunale | **Ban da volume**: rate-limiter ASP.NET per-IP → 302 su `/error/error429`. Il più severo. **Delay 9s hardcoded** |
| **astetelematiche.it** | **API JSON** (SPA) | `api.astetelematiche.it/.../getdata` + `/GetAllegati` | Scraper 1.5s; resolver 0.6s; scheduler conc=8, pausa=0 | perizia PDF, foto, storico tentativi | RG dal campo `Ruolo`; banda 0.02/0.05; match esatto 0.5% se RG non parsabile; catasto | Top produttore, API, nessun 429 |
| **spazioaste.it** | HTTP+SSR Nuxt3 | `POST api.spazioaste.it/search` | 4.0s tra aste, 0.8s download; scheduler conc=5 | perizia PDF (→S3), foto, storico | slug comune / free-text; RG `proceduraNumeroAnno`; tol 0.02/0.05; **no catasto** | CDN condiviso `documents.astalegale.net` con astalegale |
| **astalegale.net** | HTTP+SSR Nuxt3 (gemello) | `POST api.astalegale.net/search` | come spazioaste; scheduler conc=5 | perizia PDF (→S3), foto, storico | come spazioaste | **Stesso CDN** di spazioaste (cap aggregato CDN_CAP=4) |
| **fallcoaste.it** | HTTP+HTML SSR (Fallco) | `GET /ricerca.html?filter=...` | 2.0s tra aste; resolver 0.8s ricerca | perizia PDF (→S3), foto, storico, **estrae foglio/particella** | RG num/anno; geo+banda; prefiltro ±15% poi conferma ±1% + doppia àncora; catasto | CDN CloudFront proprio, nessun 429 |
| **asteannunci.it** | HTTP+BS4+JSON-LD (**Edicom**) | probe `/aste/pvp/{id}` + `?keyword=` | Edicom 2.0-2.2s; scheduler EDICOM conc=1 | perizia PDF, foto, storico | resolver Edicom (vedi sotto) | Host PDF di **tutta la famiglia Edicom**, dietro Cloudflare |
| **asteavvisi.it** | HTTP (riusa **edicom_common**) | come asteannunci; PDF schema `/public/` | 2.0s (Edicom) | perizia PDF (schema nuovo), foto | fallback integrativo dopo asteannunci | Aggiunge lo schema PDF `/public/` |
| **canaleaste.it** | stub → **edicom_common** | `/aste/pvp/{id}` (PDF sul gemello asteannunci) | 2.0s (Edicom) | perizia/foto/storico | edicom_resolver | File stub |
| **rivistaastegiudiziarie.it** | stub → **edicom_common** | `/aste/pvp/{id}` | 2.0s (Edicom) | perizia/foto/storico | edicom_resolver | Stub. Nonostante il nome è stack Edicom |
| **garavirtuale.it** | HTTP+blob Nuxt SSR | `GET api/ricerca/immobili` (JSON Laravel) | 0.6s; resolver conc≤2; scheduler conc=4, perizia=False | perizia PDF (se classificata), foto | facet regione→provincia→comune; RG `procedura`; tol 0.02/0.05; closest-price se unico; tribunale; **no catasto** | Escluso dal goal-perizie (resta nel giro resolver) |
| **venditegiudiziarieitalia.it** | HTTP + API **Typesense** | Typesense `collections/vgi_prod_inserzioni/.../search` (key read-only pubblica) | 1.2s; resolver 1.0s; scheduler conc=3, perizia=False | storico tentativi, **triplette catastali**. Perizia/foto **non scaricabili** (REST auth-gated 401) | **match diretto `idInserzioneEspVendita == lotti.id`** (id PVP); fallback comune+anno+RG+prezzo | "Portale povero": buono per storico/catasto, non per perizia |
| **gobidreal.it** | HTTP (JA3/Cloudflare-aware) | resolver via sitemap XML (`lib/site-2-map.xml`, cache 6h) | conc=2, 1.5s; scheduler conc=0, perizia=False | dati PVP da `data-pvp`, foto CloudFront. **Perizia dietro LOGIN** | `id_pvp==id_vendita`; RG num/anno (unico→accetta); prezzo 0.02/0.05; catasto; tribunale | **LOGIN `.env`**: `/root/.gobidreal.env` (`GOBID_USER`/`GOBID_PASS`, chmod 600); senza, i PDF gated restano `scaricato=0`. Cloudflare su fingerprint TLS (JA3) |

**Famiglia Edicom** (`edicom_common.py` + `edicom_resolver.py`): asteannunci.it (host PDF),
canaleaste.it e rivistaastegiudiziarie.it (stub), asteavvisi.it (schema `/public/` extra). Nello
scheduler = **un solo pool logico "EDICOM" conc=1** sull'host condiviso (Cloudflare).

**Helper:** `catasto_disambigua.py` (vince il candidato solo se esattamente 1 combacia su
foglio+particella+sub); `perizia_select.py` (accetta perizia/relazione/CTU/integrativa, scarta
avviso/ordinanza/planimetria; l'integrativa è riserva di fallback). `perizia_select.scegli_perizia`
accetta `id_vendita` e **esclude i candidati il cui url è in `perizia_url_scartate`** (blacklist
documento-sbagliato): al giro dopo lo scarto promuove un documento DIVERSO. Modulo blacklist unico:
`perizia_blacklist.py` (schema, `classifica_esito`, `urls_scartati`, `scarta_url`).

---

## 6. RESILIENZA

Il sistema è progettato per **non farsi bannare l'unico IP** e per riprendersi da solo dopo
reboot, lock, disco pieno, linea giù. Nessun componente di resilienza tocca pm2/systemctl/reboot.

### watchdog_pipeline.py (cron `*/15` + `@reboot`, flock `/tmp/pvp_watchdog.lock`)

Controlla e ripara, ogni step isolato in try/except:

- **Disco** (prima di tutto, isteresi): `<1.2GB` → STOP_PORTALI + **pausa ingest**; `<2.0GB` →
  STOP_PORTALI; `>=2.6GB` e marker presente → rimuove i freni (ma **non** toglie la pausa
  ingest: la riprende un umano). `journalctl --vacuum-size=200M` settimanale.
- **Scheduler portali**: due gate prima di agire (backoff se **linea giù** <10min; **finestra
  notturna**). Vivo con stallo >30min → graceful stop + retry. Morto con lavoro pending → rilancio
  (mai se DISK_MARKER presente).
- **Running orfani**: `portale_scan_stato` in `running` da >1h → `err` (ritentabili).
- **WAL**: se `pvp.db-wal > 200MB` → `PRAGMA wal_checkpoint(TRUNCATE)`.
- **Enrichment stantio**: processi popola/zone/immo con etime >2h → SIGTERM (batch idempotenti).
- **Bucket recovery**: probe GET sul bucket S3; se torna 200 e ci sono aste bloccate da 503
  garavirtuale → un giro una-tantum.
- **Recovery @reboot**: dopo 60s dal boot lancia il watchdog (stesso flock) → rimuove sentinelle
  stantie, resetta i running orfani, rilancia lo scheduler se ci sono aste pending.

### probe_linea (dentro portali_scheduler)

Su errore di rete il worker chiama `probe_linea()`: 2 endpoint neutri (`google.com/generate_204`,
`1.1.1.1`, timeout 5s). Se **entrambi** falliscono → **LINEA GIÙ**: attesa globale senza bruciare
tentativi, riprende da sola. Se linea OK ma portale no → è un blocco del pool.

### Circuit-breaker per-pool con escalation + cooldown persistente

Dopo N blocchi consecutivi (429/challenge) il pool va in **cooldown lungo** invece dei generici
600s. Soglie (blocchi / cooldown): astegiudiziarie **2 / 2700s** (il più severo), astetelematiche
4 / 1200s, spazioaste/astalegale/fallcoaste 3 / 1500s, EDICOM 3 / 1800s. Il cooldown è
**persistito** in `pool_cooldown` (pvp.db) e riletto all'avvio → dopo un restart il pool non
riparte a martellare l'IP appena bannato. Durante il cooldown, **1 probe** ogni ~15-20min verso
una pagina vera: 200 → riapre il pool. Le aste del pool bloccato cascano sugli altri portali.

### ban_globale

`_check_ban_globale()` conta i pool in cooldown: **≥3 pool insieme** → sospetto ban dell'IP di
uscita → evento `ban_globale` in `pipeline_salute`. Ban attivo se età <60min.

### Rotazione IP mobileproxy — QUANDO SÌ / QUANDO NO

Le 2 chiavette LTE a Bari (dongle1=TIM, dongle2=WindTre) servono **solo in ban-recovery**, mai
preventivamente. Ruota SOLO se:

- **(A)** un probe di riapertura torna ancora 429/302 → ruota quel dongle e instrada quel pool via proxy;
- **(B)** ban_globale (≥3 pool) → `rotate-all` e instrada i pool colpiti.

Dongle primario `MP_DONGLE_PRIMARIO=2` (WindTre), fallback `1` (TIM). Vincolo `MIN_ROTATE_SEC=10`.
Quando il pool torna sano (probe 200) rientra sull'IP diretto Hetzner. Se mobileproxy è
irraggiungibile tutto degrada a None senza errori. **Nota importante**: contro DataDome/Cloudflare
il ban è spesso su **fingerprint TLS (JA3), non sull'IP** → cambiare IP non aiuta.

### Finestre notturne (finestre.json — edit a caldo, ora = UTC)

```json
{
  "_default":          {"inizio": "22:00", "fine": "07:00", "attivo": true},
  "ingest_ai":         {"inizio": "22:00", "fine": "07:00", "attivo": true},
  "portali_scheduler": {"inizio": "21:00", "fine": "08:00", "attivo": true},
  "resolver_chain":    {"inizio": "21:00", "fine": "08:00", "attivo": true}
}
```

`in_finestra(componente)` letto a ogni chiamata. In dubbio (config rotta) → CHIUSO (conservativo).
La **pausa utente dell'ingest** (`ai_ingest_control.paused`) resta sovrana e indipendente dalla finestra.

### Altre guardie

- **Lock (flock)**: ogni cron pesante ha il suo `/tmp/pvp_*.lock -n` (non si sovrappone a sé stesso).
- **Recovery running orfani**: worker AI rimette i `running` >30min a `pending` (zombie recovery ogni 300s).
- **WAL / busy_timeout**: `journal_mode=WAL`, `busy_timeout=30000`, retry Python sul lock in ogni scrittura.
- **Kill-switch**: `/tmp/STOP_PORTALI` (globale) e `/tmp/STOP_<pool>` (per pool).

---

## 7. OPERATIVITÀ

### Frontend e dashboard

- **Frontend mappa**: Express `frontend/server.js`, pm2 **pvp-frontend**, porta **3210**,
  nginx `aste.regla.it` → `proxy_pass http://127.0.0.1:3210` (443 SSL Certbot + 80). API principali:
  `/api/lotti`, `/api/tabella`, `/api/vendita/:id`, `/api/perizia/:id/pdf`, `/api/comparabili/:id`,
  `/api/omi/voldist`, `/api/foto/:id`, `/api/settings`, `/api/ingest/stato|pausa`, `/api/engine`.
- **Dashboard pipeline**: `frontend/public/pipeline.html` → API `/api/pipeline/stato` (una card per
  motore), `/api/pipeline/portali` (sotto-dashboard 12 portali + copertura + cooldown),
  `/api/pipeline/deterministici` (scrape/group/localizza/sync/archivio), `/api/salute/ubuntu`.
- **Azioni** (`POST /api/pipeline/azione`): **allowlist rigida** protetta dal token in
  `/root/.dashboard_token` (chmod 600). Ogni azione = comando FISSO con `flock -n` (se già in corso
  risponde "gia_in_corso"). Azioni: sync esterni, catena perizie, sweep copertura, aggancio+raggi,
  **pausa/riprendi ingest AI**, ruota IP dongle 1/2, geocode boost, e i comandi che avviano gli
  scraper su Ubuntu Bari (idealista-http, idealista browser, immobiliare, booking) via SSH.

### Tabella oraria giornaliera (tutti gli orari sono UTC = ora server)

| Ora | Server | Cosa gira |
|---|---|---|
| ogni 15 min | Hetzner | `watchdog_pipeline.py` (ripara + health) |
| 5,20,35,50 * | Hetzner | `healthcheck_ubuntu.py` (sorveglia Bari) |
| `17 * * * *` | Hetzner | `localizza.py --geocode-budget 60` (cascata + geocode orarie) |
| `47 3,9,15,21` | Hetzner | `popola.py` (immo/zona/omi) |
| `37 4,16` | Hetzner | `zona_favorevole_calc.py` (comp_*) |
| `10 2 * * *` | Hetzner | `sync_e_aggancio.sh` (daily; **weekly domenica** aggiunge OMI) |
| **04:30** | Hetzner | pm2 pvp-scheduler → giro scrape PVP (da `settings.json`) |
| **04:30** | Ubuntu | `booking-prezzi` (nuova sessione se ultima >30gg) |
| `15 6 * * *` | Hetzner | `perizie_fallback_chain.sh` (portali+resolver+sweep+feeder) |
| `20 3 * * *` | Hetzner | `pvp_backup_s3.py` (10 DB caldi → S3) |
| `23 */6` | Ubuntu | refresh idealista_geo |
| `04:00` gg 1,15 | Ubuntu | `idealista-http` (API mobile, primario) |
| `04:00` gg 8,22 | Ubuntu | `immobiliare-scraper` (browser patchright) |
| mensile | Ubuntu | `omi-aggiorna` (quotazioni AdE) |
| `0 5 2 * *` | Hetzner | `archivia_storiche.py` (storiche → S3, snellimento locale) |
| notte 21:00-08:00 | Hetzner | portali_scheduler + resolver_chain (finestra) |
| notte 22:00-07:00 | Hetzner | ingest_worker AI (finestra) |

### Deploy sicuro (regole non negoziabili — vedi CLAUDE.md del server)

- **Un solo PM2**, home `/root/.pm2`, `pm2-root.service`. Prima di ogni comando pm2:
  `echo "$PM2_HOME"` (deve essere vuoto), `pgrep -af "PM2.*God"` (UNA sola riga).
- Dopo modifiche al codice di un'app: `node -c file.js` (se JS) → `pm2 reload <nome>` → **`pm2 save`**
  (senza, al reboot torna lo stato vecchio).
- nginx: `nginx -t && systemctl reload nginx` (mai restart se basta reload).
- **Mai** modificare un file direttamente sul server senza riportarlo nello script/repo: il
  prossimo deploy lo cancella. Deploy solo con conferma esplicita dell'utente.
- **Mai** cancellare/sovrascrivere dati di produzione senza backup verificato. DB e upload vivono
  **sul server**, non in locale.

### File di configurazione (dove stanno i segreti — mai riportati qui)

| File | Contenuto |
|---|---|
| `settings.json` | scrapeHour 4:30, enabled, raggi 500m (isolamento/hotel/idealista/immobiliare), periziaEuroMqMin 1500 |
| `finestre.json` | finestre notturne (vedi §6) |
| `tuning.json` | ultimo auto-tuning workers (8 workers, throughput 96) |
| `perizie_engine.txt` | motore AI corrente (`sonnet`) |
| `/root/.s3_pvp.env` | credenziali S3 (chmod 600) |
| `/root/.mobileproxy.env` | token Control API mobileproxy |
| `/root/.gobidreal.env` | login gobidreal (GOBID_USER/PASS) |
| `/root/.dashboard_token` | token azioni dashboard |
| `/root/.uff_pw` | password SSH verso Ubuntu Bari |
| `/root/perizie_apikey_v2.txt` | token Fleet API (worker AI) |

### Backup e restore (da S3)

- **Backup notturno** (`pvp_backup_s3.py`, 03:20): per ogni DB in DB_LIST fa snapshot consistente
  (`VACUUM INTO`, non copia il file live in WAL) → compressione zstd → upload
  `backups/<nome>_<YYYYMMDD>.db.zst` (lifecycle 30gg). Il **giorno 1 del mese** anche in
  `backups/monthly/<nome>_<YYYYMM>.db.zst` (retention 12 mesi). DB coperti: pvp, omi, comparabili,
  pvp_immobiliare, demografia, idealista_geo, idealista_geo_mobile, zone_history, caccia_perizie,
  geo_cache. **Esclusi di proposito**: catasto.db (dataset separato), booking.db e idealista.db
  (snapshot lato Ubuntu). Se un DB fallisce (lock/spazio) logga e prosegue; serve ~2x la dimensione
  del DB come spazio scratch.
- **Restore**: `s3_pvp.download_file("backups/<nome>_<data>.db.zst", locale)` → `zstd -d` →
  sostituire il DB (a scheduler/frontend fermi). Helper condiviso `s3_pvp.py`.

### Archivio storiche (cosa resta locale e perché)

L'archiviazione mensile (`0 5 2 * *`) sposta le aste **concluse con data_vendita passata**
(`archiviata_s3=0`) in DB di archivio `pvp_storico_<YYYYMM>.db` → S3 `storico/`, poi **snellisce
il locale** mettendo a NULL le colonne pesanti. **Cosa RESTA in locale e perché**: `group.py` e
`collega_tentativi.py` girano ogni notte anche sulle storiche e hanno bisogno delle **chiavi di
raggruppamento** (id, id_procedura, procedura_key, numero_lotto, gruppo, gruppo_esteso, prezzi,
date, esito) e del **catasto in beni** (foglio/particella/subalterno) — quello NON si tocca mai.
Si tolgono invece (dopo verifica del re-download): descrizione, siti, contatti (~215MB di testo),
tutto l'arricchimento (immo_*, omi_*, zona_*, comp_*, ...), e le righe storiche di
bene_consistenza. Flusso deterministico e idempotente: export → verifica su S3 → snellimento a
batch da 20k id → `archiviata_s3=1`. Guardia disco: <3GB liberi → esce.

---

## 8. TRAPPOLE NOTE

1. **Join perizia**: usa `perizia_dati.id_vendita = lotti.id`. **`lotto_id` è NULL** in 2.567 righe
   su 15.523 → un join su `lotto_id` perde una perizia su sei. Vale per tutte le tabelle perizia
   (`id_vendita` è la chiave universale).
2. **Un solo daemon PM2** (`/root/.pm2`). Se `pgrep -af "PM2.*God"` mostra due righe c'è un daemon
   fantasma: gestisce processi che `pm2 list` non mostra e ti fa credere l'app ferma mentre gira
   col codice vecchio. `echo "$PM2_HOME"` deve essere vuoto.
3. **`pm2 save`** dopo ogni modifica strutturale: senza, al reboot torna lo stato precedente.
4. **Mai modifiche a mano sul server** senza riportarle nello script/repo: il prossimo deploy le
   cancella e non esistono in git.
5. **Pausa ingest utente sovrana**: `ai_ingest_control.paused=1` ferma il worker AI e **non viene
   tolta in automatico** da nessun componente (nemmeno quando il disco si libera). La riprende un
   umano (dashboard "Riprendi ingest AI"). Al 20/08 è **paused=1**.
6. **/tmp è tmpfs su Ubuntu Bari**: gli snapshot/scratch pesanti NON vanno in /tmp (RAM) ma su disco
   vero (il sync usa `archivio_scratch`/scratch dedicato).
7. **VACUUM richiede spazio**: backup e archivio fanno `VACUUM INTO`/snapshot, serve ~2x la
   dimensione del DB come scratch libero. Il disco è all'86% (62G/75G) → guardie disco a 3GB/2GB/1.2GB.
8. **DataDome/Cloudflare = fingerprint TLS (JA3), non IP**: cambiare IP mobileproxy non sblocca un
   ban basato su fingerprint TLS. La rotazione IP aiuta solo sui rate-limiter per-IP (es.
   astegiudiziarie ASP.NET).
9. **`attiva=0` e catasto spazzatura** (fix in `group.py`, 20/08): foglio/particella vuoti o
   `"/"`/`"-"` facevano collassare lotti DISTINTI della stessa procedura in un unico "immobile",
   disattivandone tutti tranne uno (aste future sparite dal gestionale). `group.py` ora scarta il
   catasto spazzatura e ha una **guardia anti-merge** che riattiva la pubblicazione più recente per
   ogni `(id_procedura, numero_lotto)`.
10. **Storiche snellite (colonne NULL)**: su un'asta con `archiviata_s3=1` le colonne pesanti
    (descrizione, immo_*, omi_*, ...) sono NULL in locale — non è un bug, il dettaglio pieno è su
    S3 `storico/`. I consumatori (group, frontend storico prezzi) leggono solo le colonne che restano.
11. **Documento sbagliato ≠ errore transitorio (blacklist perizia)**: l'ingest_worker scarta un
    documento (blacklist url + rimozione pointer perizia_dati + rientro in ricerca) SOLO quando l'AI
    ha emesso la segnalazione `documento_non_pertinente` con esito in `{fail_non_perizia,
    fail_lotto_assente, insufficiente}`. **NON** si scarta su `ok`, su `insufficiente` SENZA quella
    segnalazione (è la perizia giusta ma povera/rettifica), su `fail_non_leggibile` (illeggibilità,
    non pertinenza), né su rate-limit/rete/parse-error (transitori). La decisione vive in
    `perizia_blacklist.classifica_esito` (unico punto). Si blacklistano SIA l'url pubblicato
    (`perizia_dati.url`) SIA l'url_orig del portale (da `allegati_portale`, match sul basename),
    perché gli scraper vedono l'url_orig e i motori PVP l'url pubblicato: chiavi diverse per lo
    stesso documento. Chi aggiunge un NUOVO motore che scrive `perizia_dati` deve chiamare
    `perizia_blacklist.is_scartato(id, url)` prima di scrivere, altrimenti riproporrebbe lo scarto.

---

*Fine documento. Verificato su 167.233.25.108 / /var/www/pvp-scraper al 20/08/2026.*
