From c6289979349a72a1ca711dce839439ae2cceb236 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Wed, 9 Sep 2026 11:29:34 +0200 Subject: [PATCH] Mostra anche la classifica di Coppa in Campionato > Classifica 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 --- docs/modules/collegamento-csi.md | 117 ++++++++++++++++++++++++------- src/lib/csi-core.ts | 8 +++ src/routes/api/public/csi.ts | 11 ++- src/routes/classifica.tsx | 90 +++++++++++++----------- test/integration/api.test.ts | 7 ++ test/unit/csi-core.test.ts | 23 +++++- 6 files changed, 186 insertions(+), 70 deletions(-) diff --git a/docs/modules/collegamento-csi.md b/docs/modules/collegamento-csi.md index 8805e3d..0fad59b 100644 --- a/docs/modules/collegamento-csi.md +++ b/docs/modules/collegamento-csi.md @@ -21,26 +21,66 @@ Il portale **non espone un'API pubblica documentata**. Vengono usati gli stessi che il sito chiama internamente via ajax: sono raggiungibili senza autenticazione e senza API key, ma **non offrono alcuna garanzia di stabilità**. -| Endpoint | Formato | Uso | -| ------------------------------------------------ | ------- | ------------------------------------------------------------------------------ | -| `components/project-sheets.php?project_id=767` | HTML | Classifica completa dei due gironi | -| `assets/json/getEventsByTeamId.php?team_id=3359` | JSON | Tutte le gare della squadra: data, ora, avversario, campo, risultato, parziali | - -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`, `team-roster.php`, `team-results.php`. +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` | `767` | -| Squadra sul portale | `C.R.A.P. Volley` (con i punti) | -| `team_id` | `3359` | -| Girone | B | +| 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 | -Gli identificativi sono costanti in `src/lib/csi-core.ts`. +`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 ``, +`parseClassifica()` in `csi-core.ts` prende quella che contiene il nome della squadra). +Colonne per `
` (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
23 - 25
..."`, 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`. --- @@ -50,16 +90,20 @@ Gli identificativi sono costanti in `src/lib/csi-core.ts`. CSI (portale) ↓ fetch server-side, cache 6 ore /api/public/csi → src/routes/api/public/csi.ts - ↓ JSON { classifica, partite, girone, aggiornato } + ↓ JSON { classifica, classificaCoppa, partite, girone, aggiornato } useCsi() → src/lib/csi.ts (React Query, staleTime 6h) ↓ -/classifica → src/routes/classifica.tsx +/classifica → src/routes/classifica.tsx (tab "Classifica": Coppa sopra, Girone sotto) ``` -- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe), - `partiteDaEventi()` (JSON → partite), `isNostraSquadra()`, `partiteGiocate()`. -- **`src/routes/api/public/csi.ts`** — unica route che contatta il CSI. Cache in memoria di - 6 ore; in caso di errore restituisce l'ultimo dato buono (`503` solo se non ne esiste uno). +- **`src/lib/csi-core.ts`** — costanti, tipi e funzioni pure: `parseClassifica()` (HTML → righe, + usata sia per `classifica` sia per `classificaCoppa`), `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 (`503` solo se non ne esiste uno). Se solo la Coppa fallisce + (`scarica(...).catch(() => "")`) la risposta resta comunque `200` con `classificaCoppa: []`: + è 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`. Con `CSI_LIVE=1` verifica anche gli endpoint reali. @@ -83,13 +127,32 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h) 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ù in `crapp-data.ts`. Il check con `CSI_LIVE=1` serve a scoprire il problema di parsing. -2. **`project_id` è legato alla stagione.** Per il 2026/27 servirà un nuovo id, ricavabile da - `team_details.php?team_id=3359`, che elenca i campionati della squadra. Oggi va aggiornato - a mano in `csi-core.ts`. +2. **`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 in `csi-core.ts`. 3. **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`). -4. **I risultati includono anche la Coppa**, non solo il girone di campionato. +4. **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 di `C.R.A.P. Volley`. Il campo `project` + distingue le due nel JSON grezzo, ma `partiteDaEventi()` (`csi-core.ts`) oggi non lo usa + per filtrare: tutte le gare finiscono in `DatiCsi.partite` senza distinzione (`storico + partite` in `/classifica` le mostra tutte insieme). Se in futuro servisse separarle, il + filtro va aggiunto su `evento.project` in `partiteDaEventi()`. + **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 di `project_id=767`, quindi `parseClassifica()` 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 un `project_id` + figlio separato generato a fine fase a gironi (verificato con `curl` diretto: + `project-main.php?project_id=848` descrive il regolamento — "due gironi sola andata, le + due vincenti in finale, gare 3 set su 5" — e `project-sheets.php?project_id=848` mostra + sia le due classifiche a girone sia, in un'altra sezione della stessa risposta, il + tabellone a eliminazione con quel `project_id` figlio). Se la squadra arrivasse in + finale, `/classifica` continuerebbe a mostrare la classifica (ormai chiusa) del proprio + girone di Coppa, non l'esito della finale: non c'è codice che segua quel `project_id` + figlio, che oltretutto cambia a ogni edizione della Coppa e non è noto in anticipo. 5. **Le partite si leggono da JSON, con parsing fragile su campi testuali.** `result` e `partials` in `getEventsByTeamId.php` sono stringhe libere tipo `"3-1"`, lette con un'espressione regolare (`punteggio()`/`parziali()` in `csi-core.ts`). Se il portale CSI @@ -126,7 +189,7 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h) verificato con `curl` diretto sul loro dominio). `leggiCsi()` (`src/routes/api/public/ csi.ts`) intercetta l'eccezione di `JSON.parse` nel `try/catch` della route e risponde `503 "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 5 test di + dato nuovo finché il portale non torna. **Effetto sulla suite test**: i test di `test/integration/api.test.ts` che leggono il CSI reale sondano `/api/public/csi` una volta prima di partire; se risponde con errore li salta (`salta()`, non `prova()`) invece di farli fallire, loggando il motivo — la suite resta verde durante un'indisponibilità diff --git a/src/lib/csi-core.ts b/src/lib/csi-core.ts index f0f1282..76e5a39 100644 --- a/src/lib/csi-core.ts +++ b/src/lib/csi-core.ts @@ -10,6 +10,12 @@ import type { RigaClassifica } from "./crapp-data"; export const CSI_BASE = "https://livescore.csibologna.it"; /** Campionato Open Misto Eccellenza 2025/26. Cambia a ogni stagione. */ export const CSI_PROJECT_ID = 767; +/** + * Coppa CSI Misto Silver 2025/26. Stesso formato di tabella del campionato (girone + * all'italiana), solo con meno squadre: `parseClassifica()` funziona invariata. Cambia a + * ogni stagione come CSI_PROJECT_ID, vedi docs/modules/collegamento-csi.md. + */ +export const CSI_COPPA_PROJECT_ID = 848; /** C.R.A.P. Volley sul portale CSI. */ export const CSI_TEAM_ID = 3359; export const CSI_GIRONE = "Girone B"; @@ -36,6 +42,8 @@ export type PartitaCsi = { export type DatiCsi = { classifica: RigaClassifica[]; + /** Classifica del girone di Coppa (project_id 848). Vuota se il parsing fallisce. */ + classificaCoppa: RigaClassifica[]; partite: PartitaCsi[]; girone: string; aggiornato: string; diff --git a/src/routes/api/public/csi.ts b/src/routes/api/public/csi.ts index aa2f4fa..99d7f86 100644 --- a/src/routes/api/public/csi.ts +++ b/src/routes/api/public/csi.ts @@ -1,5 +1,6 @@ import { createFileRoute } from "@tanstack/react-router"; import { + CSI_COPPA_PROJECT_ID, CSI_GIRONE, parseClassifica, partiteDaEventi, @@ -28,8 +29,15 @@ async function scarica(url: string): Promise { } async function leggiCsi(): Promise { - const [html, json] = await Promise.all([scarica(urlClassifica()), scarica(urlPartite())]); + const [html, htmlCoppa, json] = await Promise.all([ + scarica(urlClassifica()), + // Non blocca la risposta se fallisce: la Coppa è un dato supplementare, non critico + // come classifica/partite del girone (vedi il controllo sotto). + scarica(urlClassifica(CSI_COPPA_PROJECT_ID)).catch(() => ""), + scarica(urlPartite()), + ]); const classifica = parseClassifica(html); + const classificaCoppa = parseClassifica(htmlCoppa); const eventiGrezzi = JSON.parse(json); const partite = partiteDaEventi(eventiGrezzi); if (classifica.length === 0 && partite.length === 0) { @@ -48,6 +56,7 @@ async function leggiCsi(): Promise { } return { classifica, + classificaCoppa, partite, girone: CSI_GIRONE, aggiornato: new Date().toISOString(), diff --git a/src/routes/classifica.tsx b/src/routes/classifica.tsx index 5321513..bc65017 100644 --- a/src/routes/classifica.tsx +++ b/src/routes/classifica.tsx @@ -2,7 +2,7 @@ import { useMemo } from "react"; import { createFileRoute, Link } from "@tanstack/react-router"; import { AlertTriangle, ChevronRight, RefreshCw } from "lucide-react"; import { cn } from "@/lib/utils"; -import { formatData } from "@/lib/crapp-data"; +import { formatData, type RigaClassifica } from "@/lib/crapp-data"; import { PageHeader } from "@/components/crapp/ui-bits"; import { BarraSottosezioni } from "@/components/crapp/BarraSottosezioni"; import { useScoutMatches } from "@/lib/scout-store"; @@ -37,6 +37,44 @@ export const Route = createFileRoute("/classifica")({ component: Classifica, }); +function TabellaClassifica({ righe, vuoto }: { righe: RigaClassifica[]; vuoto: string }) { + return ( +
+
+ # + Squadra + G + Set + Pt +
+ {righe.length === 0 ? ( +

{vuoto}

+ ) : ( + righe.map((r) => { + const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley"; + return ( +
+ {r.pos} + {r.squadra} + {r.giocate} + + {r.setFatti}:{r.setSubiti} + + {r.punti} +
+ ); + }) + )} +
+ ); +} + function formatAggiornamento(iso: string) { const d = new Date(iso); const oggi = new Date().toDateString() === d.toDateString(); @@ -52,6 +90,7 @@ function Classifica() { const { eventi } = useEventi(); const classifica = useMemo(() => csi?.classifica ?? [], [csi]); + const classificaCoppa = useMemo(() => csi?.classificaCoppa ?? [], [csi]); const mvpPerMatch = useMemo(() => vincitoriMvp(votiMvp.data ?? []), [votiMvp.data]); const eventoIdPerData = useMemo( @@ -104,48 +143,17 @@ function Classifica() { ? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}` : "Dati CSI in arrivo"} -
-
- # - Squadra - G - Set - Pt -
- {classifica.length === 0 ? ( -

- Classifica non ancora disponibile. -

- ) : ( - classifica.map((r) => { - const noi = isNostraSquadra(r.squadra) || r.squadra === "CRAP Volley"; - return ( -
- - {r.pos} - - - {r.squadra} - - {r.giocate} - - {r.setFatti}:{r.setSubiti} - - {r.punti} -
- ); - }) - )} -
+ {classificaCoppa.length > 0 ? ( + <> +

Coppa

+ +

Girone

+ + ) : null} + ), - [csi, classifica], + [csi, classifica, classificaCoppa], ); const contenutoStorico = useMemo( diff --git a/test/integration/api.test.ts b/test/integration/api.test.ts index aafe0f2..4b7bd76 100644 --- a/test/integration/api.test.ts +++ b/test/integration/api.test.ts @@ -50,6 +50,7 @@ try { assert.match(res.headers.get("content-type") ?? "", /application\/json/); csi = (await json(res)) as DatiCsi; assert.ok(Array.isArray(csi.classifica) && csi.classifica.length > 0); + assert.ok(Array.isArray(csi.classificaCoppa)); assert.ok(Array.isArray(csi.partite) && csi.partite.length > 0); assert.equal(csi.girone, "Girone B"); assert.ok(!Number.isNaN(Date.parse(csi.aggiornato)), "timestamp valido"); @@ -68,6 +69,12 @@ try { ); }); + await provaCsi("la classifica di Coppa, se presente, contiene la nostra squadra", () => { + if ((csi?.classificaCoppa.length ?? 0) === 0) return; // fase a gironi finita, pagina cambiata + const noi = csi?.classificaCoppa.find((r) => isNostraSquadra(r.squadra)); + assert.ok(noi, "C.R.A.P. Volley presente nel girone di Coppa"); + }); + await provaCsi("ogni partita è coerente con il formato dell'app", () => { for (const p of csi?.partite ?? []) { assert.match(p.data, /^\d{4}-\d{2}-\d{2}$/, `${p.id}: data ISO`); diff --git a/test/unit/csi-core.test.ts b/test/unit/csi-core.test.ts index e83745e..0b2f0f9 100644 --- a/test/unit/csi-core.test.ts +++ b/test/unit/csi-core.test.ts @@ -4,6 +4,7 @@ */ import assert from "node:assert/strict"; import { + CSI_COPPA_PROJECT_ID, isNostraSquadra, parseClassifica, partiteDaEventi, @@ -48,6 +49,20 @@ assert.deepEqual(righe[1], { assert.equal(righe[0]!.squadra, "Paolo Poggi Volley & C.", "decodifica le entità HTML"); assert.deepEqual(parseClassifica("

pagina cambiata

"), [], "portale cambiato: nessun crash"); +// La Coppa ha un girone più piccolo (poche squadre) ma la stessa struttura di tabella del +// campionato: `parseClassifica()` funziona invariata, non serve un parser dedicato. +const htmlCoppa = ` + + + + + + +
PosSquadra
1Set in the City73210084
2C.R.A.P. Volley63210085
`; +const righeCoppa = parseClassifica(htmlCoppa); +assert.equal(righeCoppa.length, 2, "girone di Coppa più piccolo del campionato: ok comunque"); +assert.equal(righeCoppa[1]!.squadra, "C.R.A.P. Volley"); + assert.ok(isNostraSquadra("CRAP Volley") && isNostraSquadra("C.R.A.P. Volley ")); assert.ok(!isNostraSquadra("CRAP Volley B")); @@ -126,14 +141,20 @@ assert.equal( ); if (process.env["CSI_LIVE"]) { - const [html, json] = await Promise.all([ + const [html, htmlCoppa, json] = await Promise.all([ fetch(urlClassifica()).then((r) => r.text()), + fetch(urlClassifica(CSI_COPPA_PROJECT_ID)).then((r) => r.text()), fetch(urlPartite()).then((r) => r.json()), ]); const live = parseClassifica(html); const nostra = live.find((r) => isNostraSquadra(r.squadra)); assert.ok(nostra, "la nostra squadra è in classifica"); assert.ok(live.length >= 8 && nostra.giocate > 0); + const liveCoppa = parseClassifica(htmlCoppa); + assert.ok( + liveCoppa.some((r) => isNostraSquadra(r.squadra)), + "la nostra squadra è nel girone di Coppa", + ); const livePartite = partiteDaEventi(json); assert.ok(livePartite.length > 0 && partiteGiocate(livePartite).length > 0); console.log(`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°`);