# SpesaMIA — Flyer Engine v1.0

Motore che controlla i volantini dei supermercati, li scarica **solo quando sono nuovi**, ne estrae le offerte con l'IA e le mette in **coda di revisione**. Un prezzo arriva nel confronto solo dopo approvazione umana, per i soli negozi verificati e con la fonte autorizzata.

Non modifica il motore di ricerca, né i dati dimostrativi, né alcun file esistente: aggiunge file nuovi, tabelle nuove e colonne facoltative (NULL) a `price_staging` e `prices`.

## 1. Come funziona

```
cron (ogni ora) ──► fonti dovute? ──► connettore ──► volantino nuovo? ──► download (limiti) ──► hash
                                                                                │
                                              già visto (stesso hash) ◄─────────┤ no → scarta
                                                                                ▼
                       PDF: blocchi da 4 pagine ──► IA (Vision, uscita strutturata) ──► validazione ──► price_staging (pending)
                                                                                                          │
                                  tu: verify-store · review · approve · authorize ──► publish ──► prices (visibili nell'app)
```

| Stato del volantino | Significato |
|---|---|
| `downloaded` | scaricato e registrato, in attesa di estrazione |
| `extracting` | estrazione in corso o interrotta dai limiti: riprende da sola dal punto giusto |
| `in_review` | offerte in `price_staging`, da rivedere |
| `published` | almeno un'offerta pubblicata in `prices` |
| `failed` / `skipped` | non utilizzabile (PDF cifrato, troppo lungo, nessuna offerta, scaduto prima dell'analisi…): il motivo è in `status_note` |

**Cosa non fa mai:** inventare prezzi, date o negozi; leggere siti che lo vietano; pubblicare senza approvazione; cancellare prezzi o offerte scadute (restano come storico; si liberano solo i file PDF dopo `retention_days`).

## 2. Stato delle 7 fonti (verificato il 14 ottobre 2026)

Le pagine sono state lette con uno strumento di lettura web, **non dal tuo server**: il primo `probe` sul server (§5) è la conferma definitiva.

| Fonte | Cosa ho visto | Modalità |
|---|---|---|
| **Mersì** | Pagina con link diretto a un PDF sullo stesso sito (`…/wp-content/uploads/2026/10/VOLANTINO-al-costo.pdf`) e la scritta «dal 7 al 20 ottobre». PDF di 16 pagine, solo immagini (nessun testo): serve la visione. | `auto` |
| **Boningros** | Pagina HTML con testo (circa 250–300 blocchi di prezzo, 23 sezioni). Validità: «OFFERTE VALIDE DA GIOVEDÌ 08 A MERCOLEDÌ 21 OTTOBRE 2026»; compare anche la data dei buoni (22 ott–4 nov), che **non** viene scambiata per la validità. Prezzi con e senza IVA, quantità disponibili («PEZZI 30.000»): l'abbinamento etichetta–prezzo nel testo estratto non è sempre chiaro → **rivedere con più attenzione**. | `auto` |
| **SuperConveniente** | La pagina del negozio non contiene il PDF. `/volantini/` elenca **7 PDF, uno per zona**; quello con «…BARCELLONA/MILAZZO/**VILLAFRANCA**» è il nostro. Il motore usa la pagina elenco e tiene solo i link che citano `villafranca` (comando `keywords`), così non scarica gli altri 6. PDF multi-negozio. | `auto` + filtro |
| **Despar Messina**, **Standa (Coop)** | Il sito blocca la lettura automatica (robots.txt). | `blocked`: nessuna richiesta di rete |
| **Eurospin** | Visualizzatore JavaScript e `robots.txt` che vieta `/leaflet/`. | `blocked` |
| **ARD** | Il sito non pubblica il volantino: lo manda su WhatsApp/app. | `manual_inbox`: salvi tu il PDF in `inbox/ard/` |

Le fonti `blocked` non si possono abilitare. Si sbloccano solo con un'**autorizzazione scritta** o un feed ufficiale (allora si scrive un connettore dedicato, oppure si usa l'inbox).

## 3. File aggiunti

```
database/migrations/001_flyer_engine.sql   5 tabelle nuove + colonne NULL su price_staging e prices (ripetibile)
database/seeds/flyer_sources.sql           le 7 fonti, tutte SPENTE e NON autorizzate (ripetibile)
src/Flyers/                                registro, connettori, client HTTP sicuro, robots, date, PDF, IA, validazione, staging, pubblicazione
src/Flyers/Connectors/                     Mersi, Boningros, SuperConveniente, Restricted (bloccate), Inbox (consegna manuale)
bin/flyers_cron.php                        esecuzione pianificata (solo CLI)
bin/flyers_admin.php                       amministrazione da terminale (solo CLI)
config/flyers.example.php                  elenco delle impostazioni (da copiare in config.php)
tests/flyers_run.php, tests/Flyers/        163 test senza rete (sito e IA finti)
```

Tabelle nuove: `flyer_sources` (una riga per connettore: prossimo controllo, insuccessi, volantino corrente), `flyers` (URL, hash, formato, validità, stato, prossimo controllo), `flyer_source_stores` (negozi candidati), `flyer_stores` (negozio **verificato** per quel volantino), `flyer_checks` (registro di ogni controllo).

## 4. Installazione su cPanel Namecheap

Sostituisci `UTENTE` con il nome utente cPanel e `app.stelmarket.com` con la cartella dell'app, se diversa.

**0. Copie di sicurezza.** phpMyAdmin → il tuo database → *Esporta* → *Rapido* → *Esegui*. File Manager → seleziona la cartella dell'app → *Comprimi* e scarica lo zip.

**1. Carica i file.** File Manager → cartella del progetto (quella che contiene `src/`, `public/`, `database/`) → *Carica* `spesamia-flyer-engine-v1.0.zip` → clic destro sullo zip → *Estrai*. Lo zip contiene **solo file nuovi**: non sostituisce niente.

**2. PHP 8.3 e moduli.** cPanel → *Select PHP Version* → 8.3 → spunta `curl`, `mbstring`, `pdo_mysql` (a volte `nd_pdo_mysql`), `dom`, `xml`, `gd`, `json`. Salva.

**3. Cartella privata.** File Manager → `/home/UTENTE/` (la cartella sopra `public_html`) → *Nuova cartella* `spesamia_private`. Deve stare **fuori** da `public_html` e dal document root del sito. Qui finiscono PDF scaricati, inbox e log.

**4. Configurazione.** Apri `config/config.php` e aggiungi, accanto alle chiavi che già ci sono:
```php
'flyers' => [
    'storage_dir' => '/home/UTENTE/spesamia_private',
    'contact'     => 'tua-email@dominio.it',
],
```
La chiave Anthropic resta dov'è (`anthropic.api_key`). Permessi del file `config.php`: 600 (File Manager → *Permessi*).

**5. Database.** phpMyAdmin → clicca il database a sinistra → *Importa* → scegli `database/migrations/001_flyer_engine.sql` → *Esegui*. Poi lo stesso con `database/seeds/flyer_sources.sql`. Si possono reimportare senza errori. Controllo: il database deve ora avere **22 tabelle** (17 + 5) e 7 righe in `flyer_sources`.

**6. Verifica dell'ambiente.** Serve un terminale (cPanel → *Terminale*, oppure SSH). Il percorso di PHP 8.3 sul tuo hosting non lo conosco: prova `ls /opt/alt` oppure `ls /opt/cpanel`; di solito è `/opt/alt/php83/usr/bin/php` o `/opt/cpanel/ea-php83/root/usr/bin/php`.
```bash
cd /home/UTENTE/app.stelmarket.com        # la cartella del progetto
/opt/alt/php83/usr/bin/php bin/flyers_admin.php doctor
```
Deve finire con «Tutto a posto» (gli AVVISO non bloccano: es. mancanza di `qpdf`/Ghostscript = modalità ridotta per i PDF lunghi).
*Senza terminale:* crea un cron temporaneo (§7) con comando `… bin/flyers_admin.php doctor > /home/UTENTE/spesamia_private/doctor.txt 2>&1` per tra due minuti, leggi il file con File Manager e poi elimina il cron.

**7. Cron.** cPanel → *Cron Jobs* → riga personalizzata, **una volta all'ora**:
```
7 * * * * /opt/alt/php83/usr/bin/php -q /home/UTENTE/app.stelmarket.com/bin/flyers_cron.php >/dev/null 2>&1
```
Non fa nulla se non c'è niente di dovuto. Ogni esecuzione ha un blocco anti-sovrapposizione e limiti di richieste, chiamate IA e durata. I file `bin/*.php` si rifiutano di partire da browser (rispondono 404).

**8. Negozi.** I negozi reali vanno creati una volta (servono indirizzo e coordinate; non li invento io):
```bash
php bin/flyers_admin.php store-add --name="Mersì Villafranca" --address="…" --lat=… --lng=… --chain="Mersì" --source=mersi
php bin/flyers_admin.php store-add --name="SuperConveniente Villafranca" --address="Via Antonello da Messina 15/A" --lat=… --lng=… --chain="SuperConveniente" --source=superconveniente
```
(`--source` propone il negozio per ogni nuovo volantino di quella fonte; resta **non verificato** finché non lo confermi tu.)

**9. Sicurezza — controllo finale.** Il document root del sito deve essere `…/public`; `spesamia_private` fuori da `public_html`; `config.php` a 600; nessuna chiave in file pubblici. `doctor` controlla il percorso della cartella privata rispetto a `public/`.

## 5. Primo test su Mersì e Boningros

Cosa è già stato provato **qui** (senza rete): pipeline completa sul vero PDF di Mersì (16 pagine, 19,8 MB) servito con la struttura osservata sul sito: trovato, date 7–20 ottobre, prossimo controllo **21 ottobre ore 06:00**, divisione in 4 richieste da ~6,5 MB. Cosa **non** ho potuto provare: la connessione ai siti dal tuo server e una chiamata reale ad Anthropic (dal mio ambiente non sono raggiungibili/non ho la tua chiave). Per questo la prima prova sul server è `probe`, che non scrive nulla nel database e non usa l'IA.

```bash
# 1. Che cosa pubblica la fonte? (nessuna scrittura, nessuna IA)
php bin/flyers_admin.php probe mersi --download
php bin/flyers_admin.php probe boningros
```
Esito atteso per **Mersì**: `outcome: found`, un candidato `…VOLANTINO-al-costo.pdf`, `valid_from 2026-10-07`, `valid_to 2026-10-20`, e in `download`: `valid: true`, `pages: 16`, ~19 MB. Per **Boningros**: `outcome: found`, `format: html`, `valid_to: 2026-10-21` e un `text_sample` in cui nome prodotto e prezzo stanno sulla stessa riga. Se `outcome` è `blocked`/`not_found`/`error`, il `message` dice perché (es. host non consentito, robots.txt, pagina cambiata): **non forzare**, scrivimelo.

```bash
# 2. Se i risultati sono quelli attesi: abilita, controlla, analizza
php bin/flyers_admin.php enable mersi
php bin/flyers_admin.php enable boningros
php bin/flyers_admin.php check mersi          # scarica e registra (nessuna IA)
php bin/flyers_admin.php run                  # come il cron: estrae le offerte (usa la chiave Anthropic)
php bin/flyers_admin.php status
php bin/flyers_admin.php flyers
```
Prima di `run` controlla la chiave con `php bin/check_anthropic.php`.

**Controllo a campione (obbligatorio la prima volta).** `php bin/flyers_admin.php review <id> --limit=15`, poi apri il PDF/la pagina e confronta 10 righe (nome, prezzo, base €/kg…). Con 2 o più errori su 10 **non approvare in blocco**: correggi con `edit`, rifiuta con `reject`, oppure scrivimi quali errori vedi per affinare il prompt.

## 6. Flusso settimanale

```bash
php bin/flyers_admin.php status                                     # c'è un volantino in revisione?
php bin/flyers_admin.php show 12                                    # nota di copertura, negozi candidati
php bin/flyers_admin.php verify-store 12 <store_id> "il PDF elenca Villafranca (pag. 1)"   # OBBLIGATORIO
php bin/flyers_admin.php review 12 --max-confidence=0.8             # cosa guardare con più cura
php bin/flyers_admin.php approve 12 --create-products               # approva ≥ 0,80 e crea i prodotti nuovi
php bin/flyers_admin.php authorize mersi --note="permesso di … del …"   # una volta per fonte
php bin/flyers_admin.php publish 12
```
- `edit <id> --price=… --name=…` corregge una riga; `set-product <id> <product_id>` forza l'abbinamento; `reject 12 --below=0.5` scarta le dubbie.
- Senza `--create-products` le offerte senza un prodotto **identico** già in archivio restano in attesa (nessun abbinamento «a somiglianza»).
- `withdraw 12` ritira i prezzi dal confronto (restano nello storico). `revoke <slug>` toglie il permesso a una fonte.
- Offerte **«3 per 5 €»** (multi-acquisto) e formati incompatibili col prodotto **non vengono pubblicate** (il motivo è in `review_note`): meglio nessun prezzo che un prezzo unitario sbagliato.
- Prezzo **all'etto** → convertito in €/kg senza perdita (il testo originale resta in `conditions`). **Prezzo con carta fedeltà** → etichettato «Prezzo con carta fedeltà». Se il volantino stampa due prezzi, vale quello per tutti e quello con carta va in `conditions.loyalty_price`.
- Il prezzo mostrato nell'app è quello **con IVA** (consumatore).

## 7. Pianificazione dei controlli

| Situazione | Prossimo controllo |
|---|---|
| Volantino che scade il **20 ottobre** | **21 ottobre ore 06:00** (il giorno dopo la scadenza, non prima) |
| Il nuovo volantino non c'è ancora (stesso PDF, pagina senza PDF, errore) | dopo 3 h → 6 h → 12 h → poi **ogni 24 h** |
| Nuovo volantino trovato | giorno dopo la **sua** scadenza, ore 06:00; insuccessi azzerati |
| Volantino senza data | domani alle 06:00; imposta le date con `set-dates` (ricalcola il prossimo controllo) |
| Fonte `blocked` | nessun controllo (nessuna richiesta di rete) |
| Fonte `blocked` da robots.txt/403 dal sito vivo | ritenta tra 7 giorni (solo `robots.txt`) |
| Inbox (ARD) | ogni mattina alle 06:00 |
| Limite di richieste/IA raggiunto | tra 1 ora, senza contare insuccessi |

Non si presume mai che il volantino nuovo sia già disponibile: se il file è lo stesso (stesso hash o HTTP 304) o ha una scadenza già passata, è un insuccesso e scatta la scala dei tentativi. `status` segnala «da controllare a mano» dopo 14 insuccessi consecutivi.

## 8. Sicurezza e limiti — requisiti e dove sono rispettati

| Requisito | Come |
|---|---|
| Nessuna chiave API nei file pubblici | solo `config/config.php` (600) o `ANTHROPIC_API_KEY`; mai nel frontend né nei log (oscurata anche negli errori); `bin/` e `src/` fuori dal document root |
| Nessun download di URL arbitrari dagli utenti | gli utenti non possono indicare URL: le fonti sono righe di `flyer_sources` modificabili solo da CLI; `SafeHttp` accetta solo https:443, solo host in `allowed_hosts` (anche dopo ogni redirect), solo IP pubblici con DNS fissato (anti-SSRF) |
| Limiti di dimensione, durata, richieste | PDF 40 MB, pagina 2 MB, 25/90 s, 30 richieste, 40 chiamate IA e 540 s per esecuzione, 3 redirect, 80 pagine per volantino, 2500 offerte; pausa di 1,5 s tra richieste allo stesso sito |
| Log privati | `<storage>/logs/flyers-AAAA-MM.log` (0640, fuori dal web), ogni richiesta e chiamata IA con token usati |
| Cron protetto, solo CLI | `PHP_SAPI !== 'cli'` → 404; blocco `flock` contro sovrapposizioni |
| Nessuna modifica ai dati dimostrativi | le tabelle nuove sono separate; le colonne aggiunte sono NULL; i test verificano che prezzi, prodotti, negozi e catene demo siano identici prima e dopo; il motore di ricerca produce lo stesso risultato prima e dopo la migrazione |
| Solo metodi consentiti dalle fonti | robots.txt rispettato (404/410 = libero; 401/403/5xx/irraggiungibile = **non si accede**); fonti che vietano → `blocked`, nessuna richiesta; ARD solo da inbox |
| Mai inventare | l'IA riporta solo ciò che è stampato; il numero del prezzo deve comparire nel testo del prezzo stampato (altrimenti confidenza ≤ 0,3); date lette dal testo e marcate «dedotte» se anno o nome-file; contenuto del volantino trattato come dati (anti prompt-injection) |
| Niente duplicati / rianalisi | hash del file, `If-None-Match`/304, `dedupe_hash` unico sulle offerte, `UNIQUE(staging_id, store_id)` sui prezzi, similarità del testo per le pagine HTML (contatori di visite ecc.) |
| Storico delle offerte scadute | nessuna cancellazione di `prices`, `price_staging`, `flyers`; il motore di ricerca nasconde già i prezzi oltre `valid_to` |

## 9. Costi e limiti noti

- **Costo IA:** non l'ho potuto misurare. Ogni chiamata registra `token_in`/`token_out` nel log: dopo il primo volantino hai il costo reale per volantino. Tetti per esecuzione: `max_ai_calls_per_run`, `max_pages_per_flyer`.
- **PDF lunghi:** con `qpdf` o Ghostscript il PDF viene tagliato in blocchi da 4 pagine. Senza, il PDF intero viene riletto per intervalli di pagine (max 100 pagine e 24 MB): meno affidabile e più costoso. `doctor` dice quale modalità hai.
- **Boningros** (testo con prezzi con/senza IVA e quantità) ha il rischio più alto di abbinamenti sbagliati: il prompt scarta le offerte con due prezzi di cui non è chiaro quale sia con IVA, ma la revisione a campione resta indispensabile.
- **Prodotti:** nessun abbinamento approssimato. Lo stesso prodotto scritto in modo diverso in due volantini può diventare due prodotti (la tabella `product_aliases` è già pronta per unirli in un secondo momento).
- **Negozio:** la verifica è manuale per ogni volantino (nessuna fiducia automatica): è ciò che garantisce «prezzo collegato al negozio giusto».
- **Pannello admin web:** non c'è, la gestione è da terminale. Il motore di ricerca usa la data del database (`CURDATE()`): un volantino scade a mezzanotte secondo il fuso del server MySQL.
- **Test:** `php tests/flyers_run.php` (senza database) oppure con `SPESAMIA_TEST_DSN` su un database **di prova**. Mai su quello di produzione.
