UNPKG

@chiraitori/hoyolab-core

Version:

Core utilities for HoYoLab automation - daily check-ins and code redemption with smart rate limiting

215 lines (179 loc) 5.38 kB
/** * Utility functions for HoyoLab automation */ /** * Sleep for specified milliseconds * @param {number} ms - Milliseconds to sleep * @returns {Promise<void>} */ const sleep = (ms) => new Promise(resolve => setTimeout(resolve, ms)); /** * Retry a function with exponential backoff * @param {Function} fn - Function to retry * @param {Object} options - Retry options * @param {number} options.maxRetries - Maximum number of retries (default: 3) * @param {number} options.baseDelay - Base delay in ms (default: 1000) * @param {number} options.maxDelay - Maximum delay in ms (default: 30000) * @returns {Promise<any>} Function result */ async function retryWithBackoff(fn, options = {}) { const { maxRetries = 3, baseDelay = 1000, maxDelay = 30000 } = options; let lastError; for (let attempt = 0; attempt <= maxRetries; attempt++) { try { return await fn(); } catch (error) { lastError = error; if (attempt === maxRetries) { break; } // Calculate delay with exponential backoff const delay = Math.min(baseDelay * Math.pow(2, attempt), maxDelay); await sleep(delay); } } throw lastError; } /** * Validate HoYoLab cookie format * @param {string} cookie - Cookie string to validate * @returns {Object} Validation result */ function validateCookie(cookie) { if (!cookie || typeof cookie !== 'string') { return { valid: false, error: 'Cookie must be a non-empty string' }; } const required = ['ltoken_v2', 'ltuid_v2', 'ltmid_v2']; const optional = ['cookie_token_v2', 'account_mid_v2', 'account_id_v2']; const cookieMap = {}; cookie.split(';').forEach(c => { const [key, value] = c.trim().split('='); if (key && value) cookieMap[key] = value; }); const missing = required.filter(token => !cookieMap[token]); if (missing.length > 0) { return { valid: false, error: `Missing required tokens: ${missing.join(', ')}` }; } const hasOptional = optional.every(token => cookieMap[token]); return { valid: true, hasRedeemTokens: hasOptional, tokens: cookieMap }; } /** * Format check-in result for display * @param {Object} result - Check-in result * @returns {string} Formatted message */ function formatCheckInResult(result) { if (!result.success) { return 'Check-in failed'; } if (result.alreadyCheckedIn) { return `Already checked in today (${result.totalSignDays} days total)`; } return `Check-in successful! Day ${result.totalSignDays} - Received ${result.award.name} x${result.award.count}`; } /** * Format redemption result for display * @param {Object} result - Redemption result * @returns {string} Formatted message */ function formatRedeemResult(result) { if (!result.success) { return `Failed to redeem code: ${result.code}`; } return `Successfully redeemed code: ${result.code}`; } /** * Get timezone offset for region * @param {string} region - Region name * @returns {number} Timezone offset in minutes */ function getTimezoneOffset(region) { const offsets = { 'SEA': 480, // GMT+8 'EU': 60, // GMT+1 'NA': -300, // GMT-5 'TW/HK/MO': 480, // GMT+8 'GLOBAL': 540 // GMT+9 }; return offsets[region] || 0; } /** * Check if it's a new day in the specified region * @param {string} region - Region name * @param {Date} lastCheck - Last check date * @returns {boolean} Whether it's a new day */ function isNewDay(region, lastCheck) { if (!lastCheck) return true; const offset = getTimezoneOffset(region); const now = new Date(); const regionTime = new Date(now.getTime() + (offset * 60000)); const lastCheckRegionTime = new Date(lastCheck.getTime() + (offset * 60000)); return regionTime.toDateString() !== lastCheckRegionTime.toDateString(); } /** * Rate limiter for API calls */ class RateLimiter { constructor(maxCalls = 10, windowMs = 60000) { this.maxCalls = maxCalls; this.windowMs = windowMs; this.calls = []; } async waitIfNeeded() { const now = Date.now(); // Remove calls outside the window this.calls = this.calls.filter(time => now - time < this.windowMs); if (this.calls.length >= this.maxCalls) { const oldestCall = Math.min(...this.calls); const waitTime = this.windowMs - (now - oldestCall); await sleep(waitTime); } this.calls.push(now); } } /** * Cache for storing temporary data */ class SimpleCache { constructor(ttlMs = 300000) { // 5 minutes default this.cache = new Map(); this.ttlMs = ttlMs; } set(key, value) { this.cache.set(key, { value, timestamp: Date.now() }); } get(key) { const item = this.cache.get(key); if (!item) return null; if (Date.now() - item.timestamp > this.ttlMs) { this.cache.delete(key); return null; } return item.value; } clear() { this.cache.clear(); } } module.exports = { sleep, retryWithBackoff, validateCookie, formatCheckInResult, formatRedeemResult, getTimezoneOffset, isNewDay, RateLimiter, SimpleCache };