Replace backend submodule with Elysia/Bun sportsmanager backend

This commit is contained in:
2026-09-08 10:03:09 +02:00
parent 1c14e4506b
commit d19f0c2f8f
6 changed files with 688 additions and 1 deletions
+513
View File
@@ -0,0 +1,513 @@
import { Elysia } from "elysia";
const isDev = true;
const config = {
idFormat: isDev ? "utf8" : "base64url",
};
class Sportsmanager {
private baseUrl: string;
private params: Record<string, string>;
public constructor(baseUrl, params = {}) {
this.baseUrl = baseUrl;
this.params = {
...params,
format: "json",
option: "com_sportsmanager",
view: "sportsmanager",
};
}
private fetch = (params, options) =>
fetch(
`${this.baseUrl}/index.php?${new URLSearchParams({
...this.params,
...params,
}).toString()}`,
options,
);
private id = (id: string | number) =>
Buffer.from(`sportsmanager:${new URL(this.baseUrl).host}:${id}`).toString(
config.idFormat,
);
private number = (value: string | number | null | undefined) =>
(value ?? null) !== null && !isNaN(+value) ? Number(value) : null;
private teamName = (value: string) => value.replace("(NR)", "");
public getOverview = async () => {
const resp = await this.fetch({ content: "aktuelle_begegnungen" });
return resp
.json()
.then(({ data: { running_matches, finished_matches, next_matches } }) => {
return [...running_matches, ...finished_matches, ...next_matches]
.filter(({ zeitpunkt }) => !!zeitpunkt)
.sort((a, b) => {
if (a.zeitpunkt === b.zeitpunkt) {
return Number(a.begegnung_id) < Number(b.begegnung_id) ? -1 : 1;
}
return a.zeitpunkt < b.zeitpunkt ? -1 : 1;
})
.map((match) => {
return {
id: this.id(match.begegnung_id),
scheduledAt: new Date(match.zeitpunkt),
homeTeam: {
id: this.id(`${match.heim_team_id}:${match.heim_teamgruppe}`),
name: this.teamName(match.heim_name),
logoUrl: match.heim_bild || null,
goals: this.number(match.heim_punkte),
points: this.number(match.heim_spielpunkte),
},
awayTeam: {
id: this.id(`${match.gast_team_id}:${match.gast_teamgruppe}`),
name: this.teamName(match.gast_name),
logoUrl: match.gast_bild || null,
goals: this.number(match.gast_punkte),
points: this.number(match.gast_spielpunkte),
},
event: {
id: this.id(match.veranstaltung_id),
name: match.bezeichnung,
matchday: match.spieltag_titel || match.spieltag,
},
tableNumber: match.tisch ? `${match.tisch}` : null,
isNonSmoking: Boolean(
match.nichtraucherschutz || match.heim_name.includes("(NR)"),
),
status:
this.number(match.heim_punkte) !== null &&
this.number(match.gast_punkte) !== null
? Number(match.zwischenergebnis)
? "LIVE"
: Number(match.unbestaetigtes_ergebnis_id)
? "AWAITING_APPROVAL"
: "COMPLETED"
: "SCHEDULED",
// orig: match,
};
});
});
};
public getMatch = (id: string) =>
this.fetch({
content: "aktuelle_begegnungen",
task: "begegnung_spielplan",
id,
})
.then((r) => r.json())
.then(({ data }) => {
const {
begegnung,
heim_team,
heim_spieler,
gast_team,
gast_spieler,
veranstaltung,
modus,
spiele,
spielort,
} = data;
const player = (s) => ({
id: this.id(s.spieler_id),
name: `${s.vorname} ${s.nachname}`,
firstName: s.vorname,
lastName: s.nachname,
imageUrl: s.bild,
});
const slotPlayer = (game, key) =>
game && game[`${key}_id`]
? {
id: this.id(game[`${key}_id`]),
firstName: game[`${key}_vorname`],
lastName: game[`${key}_nachname`],
imageUrl: game[`${key}_bild`],
}
: null;
return {
id: this.id(begegnung.begegnung_id),
scheduledAt: new Date(begegnung.zeitpunkt),
homeTeam: {
id: this.id(`${heim_team.team_id}:${heim_team.teamgruppe_id}`),
name: heim_team.teamname,
logoUrl: heim_team.teambild,
goals: this.number(begegnung.heim_punkte),
points: this.number(begegnung.heim_spielpunkte),
},
awayTeam: {
id: this.id(`${gast_team.team_id}:${gast_team.teamgruppe_id}`),
name: gast_team.teamname,
logoUrl: gast_team.teambild,
goals: this.number(begegnung.gast_punkte),
points: this.number(begegnung.gast_spielpunkte),
},
event: {
id: this.id(veranstaltung.veranstaltung_id),
name: veranstaltung.bezeichnung,
matchday: begegnung.spieltag_titel || begegnung.spieltag,
},
venue: spielort
? {
name: spielort.name,
}
: undefined,
tableNumber: begegnung.tisch ? `${begegnung.tisch}` : null,
isNonSmoking: Boolean(
begegnung.nichtraucherschutz || heim_team.teamname.includes("(NR)"),
),
status:
this.number(begegnung.heim_punkte) !== null &&
this.number(begegnung.gast_punkte) !== null
? Number(begegnung.zwischenergebnis)
? "LIVE"
: Number(begegnung.unbestaetigtes_ergebnis_id)
? "AWAITING_APPROVAL"
: "COMPLETED"
: "SCHEDULED",
homeRosters: heim_spieler.map(player),
awayRosters: gast_spieler.map(player),
slots: Object.values(
modus.modus.split(",").reduce((acc, cur, idx) => {
const game = spiele[idx] ?? null;
if (!acc[cur]) {
acc[cur] = {
key: cur,
type:
cur[0] === "E"
? "SINGLE"
: cur[0] === "D"
? "DOUBLE"
: "SHOOT_OUT",
sets: [],
home: { positionKey: cur.substring(0, 2) },
away: { positionKey: cur.substring(2, 4) },
};
switch (acc[cur].type) {
case "SINGLE":
acc[cur].home.players = [
slotPlayer(game, "heim_spieler_1"),
];
acc[cur].away.players = [
slotPlayer(game, "gast_spieler_1"),
];
break;
case "DOUBLE":
acc[cur].home.players = [
slotPlayer(game, "heim_spieler_1"),
slotPlayer(game, "heim_spieler_2"),
];
acc[cur].away.players = [
slotPlayer(game, "gast_spieler_1"),
slotPlayer(game, "gast_spieler_2"),
];
break;
case "SHOOT_OUT":
acc[cur].home.players = null;
acc[cur].away.players = null;
break;
}
}
const set = game
? {
homeGoals: this.number(game.teamspiel_heim_punkte),
awayGoals: this.number(game.teamspiel_gast_punkte),
}
: null;
acc[cur].sets.push(set);
return acc;
}, {}),
),
// orig: data,
};
});
public getMatchStats = async (id: string) => {
const match = await this.fetch({
content: "aktuelle_begegnungen",
task: "begegnung_spielplan",
id,
})
.then((r) => r.json())
.then((r) => r.data);
const teams = {
[match.heim_team.team_id]: {
id: this.id(
`${match.heim_team.team_id}:${match.heim_team.teamgruppe_id}`,
),
name: match.heim_team.teamname,
logoUrl: match.heim_team.teambild,
},
[match.gast_team.team_id]: {
id: this.id(
`${match.gast_team.team_id}:${match.gast_team.teamgruppe_id}`,
),
name: match.gast_team.teamname,
logoUrl: match.gast_team.teambild,
},
};
const [homeTeam, awayTeam] = await Promise.all([
this.fetch({
content: "teams",
task: "team_details",
id: match.heim_team.team_id,
})
.then((r) => r.json())
.then((r) => r.data),
this.fetch({
content: "teams",
task: "team_details",
id: match.gast_team.team_id,
})
.then((r) => r.json())
.then((r) => r.data),
]);
const homeTeamMatches = homeTeam.begegnungen
.filter((a) => {
return (
!(!a.heim_spielpunkte && !a.gast_spielpunkte) &&
a.zeitpunkt < match.begegnung.zeitpunkt
);
})
.sort((a, b) => (a.zeitpunkt < b.zeitpunkt ? 1 : -1));
const home = {
stats: homeTeamMatches.reduce(
(acc, match, index) => {
const weight = Math.max(0.2, 1 - index * 0.8);
const isHome = Number(match.auswaerts) === 0;
const myGoals = isHome
? match.heim_spielpunkte
: match.gast_spielpunkte;
const oppGoals = isHome
? match.gast_spielpunkte
: match.heim_spielpunkte;
// Punkte für Ausgang (Sieg = 1, Remis = 0.5, Niederlage = 0)
const resultPoints =
myGoals > oppGoals ? 1 : myGoals === oppGoals ? 0.5 : 0;
// Torverhältnis-Faktor (verhindert Division durch 0 bei 0 Gegentoren)
const goalFactor = (myGoals + 1) / (oppGoals + 1);
acc.weightSum += weight;
acc.scoreSum += (resultPoints * 0.6 + goalFactor * 0.4) * weight;
return acc;
},
{
weightSum: 0,
scoreSum: 0,
},
),
form: homeTeamMatches
.slice(0, 5)
.reverse()
.map((b) => {
if (b.heim_spielpunkte === b.gast_spielpunkte) {
return "DRAW";
}
return b.auswaerts
? b.heim_spielpunkte < b.gast_spielpunkte
? "WIN"
: "LOST"
: b.heim_spielpunkte < b.gast_spielpunkte
? "LOST"
: "WIN";
}),
};
const awayTeamMatches = awayTeam.begegnungen
.filter((a) => {
return (
!(!a.heim_spielpunkte && !a.gast_spielpunkte) &&
a.zeitpunkt < match.begegnung.zeitpunkt
);
})
.sort((a, b) => (a.zeitpunkt < b.zeitpunkt ? 1 : -1));
const away = {
stats: awayTeamMatches.reduce(
(acc, match, index) => {
const weight = Math.max(0.2, 1 - index * 0.8);
const isHome = Number(match.auswaerts) === 0;
const myGoals = isHome
? match.heim_spielpunkte
: match.gast_spielpunkte;
const oppGoals = isHome
? match.gast_spielpunkte
: match.heim_spielpunkte;
// Punkte für Ausgang (Sieg = 1, Remis = 0.5, Niederlage = 0)
const resultPoints =
myGoals > oppGoals ? 1 : myGoals === oppGoals ? 0.5 : 0;
// Torverhältnis-Faktor (verhindert Division durch 0 bei 0 Gegentoren)
const goalFactor = (myGoals + 1) / (oppGoals + 1);
acc.weightSum += weight;
acc.scoreSum += (resultPoints * 0.6 + goalFactor * 0.4) * weight;
return acc;
},
{
weightSum: 0,
scoreSum: 0,
},
),
form: awayTeamMatches
.slice(0, 5)
.reverse()
.map((b) => {
if (b.heim_spielpunkte === b.gast_spielpunkte) {
return "DRAW";
}
return b.auswaerts
? b.heim_spielpunkte < b.gast_spielpunkte
? "WIN"
: "LOST"
: b.heim_spielpunkte < b.gast_spielpunkte
? "LOST"
: "WIN";
}),
};
const homePower =
home.stats.weightSum > 0 ? home.stats.scoreSum / home.stats.weightSum : 1;
const awayPower =
away.stats.weightSum > 0 ? away.stats.scoreSum / away.stats.weightSum : 1;
// 4. Heimvorteil für das anstehende Match draufschlagen (+12% auf die Power)
const finalHomePower = homePower * 1.12;
const finalAwayPower = awayPower;
// Gesamte Power des Matches
const totalPower = finalHomePower + finalAwayPower;
// Grundwahrscheinlichkeiten für Sieg
const rawHomeProb = finalHomePower / totalPower;
const rawAwayProb = finalAwayPower / totalPower;
// 5. Unentschieden extrahieren
// Je näher die "Power" der beiden Teams beieinander liegt, desto höher die Remis-Chance
const powerDiff = Math.abs(finalHomePower - finalAwayPower);
const drawPercent = Math.round(Math.max(15, 26 - powerDiff * 15));
// Rest-Prozente auf Heim- und Auswärtssieg aufteilen
const restPercent = 100 - drawPercent;
const homePercent = Math.round(rawHomeProb * restPercent);
const awayPercent = Math.round(rawAwayProb * restPercent);
return {
prediction: {
homeWinChance: homePercent,
drawChance: drawPercent,
awayWinChance: awayPercent,
},
form: {
home: home.form,
away: away.form,
},
standing: Number(match.veranstaltung.tabellenwertung)
? {
home: {
pos: Number(match.heim_team.platz),
name: match.heim_team.teamname,
logo: match.heim_team.teambild,
games:
match.heim_team.siege +
match.heim_team.unentschieden +
match.heim_team.niederlagen,
points: match.heim_team.begegnungspunkte,
diff: `${match.heim_team.spielpunkte_differenz > 0 ? "+" : ""}${match.heim_team.spielpunkte_differenz}`,
},
away: {
pos: Number(match.gast_team.platz),
name: match.gast_team.teamname,
logo: match.gast_team.teambild,
games:
match.gast_team.siege +
match.gast_team.unentschieden +
match.gast_team.niederlagen,
points: match.gast_team.begegnungspunkte,
diff: `${match.gast_team.spielpunkte_differenz > 0 ? "+" : ""}${match.gast_team.spielpunkte_differenz}`,
},
}
: undefined,
firstLeg: match.hinspiel
? {
homeTeam: {
...teams[match.hinspiel.heim_team_id],
goals: Number(match.hinspiel.heim_punkte),
points: Number(match.hinspiel.heim_spielpunkte),
},
awayTeam: {
...teams[match.hinspiel.gast_team_id],
goals: Number(match.hinspiel.gast_punkte),
points: Number(match.hinspiel.gast_spielpunkte),
},
}
: undefined,
// orig: { teams, home, away, match, homeTeam, awayTeam },
};
};
}
const systems = Object.fromEntries(
[
["https://dtfb.de", Sportsmanager],
["https://kickern-hamburg.de", Sportsmanager, { spielort_heim: "1" }],
["https://nwtfv.com", Sportsmanager],
["https://stfv.de", Sportsmanager],
["https://ntfv.de", Sportsmanager, { spielort_heim: "1" }],
].map(([domain, Instance, params]) => [
new URL(domain).host,
new Instance(domain, params),
]),
);
const app = new Elysia()
.derive(({ error }) => ({
resolveGlobalId(id) {
try {
const [, system, remoteId] = Buffer.from(id, config.idFormat)
.toString("utf8")
.split(":");
const provider = systems[system];
return { provider, remoteId };
} catch (ex) {
return { error: error(400, "") };
}
},
}))
.group("/v1", (v1) =>
v1
.get("/matches", async () => {
return Promise.all(
Object.values(systems).map((o) => o.getOverview()),
).then((r) => r.flat());
})
.get("/matches/:id", async ({ params: { id }, resolveGlobalId }) => {
const { provider, remoteId } = resolveGlobalId(id);
return provider.getMatch(remoteId);
})
.get(
"/matches/:id/stats",
async ({ params: { id }, resolveGlobalId }) => {
const { provider, remoteId } = resolveGlobalId(id);
return provider.getMatchStats(remoteId);
},
),
)
.listen({ port: 3000, host: "0.0.0.0" });
console.log(
`🦊 Elysia is running at ${app.server?.hostname}:${app.server?.port}`,
);