Files
CC-Manager/server/src/lib/gextension.ts
T
Harlekin 7876e29b90 Abgleich in der README beschreiben und Adresse konfigurierbar machen
Der Abgleich liest GExtension-Profile und ist auf deren Seitenaufbau gebaut.
Damit niemand in einer anderen Community lange sucht, warum nichts passiert,
steht das jetzt als Warnhinweis am Anfang des Abschnitts: bei einer anderen
GExtension-Instanz reicht GEXTENSION_BASE_URL, bei einem voellig anderen
Shop-System muesste der Parser angepasst werden. Der Rest der App laeuft
unabhaengig davon.

Die Adresse war bisher fest im Code verdrahtet und kommt jetzt aus der
Umgebung. Der Parser selbst bleibt frei von Konfiguration und Datenbank,
damit er ohne beides testbar ist - die Adresse reicht der Service durch.
2026-09-18 15:48:21 +02:00

193 lines
6.0 KiB
TypeScript

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<string, unknown>;
try {
entry = JSON.parse(raw) as Record<string, unknown>;
} 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*<td[^>]*>([^<]+)</i.exec(html);
return match?.[1]?.trim() || null;
}
export type Classification =
| { kind: 'package'; target: ProductTarget }
| { kind: 'usage_counter'; times: number }
| { kind: 'unclear' }
| { kind: 'ignore' };
// Nur der SCP-RP-Bereich fuehrt CC-relevante Pakete.
const RELEVANT_BUNDLE = 'scp-rp';
// Faengt Titel ab, die offensichtlich mit CCs zu tun haben, aber von keiner
// Regel sicher zugeordnet werden - die gehen in die Handpruefung statt geraten
// zu werden.
const CC_HINT = /(^|\W)(cc|custom\s*charact|custom\s*charakt|redesign)/i;
const USAGE_COUNTER = /cc[\s-]*paket.*genutzt/i;
const USAGE_TIMES = /\|\s*(\d+)\s*mal/i;
/**
* Ordnet einen Paket-Titel einer Guthaben-Art zu. Die Reihenfolge ist
* bewusst: "Custom Character Redesign" enthaelt beide Stichworte und muss als
* Redesign zaehlen, nicht als CC-Paket.
*/
export function classifyPackage(pkg: ScrapedPackage): Classification {
if (pkg.revoked) return { kind: 'ignore' };
if (pkg.serverBundle.toLowerCase() !== RELEVANT_BUNDLE) return { kind: 'ignore' };
const title = pkg.packageName;
if (USAGE_COUNTER.test(title)) {
const times = USAGE_TIMES.exec(title);
return { kind: 'usage_counter', times: times ? Number(times[1]) : 1 };
}
if (/redesign/i.test(title)) {
return { kind: 'package', target: ProductTarget.REDESIGN_PACKAGE };
}
if (/custom\s*charac?k?ter/i.test(title)) {
return { kind: 'package', target: ProductTarget.CC_PACKAGE };
}
// Klingt nach CC, passt aber in kein Muster: lieber nachfragen als raten.
if (CC_HINT.test(title)) return { kind: 'unclear' };
return { kind: 'ignore' };
}
// Standard ist die Instanz, fuer die der Parser gebaut wurde. Laeuft GExtension
// bei einer anderen Community unter eigener Domain, reicht ein anderer Wert.
export const DEFAULT_BASE_URL = 'https://gextension.senseigaming.de';
// Gemeinsam genutzte Community-Infrastruktur: hoechstens eine Anfrage pro Sekunde.
const MIN_REQUEST_GAP_MS = 1000;
let lastRequestAt = 0;
async function throttle(): Promise<void> {
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<ScrapedProfile> {
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);
}
}