UNPKG

@chiraitori/hoyolab-core

Version:

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

466 lines (406 loc) 15 kB
const HttpClient = require('./http'); const { GameConfigs, getRegionName, RateLimits } = require('./constants'); const { handleApiError, createError, ErrorCodes } = require('./errors'); class HoyoLabClient { constructor(options = {}) { this.cookie = options.cookie; this.http = new HttpClient(options.userAgent); if (!this.cookie) { throw createError(ErrorCodes.INVALID_COOKIE, 'Cookie is required'); } this.parsedCookie = this.#parseCookie(this.cookie); } /** * Perform daily check-in for a game * @param {string} game - Game identifier * @param {string} uid - Optional specific UID * @returns {Promise<Object>} Check-in result */ async dailyCheckIn(game, uid = null) { const config = GameConfigs[game]; if (!config) { throw createError(ErrorCodes.API_ERROR, `Unsupported game: ${game}`); } const accounts = await this.getAccounts(game); const account = uid ? accounts.find(acc => acc.uid === uid) : accounts[0]; if (!account) { throw createError(ErrorCodes.ACCOUNT_NOT_FOUND, 'No account found for the specified game/UID'); } // Get current sign info const signInfo = await this.#getSignInfo(config, account); if (signInfo.isSigned) { return { success: true, alreadyCheckedIn: true, totalSignDays: signInfo.totalSignDays, today: signInfo.today, account: { uid: account.uid, nickname: account.nickname, region: this.#getRegionName(account.game, account.region) } }; } // Get awards info const awards = await this.#getAwards(config, account); const todayAward = awards[signInfo.totalSignDays]; // Perform sign-in await this.#performSignIn(config, account); return { success: true, alreadyCheckedIn: false, totalSignDays: signInfo.totalSignDays + 1, award: { name: todayAward.name, count: todayAward.cnt, icon: todayAward.icon }, account: { uid: account.uid, nickname: account.nickname, region: this.#getRegionName(game, account.region) } }; } /** * Redeem a promotional code * @param {string} game - Game identifier * @param {string} code - Promotional code * @param {string} uid - Optional specific UID * @returns {Promise<Object>} Redeem result */ async redeemCode(game, code, uid = null) { const config = GameConfigs[game]; if (!config || !config.urls.redeem) { throw createError(ErrorCodes.API_ERROR, `Code redemption not supported for: ${game}`); } if (!this.parsedCookie.hasRedeemTokens) { throw createError(ErrorCodes.INVALID_COOKIE, 'Cookie missing tokens required for code redemption'); } const accounts = await this.getAccounts(game); const account = uid ? accounts.find(acc => acc.uid === uid) : accounts[0]; if (!account) { throw createError(ErrorCodes.ACCOUNT_NOT_FOUND, 'No account found for the specified game/UID'); } const redeemCookie = this.#buildRedeemCookie(); const response = await this.http.get(config.urls.redeem, { searchParams: { uid: account.uid, region: account.region, lang: 'en', cdkey: code, game_biz: config.gameBiz, sLangKey: 'en-us' }, headers: { Cookie: redeemCookie }, throwHttpErrors: false }); if (response.statusCode !== 200) { throw createError(ErrorCodes.NETWORK_ERROR, `HTTP ${response.statusCode}`); } const { retcode, message } = response.body; if (retcode !== 0) { throw handleApiError(retcode, message); } return { success: true, code, account: { uid: account.uid, nickname: account.nickname, region: this.#getRegionName(account.game, account.region) } }; } /** * Fetch available promotional codes from the built-in code pool * Note: This is optional. Developers can use their own code sources * and directly call redeemCode() with their codes. * @param {string} game - Game identifier * @param {Object} options - Fetch options * @param {string} options.source - Custom code source URL (optional) * @returns {Promise<Array>} Available codes */ async fetchAvailableCodes(game, options = {}) { const config = GameConfigs[game]; if (!config) { throw createError(ErrorCodes.API_ERROR, `Unsupported game: ${game}`); } // Use custom source if provided, otherwise use built-in const codeUrl = options.source || config.codeApi; if (!codeUrl) { throw createError(ErrorCodes.API_ERROR, `No code source available for ${game}. Either use options.source or implement your own code fetching.`); } const response = await this.http.get(codeUrl, { throwHttpErrors: false }); if (response.statusCode !== 200) { throw createError(ErrorCodes.NETWORK_ERROR, `Failed to fetch codes: HTTP ${response.statusCode}`); } // Try to parse different response formats let codes = []; if (response.body.active) { // Built-in format codes = response.body.active; } else if (Array.isArray(response.body)) { // Direct array format codes = response.body; } else if (response.body.codes) { // Custom format with codes property codes = response.body.codes; } else { throw createError(ErrorCodes.API_ERROR, 'Unknown code response format'); } return codes.map(code => ({ code: code.code, rewards: code.rewards || [], source: 'api' })); } /** * Redeem multiple codes at once * Useful for developers who have their own code sources (Discord bots, APIs, etc.) * * IMPORTANT: HoYoLab API has a 6-second rate limit between code redemptions. * Using a delay less than 6000ms may result in cooldown errors. * * @param {string} game - Game identifier * @param {Array<string>} codes - Array of codes to redeem * @param {string} uid - Optional specific UID * @param {Object} options - Redemption options * @param {number} options.delay - Delay between redemptions in ms (default: 6000 - HoYoLab's rate limit) * @param {boolean} options.stopOnError - Stop on first error (default: false) * @param {boolean} options.autoRetryOnCooldown - Automatically retry when cooldown detected (default: true) * @param {number} options.maxRetries - Maximum retries per code (default: 3) * @returns {Promise<Array>} Array of redemption results */ async redeemMultipleCodes(game, codes, uid = null, options = {}) { const { delay = RateLimits.CODE_REDEMPTION_DELAY, // Use the 6-second rate limit constant stopOnError = false, autoRetryOnCooldown = true, maxRetries = 3 } = options; const results = []; for (let i = 0; i < codes.length; i++) { const code = codes[i]; let attempts = 0; let success = false; while (attempts < maxRetries && !success) { try { const result = await this.redeemCode(game, code, uid); results.push({ code, success: true, account: result.account, attempts: attempts + 1 }); success = true; } catch (error) { attempts++; // Check if this is a cooldown error const isCooldownError = error.message.includes('cooldown') || error.message.includes('Please try again in'); const cooldownMatch = error.message.match(/(\d+)\s+second/); const cooldownSeconds = cooldownMatch ? parseInt(cooldownMatch[1]) : null; if (isCooldownError && autoRetryOnCooldown && attempts < maxRetries && cooldownSeconds) { console.log(`⏳ Cooldown detected for ${code}, waiting ${cooldownSeconds} seconds before retry...`); await new Promise(resolve => setTimeout(resolve, (cooldownSeconds + 1) * 1000)); continue; } // If not a cooldown error or max retries reached, record the failure results.push({ code, success: false, error: error.message, errorCode: error.code, attempts, wasCooldown: isCooldownError }); if (stopOnError) { return results; } break; // Exit retry loop for this code } } // Add delay between codes to avoid rate limiting (only if not the last code) if (i < codes.length - 1) { await new Promise(resolve => setTimeout(resolve, delay)); } } return results; } /** * Helper method for developers who want to use their own code fetching * This method only handles redemption logic * @param {string} game - Game identifier * @param {string} code - Code to redeem * @param {string} uid - Optional specific UID * @returns {Promise<Object>} Redemption result */ async redeemCodeOnly(game, code, uid = null) { // This is just an alias to redeemCode for clarity return this.redeemCode(game, code, uid); } /** * Get account information * @param {string} game - Optional game filter * @returns {Promise<Array>} Account information */ async getAccounts(game = null) { // Extract ltuid from cookie for the getGameRecordCard API const ltuid = this.parsedCookie.tokens.ltuid_v2 || this.parsedCookie.tokens.ltuid; if (!ltuid) { throw createError(ErrorCodes.INVALID_COOKIE, 'Missing ltuid in cookie'); } const response = await this.http.get('https://bbs-api-os.hoyolab.com/game_record/card/wapi/getGameRecordCard', { searchParams: { uid: ltuid }, headers: { Cookie: this.parsedCookie.basic }, throwHttpErrors: false }); if (response.statusCode !== 200) { throw createError(ErrorCodes.NETWORK_ERROR, `Failed to fetch accounts: HTTP ${response.statusCode}`); } const { retcode, message, data } = response.body; if (retcode !== 0) { throw handleApiError(retcode, message); } let accounts = []; if (data && data.list) { accounts = this.#parseAccountsFromResponse(data.list); } if (game) { const config = GameConfigs[game]; if (config) { accounts = accounts.filter(acc => acc.game === game); } } return accounts; } // Private methods #parseCookie(cookie) { const cookieMap = {}; cookie.split(';').forEach(c => { const [key, value] = c.trim().split('='); if (key && value) cookieMap[key] = value; }); const required = ['ltoken_v2', 'ltuid_v2', 'ltmid_v2']; const redeemTokens = ['cookie_token_v2', 'account_mid_v2', 'account_id_v2']; for (const token of required) { if (!cookieMap[token]) { throw createError(ErrorCodes.INVALID_COOKIE, `Missing required cookie token: ${token}`); } } const hasRedeemTokens = redeemTokens.every(token => cookieMap[token]); return { basic: this.#buildBasicCookie(cookieMap), redeem: hasRedeemTokens ? this.#buildRedeemCookie(cookieMap) : null, hasRedeemTokens, tokens: cookieMap }; } #buildBasicCookie(cookieMap = this.parsedCookie.tokens) { return [ `ltoken_v2=${cookieMap.ltoken_v2}`, `ltuid_v2=${cookieMap.ltuid_v2}`, `ltmid_v2=${cookieMap.ltmid_v2}` ].join('; '); } #buildRedeemCookie(cookieMap = this.parsedCookie.tokens) { return [ `cookie_token_v2=${cookieMap.cookie_token_v2}`, `account_mid_v2=${cookieMap.account_mid_v2}`, `account_id_v2=${cookieMap.account_id_v2}` ].join('; '); } #getRegionName(game, region) { return getRegionName(game, region); } async #getSignInfo(config, account) { const response = await this.http.get(config.urls.info, { searchParams: { act_id: config.actId }, headers: { Cookie: this.parsedCookie.basic, 'x-rpc-signgame': config.signGame }, throwHttpErrors: false }); if (response.statusCode !== 200) { throw createError(ErrorCodes.NETWORK_ERROR, `Failed to get sign info: HTTP ${response.statusCode}`); } const { retcode, message, data } = response.body; if (retcode !== 0) { throw handleApiError(retcode, message); } return { totalSignDays: data.total_sign_day, today: data.today, isSigned: data.is_sign }; } async #getAwards(config, account) { const response = await this.http.get(config.urls.home, { searchParams: { act_id: config.actId }, headers: { Cookie: this.parsedCookie.basic, 'x-rpc-signgame': config.signGame }, throwHttpErrors: false }); if (response.statusCode !== 200) { throw createError(ErrorCodes.NETWORK_ERROR, `Failed to get awards: HTTP ${response.statusCode}`); } const { retcode, message, data } = response.body; if (retcode !== 0) { throw handleApiError(retcode, message); } return data.awards || []; } async #performSignIn(config, account) { const response = await this.http.post(config.urls.sign, { searchParams: { act_id: config.actId }, headers: { Cookie: this.parsedCookie.basic, 'x-rpc-signgame': config.signGame }, throwHttpErrors: false }); if (response.statusCode !== 200) { throw createError(ErrorCodes.NETWORK_ERROR, `Failed to sign in: HTTP ${response.statusCode}`); } const { retcode, message } = response.body; if (retcode !== 0) { throw handleApiError(retcode, message); } return true; } #parseAccountsFromResponse(list) { const accounts = []; // Game ID mappings from the original codebase const gameIdMappings = { 1: 'honkai', // Honkai Impact 3rd 2: 'genshin', // Genshin Impact 6: 'starrail', // Honkai: Star Rail 8: 'zenless' // Zenless Zone Zero }; for (const account of list) { const gameType = gameIdMappings[account.game_id]; if (gameType && GameConfigs[gameType]) { accounts.push({ uid: account.game_role_id, nickname: account.nickname, level: account.level, region: account.region, game: gameType }); } } return accounts; } } module.exports = HoyoLabClient;