Segnala in modo visibile se il portale CSI cambia formato

Le vittorie in campionato dipendono dal JSON delle partite (non dalla
classifica HTML, come erroneamente documentato prima). Se quel formato cambia,
il parsing fallisce in silenzio: nessun errore, nessuna notifica, le vittorie
restano ferme a 0% finché qualcuno non se ne accorge per caso.

Aggiunta partiteFormatoSospetto() in csi-core.ts: confronta gli eventi grezzi
con il risultato del parsing per distinguere un vero "formato cambiato" da un
legittimo "nessuna gara ancora in programma". La route logga l'errore server e
il flag arriva fino a /classifica, dove sostituisce la riga "Dati CSI
aggiornati alle..." con un badge discreto color warning - visibile a chi apre
la pagina, senza notifiche invasive.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-08 12:01:32 +02:00
co-authored by Claude Sonnet 5
parent 8c6fceb682
commit 7e51fe6345
6 changed files with 117 additions and 10 deletions
+28
View File
@@ -90,6 +90,34 @@ useCsi() → src/lib/csi.ts (React Query, staleTime 6h)
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.
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
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`/`setLoro` a `null`) — 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 da `partiteGiocate(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/csi` lancia 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 comunque `200`
con `partite: []`. Per questo `leggiCsi()` confronta il JSON grezzo con il risultato di
`partiteDaEventi()` tramite `partiteFormatoSospetto()` (`csi-core.ts`): se ci sono eventi
grezzi ma nessuno è stato riconosciuto come nostra partita, logga un `console.error`
distingue così un vero "formato cambiato" da un legittimo "nessuna gara ancora in
programma" (dove gli eventi grezzi stessi sono vuoti). Il flag `formatoSospetto` viaggia
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 a
`partiteDaEventi()`/`punteggio()`/`parziali()` in `csi-core.ts` (gli endpoint stessi
cambiano solo se cambia il dominio o serve autenticazione, nel qual caso va toccata anche
`src/routes/api/public/csi.ts`); va poi aggiornato anche `test/unit/csi-core.test.ts` con
fixture nel nuovo formato.
---
+4 -2
View File
@@ -43,8 +43,10 @@ smart (`notifiche-smart.ts`).
## Limiti noti
- Le vittorie di campionato dipendono dal parsing HTML del portale CSI: se quel parsing si
rompe, questi tre obiettivi restano a 0% anche a fronte di vittorie reali.
- Le vittorie di campionato dipendono dal **JSON** delle partite del portale CSI (non dalla
classifica HTML): se quel parsing si rompe, questi tre obiettivi restano a 0% anche a fronte
di vittorie reali, **senza segnalazione d'errore** — vedi il dettaglio in
[Collegamento CSI § Limiti noti](collegamento-csi.md#limiti-noti).
- I target (250 presenze, 200 pagelle, ecc.) sono costanti fisse, da rivedere manualmente a
ogni stagione — con l'eccezione di "Continuità di squadra" (vedi sotto), il cui target ha un
significato specifico e non va scalato come gli altri.
+16
View File
@@ -39,6 +39,8 @@ export type DatiCsi = {
partite: PartitaCsi[];
girone: string;
aggiornato: string;
/** true se il formato delle partite sembra cambiato (vedi `partiteFormatoSospetto`). */
formatoSospetto: boolean;
};
/** "C.R.A.P. Volley" e "CRAP Volley" devono confrontarsi uguali. */
@@ -162,6 +164,20 @@ export function partiteGiocate(partite: PartitaCsi[]): PartitaCsi[] {
return partite.filter((p) => p.setNostri !== null && p.setLoro !== null);
}
/**
* True se il formato di `getEventsByTeamId.php` sembra cambiato: `partiteDaEventi()` fallisce
* in modo silenzioso (nessun array o campi non riconosciuti), quindi un array vuoto da solo non
* distingue "il portale CSI ha cambiato formato" da "la squadra non ha ancora gare in
* programma". Qui invece si confronta con la risposta grezza: se contiene eventi ma nessuno è
* stato riconosciuto come nostra partita, è quasi certamente un problema di parsing, non una
* stagione senza gare. Usata da `/api/public/csi` per loggare il caso invece di lasciarlo
* silenzioso — vedi "Limiti noti" in docs/modules/collegamento-csi.md.
*/
export function partiteFormatoSospetto(eventiGrezzi: unknown, partite: PartitaCsi[]): boolean {
if (!Array.isArray(eventiGrezzi)) return true;
return eventiGrezzi.length > 0 && partite.length === 0;
}
/** Converte una gara CSI già giocata nella forma comune usata nelle liste risultati. */
export function matchDaPartitaCsi(p: PartitaCsi) {
return {
+21 -2
View File
@@ -3,6 +3,7 @@ import {
CSI_GIRONE,
parseClassifica,
partiteDaEventi,
partiteFormatoSospetto,
urlClassifica,
urlPartite,
type DatiCsi,
@@ -29,11 +30,29 @@ async function scarica(url: string): Promise<string> {
async function leggiCsi(): Promise<DatiCsi> {
const [html, json] = await Promise.all([scarica(urlClassifica()), scarica(urlPartite())]);
const classifica = parseClassifica(html);
const partite = partiteDaEventi(JSON.parse(json));
const eventiGrezzi = JSON.parse(json);
const partite = partiteDaEventi(eventiGrezzi);
if (classifica.length === 0 && partite.length === 0) {
throw new Error("CSI: risposta senza classifica né partite");
}
return { classifica, partite, girone: CSI_GIRONE, aggiornato: new Date().toISOString() };
// La classifica basta a evitare l'errore sopra, ma se solo le partite si rompono (formato di
// getEventsByTeamId.php cambiato) la route tornerebbe comunque 200 senza che nessuno se ne
// accorga: le vittorie degli obiettivi di squadra resterebbero ferme a 0% in silenzio. Oltre
// al log server, il flag arriva fino a `/classifica` (badge discreto) perché qualcuno se ne
// accorga anche senza guardare i log.
const formatoSospetto = partiteFormatoSospetto(eventiGrezzi, partite);
if (formatoSospetto) {
console.error(
"csi: il formato di getEventsByTeamId.php sembra cambiato, nessuna partita riconosciuta",
);
}
return {
classifica,
partite,
girone: CSI_GIRONE,
aggiornato: new Date().toISOString(),
formatoSospetto,
};
}
export const Route = createFileRoute("/api/public/csi")({
+19 -6
View File
@@ -1,6 +1,6 @@
import { useMemo } from "react";
import { createFileRoute, Link } from "@tanstack/react-router";
import { ChevronRight, RefreshCw } from "lucide-react";
import { AlertTriangle, ChevronRight, RefreshCw } from "lucide-react";
import { cn } from "@/lib/utils";
import { formatData } from "@/lib/crapp-data";
import { PageHeader } from "@/components/crapp/ui-bits";
@@ -85,11 +85,24 @@ function Classifica() {
const contenutoClassifica = useMemo(
() => (
<>
<div className="mb-3 flex items-center gap-2 rounded-2xl bg-secondary px-3 py-2 text-xs text-muted-foreground">
<RefreshCw className="h-3.5 w-3.5 text-accent" />
{csi
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
: "Dati CSI in arrivo"}
<div
className={cn(
"mb-3 flex items-center gap-2 rounded-2xl px-3 py-2 text-xs",
csi?.formatoSospetto
? "bg-warning/15 text-warning"
: "bg-secondary text-muted-foreground",
)}
>
{csi?.formatoSospetto ? (
<AlertTriangle className="h-3.5 w-3.5 shrink-0 text-warning" aria-hidden />
) : (
<RefreshCw className="h-3.5 w-3.5 shrink-0 text-accent" aria-hidden />
)}
{csi?.formatoSospetto
? "Il portale CSI potrebbe aver cambiato formato: dati da verificare."
: csi
? `Dati CSI aggiornati ${formatAggiornamento(csi.aggiornato)}`
: "Dati CSI in arrivo"}
</div>
<div className="overflow-hidden rounded-3xl bg-card shadow-card">
<div className="grid grid-cols-[2rem_minmax(0,1fr)_2rem_2.5rem_2.5rem] gap-2 border-b border-border px-3 py-2 text-xs font-bold uppercase text-muted-foreground">
+29
View File
@@ -7,6 +7,7 @@ import {
isNostraSquadra,
parseClassifica,
partiteDaEventi,
partiteFormatoSospetto,
partiteGiocate,
urlClassifica,
urlPartite,
@@ -96,6 +97,34 @@ assert.deepEqual(trasferta.parziali[0], [21, 25], "anche i parziali");
assert.equal(partiteGiocate(partite).length, 2);
assert.deepEqual(partiteDaEventi("non è un array"), [], "risposta inattesa: nessun crash");
// --- partiteFormatoSospetto: distingue "nessuna gara ancora" da "formato rotto" -------------
assert.equal(
partiteFormatoSospetto(eventi, partite),
false,
"eventi grezzi presenti e riconosciuti: nessun sospetto",
);
assert.equal(
partiteFormatoSospetto([], []),
false,
"nessun evento grezzo: probabilmente solo una stagione senza gare, non un formato rotto",
);
assert.equal(
partiteFormatoSospetto("non è un array", []),
true,
"la risposta non è più un array: il formato è cambiato",
);
const eventiSenzaCampiRiconosciuti = [
{ id: 1, quando: "2025-11-12", squadraCasa: "C.R.A.P. Volley", squadraOspite: "AMCM" },
];
assert.equal(
partiteFormatoSospetto(
eventiSenzaCampiRiconosciuti,
partiteDaEventi(eventiSenzaCampiRiconosciuti),
),
true,
"eventi presenti ma con campi rinominati: nessuno riconosciuto, sospetto fondato",
);
if (process.env["CSI_LIVE"]) {
const [html, json] = await Promise.all([
fetch(urlClassifica()).then((r) => r.text()),