feat(import): add daily click-tt league table import by season
Some checks failed
Code Analysis and Production Deploy / analyze (push) Failing after 2m39s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Has been skipped

This commit is contained in:
Torsten Schulz (local)
2026-05-20 17:28:07 +02:00
parent 3658589d94
commit 21b39d4e5c
3 changed files with 241 additions and 2 deletions

View File

@@ -1,10 +1,14 @@
#!/usr/bin/env node
import { importSpielplan } from '../server/utils/spielplan-import.js'
import { importLeagueTables } from '../server/utils/spielklassen-tables-import.js'
const result = await importSpielplan()
const tables = await importLeagueTables()
console.log(`Spielplan gespeichert: ${result.jsonFile}`)
console.log(`Roh-HTML gespeichert: ${result.htmlFile}`)
console.log(`Spiele: ${result.matchCount}`)
console.log(`Zeitraum: ${result.source.season.dateStart} bis ${result.source.season.dateEnd}`)
console.log(`Tabellen gespeichert: ${tables.outputFile}`)
console.log(`Tabellen importiert: ${tables.importedCount}/${tables.teamCount} (Fehler: ${tables.errorCount})`)

View File

@@ -1,4 +1,5 @@
import { importSpielplan } from '../utils/spielplan-import.js'
import { importLeagueTables } from '../utils/spielklassen-tables-import.js'
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
const TIME_ZONE = 'Europe/Berlin'
@@ -68,8 +69,19 @@ async function runImport(reason) {
running = true
try {
const result = await importSpielplan()
loggerInfo(`[spielplan-import] ${reason}: ${result.matchCount} Spiele importiert`, { range: `${result.source.season.dateStart} - ${result.source.season.dateEnd}` })
const spielplan = await importSpielplan()
loggerInfo(`[spielplan-import] ${reason}: ${spielplan.matchCount} Spiele importiert`, { range: `${spielplan.source.season.dateStart} - ${spielplan.source.season.dateEnd}` })
try {
const tables = await importLeagueTables()
loggerInfo(`[spielplan-import] ${reason}: ${tables.importedCount}/${tables.teamCount} Tabellen importiert`, {
season: tables.seasonSlug,
outputFile: tables.outputFile,
errors: tables.errorCount
})
} catch (error) {
loggerError('[spielplan-import] Tabellen-Import fehlgeschlagen:', { error })
}
} catch (error) {
loggerError('[spielplan-import] Import fehlgeschlagen:', { error })
} finally {

View File

@@ -0,0 +1,223 @@
import { promises as fs } from 'fs'
import path from 'path'
import { getServerDataPath, getProjectPath } from './paths.js'
import { getSpieljahrForDate } from './spielplan-import.js'
import { parseDelimitedLine } from './spielplan-data.js'
const OUTPUT_DIR = getServerDataPath('spielplan-import')
const DEFAULT_ASSOCIATION = 'HeTTV'
async function fileExists(filePath) {
try {
await fs.access(filePath)
return true
} catch {
return false
}
}
function extractRemixContext(html) {
const marker = 'window.__remixContext = '
const start = html.indexOf(marker)
if (start === -1) {
throw new Error('window.__remixContext nicht gefunden')
}
const jsonStart = start + marker.length
let depth = 0
let inString = false
let escaped = false
for (let i = jsonStart; i < html.length; i += 1) {
const char = html[i]
if (inString) {
if (escaped) {
escaped = false
} else if (char === '\\') {
escaped = true
} else if (char === '"') {
inString = false
}
continue
}
if (char === '"') {
inString = true
} else if (char === '{') {
depth += 1
} else if (char === '}') {
depth -= 1
if (depth === 0) {
return JSON.parse(html.slice(jsonStart, i + 1))
}
}
}
throw new Error('Ende von window.__remixContext nicht gefunden')
}
function getTableRoutePayload(loaderData) {
const exactKey = 'routes/click-tt+/$association+/$season+/$type+/$groupname.gruppe.$urlid+/tabelle.$filter'
if (loaderData?.[exactKey]) {
return { routeKey: exactKey, payload: loaderData[exactKey] }
}
const fallbackKey = Object.keys(loaderData || {}).find((key) => key.includes('/tabelle.$filter'))
if (fallbackKey && loaderData[fallbackKey]) {
return { routeKey: fallbackKey, payload: loaderData[fallbackKey] }
}
throw new Error('Tabellen-Route in loaderData nicht gefunden')
}
function normalizeTableData(url, routeKey, payload) {
const data = payload?.data || {}
const leagueTable = Array.isArray(data.league_table) ? data.league_table : []
return {
url,
routeKey,
fetchedAt: new Date().toISOString(),
association: payload?.association || null,
season: payload?.season || null,
groupname: payload?.groupname || null,
urlid: payload?.urlid || null,
filter: payload?.filter || null,
headInfos: Array.isArray(data.head_infos) ? data.head_infos : [],
setCount: data.set_count ?? null,
gameCount: data.game_count ?? null,
noMeetings: data.no_meetings ?? null,
gamesPerSet: data.games_per_set ?? null,
leagueTtrAvg: data.league_ttr_avg ?? null,
subTableInfo: data.sub_table_info ?? null,
meetingsExcerpt: data.meetings_excerpt ?? null,
rowCount: leagueTable.length,
leagueTable
}
}
function extractSeasonSlugFromUrl(url) {
const match = String(url || '').match(/\/click-tt\/[^/]+\/(\d{2}--\d{2})\//)
return match?.[1] || null
}
async function resolveMannschaftenCsvPath() {
const candidates = [
getServerDataPath('public-data', 'mannschaften.csv'),
getServerDataPath('mannschaften.csv'),
getProjectPath('.output', 'public', 'data', 'mannschaften.csv'),
getProjectPath('public', 'data', 'mannschaften.csv')
]
for (const candidate of candidates) {
if (await fileExists(candidate)) return candidate
}
throw new Error('Mannschaften-Datei nicht gefunden')
}
function extractRowsWithTableUrl(csvContent) {
const lines = csvContent.split(/\r?\n/).filter((line) => line.trim() !== '')
const rows = []
for (const line of lines) {
if (!line.includes('mytischtennis.de')) continue
const values = parseDelimitedLine(line, ',')
const tableUrl = values.find((value) => /^https?:\/\/www\.mytischtennis\.de\//.test(value))
if (!tableUrl) continue
rows.push({
teamName: values[0] || null,
leagueName: values[1] || null,
tableUrl
})
}
return rows
}
async function fetchTable(url) {
const response = await fetch(url, {
headers: {
accept: 'text/html,application/xhtml+xml',
'accept-language': 'de-DE,de;q=0.9'
}
})
if (!response.ok) {
throw new Error(`Tabellen-Download fehlgeschlagen: HTTP ${response.status}`)
}
const html = await response.text()
const context = extractRemixContext(html)
const loaderData = context?.state?.loaderData || {}
const { routeKey, payload } = getTableRoutePayload(loaderData)
return normalizeTableData(url, routeKey, payload)
}
export async function importLeagueTables(options = {}) {
const today = options.today ?? new Date()
const season = getSpieljahrForDate(today)
const csvPath = await resolveMannschaftenCsvPath()
const csvContent = await fs.readFile(csvPath, 'utf8')
const teams = extractRowsWithTableUrl(csvContent)
const entries = []
const errors = []
for (const team of teams) {
try {
const table = await fetchTable(team.tableUrl)
entries.push({
teamName: team.teamName,
leagueName: team.leagueName,
table
})
} catch (error) {
errors.push({
teamName: team.teamName,
leagueName: team.leagueName,
tableUrl: team.tableUrl,
error: error?.message || String(error)
})
}
}
const fallbackSeasonSlug = teams.length > 0 ? extractSeasonSlugFromUrl(teams[0].tableUrl) : null
const seasonSlug = fallbackSeasonSlug || season.seasonSlug
const outputFile = path.join(OUTPUT_DIR, `tables_${seasonSlug}.json`)
const payload = {
format: 'harheimertc.tables.v1',
importedAt: new Date().toISOString(),
source: {
association: DEFAULT_ASSOCIATION,
season: {
seasonSlug,
dateStart: season.dateStart,
dateEnd: season.dateEnd
},
csvPath
},
teamCount: teams.length,
importedCount: entries.length,
errorCount: errors.length,
tables: entries,
errors
}
await fs.mkdir(OUTPUT_DIR, { recursive: true })
await fs.writeFile(outputFile, `${JSON.stringify(payload, null, 2)}\n`, 'utf8')
return {
outputFile,
seasonSlug,
teamCount: teams.length,
importedCount: entries.length,
errorCount: errors.length,
tables: entries,
errors
}
}