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
Submodule apps/backend deleted from 3dd31e693b
+42
View File
@@ -0,0 +1,42 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.js
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
# local env files
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
**/*.trace
**/*.zip
**/*.tar.gz
**/*.tgz
**/*.log
package-lock.json
**/*.bun
+15
View File
@@ -0,0 +1,15 @@
# Elysia with Bun runtime
## Getting Started
To get started with this template, simply paste this command into your terminal:
```bash
bun create elysia ./elysia-example
```
## Development
To start the development server run:
```bash
bun run dev
```
Open http://localhost:3000/ with your browser to see the result.
+15
View File
@@ -0,0 +1,15 @@
{
"name": "backend",
"version": "1.0.50",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"dev": "bun run --watch src/index.ts"
},
"dependencies": {
"elysia": "latest"
},
"devDependencies": {
"bun-types": "latest"
},
"module": "src/index.js"
}
+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}`,
);
+103
View File
@@ -0,0 +1,103 @@
{
"compilerOptions": {
/* Visit https://aka.ms/tsconfig to read more about this file */
/* Projects */
// "incremental": true, /* Save .tsbuildinfo files to allow for incremental compilation of projects. */
// "composite": true, /* Enable constraints that allow a TypeScript project to be used with project references. */
// "tsBuildInfoFile": "./.tsbuildinfo", /* Specify the path to .tsbuildinfo incremental compilation file. */
// "disableSourceOfProjectReferenceRedirect": true, /* Disable preferring source files instead of declaration files when referencing composite projects. */
// "disableSolutionSearching": true, /* Opt a project out of multi-project reference checking when editing. */
// "disableReferencedProjectLoad": true, /* Reduce the number of projects loaded automatically by TypeScript. */
/* Language and Environment */
"target": "ES2021", /* Set the JavaScript language version for emitted JavaScript and include compatible library declarations. */
// "lib": [], /* Specify a set of bundled library declaration files that describe the target runtime environment. */
// "jsx": "preserve", /* Specify what JSX code is generated. */
// "experimentalDecorators": true, /* Enable experimental support for TC39 stage 2 draft decorators. */
// "emitDecoratorMetadata": true, /* Emit design-type metadata for decorated declarations in source files. */
// "jsxFactory": "", /* Specify the JSX factory function used when targeting React JSX emit, e.g. 'React.createElement' or 'h'. */
// "jsxFragmentFactory": "", /* Specify the JSX Fragment reference used for fragments when targeting React JSX emit e.g. 'React.Fragment' or 'Fragment'. */
// "jsxImportSource": "", /* Specify module specifier used to import the JSX factory functions when using 'jsx: react-jsx*'. */
// "reactNamespace": "", /* Specify the object invoked for 'createElement'. This only applies when targeting 'react' JSX emit. */
// "noLib": true, /* Disable including any library files, including the default lib.d.ts. */
// "useDefineForClassFields": true, /* Emit ECMAScript-standard-compliant class fields. */
// "moduleDetection": "auto", /* Control what method is used to detect module-format JS files. */
/* Modules */
"module": "ES2022", /* Specify what module code is generated. */
// "rootDir": "./", /* Specify the root folder within your source files. */
"moduleResolution": "node", /* Specify how TypeScript looks up a file from a given module specifier. */
// "baseUrl": "./", /* Specify the base directory to resolve non-relative module names. */
// "paths": {}, /* Specify a set of entries that re-map imports to additional lookup locations. */
// "rootDirs": [], /* Allow multiple folders to be treated as one when resolving modules. */
// "typeRoots": [], /* Specify multiple folders that act like './node_modules/@types'. */
"types": ["bun-types"], /* Specify type package names to be included without being referenced in a source file. */
// "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */
// "moduleSuffixes": [], /* List of file name suffixes to search when resolving a module. */
// "resolveJsonModule": true, /* Enable importing .json files. */
// "noResolve": true, /* Disallow 'import's, 'require's or '<reference>'s from expanding the number of files TypeScript should add to a project. */
/* JavaScript Support */
// "allowJs": true, /* Allow JavaScript files to be a part of your program. Use the 'checkJS' option to get errors from these files. */
// "checkJs": true, /* Enable error reporting in type-checked JavaScript files. */
// "maxNodeModuleJsDepth": 1, /* Specify the maximum folder depth used for checking JavaScript files from 'node_modules'. Only applicable with 'allowJs'. */
/* Emit */
// "declaration": true, /* Generate .d.ts files from TypeScript and JavaScript files in your project. */
// "declarationMap": true, /* Create sourcemaps for d.ts files. */
// "emitDeclarationOnly": true, /* Only output d.ts files and not JavaScript files. */
// "sourceMap": true, /* Create source map files for emitted JavaScript files. */
// "outFile": "./", /* Specify a file that bundles all outputs into one JavaScript file. If 'declaration' is true, also designates a file that bundles all .d.ts output. */
// "outDir": "./", /* Specify an output folder for all emitted files. */
// "removeComments": true, /* Disable emitting comments. */
// "noEmit": true, /* Disable emitting files from a compilation. */
// "importHelpers": true, /* Allow importing helper functions from tslib once per project, instead of including them per-file. */
// "importsNotUsedAsValues": "remove", /* Specify emit/checking behavior for imports that are only used for types. */
// "downlevelIteration": true, /* Emit more compliant, but verbose and less performant JavaScript for iteration. */
// "sourceRoot": "", /* Specify the root path for debuggers to find the reference source code. */
// "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */
// "inlineSourceMap": true, /* Include sourcemap files inside the emitted JavaScript. */
// "inlineSources": true, /* Include source code in the sourcemaps inside the emitted JavaScript. */
// "emitBOM": true, /* Emit a UTF-8 Byte Order Mark (BOM) in the beginning of output files. */
// "newLine": "crlf", /* Set the newline character for emitting files. */
// "stripInternal": true, /* Disable emitting declarations that have '@internal' in their JSDoc comments. */
// "noEmitHelpers": true, /* Disable generating custom helper functions like '__extends' in compiled output. */
// "noEmitOnError": true, /* Disable emitting files if any type checking errors are reported. */
// "preserveConstEnums": true, /* Disable erasing 'const enum' declarations in generated code. */
// "declarationDir": "./", /* Specify the output directory for generated declaration files. */
// "preserveValueImports": true, /* Preserve unused imported values in the JavaScript output that would otherwise be removed. */
/* Interop Constraints */
// "isolatedModules": true, /* Ensure that each file can be safely transpiled without relying on other imports. */
// "allowSyntheticDefaultImports": true, /* Allow 'import x from y' when a module doesn't have a default export. */
"esModuleInterop": true, /* Emit additional JavaScript to ease support for importing CommonJS modules. This enables 'allowSyntheticDefaultImports' for type compatibility. */
// "preserveSymlinks": true, /* Disable resolving symlinks to their realpath. This correlates to the same flag in node. */
"forceConsistentCasingInFileNames": true, /* Ensure that casing is correct in imports. */
/* Type Checking */
"strict": true, /* Enable all strict type-checking options. */
// "noImplicitAny": true, /* Enable error reporting for expressions and declarations with an implied 'any' type. */
// "strictNullChecks": true, /* When type checking, take into account 'null' and 'undefined'. */
// "strictFunctionTypes": true, /* When assigning functions, check to ensure parameters and the return values are subtype-compatible. */
// "strictBindCallApply": true, /* Check that the arguments for 'bind', 'call', and 'apply' methods match the original function. */
// "strictPropertyInitialization": true, /* Check for class properties that are declared but not set in the constructor. */
// "noImplicitThis": true, /* Enable error reporting when 'this' is given the type 'any'. */
// "useUnknownInCatchVariables": true, /* Default catch clause variables as 'unknown' instead of 'any'. */
// "alwaysStrict": true, /* Ensure 'use strict' is always emitted. */
// "noUnusedLocals": true, /* Enable error reporting when local variables aren't read. */
// "noUnusedParameters": true, /* Raise an error when a function parameter isn't read. */
// "exactOptionalPropertyTypes": true, /* Interpret optional property types as written, rather than adding 'undefined'. */
// "noImplicitReturns": true, /* Enable error reporting for codepaths that do not explicitly return in a function. */
// "noFallthroughCasesInSwitch": true, /* Enable error reporting for fallthrough cases in switch statements. */
// "noUncheckedIndexedAccess": true, /* Add 'undefined' to a type when accessed using an index. */
// "noImplicitOverride": true, /* Ensure overriding members in derived classes are marked with an override modifier. */
// "noPropertyAccessFromIndexSignature": true, /* Enforces using indexed accessors for keys declared using an indexed type. */
// "allowUnusedLabels": true, /* Disable error reporting for unused labels. */
// "allowUnreachableCode": true, /* Disable error reporting for unreachable code. */
/* Completeness */
// "skipDefaultLibCheck": true, /* Skip type checking .d.ts files that are included with TypeScript. */
"skipLibCheck": true /* Skip type checking all .d.ts files. */
}
}