@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
JavaScript
/**
* 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
};