parseClassifica() legge project-sheets.php?project_id=848 con lo stesso parsing del girone di campionato (stessa struttura di tabella), quindi nessun parser dedicato. La Coppa resta limitata alla fase a gironi: la finale secca vive in un project_id figlio non tracciato dal codice. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
13 KiB
Modulo — Collegamento CSI
Stato: implementato (stagione 2025/26)
Route interessata: /classifica
Obiettivo
Mostrare nell'app la classifica e i risultati ufficiali del campionato CSI, al posto
dei dati dimostrativi hardcoded in crapp-data.ts. Nessun inserimento manuale da parte
degli amministratori: è esattamente il tipo di lavoro amministrativo che CrAPP deve togliere.
Sorgente dati
Portale Livescore CSI Bologna (https://livescore.csibologna.it).
Il portale non espone un'API pubblica documentata. Vengono usati gli stessi endpoint che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza API key, ma non offrono alcuna garanzia di stabilità.
Le pagine "umane" (league_details.php, team_details.php) sono gusci lato server: non
contengono dati, li caricano dopo via JS dagli stessi endpoint components/*.php —
verificato leggendo assets/js/project.js e assets/js/team.js, referenziati in fondo
alle due pagine. Servono solo per la consultazione manuale nel browser (es. per ritrovare
un project_id), nessun codice le chiama direttamente.
Identificativi (stagione 2025/26)
| Cosa | Valore |
|---|---|
| Campionato | PVM - Campionato Open Misto Eccellenza |
project_id (girone) |
767 |
| Coppa | PVM Coppa CSI Misto Silver |
project_id (coppa) |
848 |
| Squadra sul portale | C.R.A.P. Volley (con i punti) |
team_id |
3359 |
| Girone | B |
project_id (767), CSI_COPPA_PROJECT_ID (848) e team_id (3359) sono costanti in
src/lib/csi-core.ts.
Per ritrovare questi id a ogni cambio stagione: components/team-main.php?team_id=3359
(dietro team_details.php) contiene una sezione "Campionati" con un link
league_details.php?project_id=… per ogni competizione a cui la squadra è iscritta —
verificato chiamando l'endpoint direttamente, che oggi restituisce sia
project_id=848 (Coppa) sia project_id=767 (Campionato). Non serve aprire
team_details.php nel browser, questo componente basta.
Endpoint usati dall'app
| Endpoint | Formato | Uso | Pagina "umana" corrispondente |
|---|---|---|---|
components/project-sheets.php?project_id=767 |
HTML | Classifica completa dei due gironi di campionato | league_details.php?project_id=767 (tab "Classifica") |
components/project-sheets.php?project_id=848 |
HTML | Classifica del girone di Coppa (solo fase a gironi, vedi limite 4) | league_details.php?project_id=848 (tab "Classifica") |
assets/json/getEventsByTeamId.php?team_id=3359 |
JSON | Tutte le gare della squadra | team_details.php?team_id=3359 (tab "Calendario", team-calendar.php) |
Formato di project-sheets.php — tabella HTML per girone (una per <table>,
parseClassifica() in csi-core.ts prende quella che contiene il nome della squadra).
Colonne per <td> (0-indicizzate): 0 Pos · 1 Squadra (nome + logo + link a
team_details.php?team_id=…) · 2 Punti · 3 Partite giocate · 4 Vinte · 5 Perse ·
6-7 Tie-break vinti/persi (non lette) · 8 Set fatti · 9 Set subiti · poi punti
fatti/subiti, quoziente, ultime cinque (non lette). Stessa struttura per project_id=767
(campionato) e project_id=848 (fase a gironi della Coppa): parseClassifica() è
condivisa, nessun parser dedicato per la Coppa.
Formato di getEventsByTeamId.php — array JSON, un oggetto per gara (girone e
Coppa insieme, vedi limite 4), con: id, start ("2025-11-12T22:00:00", data+ora
locale), team1/team2 (nomi squadre), result ("3 - 1", stringa libera), partials
("25 - 23</br>23 - 25</br>...", HTML nei separatori), field (impianto), project
(nome campionato, es. "PVM - Coppa CSI Misto Silver" — usato per distinguere le
competizioni, vedi limite 4), league, group (es. "Girone B"), match_number. Letto
da partiteDaEventi() in csi-core.ts, che estrae punteggio/parziali con le regex
punteggio()/parziali() — vedi limite 5 sui rischi di questo parsing.
Altri endpoint disponibili ma non usati: getEventsByProjectIdHierarchical.php (tutte le
gare del campionato), project-chart-rankings.php (solo punti), project-next_matches.php,
project-last_results.php, project-sheets-scorers.php/-results.php/-measures.php
(sotto-tab di project-sheets: marcatori, risultati per giornata, provvedimenti
disciplinari), team-roster.php (rosa), team-staff.php, team-results.php,
team-scorers.php.
Implementazione
CSI (portale)
↓ fetch server-side, cache 6 ore
/api/public/csi → src/routes/api/public/csi.ts
↓ JSON { classifica, classificaCoppa, partite, girone, aggiornato }
useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
↓
/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone sotto)
src/lib/csi-core.ts— costanti, tipi e funzioni pure:parseClassifica()(HTML → righe, usata sia perclassificasia perclassificaCoppa),partiteDaEventi()(JSON → partite),isNostraSquadra(),partiteGiocate().src/routes/api/public/csi.ts— unica route che contatta il CSI: tre fetch in parallelo (classifica girone, classifica Coppa, partite). Cache in memoria di 6 ore; in caso di errore restituisce l'ultimo dato buono (503solo se non ne esiste uno). Se solo la Coppa fallisce (scarica(...).catch(() => "")) la risposta resta comunque200conclassificaCoppa: []: è un dato supplementare, non blocca la classifica del girone.src/lib/csi.ts— hook client, una lettura per sessione.test/unit/csi-core.test.ts— check del parsing:bun test/unit/csi-core.test.ts. ConCSI_LIVE=1verifica anche gli endpoint reali.
Regole rispettate
- Nessuna chiamata dal browser: il portale viene contattato solo lato server, al massimo 4 volte al giorno, indipendentemente da quanti giocatori aprono l'app (regola anti-consumo).
- Nessuna dipendenza nuova: parsing con espressioni regolari sulla struttura della tabella.
- Fallback: se il CSI non risponde, l'endpoint
/api/public/csirestituisce l'ultimo dato buono in cache; se non ne ha ancora uno, la classifica resta vuota e i risultati ricadono sulle partite dello Scout Live locale (useScoutMatches()). - Portabilità (DD-013): endpoint HTTP standard, nessun servizio esclusivo.
Limiti noti
- La classifica si legge da HTML. Se il portale cambia la struttura della tabella il
parsing restituisce un array vuoto:
/classificanon si rompe, ma mostra "Classifica non ancora disponibile" (o l'ultimo dato buono in cache, se ce n'è uno) e i risultati ricadono sulle partite dello Scout Live locale, non su dati demo — non esistono più incrapp-data.ts. Il check conCSI_LIVE=1serve a scoprire il problema di parsing. project_idè legato alla stagione. Per il 2026/27 servirà un nuovo id (vedi "Sorgente dati" sopra per come ritrovarlo). Oggi va aggiornato a mano incsi-core.ts.- La cache vive nel processo del server. Si perde a ogni cold start e non è condivisa tra
istanze. Sufficiente per una squadra; se serve di più, spostare i dati in una tabella
Supabase riempita da un job cron (stesso pattern di
promemoria-palloni). - Le partite includono sia il girone di campionato sia la Coppa, mescolate.
getEventsByTeamId.php?team_id=3359è per squadra, non per competizione (vedi tabella endpoint sopra): risponde con tutte le gare diC.R.A.P. Volley. Il campoprojectdistingue le due nel JSON grezzo, mapartiteDaEventi()(csi-core.ts) oggi non lo usa per filtrare: tutte le gare finiscono inDatiCsi.partitesenza distinzione (storico partitein/classificale mostra tutte insieme). Se in futuro servisse separarle, il filtro va aggiunto suevento.projectinpartiteDaEventi(). La classifica della Coppa, invece, è mostrata (sopra quella del girone in/classifica):project-sheets.php?project_id=848(CSI_COPPA_PROJECT_ID) ha la stessa struttura a tabella-per-girone diproject_id=767, quindiparseClassifica()funziona invariata — nessun parser dedicato. Resta un limite: quella pagina copre solo la fase a gironi. La Coppa (PVM Coppa CSI Misto Silver) prevede due gironi da 4 squadre sola andata seguiti da una finale secca tra le due vincenti, disputata in unproject_idfiglio separato generato a fine fase a gironi (verificato concurldiretto:project-main.php?project_id=848descrive il regolamento — "due gironi sola andata, le due vincenti in finale, gare 3 set su 5" — eproject-sheets.php?project_id=848mostra sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il tabellone a eliminazione con quelproject_idfiglio). Se la squadra arrivasse in finale,/classificacontinuerebbe a mostrare la classifica (ormai chiusa) del proprio girone di Coppa, non l'esito della finale: non c'è codice che segua quelproject_idfiglio, che oltretutto cambia a ogni edizione della Coppa e non è noto in anticipo. - Le partite si leggono da JSON, con parsing fragile su campi testuali.
resultepartialsingetEventsByTeamId.phpsono stringhe libere tipo"3-1", lette con un'espressione regolare (punteggio()/parziali()incsi-core.ts). Se il portale CSI cambiasse formato (es."3:1", o un punteggio come oggetto invece che stringa), la regex non troverebbe corrispondenza e la partita risulterebbe "non ancora giocata" (setNostri/setLoroanull) — silenziosamente, senza errori. Se invece la risposta cambiasse forma radicalmente (non più un array),partiteDaEventi()torna[]. Conseguenza sugli obiettivi di squadra: le "vittorie in campionato" (obiettivi.ts, obiettivi o3/o4/o5) dipendono dapartiteGiocate(csi.partite)— se il parsing delle partite si rompe così, questi tre obiettivi restano bloccati a 0% anche a fronte di vittorie reali. Il fallback della route non se ne accorgerebbe da solo:/api/public/csilancia un errore solo se sia la classifica sia le partite sono vuote insieme (classifica.length === 0 && partite.length === 0); se si rompe solo il parsing delle partite mentre la classifica HTML continua a funzionare, la route risponde comunque200conpartite: []. Per questoleggiCsi()confronta il JSON grezzo con il risultato dipartiteDaEventi()tramitepartiteFormatoSospetto()(csi-core.ts): se ci sono eventi grezzi ma nessuno è stato riconosciuto come nostra partita, logga unconsole.error— distingue così un vero "formato cambiato" da un legittimo "nessuna gara ancora in programma" (dove gli eventi grezzi stessi sono vuoti). Il flagformatoSospettoviaggia anche nella risposta JSON (DatiCsi.formatoSospetto) fino a/classifica(src/routes/classifica.tsx), dove mostra un badge discreto ("Il portale CSI potrebbe aver cambiato formato: dati da verificare.") al posto della normale riga "Dati CSI aggiornati alle...": un log server passa inosservato per settimane, un badge visibile a chi apre la pagina campionato molto meno. Il fix, quando succede, è isolato apartiteDaEventi()/punteggio()/parziali()incsi-core.ts(gli endpoint stessi cambiano solo se cambia il dominio o serve autenticazione, nel qual caso va toccata anchesrc/routes/api/public/csi.ts); va poi aggiornato anchetest/unit/csi-core.test.tscon fixture nel nuovo formato. - Il portale può essere del tutto irraggiungibile, non solo cambiare formato. Scenario
diverso dal punto 5 (lì il JSON è valido ma non riconosciuto, qui la risposta non è
nemmeno JSON): l'8 settembre 2026
getEventsByTeamId.phpha risposto con200ma un errore SQL del loro backend in chiaro al posto del JSON (Query non valida (getProjectTeams): Table 'uqc2os2x_livescore.seasons' doesn't exist, verificato concurldiretto sul loro dominio).leggiCsi()(src/routes/api/public/ csi.ts) intercetta l'eccezione diJSON.parseneltry/catchdella route e risponde503 "CSI non raggiungibile"(o serve la cache se ce n'è una) — nessun crash, ma nessun dato nuovo finché il portale non torna. Effetto sulla suite test: i test ditest/integration/api.test.tsche leggono il CSI reale sondano/api/public/csiuna volta prima di partire; se risponde con errore li salta (salta(), nonprova()) invece di farli fallire, loggando il motivo — la suite resta verde durante un'indisponibilità temporanea del portale, senza che quei 5 test vengano cancellati o disattivati in modo permanente: tornano a girare da soli non appena il CSI risponde di nuovo con200.
Evoluzioni possibili
- Prossima partita ufficiale nella home e nel calendario (i dati sono già disponibili).
- Creazione automatica degli eventi partita da calendario CSI.
- Confronto tra i parziali ufficiali e quelli dello Scout Live.