@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
JavaScript
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;