Replace backend submodule with Elysia/Bun sportsmanager backend
This commit is contained in:
@@ -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}`,
|
||||
);
|
||||
Reference in New Issue
Block a user