feat(import): add daily click-tt league table import by season
This commit is contained in:
@@ -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})`)
|
||||
|
||||
@@ -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 {
|
||||
|
||||
223
server/utils/spielklassen-tables-import.js
Normal file
223
server/utils/spielklassen-tables-import.js
Normal 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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user