(Feat): Added a minimal pikanetwork client

This commit is contained in:
2025-12-01 13:08:01 +00:00
commit 101d093965
68 changed files with 18007 additions and 0 deletions

280
src/api/pika/cache.ts Normal file
View File

@@ -0,0 +1,280 @@
/**
* PikaNetwork API Cache System
* Implements TTL-based caching for API responses
*/
import type { ProfileResponse, ClanResponse, LeaderboardResponse } from './types.ts';
interface CacheEntry<T> {
data: T;
expires: number;
}
/**
* Generic cache with TTL support
*/
class TTLCache<T> {
private cache = new Map<string, CacheEntry<T>>();
private readonly defaultTTL: number;
constructor(defaultTTL: number) {
this.defaultTTL = defaultTTL;
}
/**
* Check if an entry has expired
*/
private isExpired(expires: number): boolean {
return Date.now() > expires;
}
/**
* Get a value from the cache
*/
get(key: string): T | null {
const entry = this.cache.get(key.toLowerCase());
if (!entry) return null;
if (this.isExpired(entry.expires)) {
this.cache.delete(key.toLowerCase());
return null;
}
return entry.data;
}
/**
* Set a value in the cache
*/
set(key: string, data: T, ttl?: number): void {
this.cache.set(key.toLowerCase(), {
data,
expires: Date.now() + (ttl ?? this.defaultTTL),
});
}
/**
* Check if a key exists and is not expired
*/
has(key: string): boolean {
const entry = this.cache.get(key.toLowerCase());
if (!entry) return false;
if (this.isExpired(entry.expires)) {
this.cache.delete(key.toLowerCase());
return false;
}
return true;
}
/**
* Delete a specific key
*/
delete(key: string): boolean {
return this.cache.delete(key.toLowerCase());
}
/**
* Clear all entries
*/
clear(): void {
this.cache.clear();
}
/**
* Get the number of entries
*/
get size(): number {
return this.cache.size;
}
/**
* Remove expired entries
*/
cleanup(): number {
const now = Date.now();
let removed = 0;
for (const [key, entry] of this.cache.entries()) {
if (now > entry.expires) {
this.cache.delete(key);
removed++;
}
}
return removed;
}
}
/**
* PikaNetwork API Cache
* Manages caching for profiles, clans, and leaderboards
*/
export class PikaCache {
private profiles: TTLCache<ProfileResponse>;
private clans: TTLCache<ClanResponse>;
private leaderboards: TTLCache<LeaderboardResponse>;
private cleanupInterval: number | undefined;
constructor(ttl: number = 3600000) {
this.profiles = new TTLCache<ProfileResponse>(ttl);
this.clans = new TTLCache<ClanResponse>(ttl);
this.leaderboards = new TTLCache<LeaderboardResponse>(ttl);
// Run cleanup every 5 minutes
this.cleanupInterval = setInterval(() => this.cleanup(), 300000);
}
// =========================================================================
// Profile Cache
// =========================================================================
/**
* Get a cached profile
*/
getProfile(username: string): ProfileResponse | null {
return this.profiles.get(username);
}
/**
* Cache a profile
*/
setProfile(username: string, data: ProfileResponse, ttl?: number): void {
this.profiles.set(username, data, ttl);
}
/**
* Check if a profile is cached
*/
hasProfile(username: string): boolean {
return this.profiles.has(username);
}
// =========================================================================
// Clan Cache
// =========================================================================
/**
* Get a cached clan
*/
getClan(name: string): ClanResponse | null {
return this.clans.get(name);
}
/**
* Cache a clan
*/
setClan(name: string, data: ClanResponse, ttl?: number): void {
this.clans.set(name, data, ttl);
}
/**
* Check if a clan is cached
*/
hasClan(name: string): boolean {
return this.clans.has(name);
}
// =========================================================================
// Leaderboard Cache
// =========================================================================
/**
* Generate a cache key for leaderboard data
*/
private getLeaderboardKey(
username: string,
gamemode: string,
mode: string,
interval: string
): string {
return `${username}:${gamemode}:${mode}:${interval}`;
}
/**
* Get cached leaderboard data
*/
getLeaderboard(
username: string,
gamemode: string,
mode: string,
interval: string
): LeaderboardResponse | null {
const key = this.getLeaderboardKey(username, gamemode, mode, interval);
return this.leaderboards.get(key);
}
/**
* Cache leaderboard data
*/
setLeaderboard(
username: string,
gamemode: string,
mode: string,
interval: string,
data: LeaderboardResponse,
ttl?: number
): void {
const key = this.getLeaderboardKey(username, gamemode, mode, interval);
this.leaderboards.set(key, data, ttl);
}
/**
* Check if leaderboard data is cached
*/
hasLeaderboard(
username: string,
gamemode: string,
mode: string,
interval: string
): boolean {
const key = this.getLeaderboardKey(username, gamemode, mode, interval);
return this.leaderboards.has(key);
}
// =========================================================================
// General Methods
// =========================================================================
/**
* Clear all caches
*/
clear(): void {
this.profiles.clear();
this.clans.clear();
this.leaderboards.clear();
}
/**
* Run cleanup on all caches
*/
cleanup(): { profiles: number; clans: number; leaderboards: number } {
return {
profiles: this.profiles.cleanup(),
clans: this.clans.cleanup(),
leaderboards: this.leaderboards.cleanup(),
};
}
/**
* Get cache statistics
*/
getStats(): { profiles: number; clans: number; leaderboards: number } {
return {
profiles: this.profiles.size,
clans: this.clans.size,
leaderboards: this.leaderboards.size,
};
}
/**
* Destroy the cache and stop cleanup interval
*/
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
}
this.clear();
}
}

779
src/api/pika/client.ts Normal file
View File

@@ -0,0 +1,779 @@
/**
* PikaNetwork API Client
* Modern TypeScript implementation without proxy support
* Based on pikanetwork.js but rewritten with improvements
*/
import { PikaCache } from './cache.ts';
import type {
ProfileResponse,
ClanResponse,
LeaderboardResponse,
GameMode,
Interval,
PikaAPIOptions,
BedWarsStats,
SkyWarsStats,
MinimalLeaderboardData,
Punishment,
PunishmentType,
StaffList,
VoteLeaderboard,
VoteEntry,
ServerStatus,
TotalLeaderboardEntry,
TotalLeaderboardOptions,
JoinInfo,
MiscInfo,
} from './types.ts';
/**
* PikaNetwork API Client
* Provides methods to fetch player profiles, clan information, leaderboard data,
* punishments, staff lists, vote leaderboards, and server status
*/
export class PikaNetworkAPI {
private readonly baseUrl = 'https://stats.pika-network.net/api';
private readonly forumUrl = 'https://pika-network.net';
private readonly cache: PikaCache;
private readonly timeout: number;
private readonly userAgent: string;
// Staff roles for scraping
private readonly staffRoles = new Set([
'owner', 'manager', 'lead developer', 'developer',
'admin', 'sr mod', 'moderator', 'helper', 'trial'
]);
// Punishment type mapping
private readonly punishmentMap: Record<string, string> = {
warn: 'warnings',
mute: 'mutes',
kick: 'kicks',
ban: 'bans',
};
constructor(options: PikaAPIOptions = {}) {
this.cache = new PikaCache(options.cacheTTL ?? 3600000); // 1 hour default
this.timeout = options.timeout ?? 10000; // 10 seconds default
this.userAgent = options.userAgent ?? 'Elly Discord Bot/1.0';
}
// =========================================================================
// Private Helper Methods
// =========================================================================
/**
* Make an HTTP request with timeout and error handling
*/
private async request<T>(endpoint: string): Promise<T | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(`${this.baseUrl}${endpoint}`, {
signal: controller.signal,
headers: {
'User-Agent': this.userAgent,
'Accept': 'application/json',
},
});
clearTimeout(timeoutId);
if (!response.ok) {
console.error(`[PikaAPI] Request failed: ${response.status} ${response.statusText} for ${endpoint}`);
return null;
}
// Get response text first to handle empty responses
const text = await response.text();
// Handle empty responses
if (!text || text.trim() === '') {
console.warn(`[PikaAPI] Empty response for ${endpoint}`);
return null;
}
// Try to parse JSON
try {
return JSON.parse(text) as T;
} catch {
console.error(`[PikaAPI] Invalid JSON response for ${endpoint}: ${text.substring(0, 100)}...`);
return null;
}
} catch (error) {
if (error instanceof Error) {
if (error.name === 'AbortError') {
console.error(`[PikaAPI] Request timeout for ${endpoint}`);
} else {
console.error(`[PikaAPI] Request error for ${endpoint}: ${error.message}`);
}
}
return null;
}
}
/**
* Delay execution for rate limiting
*/
private delay(ms: number): Promise<void> {
return new Promise((resolve) => setTimeout(resolve, ms));
}
// =========================================================================
// Profile Methods
// =========================================================================
/**
* Get a player's profile
*/
async getProfile(username: string): Promise<ProfileResponse | null> {
// Check cache first
const cached = this.cache.getProfile(username);
if (cached) return cached;
// Fetch from API
const data = await this.request<ProfileResponse>(`/profile/${encodeURIComponent(username)}`);
if (data && typeof data === 'object' && 'username' in data) {
this.cache.setProfile(username, data);
return data;
}
return null;
}
/**
* Check if a player exists
*/
async playerExists(username: string): Promise<boolean> {
const profile = await this.getProfile(username);
return profile !== null;
}
// =========================================================================
// Clan Methods
// =========================================================================
/**
* Get clan information
*/
async getClan(name: string): Promise<ClanResponse | null> {
// Check cache first
const cached = this.cache.getClan(name);
if (cached) return cached;
// Fetch from API
const data = await this.request<ClanResponse>(`/clans/${encodeURIComponent(name)}`);
if (data && typeof data === 'object' && 'name' in data) {
this.cache.setClan(name, data);
return data;
}
return null;
}
/**
* Get all members of a clan
*/
async getClanMembers(name: string): Promise<string[]> {
const clan = await this.getClan(name);
if (!clan) return [];
return clan.members.map((member) => member.user.username);
}
// =========================================================================
// Leaderboard Methods
// =========================================================================
/**
* Get leaderboard data for a player
*/
async getLeaderboard(
username: string,
gamemode: GameMode,
interval: Interval = 'lifetime',
mode: string = 'all_modes'
): Promise<LeaderboardResponse | null> {
// Check cache first
const cached = this.cache.getLeaderboard(username, gamemode, mode, interval);
if (cached) return cached;
// Build URL with query parameters
const params = new URLSearchParams({
type: gamemode,
interval: interval,
mode: mode,
});
const data = await this.request<LeaderboardResponse>(
`/profile/${encodeURIComponent(username)}/leaderboard?${params.toString()}`
);
if (data) {
this.cache.setLeaderboard(username, gamemode, mode, interval, data);
return data;
}
return null;
}
/**
* Get parsed BedWars stats for a player
*/
async getBedWarsStats(
username: string,
interval: Interval = 'lifetime',
mode: string = 'all_modes'
): Promise<BedWarsStats | null> {
const data = await this.getLeaderboard(username, 'bedwars', interval, mode);
if (!data) return null;
return this.parseBedWarsStats(data);
}
/**
* Get parsed SkyWars stats for a player
*/
async getSkyWarsStats(
username: string,
interval: Interval = 'lifetime',
mode: string = 'all_modes'
): Promise<SkyWarsStats | null> {
const data = await this.getLeaderboard(username, 'skywars', interval, mode);
if (!data) return null;
return this.parseSkyWarsStats(data);
}
// =========================================================================
// Batch Methods
// =========================================================================
/**
* Get minimal leaderboard data for multiple players
* Useful for guild activity reports
*/
async getMinimalBatchLeaderboard(
usernames: string[],
interval: Interval = 'lifetime'
): Promise<MinimalLeaderboardData[]> {
const results: MinimalLeaderboardData[] = [];
const batchSize = 5;
const delayMs = 200;
for (let i = 0; i < usernames.length; i += batchSize) {
const batch = usernames.slice(i, i + batchSize);
const promises = batch.map(async (username) => {
const [bedwars, skywars] = await Promise.all([
this.getLeaderboard(username, 'bedwars', interval),
this.getLeaderboard(username, 'skywars', interval),
]);
const bwWins = this.getStatValue(bedwars, 'Wins');
const swWins = this.getStatValue(skywars, 'Wins');
return {
username,
bedwars_wins: bwWins,
skywars_wins: swWins,
total_wins: bwWins >= 0 && swWins >= 0 ? bwWins + swWins : -1,
};
});
const batchResults = await Promise.all(promises);
results.push(...batchResults);
// Rate limiting delay between batches
if (i + batchSize < usernames.length) {
await this.delay(delayMs);
}
}
return results;
}
/**
* Get full leaderboard data for multiple players
*/
async getBatchLeaderboard(
usernames: string[],
gamemode: GameMode,
interval: Interval = 'lifetime'
): Promise<Map<string, LeaderboardResponse | null>> {
const results = new Map<string, LeaderboardResponse | null>();
const batchSize = 5;
const delayMs = 200;
for (let i = 0; i < usernames.length; i += batchSize) {
const batch = usernames.slice(i, i + batchSize);
const promises = batch.map(async (username) => {
const data = await this.getLeaderboard(username, gamemode, interval);
return { username, data };
});
const batchResults = await Promise.all(promises);
batchResults.forEach(({ username, data }) => results.set(username, data));
// Rate limiting delay between batches
if (i + batchSize < usernames.length) {
await this.delay(delayMs);
}
}
return results;
}
// =========================================================================
// Stat Parsing Methods
// =========================================================================
/**
* Get a stat value from leaderboard data
*/
private getStatValue(data: LeaderboardResponse | null, key: string): number {
if (!data || !data[key] || !data[key].entries || data[key].entries.length === 0) {
return 0;
}
return data[key].entries[0].value;
}
/**
* Get a stat position from leaderboard data
*/
private getStatPosition(data: LeaderboardResponse | null, key: string): number {
if (!data || !data[key] || !data[key].entries || data[key].entries.length === 0) {
return 0;
}
return data[key].entries[0].place;
}
/**
* Calculate ratio between two numbers
*/
private calculateRatio(numerator: number, denominator: number): number {
if (denominator === 0) return numerator;
if (numerator === 0) return 0;
return Math.round((numerator / denominator) * 100) / 100;
}
/**
* Parse raw leaderboard data into BedWars stats
*/
private parseBedWarsStats(data: LeaderboardResponse): BedWarsStats {
const kills = this.getStatValue(data, 'Kills');
const deaths = this.getStatValue(data, 'Deaths');
const finalKills = this.getStatValue(data, 'Final kills');
const finalDeaths = this.getStatValue(data, 'Final deaths');
const wins = this.getStatValue(data, 'Wins');
const losses = this.getStatValue(data, 'Losses');
return {
kills,
deaths,
finalKills,
finalDeaths,
wins,
losses,
bedsDestroyed: this.getStatValue(data, 'Beds destroyed'),
gamesPlayed: this.getStatValue(data, 'Games played'),
highestWinstreak: this.getStatValue(data, 'Highest winstreak reached'),
bowKills: this.getStatValue(data, 'Bow kills'),
arrowsShot: this.getStatValue(data, 'Arrows shot'),
arrowsHit: this.getStatValue(data, 'Arrows hit'),
meleeKills: this.getStatValue(data, 'Melee kills'),
voidKills: this.getStatValue(data, 'Void kills'),
kdr: this.calculateRatio(kills, deaths),
fkdr: this.calculateRatio(finalKills, finalDeaths),
wlr: this.calculateRatio(wins, losses),
positions: {
kills: this.getStatPosition(data, 'Kills'),
deaths: this.getStatPosition(data, 'Deaths'),
finalKills: this.getStatPosition(data, 'Final kills'),
finalDeaths: this.getStatPosition(data, 'Final deaths'),
wins: this.getStatPosition(data, 'Wins'),
losses: this.getStatPosition(data, 'Losses'),
bedsDestroyed: this.getStatPosition(data, 'Beds destroyed'),
gamesPlayed: this.getStatPosition(data, 'Games played'),
highestWinstreak: this.getStatPosition(data, 'Highest winstreak reached'),
},
};
}
/**
* Parse raw leaderboard data into SkyWars stats
*/
private parseSkyWarsStats(data: LeaderboardResponse): SkyWarsStats {
const kills = this.getStatValue(data, 'Kills');
const deaths = this.getStatValue(data, 'Deaths');
const wins = this.getStatValue(data, 'Wins');
const losses = this.getStatValue(data, 'Losses');
return {
kills,
deaths,
wins,
losses,
gamesPlayed: this.getStatValue(data, 'Games played'),
highestWinstreak: this.getStatValue(data, 'Highest winstreak reached'),
bowKills: this.getStatValue(data, 'Bow kills'),
arrowsShot: this.getStatValue(data, 'Arrows shot'),
arrowsHit: this.getStatValue(data, 'Arrows hit'),
meleeKills: this.getStatValue(data, 'Melee kills'),
voidKills: this.getStatValue(data, 'Void kills'),
kdr: this.calculateRatio(kills, deaths),
wlr: this.calculateRatio(wins, losses),
positions: {
kills: this.getStatPosition(data, 'Kills'),
deaths: this.getStatPosition(data, 'Deaths'),
wins: this.getStatPosition(data, 'Wins'),
losses: this.getStatPosition(data, 'Losses'),
gamesPlayed: this.getStatPosition(data, 'Games played'),
highestWinstreak: this.getStatPosition(data, 'Highest winstreak reached'),
},
};
}
// =========================================================================
// Total Leaderboard Methods
// =========================================================================
/**
* Get total leaderboard data (top players for a stat)
*/
async getTotalLeaderboard(options: TotalLeaderboardOptions): Promise<TotalLeaderboardEntry[] | null> {
const params = new URLSearchParams({
type: options.gamemode,
interval: options.interval,
stat: options.stat,
mode: options.mode,
offset: String(options.offset ?? 0),
limit: String(options.limit ?? 15),
});
const data = await this.request<TotalLeaderboardEntry[]>(
`/leaderboards?${params.toString()}`
);
return data;
}
// =========================================================================
// Profile Extended Methods
// =========================================================================
/**
* Get friend list for a player
*/
async getFriendList(username: string): Promise<string[]> {
const profile = await this.getProfile(username);
if (!profile) return [];
return profile.friends.map((f) => f.username);
}
/**
* Get guild info for a player
*/
async getPlayerGuild(username: string): Promise<ClanResponse | null> {
const profile = await this.getProfile(username);
if (!profile?.clan) return null;
return profile.clan as ClanResponse;
}
/**
* Get rank info for a player
*/
async getRankInfo(username: string): Promise<{ level: number; percentage: number; display: string } | null> {
const profile = await this.getProfile(username);
if (!profile) return null;
return {
level: profile.rank.level,
percentage: profile.rank.percentage,
display: profile.rank.rankDisplay,
};
}
/**
* Get miscellaneous info for a player
*/
async getMiscInfo(username: string): Promise<MiscInfo | null> {
const profile = await this.getProfile(username);
if (!profile) return null;
return {
discordBoosting: profile.discord_boosting,
discordVerified: profile.discord_verified,
emailVerified: profile.email_verified,
username: profile.username,
};
}
/**
* Get join info for a player
*/
async getJoinInfo(username: string): Promise<JoinInfo | null> {
const profile = await this.getProfile(username);
if (!profile) return null;
const lastJoinDate = new Date(profile.lastSeen);
const formatOptions: Intl.DateTimeFormatOptions = {
day: 'numeric',
month: 'long',
year: 'numeric',
hour: '2-digit',
minute: '2-digit',
hour12: true,
};
return {
lastJoin: profile.lastSeen,
lastJoinFormatted: lastJoinDate.toLocaleString('en-US', formatOptions),
estimatedFirstJoin: null, // Would require punishment scraping
estimatedFirstJoinFormatted: 'N/A',
};
}
// =========================================================================
// Server Status Methods
// =========================================================================
/**
* Get PikaNetwork server status
*/
async getServerStatus(serverIP: string = 'play.pika-network.net'): Promise<ServerStatus | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(`https://api.mcstatus.io/v2/status/java/${serverIP}`, {
signal: controller.signal,
headers: {
'User-Agent': this.userAgent,
'Accept': 'application/json',
},
});
clearTimeout(timeoutId);
if (!response.ok) return null;
const text = await response.text();
if (!text || text.trim() === '') return null;
const data = JSON.parse(text);
const motdLines = data.motd?.clean?.split('\n').map((p: string) => p.trim()) ?? [];
const serverData: ServerStatus = {
host: data.host ?? serverIP,
ip: data.ip_address ?? serverIP,
port: data.port ?? 25565,
icon: `https://eu.mc-api.net/v3/server/favicon/${serverIP}`,
banner: `https://api.loohpjames.com/serverbanner.png?ip=${serverIP}`,
online: data.online ?? false,
software: data.version?.name_clean ?? 'Unknown',
protocol: data.version?.protocol ?? 0,
playersOnline: data.players?.online ?? 0,
playersMax: data.players?.max ?? 0,
motd: motdLines,
};
if (serverIP === 'play.pika-network.net') {
serverData.website = 'https://pika-network.net/';
serverData.discord = 'https://discord.gg/pikanetwork';
}
return serverData;
} catch (error) {
console.error(`[PikaAPI] Server status error: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
// =========================================================================
// HTML Scraping Methods (Forum Data)
// =========================================================================
/**
* Fetch HTML from a URL
*/
private async fetchHtml(url: string): Promise<string | null> {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': this.userAgent,
'Accept': 'text/html',
},
});
clearTimeout(timeoutId);
if (!response.ok) return null;
return await response.text();
} catch (error) {
console.error(`[PikaAPI] HTML fetch error for ${url}: ${error instanceof Error ? error.message : String(error)}`);
return null;
}
}
/**
* Clean punishment reason (remove Minecraft formatting codes)
*/
private cleanReason(reason: string): string {
const minecraftRegex = /(?:^\s+|(&|§)([0-9A-Fa-f])\b|&e[0-9]?\s?|^\[VL[^\]]*\]|^\?\s*)/g;
const formattingCodesRegex = /(§[0-9a-fk-or])|(&[0-9a-fk-or])/gi;
const cleaned = reason.replace(minecraftRegex, '').replace(formattingCodesRegex, '');
return cleaned || 'N/A';
}
/**
* Parse HTML to extract text (simple implementation without cheerio)
*/
private extractTextFromHtml(html: string, selector: string): string[] {
// Simple regex-based extraction for common patterns
const results: string[] = [];
// Match class-based selectors
const classMatch = selector.match(/\.([a-zA-Z0-9_-]+)/g);
if (classMatch) {
const className = classMatch[0].substring(1);
const regex = new RegExp(`class="[^"]*${className}[^"]*"[^>]*>([^<]+)`, 'gi');
let match;
while ((match = regex.exec(html)) !== null) {
results.push(match[1].trim());
}
}
return results;
}
/**
* Get punishments for a player (basic implementation)
* Note: Full implementation would require cheerio for HTML parsing
*/
async getPunishments(
username: string,
filter?: PunishmentType,
includeConsole: boolean = true
): Promise<Punishment[]> {
const html = await this.fetchHtml(`${this.forumUrl}/bans/search/${encodeURIComponent(username)}/`);
if (!html) return [];
// Basic parsing - for full implementation, use a proper HTML parser
const punishments: Punishment[] = [];
// Extract punishment rows using regex (simplified)
const rowRegex = /<div class="row"[^>]*>([\s\S]*?)<\/div>\s*<\/div>\s*<\/div>/gi;
let match;
while ((match = rowRegex.exec(html)) !== null) {
const row = match[1];
// Extract type
const typeMatch = row.match(/class="td _type"[^>]*>.*?<b>([^<]+)<\/b>/i);
const type = typeMatch ? typeMatch[1].trim().toLowerCase() : '';
// Extract staff
const staffMatch = row.match(/class="td _staff"[^>]*>([^<]+)/i);
const staff = staffMatch ? staffMatch[1].trim() : 'N/A';
// Extract reason
const reasonMatch = row.match(/class="td _reason"[^>]*>([^<]+)/i);
const reason = reasonMatch ? this.cleanReason(reasonMatch[1].trim()) : 'N/A';
// Extract date
const dateMatch = row.match(/class="td _date"[^>]*>([^<]+)/i);
const date = dateMatch ? dateMatch[1].trim() : '';
// Extract expires
const expiresMatch = row.match(/class="td _expires"[^>]*>([^<]+)/i);
const expires = expiresMatch ? expiresMatch[1].trim() : '';
if (type) {
const punishment: Punishment = {
type,
staff,
reason,
date,
expires,
};
// Filter by type if specified
if (filter && type !== filter) continue;
// Filter console punishments if needed
if (!includeConsole && staff.toLowerCase().includes('console')) continue;
punishments.push(punishment);
}
}
return punishments;
}
/**
* Get vote leaderboard (basic implementation)
*/
async getVoteLeaderboard(): Promise<VoteLeaderboard | null> {
const html = await this.fetchHtml(`${this.forumUrl}/vote`);
if (!html) return null;
const voters: VoteEntry[] = [];
const runnerUps: VoteEntry[] = [];
// Extract voters using regex (simplified)
const voterRegex = /class="voter[^"]*"[^>]*>[\s\S]*?class="position"[^>]*>#?(\d+)[\s\S]*?class="username"[^>]*>([^<]+)[\s\S]*?(\d+)\s*votes/gi;
let match;
let position = 1;
while ((match = voterRegex.exec(html)) !== null) {
const entry: VoteEntry = {
position: parseInt(match[1]) || position,
username: match[2].trim(),
votes: parseInt(match[3]) || 0,
};
if (html.indexOf(match[0]) < html.indexOf('runners-up')) {
voters.push(entry);
} else {
runnerUps.push(entry);
}
position++;
}
return { voters, runnerUps };
}
// =========================================================================
// Cache Management
// =========================================================================
/**
* Clear all cached data
*/
clearCache(): void {
this.cache.clear();
}
/**
* Get cache statistics
*/
getCacheStats(): { profiles: number; clans: number; leaderboards: number } {
return this.cache.getStats();
}
/**
* Destroy the client and cleanup resources
*/
destroy(): void {
this.cache.destroy();
}
}

63
src/api/pika/index.ts Normal file
View File

@@ -0,0 +1,63 @@
/**
* PikaNetwork API Module
* Exports all API-related types and classes
* Based on pikanetwork.js but rewritten in TypeScript with improvements
*/
export { PikaNetworkAPI } from './client.ts';
export { PikaCache } from './cache.ts';
export type {
// Profile types
ProfileResponse,
Rank,
PlayerRank,
Friend,
ClanInfo,
ClanLeveling,
ClanMember,
ClanMemberUser,
ClanOwner,
// Clan types
ClanResponse,
// Leaderboard types
LeaderboardResponse,
LeaderboardEntry,
LeaderboardEntryValue,
// Parsed stats types
BedWarsStats,
SkyWarsStats,
// Options and enums
GameMode,
Interval,
BedWarsMode,
SkyWarsMode,
PikaAPIOptions,
// Batch types
BatchLeaderboardResult,
MinimalLeaderboardData,
// Error types
PikaAPIError,
// Punishment types
Punishment,
PunishmentType,
// Staff types
StaffList,
StaffRole,
// Vote types
VoteEntry,
VoteLeaderboard,
// Server types
ServerStatus,
// Total leaderboard types
TotalLeaderboardEntry,
TotalLeaderboardOptions,
// Extended profile types
JoinInfo,
MiscInfo,
} from './types.ts';
export {
isProfileResponse,
isClanResponse,
isLeaderboardResponse,
} from './types.ts';

354
src/api/pika/types.ts Normal file
View File

@@ -0,0 +1,354 @@
/**
* PikaNetwork API Type Definitions
* Comprehensive TypeScript interfaces for all API responses
*/
// ============================================================================
// Profile Types
// ============================================================================
export interface Rank {
displayName: string;
name: string;
priority: number;
}
export interface PlayerRank {
level: number;
percentage: number;
rankDisplay: string;
}
export interface Friend {
username: string;
}
export interface ClanLeveling {
level: number;
exp: number;
totalExp: number;
}
export interface ClanMemberUser {
username: string;
}
export interface ClanMember {
user: ClanMemberUser;
joinTime: string;
}
export interface ClanOwner {
username: string;
}
export interface ClanInfo {
name: string;
tag: string;
currentTrophies: number;
creationTime: string;
members: ClanMember[];
owner: ClanOwner;
leveling: ClanLeveling;
}
export interface ProfileResponse {
username: string;
discord_verified: boolean;
lastSeen: number;
ranks: Rank[];
email_verified: boolean;
discord_boosting: boolean;
clan: ClanInfo | null;
rank: PlayerRank;
friends: Friend[];
}
// ============================================================================
// Clan Types
// ============================================================================
export interface ClanResponse {
name: string;
tag: string;
currentTrophies: number;
creationTime: string;
members: ClanMember[];
owner: ClanOwner;
leveling: ClanLeveling;
}
// ============================================================================
// Leaderboard Types
// ============================================================================
export interface LeaderboardEntryValue {
value: number;
place: number;
}
export interface LeaderboardEntry {
entries: LeaderboardEntryValue[] | null;
}
export interface LeaderboardResponse {
[key: string]: LeaderboardEntry;
}
// ============================================================================
// Parsed Stats Types
// ============================================================================
export interface BedWarsStats {
kills: number;
deaths: number;
finalKills: number;
finalDeaths: number;
wins: number;
losses: number;
bedsDestroyed: number;
gamesPlayed: number;
highestWinstreak: number;
bowKills: number;
arrowsShot: number;
arrowsHit: number;
meleeKills: number;
voidKills: number;
// Calculated ratios
kdr: number;
fkdr: number;
wlr: number;
// Leaderboard positions
positions: {
kills: number;
deaths: number;
finalKills: number;
finalDeaths: number;
wins: number;
losses: number;
bedsDestroyed: number;
gamesPlayed: number;
highestWinstreak: number;
};
}
export interface SkyWarsStats {
kills: number;
deaths: number;
wins: number;
losses: number;
gamesPlayed: number;
highestWinstreak: number;
bowKills: number;
arrowsShot: number;
arrowsHit: number;
meleeKills: number;
voidKills: number;
// Calculated ratios
kdr: number;
wlr: number;
// Leaderboard positions
positions: {
kills: number;
deaths: number;
wins: number;
losses: number;
gamesPlayed: number;
highestWinstreak: number;
};
}
// ============================================================================
// API Options & Enums
// ============================================================================
export type GameMode = 'bedwars' | 'skywars';
export type Interval = 'daily' | 'weekly' | 'monthly' | 'yearly' | 'lifetime';
export type BedWarsMode = 'solo' | 'doubles' | 'triples' | 'quad' | 'all_modes';
export type SkyWarsMode = 'solo' | 'doubles' | 'all_modes';
export interface PikaAPIOptions {
cacheTTL?: number;
timeout?: number;
userAgent?: string;
}
// ============================================================================
// Batch Request Types
// ============================================================================
export interface BatchLeaderboardResult {
username: string;
bedwarsWins: number;
skywarsWins: number;
totalWins: number;
}
export interface MinimalLeaderboardData {
username: string;
bedwars_wins: number;
skywars_wins: number;
total_wins: number;
}
// ============================================================================
// Error Types
// ============================================================================
export interface PikaAPIError {
status: number;
message: string;
endpoint: string;
}
// ============================================================================
// Helper Functions for Type Guards
// ============================================================================
export function isProfileResponse(data: unknown): data is ProfileResponse {
return (
typeof data === 'object' &&
data !== null &&
'username' in data &&
'rank' in data &&
'ranks' in data
);
}
export function isClanResponse(data: unknown): data is ClanResponse {
return (
typeof data === 'object' &&
data !== null &&
'name' in data &&
'tag' in data &&
'members' in data &&
'owner' in data
);
}
export function isLeaderboardResponse(data: unknown): data is LeaderboardResponse {
return typeof data === 'object' && data !== null;
}
// ============================================================================
// Punishment Types (Forum Scraping)
// ============================================================================
export type PunishmentType = 'warn' | 'kick' | 'ban' | 'mute';
export interface Punishment {
type: string;
player?: string;
playerAvatar?: string;
staff?: string;
staffAvatar?: string;
reason: string;
date: string;
expires: string;
}
// ============================================================================
// Staff Types (Forum Scraping)
// ============================================================================
export type StaffRole =
| 'owner'
| 'manager'
| 'leaddeveloper'
| 'developer'
| 'admin'
| 'srmod'
| 'moderator'
| 'helper'
| 'trial';
export interface StaffList {
owner: string[];
manager: string[];
leaddeveloper: string[];
developer: string[];
admin: string[];
srmod: string[];
moderator: string[];
helper: string[];
trial: string[];
}
// ============================================================================
// Vote Leaderboard Types
// ============================================================================
export interface VoteEntry {
position: number;
username: string;
votes: number;
}
export interface VoteLeaderboard {
voters: VoteEntry[];
runnerUps: VoteEntry[];
}
// ============================================================================
// Server Status Types
// ============================================================================
export interface ServerStatus {
host: string;
ip: string;
port: number;
icon: string;
banner: string;
online: boolean;
software: string;
protocol: number;
playersOnline: number;
playersMax: number;
motd: string[];
website?: string;
discord?: string;
}
// ============================================================================
// Total Leaderboard Types
// ============================================================================
export interface TotalLeaderboardEntry {
name: string;
value: number;
place: number;
}
export interface TotalLeaderboardOptions {
gamemode: GameMode;
interval: Interval;
stat: string;
mode: string;
offset?: number;
limit?: number;
}
// ============================================================================
// Join Info Types
// ============================================================================
export interface JoinInfo {
lastJoin: number;
lastJoinFormatted: string;
estimatedFirstJoin: Date | null;
estimatedFirstJoinFormatted: string;
}
// ============================================================================
// Misc Info Types
// ============================================================================
export interface MiscInfo {
discordBoosting: boolean;
discordVerified: boolean;
emailVerified: boolean;
username: string;
}