Aggiunge il dettaglio esteso di partita: formazioni e scontri diretti dal CSI

In Campionato > Storico partite ogni gara diventa cliccabile: verso /partita/$id
se collegata a un evento CrAPP, verso la nuova /partita-csi/$id altrimenti (nessuna
creazione automatica di eventi dal calendario CSI, quindi molte gare ne sono prive).

Il dettaglio aggiunge formazioni (titolari/panchina/staff), storico scontri diretti
e probabilità di vittoria calcolata dal CSI, letti on-demand da tre nuovi endpoint
per-partita (match-main/players/stats.php) dietro /api/public/csi-partita/$id, con
cache server per-partita separata da quella di /api/public/csi — è un dato aperto a
richiesta, non precaricato per tutti come classifica e partite.

Include anche logo squadra e metadati leggeri (girone, n° gara, arbitro, link al
referto ufficiale) già presenti nel JSON esistente, a costo zero.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-09 12:07:03 +02:00
co-authored by Claude Sonnet 5
parent c628997934
commit 5569d08b59
12 changed files with 1207 additions and 38 deletions
+15 -1
View File
@@ -70,11 +70,25 @@ try {
const html = await pagina("/classifica");
assert.match(titolo(html), /Classifica campionato/);
assert.doesNotMatch(html, /CSI Milano/, "nessun residuo dei dati demo nel guscio");
// Una gara giocata deve avere un dettaglio "solo CSI" raggiungibile, con il suo
// endpoint per-partita servito (formazioni/precedenti): vedi partita-csi.$id.tsx.
const giocata = csi.partite.find((p) => p.setNostri !== null);
if (giocata) {
const htmlPartita = await pagina(`/partita-csi/${giocata.id}`);
assert.match(titolo(htmlPartita), /Dettaglio partita/);
const dettaglio = await fetch(url(`/api/public/csi-partita/${giocata.id}`));
assert.equal(dettaglio.status, 200, "l'endpoint per-partita risponde");
}
});
// --- 4. Dettaglio di un evento --------------------------------------------
await prova("le pagine di dettaglio reggono un id inesistente", async () => {
for (const percorso of ["/partita/non-esiste", "/allenamento/non-esiste"]) {
for (const percorso of [
"/partita/non-esiste",
"/allenamento/non-esiste",
"/partita-csi/non-esiste",
]) {
const res = await fetch(url(percorso));
assert.ok(res.status < 500, `${percorso}: nessun errore server (era ${res.status})`);
}
+45 -1
View File
@@ -5,7 +5,12 @@
*/
import assert from "node:assert/strict";
import { giocatori } from "@/lib/crapp-data";
import { isNostraSquadra, partiteGiocate, type DatiCsi } from "@/lib/csi-core";
import {
isNostraSquadra,
partiteGiocate,
type DatiCsi,
type DettaglioPartitaCsi,
} from "@/lib/csi-core";
import { obiettiviSquadra } from "@/lib/obiettivi";
import { avviaServer, haSupabase, json } from "../helpers/server";
import { prova, riepilogo, salta } from "../helpers/prova";
@@ -119,6 +124,45 @@ try {
assert.ok(Date.now() - t0 < 2_000, "risposta immediata");
});
await provaCsi(
"GET /api/public/csi-partita/$id restituisce formazioni e precedenti",
async () => {
const primaGiocata = partiteGiocate(csi?.partite ?? [])[0];
assert.ok(primaGiocata, "serve almeno una gara giocata per testare il dettaglio");
const res = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
assert.equal(res.status, 200);
const dettaglio = (await json(res)) as DettaglioPartitaCsi;
// formazioni/precedenti possono essere null se il portale non li pubblica per questa
// gara (es. referto non compilato): qui si verifica solo che, quando presenti, siano
// coerenti — non che ci siano sempre, vedi parseFormazioni()/parsePrecedenti().
if (dettaglio.formazioni) {
assert.ok(isNostraSquadra(dettaglio.formazioni.noi.squadra));
assert.ok(dettaglio.formazioni.noi.titolari.length > 0, "almeno un titolare");
}
if (dettaglio.precedenti) {
assert.ok(dettaglio.precedenti.totale >= 0);
}
const t0 = Date.now();
const secondo = await fetch(url(`/api/public/csi-partita/${primaGiocata.id}`));
assert.equal(secondo.status, 200);
assert.ok(Date.now() - t0 < 2_000, "seconda chiamata dalla cache: risposta immediata");
},
);
await provaCsi(
"GET /api/public/csi-partita/$id con un id davvero inesistente risponde 503, non rompe",
async () => {
// Un match_id inventato fa rispondere 500 a match-stats.php lato CSI (verificato a
// mano con curl): non è "gara senza formazioni ancora pubblicate" (quello risponde
// 200 con markup vuoto, gestito da parseFormazioni()/parsePrecedenti() → null), è un
// vero errore a monte. La route lo tratta come /api/public/csi tratta un CSI giù:
// 503 pulito, non un crash o un 200 con dati inventati.
const res = await fetch(url("/api/public/csi-partita/999999999"));
assert.equal(res.status, 503);
},
);
// --- GET /api/public/push-config -------------------------------------------
await prova("GET /api/public/push-config espone solo la chiave pubblica", async () => {
const res = await fetch(url("/api/public/push-config"));
+210 -2
View File
@@ -6,12 +6,19 @@ import assert from "node:assert/strict";
import {
CSI_COPPA_PROJECT_ID,
isNostraSquadra,
matchDaPartitaCsi,
parseClassifica,
parseFormazioni,
parseInfoPartita,
parsePrecedenti,
partiteDaEventi,
partiteFormatoSospetto,
partiteGiocate,
urlClassifica,
urlPartite,
urlPartitaFormazioni,
urlPartitaInfo,
urlPartitaPrecedenti,
} from "@/lib/csi-core";
const htmlClassifica = `
@@ -71,11 +78,17 @@ const eventi = [
id: 1,
start: "2025-11-12T22:00:00",
team1: "C.R.A.P. Volley",
team1_logo: "images/teams/logos/CRAP_Volley_3359.jpg",
team2: "AMCM",
team2_logo: "images/teams/logos/AMCM_1943.JPG",
result: "3 - 1",
partials: "25 - 23</br>23 - 25</br>25 - 18</br>25 - 22</br>",
field: "Sabin Piccolo",
project: "PVM - Campionato Open Misto Eccellenza",
group: "Girone B",
match_number: "5/XEB",
referees: "",
link: "https://livescore.csibologna.it/match_details.php?id=1",
},
{
id: 2,
@@ -100,6 +113,14 @@ const casa = partite.find((p) => p.id === "1")!;
assert.deepEqual([casa.casa, casa.avversario, casa.setNostri, casa.setLoro], [true, "AMCM", 3, 1]);
assert.deepEqual(casa.parziali[1], [23, 25]);
assert.equal(casa.ora, "22:00");
assert.equal(
casa.logoAvversario,
"images/teams/logos/AMCM_1943.JPG",
"logo dell'avversario, non il nostro",
);
assert.equal(casa.girone, "Girone B");
assert.equal(casa.numeroGara, "5/XEB");
assert.equal(casa.link, "https://livescore.csibologna.it/match_details.php?id=1");
const trasferta = partite.find((p) => p.id === "2")!;
assert.deepEqual(
@@ -112,6 +133,24 @@ 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");
// --- matchDaPartitaCsi: i campi leggeri (logo, girone, n° gara, arbitro, link) arrivano
// fino alla forma usata nelle liste risultati, non solo nel tipo interno --------------------
assert.deepEqual(matchDaPartitaCsi(casa), {
id: "1",
data: "2025-11-12",
avversario: "AMCM",
logoAvversario: "images/teams/logos/AMCM_1943.JPG",
casa: true,
setNostri: 3,
setLoro: 1,
parziali: casa.parziali,
campo: "Sabin Piccolo",
girone: "Girone B",
numeroGara: "5/XEB",
arbitro: "",
link: "https://livescore.csibologna.it/match_details.php?id=1",
});
// --- partiteFormatoSospetto: distingue "nessuna gara ancora" da "formato rotto" -------------
assert.equal(
partiteFormatoSospetto(eventi, partite),
@@ -140,6 +179,154 @@ assert.equal(
"eventi presenti ma con campi rinominati: nessuno riconosciuto, sospetto fondato",
);
// --- parseInfoPartita: giornata e nota libera da match-main.php -----------------------------
const htmlMatchMain = `
<div>
<small><small class="d-block">2<sup>a</sup> Giornata</small></small>
<div class="pt-3 border-top text-center">
Impianto: <a href="field_details.php?id=400">Sabin Piccolo</a>
<div class="text-start"><span>Pubblico non ammesso</span></div>
</div>
</div>`;
assert.deepEqual(parseInfoPartita(htmlMatchMain), {
giornata: "2ª Giornata",
nota: "Pubblico non ammesso",
});
assert.deepEqual(
parseInfoPartita("<p>pagina cambiata</p>"),
{ giornata: "", nota: "" },
"portale cambiato: nessun crash, campi vuoti",
);
// --- parseFormazioni: titolari/panchina/staff di entrambe le squadre, noi riconosciuti
// tramite isNostraSquadra() e non un ordine fisso ------------------------------------------
const htmlFormazioni = `
<!-- SQUADRA CASA -->
<div class="col-12 col-md-6 mt-4">
<strong><a href="team_details.php?team_id=3359">C.R.A.P. Volley</a></strong>
<ul class="list-group mt-2">
<li class="list-group-item">
<span class="fa-layers-text" data-fa-transform="shrink-8">73</span>
<strong><a href="person_details.php?id=1">Bologna Mattias</a></strong>
<div class="small"></div>
</li>
<li class="list-group-item">
<span class="fa-layers-text" data-fa-transform="shrink-8">11</span>
<strong><a href="person_details.php?id=2">Chilese Silvia</a></strong>
<div class="small">Libero</div>
</li>
<li class="list-group-item bg-light"><strong>A DISPOSIZIONE</strong></li>
<li class="list-group-item">
<span class="fa-layers-text" data-fa-transform="shrink-8">14</span>
<strong><a href="person_details.php?id=3">Di Castelnuovo Carlo</a></strong>
<div class="small"></div>
</li>
<li class="list-group-item bg-light"><strong>STAFF</strong></li>
<li class="list-group-item">
<strong><a href="person_details.php?id=4">Ricci Iacopo</a></strong>
<div class="small">Allenatore</div>
</li>
</ul>
</div>
<!-- SQUADRA OSPITE -->
<div class="col-12 col-md-6 mt-4">
<strong><a href="team_details.php?team_id=3357">AMCM</a></strong>
<ul class="list-group mt-2">
<li class="list-group-item">
<span class="fa-layers-text" data-fa-transform="shrink-8">7</span>
<strong><a href="person_details.php?id=5">Rossi Anna</a></strong>
<div class="small"></div>
</li>
</ul>
</div>`;
const formazioni = parseFormazioni(htmlFormazioni)!;
assert.equal(formazioni.noi.squadra, "C.R.A.P. Volley");
assert.equal(
formazioni.avversario.squadra,
"AMCM",
"riconosciuta come avversario anche da seconda posizione",
);
assert.deepEqual(formazioni.noi.titolari, [
{ numero: "73", nome: "Bologna Mattias", ruolo: "" },
{ numero: "11", nome: "Chilese Silvia", ruolo: "Libero" },
]);
assert.deepEqual(formazioni.noi.panchina, [
{ numero: "14", nome: "Di Castelnuovo Carlo", ruolo: "" },
]);
assert.deepEqual(formazioni.noi.staff, [{ nome: "Ricci Iacopo", ruolo: "Allenatore" }]);
assert.equal(formazioni.avversario.titolari.length, 1);
assert.deepEqual(parseFormazioni("<p>pagina cambiata</p>"), null, "portale cambiato: nessun crash");
assert.deepEqual(
parseFormazioni(htmlFormazioni.replace("C.R.A.P. Volley", "Un'altra squadra")),
null,
"nessuna delle due è la nostra squadra: formato non riconosciuto",
);
// --- parsePrecedenti: storico scontri diretti e probabilità di vittoria da match-stats.php --
const htmlPrecedenti = `
<div class="col-12 col-md-6 my-3">Squadra casa
<strong><a href="team_details.php?team_id=3359">C.R.A.P. Volley</a></strong>
</div>
<div class="col-12 col-md-6 my-3">Squadra ospite
<strong><a href="team_details.php?team_id=3357">AMCM</a></strong>
</div>
<div><span class="text-muted">Precedenti:</span> <b><a href="#">5</a></b></div>
<div class="d-flex justify-content-between pt-2 px-5">
<div><b>3</b></div>
<div class="">vittorie</div>
<div><b>2</b></div>
</div>
<div class="d-flex justify-content-between small text-muted px-5">
<div><b>1</b></div>
<div>in casa</div>
<div><b>1</b></div>
</div>
<div class="d-flex justify-content-between small text-muted px-5">
<div><b>2</b></div>
<div>fuori</div>
<div><b>1</b></div>
</div>
<div class="progress-bar bg-success" style="width: 56.97%;">56.97%</div>
<div class="progress-bar bg-danger" style="width: 43.03%;">43.03%</div>`;
assert.deepEqual(parsePrecedenti(htmlPrecedenti), {
totale: 5,
vinteNoi: 3,
vinteAvversario: 2,
casaNoi: 1,
casaAvversario: 1,
fuoriNoi: 2,
fuoriAvversario: 1,
probabilitaNoi: 56.97,
probabilitaAvversario: 43.03,
});
// Se siamo la squadra ospite invece che casa, i numeri vanno scambiati di conseguenza.
const precedentiOspiti = parsePrecedenti(
htmlPrecedenti
.replace("C.R.A.P. Volley", "__TMP__")
.replace("AMCM", "C.R.A.P. Volley")
.replace("__TMP__", "AMCM"),
)!;
assert.deepEqual(
[precedentiOspiti.vinteNoi, precedentiOspiti.vinteAvversario, precedentiOspiti.probabilitaNoi],
[2, 3, 43.03],
"noi/avversario scambiati quando siamo la squadra ospite",
);
assert.equal(parsePrecedenti("<p>pagina cambiata</p>"), null, "portale cambiato: nessun crash");
// --- URL dei nuovi endpoint per-partita ------------------------------------------------------
assert.equal(
urlPartitaInfo("33694"),
"https://livescore.csibologna.it/components/match-main.php?match_id=33694",
);
assert.equal(
urlPartitaFormazioni("33694"),
"https://livescore.csibologna.it/components/match-players.php?match_id=33694",
);
assert.equal(
urlPartitaPrecedenti("33694"),
"https://livescore.csibologna.it/components/match-stats.php?match_id=33694",
);
if (process.env["CSI_LIVE"]) {
const [html, htmlCoppa, json] = await Promise.all([
fetch(urlClassifica()).then((r) => r.text()),
@@ -156,8 +343,29 @@ if (process.env["CSI_LIVE"]) {
"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}°`);
const liveGiocate = partiteGiocate(livePartite);
assert.ok(livePartite.length > 0 && liveGiocate.length > 0);
assert.ok(
liveGiocate.every((p) => p.link.startsWith("https://")),
"ogni partita ha un referto",
);
const primaGiocata = liveGiocate[0]!;
const [liveMain, livePlayers, liveStats] = await Promise.all([
fetch(urlPartitaInfo(primaGiocata.id)).then((r) => r.text()),
fetch(urlPartitaFormazioni(primaGiocata.id)).then((r) => r.text()),
fetch(urlPartitaPrecedenti(primaGiocata.id)).then((r) => r.text()),
]);
const liveFormazioni = parseFormazioni(livePlayers);
assert.ok(liveFormazioni, "formazioni riconosciute per una gara già giocata");
assert.ok(isNostraSquadra(liveFormazioni!.noi.squadra));
const livePrecedenti = parsePrecedenti(liveStats);
assert.ok(livePrecedenti, "precedenti riconosciuti");
console.log(
`live: ${live.length} squadre, ${livePartite.length} partite, noi ${nostra.pos}°, ` +
`${parseInfoPartita(liveMain).giornata || "(senza giornata)"}, ` +
`formazione ${liveFormazioni!.noi.titolari.length} titolari`,
);
}
console.log("csi-core: ok");