(Feat): Initial Commit

This commit is contained in:
2025-12-12 16:54:00 +00:00
parent 101d093965
commit 470e5a13aa
10 changed files with 1330 additions and 173 deletions

View File

@@ -1,45 +1,127 @@
/**
* PikaNetwork API Cache System
* Implements TTL-based caching for API responses
* Advanced TTL-based caching with LRU eviction, statistics, and persistence support
*/
import type { ProfileResponse, ClanResponse, LeaderboardResponse } from './types.ts';
import type {
ProfileResponse,
ClanResponse,
LeaderboardResponse,
StaffList,
VoteLeaderboard,
ServerStatus,
Punishment,
} from './types.ts';
// ============================================================================
// Cache Entry Types
// ============================================================================
interface CacheEntry<T> {
data: T;
expires: number;
createdAt: number;
hits: number;
lastAccess: number;
}
interface CacheStats {
hits: number;
misses: number;
size: number;
evictions: number;
hitRate: number;
}
interface CacheOptions {
maxSize?: number;
defaultTTL?: number;
enableLRU?: boolean;
onEvict?: (key: string, reason: 'expired' | 'lru' | 'manual') => void;
}
// ============================================================================
// Advanced TTL Cache with LRU Eviction
// ============================================================================
/**
* Generic cache with TTL support
* Generic cache with TTL support and LRU eviction
*/
class TTLCache<T> {
class AdvancedCache<T> {
private cache = new Map<string, CacheEntry<T>>();
private readonly defaultTTL: number;
private readonly maxSize: number;
private readonly enableLRU: boolean;
private readonly onEvict?: (key: string, reason: 'expired' | 'lru' | 'manual') => void;
constructor(defaultTTL: number) {
this.defaultTTL = defaultTTL;
// Statistics
private stats = {
hits: 0,
misses: 0,
evictions: 0,
};
constructor(options: CacheOptions = {}) {
this.defaultTTL = options.defaultTTL ?? 3600000; // 1 hour default
this.maxSize = options.maxSize ?? 1000;
this.enableLRU = options.enableLRU ?? true;
this.onEvict = options.onEvict;
}
/**
* Check if an entry has expired
*/
private isExpired(expires: number): boolean {
return Date.now() > expires;
private isExpired(entry: CacheEntry<T>): boolean {
return Date.now() > entry.expires;
}
/**
* Evict the least recently used entry
*/
private evictLRU(): void {
if (!this.enableLRU || this.cache.size === 0) return;
let oldestKey: string | null = null;
let oldestAccess = Infinity;
for (const [key, entry] of this.cache.entries()) {
if (entry.lastAccess < oldestAccess) {
oldestAccess = entry.lastAccess;
oldestKey = key;
}
}
if (oldestKey) {
this.cache.delete(oldestKey);
this.stats.evictions++;
this.onEvict?.(oldestKey, 'lru');
}
}
/**
* Get a value from the cache
*/
get(key: string): T | null {
const entry = this.cache.get(key.toLowerCase());
if (!entry) return null;
const normalizedKey = key.toLowerCase();
const entry = this.cache.get(normalizedKey);
if (this.isExpired(entry.expires)) {
this.cache.delete(key.toLowerCase());
if (!entry) {
this.stats.misses++;
return null;
}
if (this.isExpired(entry)) {
this.cache.delete(normalizedKey);
this.onEvict?.(normalizedKey, 'expired');
this.stats.misses++;
return null;
}
// Update access stats
entry.hits++;
entry.lastAccess = Date.now();
this.stats.hits++;
return entry.data;
}
@@ -47,9 +129,20 @@ class TTLCache<T> {
* Set a value in the cache
*/
set(key: string, data: T, ttl?: number): void {
this.cache.set(key.toLowerCase(), {
const normalizedKey = key.toLowerCase();
// Evict if at max capacity
if (this.cache.size >= this.maxSize && !this.cache.has(normalizedKey)) {
this.evictLRU();
}
const now = Date.now();
this.cache.set(normalizedKey, {
data,
expires: Date.now() + (ttl ?? this.defaultTTL),
expires: now + (ttl ?? this.defaultTTL),
createdAt: now,
hits: 0,
lastAccess: now,
});
}
@@ -57,11 +150,14 @@ class TTLCache<T> {
* Check if a key exists and is not expired
*/
has(key: string): boolean {
const entry = this.cache.get(key.toLowerCase());
const normalizedKey = key.toLowerCase();
const entry = this.cache.get(normalizedKey);
if (!entry) return false;
if (this.isExpired(entry.expires)) {
this.cache.delete(key.toLowerCase());
if (this.isExpired(entry)) {
this.cache.delete(normalizedKey);
this.onEvict?.(normalizedKey, 'expired');
return false;
}
@@ -72,7 +168,13 @@ class TTLCache<T> {
* Delete a specific key
*/
delete(key: string): boolean {
return this.cache.delete(key.toLowerCase());
const normalizedKey = key.toLowerCase();
const existed = this.cache.has(normalizedKey);
if (existed) {
this.cache.delete(normalizedKey);
this.onEvict?.(normalizedKey, 'manual');
}
return existed;
}
/**
@@ -80,6 +182,7 @@ class TTLCache<T> {
*/
clear(): void {
this.cache.clear();
this.stats = { hits: 0, misses: 0, evictions: 0 };
}
/**
@@ -99,90 +202,200 @@ class TTLCache<T> {
for (const [key, entry] of this.cache.entries()) {
if (now > entry.expires) {
this.cache.delete(key);
this.onEvict?.(key, 'expired');
removed++;
}
}
return removed;
}
/**
* Get cache statistics
*/
getStats(): CacheStats {
const total = this.stats.hits + this.stats.misses;
return {
hits: this.stats.hits,
misses: this.stats.misses,
size: this.cache.size,
evictions: this.stats.evictions,
hitRate: total > 0 ? this.stats.hits / total : 0,
};
}
/**
* Get all keys
*/
keys(): string[] {
return Array.from(this.cache.keys());
}
/**
* Get entry metadata (without data)
*/
getMetadata(key: string): Omit<CacheEntry<T>, 'data'> | null {
const entry = this.cache.get(key.toLowerCase());
if (!entry) return null;
const { data, ...metadata } = entry;
return metadata;
}
/**
* Update TTL for an existing entry
*/
touch(key: string, ttl?: number): boolean {
const normalizedKey = key.toLowerCase();
const entry = this.cache.get(normalizedKey);
if (!entry || this.isExpired(entry)) return false;
entry.expires = Date.now() + (ttl ?? this.defaultTTL);
entry.lastAccess = Date.now();
return true;
}
}
// ============================================================================
// PikaNetwork API Cache Manager
// ============================================================================
export interface PikaCacheOptions {
profileTTL?: number;
clanTTL?: number;
leaderboardTTL?: number;
staffTTL?: number;
voteTTL?: number;
serverTTL?: number;
punishmentTTL?: number;
maxProfileEntries?: number;
maxClanEntries?: number;
maxLeaderboardEntries?: number;
cleanupIntervalMs?: number;
}
/**
* PikaNetwork API Cache
* Manages caching for profiles, clans, and leaderboards
* Manages caching for all API responses with configurable TTLs
*/
export class PikaCache {
private profiles: TTLCache<ProfileResponse>;
private clans: TTLCache<ClanResponse>;
private leaderboards: TTLCache<LeaderboardResponse>;
private profiles: AdvancedCache<ProfileResponse>;
private clans: AdvancedCache<ClanResponse>;
private leaderboards: AdvancedCache<LeaderboardResponse>;
private staff: AdvancedCache<StaffList>;
private votes: AdvancedCache<VoteLeaderboard>;
private server: AdvancedCache<ServerStatus>;
private punishments: AdvancedCache<Punishment[]>;
private generic: AdvancedCache<unknown>;
private cleanupInterval: number | undefined;
private readonly options: Required<PikaCacheOptions>;
constructor(ttl: number = 3600000) {
this.profiles = new TTLCache<ProfileResponse>(ttl);
this.clans = new TTLCache<ClanResponse>(ttl);
this.leaderboards = new TTLCache<LeaderboardResponse>(ttl);
constructor(options: PikaCacheOptions = {}) {
this.options = {
profileTTL: options.profileTTL ?? 600000, // 10 minutes
clanTTL: options.clanTTL ?? 900000, // 15 minutes
leaderboardTTL: options.leaderboardTTL ?? 300000, // 5 minutes
staffTTL: options.staffTTL ?? 3600000, // 1 hour
voteTTL: options.voteTTL ?? 1800000, // 30 minutes
serverTTL: options.serverTTL ?? 60000, // 1 minute
punishmentTTL: options.punishmentTTL ?? 600000, // 10 minutes
maxProfileEntries: options.maxProfileEntries ?? 500,
maxClanEntries: options.maxClanEntries ?? 100,
maxLeaderboardEntries: options.maxLeaderboardEntries ?? 2000,
cleanupIntervalMs: options.cleanupIntervalMs ?? 300000, // 5 minutes
};
// Run cleanup every 5 minutes
this.cleanupInterval = setInterval(() => this.cleanup(), 300000);
this.profiles = new AdvancedCache<ProfileResponse>({
defaultTTL: this.options.profileTTL,
maxSize: this.options.maxProfileEntries,
});
this.clans = new AdvancedCache<ClanResponse>({
defaultTTL: this.options.clanTTL,
maxSize: this.options.maxClanEntries,
});
this.leaderboards = new AdvancedCache<LeaderboardResponse>({
defaultTTL: this.options.leaderboardTTL,
maxSize: this.options.maxLeaderboardEntries,
});
this.staff = new AdvancedCache<StaffList>({
defaultTTL: this.options.staffTTL,
maxSize: 10,
});
this.votes = new AdvancedCache<VoteLeaderboard>({
defaultTTL: this.options.voteTTL,
maxSize: 10,
});
this.server = new AdvancedCache<ServerStatus>({
defaultTTL: this.options.serverTTL,
maxSize: 20,
});
this.punishments = new AdvancedCache<Punishment[]>({
defaultTTL: this.options.punishmentTTL,
maxSize: 200,
});
this.generic = new AdvancedCache<unknown>({
defaultTTL: 300000,
maxSize: 100,
});
// Run cleanup periodically
this.cleanupInterval = setInterval(
() => this.cleanup(),
this.options.cleanupIntervalMs
);
}
// =========================================================================
// 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);
}
deleteProfile(username: string): boolean {
return this.profiles.delete(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);
}
deleteClan(name: string): boolean {
return this.clans.delete(name);
}
// =========================================================================
// Leaderboard Cache
// =========================================================================
/**
* Generate a cache key for leaderboard data
*/
private getLeaderboardKey(
username: string,
gamemode: string,
@@ -192,9 +405,6 @@ export class PikaCache {
return `${username}:${gamemode}:${mode}:${interval}`;
}
/**
* Get cached leaderboard data
*/
getLeaderboard(
username: string,
gamemode: string,
@@ -205,9 +415,6 @@ export class PikaCache {
return this.leaderboards.get(key);
}
/**
* Cache leaderboard data
*/
setLeaderboard(
username: string,
gamemode: string,
@@ -220,9 +427,6 @@ export class PikaCache {
this.leaderboards.set(key, data, ttl);
}
/**
* Check if leaderboard data is cached
*/
hasLeaderboard(
username: string,
gamemode: string,
@@ -233,6 +437,93 @@ export class PikaCache {
return this.leaderboards.has(key);
}
// =========================================================================
// Staff Cache
// =========================================================================
getStaff(): StaffList | null {
return this.staff.get('staff');
}
setStaff(data: StaffList, ttl?: number): void {
this.staff.set('staff', data, ttl);
}
hasStaff(): boolean {
return this.staff.has('staff');
}
// =========================================================================
// Vote Leaderboard Cache
// =========================================================================
getVotes(): VoteLeaderboard | null {
return this.votes.get('votes');
}
setVotes(data: VoteLeaderboard, ttl?: number): void {
this.votes.set('votes', data, ttl);
}
hasVotes(): boolean {
return this.votes.has('votes');
}
// =========================================================================
// Server Status Cache
// =========================================================================
getServer(ip: string): ServerStatus | null {
return this.server.get(ip);
}
setServer(ip: string, data: ServerStatus, ttl?: number): void {
this.server.set(ip, data, ttl);
}
hasServer(ip: string): boolean {
return this.server.has(ip);
}
// =========================================================================
// Punishments Cache
// =========================================================================
private getPunishmentKey(username: string, filter?: string): string {
return `${username}:${filter ?? 'all'}`;
}
getPunishments(username: string, filter?: string): Punishment[] | null {
const key = this.getPunishmentKey(username, filter);
return this.punishments.get(key);
}
setPunishments(username: string, data: Punishment[], filter?: string, ttl?: number): void {
const key = this.getPunishmentKey(username, filter);
this.punishments.set(key, data, ttl);
}
hasPunishments(username: string, filter?: string): boolean {
const key = this.getPunishmentKey(username, filter);
return this.punishments.has(key);
}
// =========================================================================
// Generic Cache (for custom data)
// =========================================================================
getGeneric<T>(key: string): T | null {
return this.generic.get(key) as T | null;
}
setGeneric<T>(key: string, data: T, ttl?: number): void {
this.generic.set(key, data, ttl);
}
hasGeneric(key: string): boolean {
return this.generic.has(key);
}
// =========================================================================
// General Methods
// =========================================================================
@@ -244,23 +535,98 @@ export class PikaCache {
this.profiles.clear();
this.clans.clear();
this.leaderboards.clear();
this.staff.clear();
this.votes.clear();
this.server.clear();
this.punishments.clear();
this.generic.clear();
}
/**
* Clear specific cache type
*/
clearType(type: 'profiles' | 'clans' | 'leaderboards' | 'staff' | 'votes' | 'server' | 'punishments' | 'generic'): void {
this[type].clear();
}
/**
* Run cleanup on all caches
*/
cleanup(): { profiles: number; clans: number; leaderboards: number } {
return {
cleanup(): {
profiles: number;
clans: number;
leaderboards: number;
staff: number;
votes: number;
server: number;
punishments: number;
generic: number;
total: number;
} {
const result = {
profiles: this.profiles.cleanup(),
clans: this.clans.cleanup(),
leaderboards: this.leaderboards.cleanup(),
staff: this.staff.cleanup(),
votes: this.votes.cleanup(),
server: this.server.cleanup(),
punishments: this.punishments.cleanup(),
generic: this.generic.cleanup(),
total: 0,
};
result.total = Object.values(result).reduce((a, b) => a + b, 0) - result.total;
return result;
}
/**
* Get cache statistics
*/
getStats(): { profiles: number; clans: number; leaderboards: number } {
getStats(): {
profiles: CacheStats;
clans: CacheStats;
leaderboards: CacheStats;
staff: CacheStats;
votes: CacheStats;
server: CacheStats;
punishments: CacheStats;
generic: CacheStats;
totalSize: number;
totalHitRate: number;
} {
const profileStats = this.profiles.getStats();
const clanStats = this.clans.getStats();
const leaderboardStats = this.leaderboards.getStats();
const staffStats = this.staff.getStats();
const voteStats = this.votes.getStats();
const serverStats = this.server.getStats();
const punishmentStats = this.punishments.getStats();
const genericStats = this.generic.getStats();
const totalHits = profileStats.hits + clanStats.hits + leaderboardStats.hits +
staffStats.hits + voteStats.hits + serverStats.hits + punishmentStats.hits + genericStats.hits;
const totalMisses = profileStats.misses + clanStats.misses + leaderboardStats.misses +
staffStats.misses + voteStats.misses + serverStats.misses + punishmentStats.misses + genericStats.misses;
const totalRequests = totalHits + totalMisses;
return {
profiles: profileStats,
clans: clanStats,
leaderboards: leaderboardStats,
staff: staffStats,
votes: voteStats,
server: serverStats,
punishments: punishmentStats,
generic: genericStats,
totalSize: profileStats.size + clanStats.size + leaderboardStats.size +
staffStats.size + voteStats.size + serverStats.size + punishmentStats.size + genericStats.size,
totalHitRate: totalRequests > 0 ? totalHits / totalRequests : 0,
};
}
/**
* Get simple stats (backwards compatibility)
*/
getSimpleStats(): { profiles: number; clans: number; leaderboards: number } {
return {
profiles: this.profiles.size,
clans: this.clans.size,
@@ -274,6 +640,7 @@ export class PikaCache {
destroy(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval);
this.cleanupInterval = undefined;
}
this.clear();
}

View File

@@ -1,17 +1,16 @@
/**
* PikaNetwork API Client
* Modern TypeScript implementation without proxy support
* Based on pikanetwork.js but rewritten with improvements
* Full TypeScript implementation based on pikanetwork.js
* Features: Profile, Leaderboards, Punishments, Staff, Votes, Server Status
*/
import { PikaCache } from './cache.ts';
import { PikaCache, type PikaCacheOptions } from './cache.ts';
import type {
ProfileResponse,
ClanResponse,
LeaderboardResponse,
GameMode,
Interval,
PikaAPIOptions,
BedWarsStats,
SkyWarsStats,
MinimalLeaderboardData,
@@ -25,12 +24,50 @@ import type {
TotalLeaderboardOptions,
JoinInfo,
MiscInfo,
PlayerRank,
Rank,
ClanInfo,
} from './types.ts';
// ============================================================================
// API Options
// ============================================================================
export interface PikaAPIOptions {
/** Request timeout in milliseconds (default: 10000) */
timeout?: number;
/** User agent string for requests */
userAgent?: string;
/** Rate limit delay between batch requests in ms (default: 200) */
rateLimitDelay?: number;
/** Batch size for bulk operations (default: 5) */
batchSize?: number;
/** Cache options */
cache?: PikaCacheOptions;
/** Enable debug logging */
debug?: boolean;
}
// ============================================================================
// Ratio Data Types
// ============================================================================
export interface RatioData {
killDeathRatio: number;
kdrInfo: string;
winLossRatio: number;
wlrInfo: string;
winPlayRatio: number;
wprInfo: string;
arrowsHitShotRatio: number;
ahsrInfo: string;
finalKillDeathRatio?: number;
fkdrInfo?: string;
}
/**
* PikaNetwork API Client
* Provides methods to fetch player profiles, clan information, leaderboard data,
* punishments, staff lists, vote leaderboards, and server status
* Full-featured client for PikaNetwork stats API and forum scraping
*/
export class PikaNetworkAPI {
private readonly baseUrl = 'https://stats.pika-network.net/api';
@@ -38,6 +75,9 @@ export class PikaNetworkAPI {
private readonly cache: PikaCache;
private readonly timeout: number;
private readonly userAgent: string;
private readonly rateLimitDelay: number;
private readonly batchSize: number;
private readonly debug: boolean;
// Staff roles for scraping
private readonly staffRoles = new Set([
@@ -53,10 +93,30 @@ export class PikaNetworkAPI {
ban: 'bans',
};
// Request statistics
private stats = {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
totalLatency: 0,
};
constructor(options: PikaAPIOptions = {}) {
this.cache = new PikaCache(options.cacheTTL ?? 3600000); // 1 hour default
this.timeout = options.timeout ?? 10000; // 10 seconds default
this.cache = new PikaCache(options.cache ?? {});
this.timeout = options.timeout ?? 10000;
this.userAgent = options.userAgent ?? 'Elly Discord Bot/1.0';
this.rateLimitDelay = options.rateLimitDelay ?? 200;
this.batchSize = options.batchSize ?? 5;
this.debug = options.debug ?? false;
}
/**
* Log debug message
*/
private log(message: string, ...args: unknown[]): void {
if (this.debug) {
console.log(`[PikaAPI] ${message}`, ...args);
}
}
// =========================================================================
@@ -66,12 +126,18 @@ export class PikaNetworkAPI {
/**
* Make an HTTP request with timeout and error handling
*/
private async request<T>(endpoint: string): Promise<T | null> {
private async request<T>(endpoint: string, baseUrl?: string): Promise<T | null> {
const url = `${baseUrl ?? this.baseUrl}${endpoint}`;
const startTime = Date.now();
this.stats.totalRequests++;
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), this.timeout);
const response = await fetch(`${this.baseUrl}${endpoint}`, {
this.log(`Requesting: ${url}`);
const response = await fetch(url, {
signal: controller.signal,
headers: {
'User-Agent': this.userAgent,
@@ -80,9 +146,12 @@ export class PikaNetworkAPI {
});
clearTimeout(timeoutId);
const latency = Date.now() - startTime;
this.stats.totalLatency += latency;
if (!response.ok) {
console.error(`[PikaAPI] Request failed: ${response.status} ${response.statusText} for ${endpoint}`);
this.stats.failedRequests++;
this.log(`Request failed: ${response.status} ${response.statusText} for ${endpoint}`);
return null;
}
@@ -91,23 +160,28 @@ export class PikaNetworkAPI {
// Handle empty responses
if (!text || text.trim() === '') {
console.warn(`[PikaAPI] Empty response for ${endpoint}`);
this.stats.failedRequests++;
this.log(`Empty response for ${endpoint}`);
return null;
}
// Try to parse JSON
try {
this.stats.successfulRequests++;
this.log(`Request successful (${latency}ms): ${endpoint}`);
return JSON.parse(text) as T;
} catch {
console.error(`[PikaAPI] Invalid JSON response for ${endpoint}: ${text.substring(0, 100)}...`);
this.stats.failedRequests++;
this.log(`Invalid JSON response for ${endpoint}: ${text.substring(0, 100)}...`);
return null;
}
} catch (error) {
this.stats.failedRequests++;
if (error instanceof Error) {
if (error.name === 'AbortError') {
console.error(`[PikaAPI] Request timeout for ${endpoint}`);
this.log(`Request timeout for ${endpoint}`);
} else {
console.error(`[PikaAPI] Request error for ${endpoint}: ${error.message}`);
this.log(`Request error for ${endpoint}: ${error.message}`);
}
}
return null;
@@ -131,7 +205,10 @@ export class PikaNetworkAPI {
async getProfile(username: string): Promise<ProfileResponse | null> {
// Check cache first
const cached = this.cache.getProfile(username);
if (cached) return cached;
if (cached) {
this.log(`Cache hit for profile: ${username}`);
return cached;
}
// Fetch from API
const data = await this.request<ProfileResponse>(`/profile/${encodeURIComponent(username)}`);
@@ -152,6 +229,100 @@ export class PikaNetworkAPI {
return profile !== null;
}
/**
* 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 levelling info for a player
*/
async getLevellingInfo(username: string): Promise<PlayerRank | null> {
const profile = await this.getProfile(username);
if (!profile) return null;
return profile.rank;
}
/**
* Get guild/clan info for a player
*/
async getGuildInfo(username: string): Promise<ClanInfo | null> {
const profile = await this.getProfile(username);
if (!profile) return null;
return profile.clan;
}
/**
* Get rank info for a player (display ranks like VIP, MVP, etc.)
*/
async getRankInfo(username: string): Promise<Rank[] | null> {
const profile = await this.getProfile(username);
if (!profile) return null;
return profile.ranks;
}
/**
* 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,
};
// Try to get oldest punishment date for estimated first join
let estimatedFirstJoin: Date | null = null;
let estimatedFirstJoinFormatted = 'N/A';
try {
const punishments = await this.getPunishments(username);
if (punishments.length > 0) {
const dates = punishments
.map((p) => new Date(p.date))
.filter((d) => !isNaN(d.getTime()));
if (dates.length > 0) {
estimatedFirstJoin = new Date(Math.min(...dates.map((d) => d.getTime())));
estimatedFirstJoinFormatted = estimatedFirstJoin.toLocaleString('en-US', formatOptions);
}
}
} catch {
// Ignore punishment fetch errors
}
return {
lastJoin: profile.lastSeen,
lastJoinFormatted: lastJoinDate.toLocaleString('en-US', formatOptions),
estimatedFirstJoin,
estimatedFirstJoinFormatted,
};
}
// =========================================================================
// Clan Methods
// =========================================================================
@@ -200,7 +371,10 @@ export class PikaNetworkAPI {
): Promise<LeaderboardResponse | null> {
// Check cache first
const cached = this.cache.getLeaderboard(username, gamemode, mode, interval);
if (cached) return cached;
if (cached) {
this.log(`Cache hit for leaderboard: ${username}/${gamemode}/${mode}/${interval}`);
return cached;
}
// Build URL with query parameters
const params = new URLSearchParams({
@@ -221,6 +395,51 @@ export class PikaNetworkAPI {
return null;
}
/**
* Get ratio data for a player (like pikanetwork.js getRatioData)
*/
async getRatioData(
username: string,
gamemode: GameMode,
interval: Interval = 'lifetime',
mode: string = 'all_modes'
): Promise<RatioData | null> {
const data = await this.getLeaderboard(username, gamemode, interval, mode);
if (!data) return null;
const ratios: RatioData = {
killDeathRatio: this.calculateRatioFromData(data, 'Kills', 'Deaths'),
kdrInfo: 'Kills/Deaths',
winLossRatio: this.calculateRatioFromData(data, 'Wins', 'Losses'),
wlrInfo: 'Wins/Losses',
winPlayRatio: this.calculateRatioFromData(data, 'Wins', 'Games played'),
wprInfo: 'Wins/Games Played',
arrowsHitShotRatio: this.calculateRatioFromData(data, 'Arrows hit', 'Arrows shot'),
ahsrInfo: 'Arrows Hit/Arrows Shot',
};
if (gamemode === 'bedwars') {
ratios.finalKillDeathRatio = this.calculateRatioFromData(data, 'Final kills', 'Final deaths');
ratios.fkdrInfo = 'Final Kills/Final Deaths';
}
return ratios;
}
/**
* Calculate ratio from leaderboard data
*/
private calculateRatioFromData(data: LeaderboardResponse, key1: string, key2: string): number {
const val1 = this.getStatValue(data, key1);
const val2 = this.getStatValue(data, key2);
if (val1 === 0 && val2 === 0) return NaN;
if (val2 === 0) return val1;
if (val1 === 0) return 0;
return Math.round((val1 / val2) * 100) / 100;
}
/**
* Get parsed BedWars stats for a player
*/
@@ -464,77 +683,121 @@ export class PikaNetworkAPI {
}
// =========================================================================
// Profile Extended Methods
// Staff List Methods
// =========================================================================
/**
* Get friend list for a player
* Get the staff list from the forum
*/
async getFriendList(username: string): Promise<string[]> {
const profile = await this.getProfile(username);
if (!profile) return [];
return profile.friends.map((f) => f.username);
async getStaffList(): Promise<StaffList | null> {
// Check cache first
const cached = this.cache.getStaff();
if (cached) {
this.log('Cache hit for staff list');
return cached;
}
const html = await this.fetchHtml(`${this.forumUrl}/staff/`);
if (!html) return null;
const staff: StaffList = {
owner: [],
manager: [],
leaddeveloper: [],
developer: [],
admin: [],
srmod: [],
moderator: [],
helper: [],
trial: [],
};
// Parse staff from HTML using regex
// Look for patterns like: <span>Username</span><span>Role</span>
const spanPairRegex = /<span[^>]*>([^<]+)<\/span>\s*<span[^>]*>([^<]+)<\/span>/gi;
let match;
while ((match = spanPairRegex.exec(html)) !== null) {
const username = match[1].trim().replace(/\s/g, '');
const roleText = match[2].trim().toLowerCase();
if (this.staffRoles.has(roleText)) {
const role = roleText.replace(/\s/g, '') as keyof StaffList;
if (staff[role] && !staff[role].includes(username)) {
staff[role].push(username);
}
}
}
this.cache.setStaff(staff);
return staff;
}
/**
* Get guild info for a player
* Check if a player is staff
*/
async getPlayerGuild(username: string): Promise<ClanResponse | null> {
const profile = await this.getProfile(username);
if (!profile?.clan) return null;
return profile.clan as ClanResponse;
async isStaff(username: string): Promise<{ isStaff: boolean; role?: string }> {
const staffList = await this.getStaffList();
if (!staffList) return { isStaff: false };
const normalizedUsername = username.toLowerCase();
for (const [role, members] of Object.entries(staffList)) {
if (members.some((m: string) => m.toLowerCase() === normalizedUsername)) {
return { isStaff: true, role };
}
}
return { isStaff: false };
}
/**
* 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,
};
}
// =========================================================================
// Vote Leaderboard Methods
// =========================================================================
/**
* Get miscellaneous info for a player
* Get the vote leaderboard
*/
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,
};
}
async getVoteLeaderboard(): Promise<VoteLeaderboard | null> {
// Check cache first
const cached = this.cache.getVotes();
if (cached) {
this.log('Cache hit for vote leaderboard');
return cached;
}
/**
* Get join info for a player
*/
async getJoinInfo(username: string): Promise<JoinInfo | null> {
const profile = await this.getProfile(username);
if (!profile) return null;
const html = await this.fetchHtml(`${this.forumUrl}/vote`);
if (!html) 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,
};
const voters: VoteEntry[] = [];
const runnerUps: VoteEntry[] = [];
return {
lastJoin: profile.lastSeen,
lastJoinFormatted: lastJoinDate.toLocaleString('en-US', formatOptions),
estimatedFirstJoin: null, // Would require punishment scraping
estimatedFirstJoinFormatted: 'N/A',
};
// Parse winning voters
const winningSection = html.match(/block-voters[\s\S]*?(?=block\.runners-up|$)/i)?.[0] ?? '';
const runnerUpSection = html.match(/block\.runners-up[\s\S]*/i)?.[0] ?? '';
// Extract voter data
const voterRegex = /class="position"[^>]*>#?(\d+)[\s\S]*?class="username"[^>]*>([^<]+)[\s\S]*?(\d+)\s*votes/gi;
let match;
while ((match = voterRegex.exec(winningSection)) !== null) {
voters.push({
position: parseInt(match[1]) || voters.length + 1,
username: match[2].trim(),
votes: parseInt(match[3]) || 0,
});
}
while ((match = voterRegex.exec(runnerUpSection)) !== null) {
runnerUps.push({
position: parseInt(match[1]) || runnerUps.length + 1,
username: match[2].trim(),
votes: parseInt(match[3]) || 0,
});
}
const result = { voters, runnerUps };
this.cache.setVotes(result);
return result;
}
// =========================================================================
@@ -719,39 +982,6 @@ export class PikaNetworkAPI {
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
// =========================================================================
@@ -763,17 +993,84 @@ export class PikaNetworkAPI {
this.cache.clear();
}
/**
* Clear specific cache type
*/
clearCacheType(type: 'profiles' | 'clans' | 'leaderboards' | 'staff' | 'votes' | 'server' | 'punishments'): void {
this.cache.clearType(type);
}
/**
* Get cache statistics
*/
getCacheStats(): { profiles: number; clans: number; leaderboards: number } {
getCacheStats() {
return this.cache.getStats();
}
/**
* Get simple cache stats (backwards compatibility)
*/
getSimpleCacheStats(): { profiles: number; clans: number; leaderboards: number } {
return this.cache.getSimpleStats();
}
/**
* Get request statistics
*/
getRequestStats(): {
totalRequests: number;
successfulRequests: number;
failedRequests: number;
averageLatency: number;
successRate: number;
} {
const avgLatency = this.stats.totalRequests > 0
? Math.round(this.stats.totalLatency / this.stats.totalRequests)
: 0;
const successRate = this.stats.totalRequests > 0
? this.stats.successfulRequests / this.stats.totalRequests
: 0;
return {
totalRequests: this.stats.totalRequests,
successfulRequests: this.stats.successfulRequests,
failedRequests: this.stats.failedRequests,
averageLatency: avgLatency,
successRate,
};
}
/**
* Reset request statistics
*/
resetStats(): void {
this.stats = {
totalRequests: 0,
successfulRequests: 0,
failedRequests: 0,
totalLatency: 0,
};
}
/**
* Destroy the client and cleanup resources
*/
destroy(): void {
this.cache.destroy();
}
/**
* Check if the API is reachable
*/
async healthCheck(): Promise<boolean> {
try {
const response = await fetch(`${this.baseUrl}/profile/Technoblade`, {
method: 'HEAD',
signal: AbortSignal.timeout(5000),
});
return response.ok;
} catch {
return false;
}
}
}

View File

@@ -1,11 +1,14 @@
/**
* PikaNetwork API Module
* Exports all API-related types and classes
* Based on pikanetwork.js but rewritten in TypeScript with improvements
* Full TypeScript implementation based on pikanetwork.js
* Features: Profile, Leaderboards, Punishments, Staff, Votes, Server Status
*/
export { PikaNetworkAPI } from './client.ts';
export { PikaCache } from './cache.ts';
// Main exports
export { PikaNetworkAPI, type PikaAPIOptions, type RatioData } from './client.ts';
export { PikaCache, type PikaCacheOptions } from './cache.ts';
// Type exports from types.ts
export type {
// Profile types
ProfileResponse,
@@ -31,7 +34,6 @@ export type {
Interval,
BedWarsMode,
SkyWarsMode,
PikaAPIOptions,
// Batch types
BatchLeaderboardResult,
MinimalLeaderboardData,
@@ -56,6 +58,7 @@ export type {
MiscInfo,
} from './types.ts';
// Type guard exports
export {
isProfileResponse,
isClanResponse,

View File

@@ -123,8 +123,11 @@ export class EllyClient extends Client {
});
this.pikaAPI = new PikaNetworkAPI({
cacheTTL: config.api.pika_cache_ttl,
timeout: config.api.pika_request_timeout,
cache: {
profileTTL: config.api.pika_cache_ttl,
leaderboardTTL: Math.floor(config.api.pika_cache_ttl / 2), // Shorter TTL for leaderboards
},
});
this.database = new JsonDatabase(config.database.path.replace('.db', '.json'));