diff --git a/scripts/import-spielplan.js b/scripts/import-spielplan.js index b95d1b1..592af77 100644 --- a/scripts/import-spielplan.js +++ b/scripts/import-spielplan.js @@ -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})`) diff --git a/server/plugins/spielplan-import-scheduler.js b/server/plugins/spielplan-import-scheduler.js index 02f4944..de95ea7 100644 --- a/server/plugins/spielplan-import-scheduler.js +++ b/server/plugins/spielplan-import-scheduler.js @@ -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 { diff --git a/server/utils/spielklassen-tables-import.js b/server/utils/spielklassen-tables-import.js new file mode 100644 index 0000000..8fab970 --- /dev/null +++ b/server/utils/spielklassen-tables-import.js @@ -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 + } +} \ No newline at end of file