import { ProductTarget } from '@prisma/client'; import { badRequest } from '../errors.js'; import { normalizeSteamId } from './steamid.js'; /** * Liest die Paket-Liste eines GExtension-Profils aus. Die Seite bettet die * Kaeufe als donations_data-Zuweisungen ins HTML ein, also wird das JSON * gelesen statt die gerenderte Tabelle geparst - das ist deutlich robuster * gegenueber Layout-Aenderungen und liefert zusaetzlich Kaufdatum, Betrag * und den Storno-Status. */ export interface ScrapedPackage { packageBoughtId: number; packageName: string; serverBundle: string; purchasedAt: Date | null; amount: string; gateway: string; revoked: boolean; } export interface ScrapedProfile { steamId64: string; nickname: string | null; packages: ScrapedPackage[]; } const DONATION_ENTRY = /donations_data\[(\d+)\]\s*=\s*(\{.*?\});/gs; // "06.10.2025 20:54" const GERMAN_DATE = /^(\d{2})\.(\d{2})\.(\d{4})(?:\s+(\d{2}):(\d{2}))?$/; function parseGermanDate(value: unknown): Date | null { if (typeof value !== 'string') return null; const match = GERMAN_DATE.exec(value.trim()); if (!match) return null; const [, day, month, year, hour, minute] = match; const parsed = new Date( Number(year), Number(month) - 1, Number(day), Number(hour ?? 0), Number(minute ?? 0), ); return Number.isNaN(parsed.getTime()) ? null : parsed; } export function parseProfile(html: string, steamId64: string): ScrapedProfile { const packages: ScrapedPackage[] = []; for (const match of html.matchAll(DONATION_ENTRY)) { const raw = match[2]; if (!raw) continue; let entry: Record; try { entry = JSON.parse(raw) as Record; } catch { // Einzelne kaputte Zeile darf nicht den ganzen Lauf kippen. continue; } const name = typeof entry.package_name === 'string' ? entry.package_name.trim() : ''; if (!name) continue; packages.push({ packageBoughtId: Number(entry.package_bought_id ?? match[1]), packageName: name, serverBundle: typeof entry.serverbundle_name === 'string' ? entry.serverbundle_name : '', purchasedAt: parseGermanDate(entry.date), amount: typeof entry.amount === 'string' ? entry.amount : '', gateway: typeof entry.gateway === 'string' ? entry.gateway : '', revoked: entry.revoked === 1 || entry.revoked === true || entry.status === 0, }); } const nickname = extractNickname(html); return { steamId64, nickname, packages }; } function extractNickname(html: string): string | null { const match = /Nickname\s*<\/td>\s*]*>([^<]+) { const wait = lastRequestAt + MIN_REQUEST_GAP_MS - Date.now(); if (wait > 0) await new Promise((resolve) => setTimeout(resolve, wait)); lastRequestAt = Date.now(); } export interface FetchOptions { baseUrl?: string; timeoutMs?: number; } export async function fetchProfile( steamId: string, options: FetchOptions = {}, ): Promise { const steamId64 = normalizeSteamId(steamId); if (!steamId64) throw badRequest('INVALID_STEAMID', `Keine gueltige SteamID: ${steamId}`); const baseUrl = (options.baseUrl ?? DEFAULT_BASE_URL).replace(/\/+$/, ''); const timeoutMs = options.timeoutMs ?? 15000; await throttle(); const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(`${baseUrl}/index.php?t=user&id=${steamId64}`, { signal: controller.signal, headers: { // Ehrliche Kennzeichnung statt Browser-Tarnung. 'user-agent': 'CC-Manager/1.0 (Paket-Abgleich fuer das CC-Team)', accept: 'text/html', }, }); if (!response.ok) { throw badRequest('PROFILE_UNAVAILABLE', `Profil nicht abrufbar (HTTP ${response.status})`); } return parseProfile(await response.text(), steamId64); } catch (error) { if (error instanceof Error && error.name === 'AbortError') { throw badRequest('PROFILE_TIMEOUT', 'Zeitueberschreitung beim Abruf des Profils'); } throw error; } finally { clearTimeout(timer); } }