Files
harheimertc/server/utils/spielklassen-tables-import.js
Torsten Schulz (local) fd83b18642
All checks were successful
Code Analysis and Production Deploy / analyze (push) Successful in 2m44s
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m0s
fix(import): prefer seasonal mannschaften csv for tables
2026-05-20 18:58:46 +02:00

273 lines
7.6 KiB
JavaScript

import { promises as fs } from 'fs'
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
}
function sanitizeSeasonSlug(value) {
const seasonSlug = String(value || '').trim()
return /^\d{2}--\d{2}$/.test(seasonSlug) ? seasonSlug : null
}
async function resolveMannschaftenCsvPath(seasonSlug) {
const seasonalName = sanitizeSeasonSlug(seasonSlug) ? `mannschaften_${seasonSlug}.csv` : null
const fileNames = seasonalName ? [seasonalName, 'mannschaften.csv'] : ['mannschaften.csv']
const candidates = []
for (const fileName of fileNames) {
candidates.push(
getServerDataPath('public-data', fileName),
getServerDataPath(fileName),
getProjectPath('.output', 'public', 'data', fileName),
getProjectPath('public', 'data', fileName)
)
}
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(season.seasonSlug)
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 = sanitizeSeasonSlug(fallbackSeasonSlug) || sanitizeSeasonSlug(season.seasonSlug) || getSpieljahrForDate(today).seasonSlug
const outputFile = `${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
}
}