Add script for importing match schedule and logging
Some checks failed
Code Analysis and Production Deploy / analyze (push) Has been skipped
Code Analysis and Production Deploy / deploy-production (push) Has been skipped
Code Analysis and Production Deploy / deploy-test (push) Successful in 2m2s
Code Analysis and Production Deploy / analyze (pull_request) Failing after 33s
Code Analysis and Production Deploy / deploy-production (pull_request) Has been skipped
Code Analysis and Production Deploy / deploy-test (pull_request) Has been skipped
Require Package Version Change / check (pull_request) Failing after 10s

- Created `import-spielplan.js` to fetch and parse the match schedule from the specified URL, saving the output as JSON.
- Added `run-spielplan-import.sh` to automate the execution of the import script and log output.
- Introduced `spielplan.html` file to store the downloaded HTML content for further processing.
This commit is contained in:
Torsten Schulz (local)
2026-05-19 16:23:28 +02:00
parent c78adc0d52
commit 0849c625cb
21 changed files with 11413 additions and 233 deletions

View File

@@ -1,6 +1,12 @@
import fs from 'fs/promises'
import path from 'path'
import { getUserFromToken, hasAnyRole } from '../../utils/auth.js'
import {
createSpielplanJsonFromCsv,
getCurrentSeasonSlug,
getSpielplanSeasonJsonPathForCsvPath,
inferSeasonSlugFromRows
} from '../../utils/spielplan-data.js'
export default defineEventHandler(async (event) => {
try {
@@ -104,11 +110,20 @@ export default defineEventHandler(async (event) => {
const uniquePaths = [...new Set([...internalPaths])]
const writeResults = []
const writeErrors = []
const jsonWriteResults = []
for (const targetPath of uniquePaths) {
try {
await writeFileAtomicAndVerify(targetPath, content)
writeResults.push(targetPath)
if (filename === 'spielplan.csv') {
const spielplanJson = createSpielplanJsonFromCsv(content)
const seasonSlug = inferSeasonSlugFromRows(spielplanJson.data) || getCurrentSeasonSlug()
const jsonContent = `${JSON.stringify(spielplanJson, null, 2)}\n`
const jsonPath = getSpielplanSeasonJsonPathForCsvPath(targetPath, seasonSlug)
await writeFileAtomicAndVerify(jsonPath, jsonContent)
jsonWriteResults.push(jsonPath)
}
} catch (e) {
writeErrors.push({ targetPath, error: e?.message || String(e) })
}
@@ -125,7 +140,8 @@ export default defineEventHandler(async (event) => {
return {
success: true,
message: 'Datei erfolgreich gespeichert',
writtenTo: writeResults
writtenTo: writeResults,
jsonWrittenTo: jsonWriteResults
}
} catch (error) {

View File

@@ -1,61 +1,39 @@
import fs from 'fs/promises'
import path from 'path'
import { listSpielplanSeasons, readSpielplanData } from '../utils/spielplan-data.js'
export default defineEventHandler(async (event) => {
try {
const filePath = path.join(process.cwd(), 'public', 'data', 'spielplan.csv')
// Prüfe ob Datei existiert
try {
await fs.access(filePath)
} catch (_error) {
const query = getQuery(event)
const [spielplan, seasons] = await Promise.all([
readSpielplanData({ season: query.season }),
listSpielplanSeasons()
])
if (!spielplan.data.length || !spielplan.headers.length) {
return {
success: false,
message: 'Spielplan-Datei nicht gefunden',
data: []
message: 'Spielplan-Datei nicht gefunden oder leer',
data: [],
headers: []
}
}
// CSV-Datei lesen
const csvContent = await fs.readFile(filePath, 'utf-8')
const lines = csvContent.split('\n').filter(line => line.trim() !== '')
if (lines.length < 2) {
return {
success: false,
message: 'Spielplan-Datei ist leer oder unvollständig',
data: []
}
}
// Header-Zeile parsen
const headers = lines[0].split(';').map(header => header.trim())
// Datenzeilen parsen
const data = lines.slice(1).map(line => {
const values = line.split(';').map(value => value.trim())
const row = {}
headers.forEach((header, index) => {
row[header] = values[index] || ''
})
return row
})
return {
success: true,
message: 'Spielplan erfolgreich geladen',
data: data,
headers: headers
data: spielplan.data,
headers: spielplan.headers,
source: spielplan.source,
filePath: spielplan.filePath,
season: spielplan.season,
seasons
}
} catch (error) {
console.error('Fehler beim Laden des Spielplans:', error)
return {
success: false,
message: 'Fehler beim Laden des Spielplans',
data: []
data: [],
headers: []
}
}
})

View File

@@ -1,5 +1,6 @@
import fs from 'fs/promises'
import path from 'path'
import { readSpielplanData } from '../../utils/spielplan-data.js'
export default defineEventHandler(async (event) => {
try {
@@ -13,56 +14,15 @@ export default defineEventHandler(async (event) => {
})
}
// Lade Spielplandaten - bevorzugt aus server/data
let csvPath = path.join(process.cwd(), 'server/data/spielplan.csv')
try {
await fs.access(csvPath)
} catch {
csvPath = path.join(process.cwd(), 'public/data/spielplan.csv')
}
let csvContent
try {
csvContent = await fs.readFile(csvPath, 'utf-8')
} catch (_error) {
const spielplan = await readSpielplanData({ season: query.season })
if (!spielplan.data.length || !spielplan.headers.length) {
throw createError({
statusCode: 404,
statusMessage: 'Spielplandaten nicht gefunden'
})
}
// Parse CSV
const lines = csvContent.split('\n').filter(line => line.trim())
if (lines.length < 2) {
throw createError({
statusCode: 400,
statusMessage: 'Keine Spielplandaten verfügbar'
})
}
// Automatische Erkennung des Trennzeichens
const firstLine = lines[0]
const tabCount = (firstLine.match(/\t/g) || []).length
const semicolonCount = (firstLine.match(/;/g) || []).length
const delimiter = tabCount > semicolonCount ? '\t' : ';'
// nosemgrep: javascript.lang.security.audit.unsafe-formatstring.unsafe-formatstring
console.log(`Verwendetes Trennzeichen: ${delimiter === '\t' ? 'Tab' : 'Semikolon'}`)
const headers = firstLine.split(delimiter)
console.log('CSV-Header:', headers)
const dataRows = lines.slice(1).map(line => {
const values = line.split(delimiter)
const row = {}
headers.forEach((header, index) => {
row[header] = values[index] || ''
})
return row
})
console.log('Anzahl Datenzeilen:', dataRows.length)
console.log('Erste Datenzeile:', dataRows[0])
const dataRows = spielplan.data
// Filtere Daten basierend auf Team
let filteredData = dataRows
@@ -175,33 +135,6 @@ export default defineEventHandler(async (event) => {
})
}
// Filtere nach aktueller Saison (2025/26)
const currentSaisonStart = new Date(2025, 6, 1) // 01.07.2025
const currentSaisonEnd = new Date(2026, 5, 30) // 30.06.2026
filteredData = filteredData.filter(row => {
const termin = row.Termin
if (!termin) return false
try {
let spielDatum
if (termin.includes(' ')) {
const datumTeil = termin.split(' ')[0]
const [tag, monat, jahr] = datumTeil.split('.')
spielDatum = new Date(jahr, monat - 1, tag)
} else {
spielDatum = new Date(termin)
}
if (isNaN(spielDatum.getTime())) return false
return spielDatum >= currentSaisonStart && spielDatum <= currentSaisonEnd
} catch (_error) {
return false
}
})
// Sammle Halle-Informationen für die jeweilige Mannschaft
const hallenMap = new Map()

View File

@@ -0,0 +1,24 @@
import { getCurrentSeasonSlug, listSpielplanSeasons } from '../../utils/spielplan-data.js'
export default defineEventHandler(async () => {
try {
const seasons = await listSpielplanSeasons()
const currentSeason = getCurrentSeasonSlug()
const defaultSeason = seasons.find((season) => season.slug === currentSeason)?.slug || seasons[0]?.slug || null
return {
success: true,
seasons,
currentSeason,
defaultSeason
}
} catch (error) {
console.error('Fehler beim Laden der Spielplan-Saisons:', error)
return {
success: false,
seasons: [],
currentSeason: getCurrentSeasonSlug(),
defaultSeason: null
}
}
})

View File

@@ -0,0 +1,107 @@
import { importSpielplan } from '../utils/spielplan-import.js'
const TIME_ZONE = 'Europe/Berlin'
const RUN_HOUR = 7
const RUN_MINUTE = 0
const MAX_TIMEOUT = 2_147_483_647
let timer = null
let running = false
function getTimeParts(date) {
const parts = new Intl.DateTimeFormat('en-CA', {
timeZone: TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
}).formatToParts(date)
return Object.fromEntries(parts.map((part) => [part.type, part.value]))
}
function getTimeZoneOffset(date) {
const parts = getTimeParts(date)
const zonedAsUtc = Date.UTC(
Number(parts.year),
Number(parts.month) - 1,
Number(parts.day),
Number(parts.hour),
Number(parts.minute),
Number(parts.second)
)
return zonedAsUtc - date.getTime()
}
function zonedDateToUtc(year, month, day, hour, minute) {
const utcGuess = new Date(Date.UTC(year, month - 1, day, hour, minute, 0))
const offset = getTimeZoneOffset(utcGuess)
return new Date(utcGuess.getTime() - offset)
}
function nextRunAt(now = new Date()) {
const parts = getTimeParts(now)
let year = Number(parts.year)
let month = Number(parts.month)
let day = Number(parts.day)
let candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE)
if (candidate <= now) {
const nextDay = zonedDateToUtc(year, month, day + 1, 12, 0)
const nextParts = getTimeParts(nextDay)
year = Number(nextParts.year)
month = Number(nextParts.month)
day = Number(nextParts.day)
candidate = zonedDateToUtc(year, month, day, RUN_HOUR, RUN_MINUTE)
}
return candidate
}
async function runImport(reason) {
if (running) return
running = true
try {
const result = await importSpielplan()
console.log(`[spielplan-import] ${reason}: ${result.matchCount} Spiele importiert (${result.source.season.dateStart} bis ${result.source.season.dateEnd})`)
} catch (error) {
console.error('[spielplan-import] Import fehlgeschlagen:', error)
} finally {
running = false
}
}
function scheduleNext() {
const runAt = nextRunAt()
const delay = Math.min(Math.max(runAt.getTime() - Date.now(), 1_000), MAX_TIMEOUT)
timer = setTimeout(async () => {
await runImport('taeglicher Lauf')
scheduleNext()
}, delay)
timer.unref?.()
console.log(`[spielplan-import] Naechster Lauf: ${runAt.toISOString()} (${TIME_ZONE} ${String(RUN_HOUR).padStart(2, '0')}:${String(RUN_MINUTE).padStart(2, '0')})`)
}
export default defineNitroPlugin((nitroApp) => {
if (process.env.SPIELPLAN_IMPORT_DISABLED === 'true') {
console.log('[spielplan-import] Scheduler deaktiviert')
return
}
scheduleNext()
if (process.env.SPIELPLAN_IMPORT_RUN_ON_START === 'true') {
runImport('Startlauf')
}
nitroApp.hooks.hookOnce('close', () => {
if (timer) clearTimeout(timer)
})
})

View File

@@ -0,0 +1,472 @@
import { promises as fs } from 'fs'
import path from 'path'
import { getProjectPath, getServerDataPath } from './paths.js'
const SPIELPLAN_HEADERS = [
'Termin',
'Timestamp',
'Wochentag',
'Verband',
'Saison',
'Meisterschaft',
'Altersklasse',
'Liga',
'Staffel',
'Runde',
'BegegnungNr',
'HalleNr',
'HalleName',
'HalleStrasse',
'HallePLZ',
'HalleOrt',
'HeimVereinVerband',
'HeimVereinNr',
'HeimVereinName',
'HeimMannschaftAltersklasse',
'HeimMannschaftNr',
'HeimMannschaft',
'GastVereinVerband',
'GastVereinNr',
'GastVereinName',
'GastMannschaftAltersklasse',
'GastMannschaftNr',
'GastMannschaft',
'SpieleHeim',
'SpieleGast'
]
const CLUB_ID = '43030'
const CLUB_NAME = 'Harheimer TC'
const SEASON_SLUG_PATTERN = /^\d{2}--\d{2}$/
const SEASON_FILE_PATTERN = /^spielplan-(\d{2}--\d{2})\.json$/
function formatGermanDateTimeFromTimestamp(timestamp) {
if (!Number.isFinite(timestamp)) return ''
const parts = new Intl.DateTimeFormat('de-DE', {
timeZone: 'Europe/Berlin',
day: '2-digit',
month: '2-digit',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: false
}).formatToParts(new Date(timestamp * 1000))
const values = Object.fromEntries(parts.map((part) => [part.type, part.value]))
return `${values.day}.${values.month}.${values.year} ${values.hour}:${values.minute}`
}
function getMatchTimestamp(match) {
if (Number.isFinite(match?.timestamp)) return match.timestamp
const parsedDate = match?.date ? Date.parse(match.date) : NaN
return Number.isNaN(parsedDate) ? null : Math.floor(parsedDate / 1000)
}
function seasonLabel(season) {
if (!season?.startYear || !season?.endYear) return ''
return `${season.startYear}/${String(season.endYear).slice(-2)}`
}
function seasonSlugToLabel(slug) {
const match = String(slug || '').match(/^(\d{2})--(\d{2})$/)
if (!match) return ''
return `20${match[1]}/${match[2]}`
}
export function getCurrentSeasonSlug(date = new Date()) {
const year = date.getFullYear()
const month = date.getMonth()
const startYear = month >= 6 ? year : year - 1
const endYear = startYear + 1
return `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`
}
export function inferSeasonSlugFromRows(data) {
if (!Array.isArray(data)) return null
for (const row of data) {
const seasonValue = String(row?.Saison || '')
const seasonMatch = seasonValue.match(/(\d{4})\/(\d{2})/)
if (seasonMatch) {
return `${seasonMatch[1].slice(-2)}--${seasonMatch[2]}`
}
}
for (const row of data) {
const timestamp = Number(row?.Timestamp)
if (Number.isFinite(timestamp) && timestamp > 0) {
return getCurrentSeasonSlug(new Date(timestamp * 1000))
}
const termin = String(row?.Termin || '').trim()
const dateMatch = termin.match(/^(\d{1,2})\.(\d{1,2})\.(\d{4})/)
if (dateMatch) {
const [, day, month, year] = dateMatch
return getCurrentSeasonSlug(new Date(Number(year), Number(month) - 1, Number(day)))
}
}
return null
}
function inferAgeClass(match, teamName) {
const haystack = [
teamName,
match.leagueName,
match.leagueShortName
].filter(Boolean).join(' ').toLowerCase()
if (haystack.includes('jugend') || /\bj\d{2}\b/.test(haystack)) {
const ageMatch = haystack.match(/j(?:ugend\s*)?(\d{2})/)
return ageMatch ? `Jugend ${ageMatch[1]}` : 'Jugend'
}
return 'Erwachsene'
}
function inferTeamNumber(teamName) {
const normalized = String(teamName || '').trim()
if (!normalized) return ''
if (normalized === CLUB_NAME || normalized.includes('(J')) return '1'
const match = normalized.match(/\b([IVX]+)\b$/i)
if (!match) return '1'
const values = { I: 1, V: 5, X: 10 }
const roman = match[1].toUpperCase()
let total = 0
for (let i = 0; i < roman.length; i += 1) {
const current = values[roman[i]] || 0
const next = values[roman[i + 1]] || 0
total += current < next ? -current : current
}
return String(total || '')
}
function splitResult(result) {
const match = String(result || '').match(/^(\d+)\s*:\s*(\d+)$/)
return {
home: match ? match[1] : '',
away: match ? match[2] : ''
}
}
function isHarheimerMatch(match) {
return match?.teamHomeClubId === CLUB_ID || match?.teamAwayClubId === CLUB_ID
}
export function validateImportedSpielplan(imported) {
if (!imported || typeof imported !== 'object') {
throw new Error('Import-Datei ist kein JSON-Objekt')
}
if (imported.source?.clubId !== CLUB_ID) {
throw new Error(`Unerwartete Vereinsnummer: ${imported.source?.clubId || 'leer'}`)
}
if (!Array.isArray(imported.matches) || imported.matches.length === 0) {
throw new Error('Import-Datei enthaelt keine Spiele')
}
if (imported.matchCount !== imported.matches.length) {
throw new Error(`matchCount passt nicht zu matches.length (${imported.matchCount} != ${imported.matches.length})`)
}
imported.matches.forEach((match, index) => {
for (const field of ['date', 'meetingId', 'teamHome', 'teamAway']) {
if (!match[field]) {
throw new Error(`Spiel ${index + 1}: Pflichtfeld ${field} fehlt`)
}
}
if (!Number.isFinite(getMatchTimestamp(match))) {
throw new Error(`Spiel ${index + 1}: timestamp/date fehlt oder ist ungueltig`)
}
if (!isHarheimerMatch(match)) {
throw new Error(`Spiel ${index + 1}: keine Harheimer Beteiligung`)
}
})
return true
}
export function convertImportedSpielplanToJson(imported) {
validateImportedSpielplan(imported)
const season = seasonLabel(imported.source?.season)
const data = imported.matches.map((match) => {
const result = splitResult(match.result)
const timestamp = getMatchTimestamp(match)
const homeIsHarheim = match.teamHomeClubId === CLUB_ID
const awayIsHarheim = match.teamAwayClubId === CLUB_ID
return {
Termin: formatGermanDateTimeFromTimestamp(timestamp),
Timestamp: String(timestamp),
Wochentag: match.formattedDay ? match.formattedDay.split(',')[0] : '',
Verband: match.leagueOrgShortName || imported.source?.association || '',
Saison: season,
Meisterschaft: season ? `Kreis Frankfurt ${season}` : '',
Altersklasse: inferAgeClass(match, homeIsHarheim ? match.teamHome : match.teamAway),
Liga: match.leagueName || '',
Staffel: match.leagueShortName || '',
Runde: match.roundName || '',
BegegnungNr: match.meetingNumber || match.meetingId || '',
HalleNr: match.hallNumber || '',
HalleName: match.location?.label || '',
HalleStrasse: match.location?.street || '',
HallePLZ: match.location?.zip || '',
HalleOrt: match.location?.city || '',
HeimVereinVerband: match.leagueOrgShortName || imported.source?.association || '',
HeimVereinNr: match.teamHomeClubId || '',
HeimVereinName: homeIsHarheim ? CLUB_NAME : '',
HeimMannschaftAltersklasse: inferAgeClass(match, match.teamHome),
HeimMannschaftNr: inferTeamNumber(match.teamHome),
HeimMannschaft: match.teamHome || '',
GastVereinVerband: match.leagueOrgShortName || imported.source?.association || '',
GastVereinNr: match.teamAwayClubId || '',
GastVereinName: awayIsHarheim ? CLUB_NAME : '',
GastMannschaftAltersklasse: inferAgeClass(match, match.teamAway),
GastMannschaftNr: inferTeamNumber(match.teamAway),
GastMannschaft: match.teamAway || '',
SpieleHeim: result.home,
SpieleGast: result.away
}
})
return {
format: 'harheimertc.spielplan.v1',
sourceFormat: 'mytischtennis.import.v1',
updatedAt: new Date().toISOString(),
importedAt: imported.importedAt || null,
source: imported.source,
headers: SPIELPLAN_HEADERS,
data
}
}
export function parseDelimitedLine(line, delimiter) {
const values = []
let current = ''
let inQuotes = false
for (let i = 0; i < line.length; i += 1) {
const char = line[i]
const nextChar = line[i + 1]
if (char === '"' && inQuotes && nextChar === '"') {
current += '"'
i += 1
} else if (char === '"') {
inQuotes = !inQuotes
} else if (char === delimiter && !inQuotes) {
values.push(current.trim())
current = ''
} else {
current += char
}
}
values.push(current.trim())
return values
}
export function detectDelimiter(headerLine) {
const tabCount = (headerLine.match(/\t/g) || []).length
const semicolonCount = (headerLine.match(/;/g) || []).length
const commaCount = (headerLine.match(/,/g) || []).length
if (tabCount > semicolonCount && tabCount > commaCount) return '\t'
if (commaCount > semicolonCount) return ','
return ';'
}
export function parseSpielplanCsv(content) {
const lines = content.split(/\r?\n/).filter(line => line.trim() !== '')
if (lines.length < 2) {
return { headers: [], data: [] }
}
const delimiter = detectDelimiter(lines[0])
const headers = parseDelimitedLine(lines[0], delimiter)
const data = lines.slice(1).map((line) => {
const values = parseDelimitedLine(line, delimiter)
const row = {}
headers.forEach((header, index) => {
row[header] = values[index] || ''
})
return row
})
return { headers, data }
}
function escapeCsvValue(value) {
const stringValue = String(value ?? '')
if (/[;"\n\r]/.test(stringValue)) {
return `"${stringValue.replace(/"/g, '""')}"`
}
return stringValue
}
export function stringifySpielplanCsv(headers, data) {
return [
headers.map(escapeCsvValue).join(';'),
...data.map((row) => headers.map((header) => escapeCsvValue(row[header])).join(';'))
].join('\n')
}
export function createSpielplanJsonFromCsv(content) {
const parsed = parseSpielplanCsv(content)
const seasonSlug = inferSeasonSlugFromRows(parsed.data)
return {
format: 'harheimertc.spielplan.v1',
updatedAt: new Date().toISOString(),
source: seasonSlug
? {
season: {
seasonSlug,
label: seasonSlugToLabel(seasonSlug)
}
}
: undefined,
headers: parsed.headers,
data: parsed.data
}
}
async function readFirstExistingJson(paths) {
for (const filePath of paths) {
try {
const content = await fs.readFile(filePath, 'utf8')
const parsed = JSON.parse(content)
const headers = Array.isArray(parsed.headers) ? parsed.headers : SPIELPLAN_HEADERS
const data = Array.isArray(parsed.data) ? parsed.data : []
const fileName = path.basename(filePath)
const season = parsed.source?.season?.seasonSlug || fileName.match(SEASON_FILE_PATTERN)?.[1] || null
return {
source: 'json',
filePath,
season,
headers,
data
}
} catch (error) {
if (error.code !== 'ENOENT') {
console.error(`Fehler beim Lesen der Spielplan-JSON ${filePath}:`, error)
}
}
}
return null
}
async function readFirstExistingCsv(paths) {
for (const filePath of paths) {
try {
const content = await fs.readFile(filePath, 'utf8')
const parsed = parseSpielplanCsv(content)
return {
source: 'csv',
filePath,
season: inferSeasonSlugFromRows(parsed.data),
...parsed
}
} catch (error) {
if (error.code !== 'ENOENT') {
console.error(`Fehler beim Lesen der Spielplan-CSV ${filePath}:`, error)
}
}
}
return null
}
async function readSeasonFileInfo(filePath, slug) {
try {
const content = await fs.readFile(filePath, 'utf8')
const parsed = JSON.parse(content)
return {
slug,
label: parsed.source?.season?.label || seasonSlugToLabel(slug),
filePath,
updatedAt: parsed.updatedAt || null,
importedAt: parsed.importedAt || null,
count: Array.isArray(parsed.data) ? parsed.data.length : 0
}
} catch (_error) {
return {
slug,
label: seasonSlugToLabel(slug),
filePath,
updatedAt: null,
importedAt: null,
count: 0
}
}
}
export async function listSpielplanSeasons() {
const directories = [
getServerDataPath('public-data', 'spielplaene'),
getProjectPath('public', 'data', 'spielplaene')
]
const bySlug = new Map()
for (const directory of directories) {
let entries = []
try {
entries = await fs.readdir(directory)
} catch (error) {
if (error.code !== 'ENOENT') {
console.error(`Fehler beim Lesen des Spielplan-Saisonverzeichnisses ${directory}:`, error)
}
continue
}
for (const entry of entries) {
const match = entry.match(SEASON_FILE_PATTERN)
if (!match || bySlug.has(match[1])) continue
bySlug.set(match[1], await readSeasonFileInfo(path.join(directory, entry), match[1]))
}
}
return [...bySlug.values()].sort((a, b) => b.slug.localeCompare(a.slug))
}
export async function getDefaultSpielplanSeason() {
const seasons = await listSpielplanSeasons()
const currentSeason = getCurrentSeasonSlug()
return seasons.find((season) => season.slug === currentSeason)?.slug || seasons[0]?.slug || null
}
export async function readSpielplanData(options = {}) {
const season = options.season && SEASON_SLUG_PATTERN.test(options.season)
? options.season
: await getDefaultSpielplanSeason()
const seasonFile = season ? `spielplan-${season}.json` : null
const jsonPaths = seasonFile ? [
getServerDataPath('public-data', 'spielplaene', seasonFile),
getProjectPath('public', 'data', 'spielplaene', seasonFile)
] : []
const csvPaths = seasonFile
? []
: [
getServerDataPath('public-data', 'spielplan.csv'),
getServerDataPath('spielplan.csv'),
getProjectPath('public', 'data', 'spielplan.csv')
]
return await readFirstExistingJson(jsonPaths) || await readFirstExistingCsv(csvPaths) || {
source: null,
filePath: null,
season,
headers: [],
data: []
}
}
export function getSpielplanSeasonJsonPathForCsvPath(csvPath, seasonSlug) {
return path.join(path.dirname(csvPath), 'spielplaene', `spielplan-${seasonSlug}.json`)
}

View File

@@ -0,0 +1,202 @@
import { promises as fs } from 'fs'
import path from 'path'
import { getServerDataPath } from './paths.js'
const DEFAULT_CONFIG = {
association: 'HeTTV',
clubId: '43030',
clubName: 'Harheimer_TC'
}
const OUTPUT_DIR = getServerDataPath('spielplan-import')
const JSON_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.json')
const HTML_FILE = path.join(OUTPUT_DIR, 'harheimer_tc_spielplan.html')
function pad2(value) {
return String(value).padStart(2, '0')
}
export function getSpieljahrForDate(date = new Date()) {
const year = date.getFullYear()
const startYear = date.getMonth() >= 6 ? year : year - 1
const endYear = startYear + 1
return {
startYear,
endYear,
seasonSlug: `${String(startYear).slice(-2)}--${String(endYear).slice(-2)}`,
dateStart: `${startYear}-07-01`,
dateEnd: `${endYear}-06-30`
}
}
export function buildSpielplanUrl(season, config = DEFAULT_CONFIG) {
const base = `https://www.mytischtennis.de/click-tt/${config.association}/${season.seasonSlug}/verein/${config.clubId}/${config.clubName}/spielplan`
return `${base}?date_start=${season.dateStart}&date_end=${season.dateEnd}`
}
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 looksLikeScheduleByDate(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return false
}
return Object.entries(value).some(([key, list]) => (
/^\d{4}-\d{2}-\d{2}$/.test(key) &&
Array.isArray(list) &&
list.some((item) => item && item.team_home && item.team_away && item.meeting_id)
))
}
function findSchedule(value, trail = []) {
if (looksLikeScheduleByDate(value)) {
return { schedule: value, path: trail }
}
if (!value || typeof value !== 'object' || Array.isArray(value)) {
return null
}
for (const [key, child] of Object.entries(value)) {
const result = findSchedule(child, trail.concat(key))
if (result) return result
}
return null
}
function normalizeMatch(day, match) {
const dateTimestamp = match.date ? Date.parse(match.date) : NaN
return {
day,
date: match.date ?? null,
timestamp: Number.isNaN(dateTimestamp) ? null : Math.floor(dateTimestamp / 1000),
formattedDay: match.formattedDay ?? null,
formattedTime: match.formattedTime ?? null,
state: match.state ?? null,
meetingId: match.meeting_id ?? null,
meetingNumber: match.meeting_number ?? null,
leagueId: match.league_id ?? null,
leagueName: match.league_name ?? null,
leagueShortName: match.league_short_name ?? null,
leagueOrgShortName: match.league_org_short_name ?? null,
roundName: match.round_name ?? null,
teamHome: match.team_home ?? null,
teamHomeId: match.team_home_id ?? null,
teamHomeClubId: match.team_home_club_id ?? null,
teamAway: match.team_away ?? null,
teamAwayId: match.team_away_id ?? null,
teamAwayClubId: match.team_away_club_id ?? null,
result: match.matches_won != null && match.matches_lost != null
? `${match.matches_won}:${match.matches_lost}`
: null,
isConfirmed: match.is_confirmed ?? null,
isComplete: match.is_meeting_complete ?? null,
originalDate: match.original_date ?? null,
location: match.location ?? null,
pdfUrl: match.pdf_url ?? null
}
}
export function parseSpielplanHtml(html, source) {
const context = extractRemixContext(html)
const result = findSchedule(context.state?.loaderData)
if (!result) {
throw new Error('Keinen Spielplan im Remix loaderData gefunden')
}
const matchesByDay = result.schedule
const matches = Object.keys(matchesByDay)
.sort()
.flatMap((day) => matchesByDay[day].map((match) => normalizeMatch(day, match)))
return {
importedAt: new Date().toISOString(),
source,
loaderDataPath: result.path.join('.'),
matchCount: matches.length,
matchesByDay,
matches
}
}
export async function importSpielplan(options = {}) {
const today = options.today ?? new Date()
const season = getSpieljahrForDate(today)
const url = buildSpielplanUrl(season)
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(`Spielplan-Download fehlgeschlagen: HTTP ${response.status}`)
}
const html = await response.text()
const parsed = parseSpielplanHtml(html, {
url,
clubId: DEFAULT_CONFIG.clubId,
clubName: DEFAULT_CONFIG.clubName,
association: DEFAULT_CONFIG.association,
season
})
await fs.mkdir(OUTPUT_DIR, { recursive: true })
await fs.writeFile(HTML_FILE, html, 'utf8')
await fs.writeFile(JSON_FILE, `${JSON.stringify(parsed, null, 2)}\n`, 'utf8')
return {
jsonFile: JSON_FILE,
htmlFile: HTML_FILE,
...parsed
}
}
export async function readImportedSpielplan() {
const content = await fs.readFile(JSON_FILE, 'utf8')
return JSON.parse(content)
}