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' function normalizeHeaderName(value) { return String(value || '').trim().toLowerCase() } function toUrlOrNull(value) { const raw = String(value || '').trim() if (!raw) return null try { return new URL(raw) } catch { return null } } function isSupportedTableUrl(urlValue) { const parsed = toUrlOrNull(urlValue) if (!parsed) return false const host = parsed.hostname.toLowerCase() if (host !== 'www.mytischtennis.de' && host !== 'mytischtennis.de' && host !== 'click-tt.de' && host !== 'www.click-tt.de') { return false } return parsed.pathname.includes('/tabelle/') } 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 || {} if (!Array.isArray(data.league_table)) { throw new Error('Ungueltige Tabellenstruktur: league_table fehlt') } const leagueTable = 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, 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() !== '') if (lines.length < 2) return [] const headers = parseDelimitedLine(lines[0], ',').map(normalizeHeaderName) const teamIdx = headers.indexOf('mannschaft') const leagueIdx = headers.indexOf('liga') const infoLinkIdx = headers.indexOf('weitere informationen link') if (infoLinkIdx === -1) { throw new Error('CSV-Spalte weitere_informationen_link nicht gefunden') } const rows = [] for (const line of lines.slice(1)) { const values = parseDelimitedLine(line, ',') const tableUrl = values[infoLinkIdx] ? String(values[infoLinkIdx]).trim() : '' if (!tableUrl) continue if (!isSupportedTableUrl(tableUrl)) continue rows.push({ teamName: teamIdx >= 0 ? (values[teamIdx] || null) : null, leagueName: leagueIdx >= 0 ? (values[leagueIdx] || null) : 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 } }