#!/usr/bin/env node import fs from "node:fs"; import path from "node:path"; import { execFileSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const CONFIG = { association: "HeTTV", clubId: "43030", clubName: "Harheimer_TC", outputDir: path.join(__dirname, "data"), outputFile: "harheimer_tc_spielplan.json", htmlFile: "harheimer_tc_spielplan.html", }; function parseArgs(argv) { const args = {}; for (let i = 2; i < argv.length; i += 1) { const arg = argv[i]; if (arg.startsWith("--")) { const key = arg.slice(2); const next = argv[i + 1]; if (!next || next.startsWith("--")) { args[key] = true; } else { args[key] = next; i += 1; } } } return args; } function pad2(value) { return String(value).padStart(2, "0"); } function seasonForDate(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`, }; } function buildUrl(season) { 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 readHtml(args, url) { if (args.input) { return fs.readFileSync(path.resolve(args.input), "utf8"); } const html = execFileSync("curl", ["-fsSL", "--compressed", url], { encoding: "utf8", maxBuffer: 20 * 1024 * 1024, }); fs.mkdirSync(CONFIG.outputDir, { recursive: true }); fs.writeFileSync(path.join(CONFIG.outputDir, CONFIG.htmlFile), html); return html; } 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; } const entries = Object.entries(value); if (entries.length === 0) { return false; } return entries.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") { return null; } if (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) { return { day, date: match.date ?? null, 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, }; } function parseSchedule(html, meta) { 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: meta, loaderDataPath: result.path.join("."), matchCount: matches.length, matchesByDay, matches, }; } function main() { const args = parseArgs(process.argv); const today = args.today ? new Date(`${args.today}T12:00:00`) : new Date(); if (Number.isNaN(today.getTime())) { throw new Error(`Ungueltiges Datum fuer --today: ${args.today}`); } const season = seasonForDate(today); const url = buildUrl(season); const html = readHtml(args, url); const parsed = parseSchedule(html, { url, clubId: CONFIG.clubId, clubName: CONFIG.clubName, association: CONFIG.association, season, }); fs.mkdirSync(CONFIG.outputDir, { recursive: true }); const outputPath = path.join(CONFIG.outputDir, CONFIG.outputFile); fs.writeFileSync(outputPath, `${JSON.stringify(parsed, null, 2)}\n`); console.log(`Spielplan gespeichert: ${outputPath}`); console.log(`Spiele: ${parsed.matchCount}`); console.log(`Zeitraum: ${season.dateStart} bis ${season.dateEnd}`); } main();