feat(import): add daily click-tt league table import by season
This commit is contained in:
@@ -1,10 +1,14 @@
|
|||||||
#!/usr/bin/env node
|
#!/usr/bin/env node
|
||||||
|
|
||||||
import { importSpielplan } from '../server/utils/spielplan-import.js'
|
import { importSpielplan } from '../server/utils/spielplan-import.js'
|
||||||
|
import { importLeagueTables } from '../server/utils/spielklassen-tables-import.js'
|
||||||
|
|
||||||
const result = await importSpielplan()
|
const result = await importSpielplan()
|
||||||
|
const tables = await importLeagueTables()
|
||||||
|
|
||||||
console.log(`Spielplan gespeichert: ${result.jsonFile}`)
|
console.log(`Spielplan gespeichert: ${result.jsonFile}`)
|
||||||
console.log(`Roh-HTML gespeichert: ${result.htmlFile}`)
|
console.log(`Roh-HTML gespeichert: ${result.htmlFile}`)
|
||||||
console.log(`Spiele: ${result.matchCount}`)
|
console.log(`Spiele: ${result.matchCount}`)
|
||||||
console.log(`Zeitraum: ${result.source.season.dateStart} bis ${result.source.season.dateEnd}`)
|
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 { 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'
|
import { info as loggerInfo, error as loggerError } from '../utils/logger.js'
|
||||||
|
|
||||||
const TIME_ZONE = 'Europe/Berlin'
|
const TIME_ZONE = 'Europe/Berlin'
|
||||||
@@ -68,8 +69,19 @@ async function runImport(reason) {
|
|||||||
|
|
||||||
running = true
|
running = true
|
||||||
try {
|
try {
|
||||||
const result = await importSpielplan()
|
const spielplan = await importSpielplan()
|
||||||
loggerInfo(`[spielplan-import] ${reason}: ${result.matchCount} Spiele importiert`, { range: `${result.source.season.dateStart} - ${result.source.season.dateEnd}` })
|
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) {
|
} catch (error) {
|
||||||
loggerError('[spielplan-import] Import fehlgeschlagen:', { error })
|
loggerError('[spielplan-import] Import fehlgeschlagen:', { error })
|
||||||
} finally {
|
} 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