Skip to content

Instantly share code, notes, and snippets.

@KrammyGod
Last active July 15, 2024 21:00
Show Gist options
  • Save KrammyGod/bdaf74b6b3eb4cee9d6bed076382ee22 to your computer and use it in GitHub Desktop.
Save KrammyGod/bdaf74b6b3eb4cee9d6bed076382ee22 to your computer and use it in GitHub Desktop.
Hoyolab Autocollector.

Read this before examining the file.

This is the exact same script used inside the actual implementation of the bot.

To have the full context, and how to use it yourself, see the configuration file, and the command to run it under collect:game in package.json.

Modification to manually run the cookie will be required (no access to my private database).

Or, you can simply use the hoyolab command in the bot to have it run on a daily basis automatically.

import config from '@config';
import { getUID } from '@modules/hoyolab';
import { Client } from 'pg';
import { inspect } from 'util';
const client = new Client({ connectionTimeoutMillis: 2000 });
const LOGGER = {
today: new Date().toLocaleDateString(),
start() {
console.log(
'\x1b[92m%s\x1b[0m',
`BGN [${LOGGER.today}]: BEGIN ${process.env.name} ON ${new Date().toLocaleTimeString()} UTC`,
);
},
log(msg?: unknown) {
if (!msg) return console.log('\x1b[96m%s\x1b[0m', `LOG [${LOGGER.today}]:`);
const lines = typeof msg === 'string' ? msg : inspect(msg, {
colors: true,
depth: null,
compact: false,
});
for (const line of lines.split('\n')) {
console.log('\x1b[96m%s\x1b[0m%s', `LOG [${LOGGER.today}]: `, line);
}
},
error(msg?: unknown) {
if (!msg) return console.log('\x1b[31m%s\x1b[0m', `ERR [${LOGGER.today}]:`);
const lines = typeof msg === 'string' ? msg : inspect(msg, {
colors: true,
depth: null,
compact: false,
});
for (const line of lines.split('\n')) {
console.log('\x1b[31m%s\x1b[0m%s', `ERR [${LOGGER.today}]: `, line);
}
},
end() {
console.log(
'\x1b[95m%s\x1b[0m',
`END [${LOGGER.today}]: END ${process.env.name} ON ${new Date().toLocaleTimeString()} UTC\n`,
);
},
};
const ret = [''];
function add(msg: string) {
if (msg.length >= 2000) throw new Error(`Message too big!\n${msg}`);
// Discord message limitation
if (ret[ret.length - 1].length + msg.length > 2000) {
ret.push('');
}
ret[ret.length - 1] += msg;
}
function on_account_error(err: object, aid: string, uid: string) {
let msg = `\nAccount ID: ${aid}`;
msg += `\nUser ID: ${uid}`;
LOGGER.error(msg);
LOGGER.error();
LOGGER.error(err);
msg += '\n```\n' + inspect(err) + '```';
add(msg + '\n\n');
}
const CONFIG = {
actID: process.env.actID!,
rewardURL: process.env.rewardURL!,
roleURL: process.env.roleURL!,
infoURL: process.env.infoURL!,
signURL: process.env.signURL!,
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) ' +
'Chrome/114.0.0.0 Safari/537.36',
origin: 'https://act.hoyolab.com',
};
type RewardAPIResponse = {
readonly retcode: number;
readonly message: string;
readonly data: {
readonly month: number;
readonly awards: readonly {
readonly icon: string;
readonly name: string;
readonly cnt: string;
}[]
readonly resign: boolean;
readonly now: string; // Epoch format
};
};
type InfoAPIResponse = {
readonly retcode: number;
readonly message: string;
readonly data: {
readonly total_sign_day: number;
readonly today: string;
readonly is_sign: boolean;
readonly first_bind: boolean;
readonly is_sub: boolean;
readonly region: string;
readonly month_last_day: boolean;
} | null;
};
type RoleAPIResponse = {
readonly retcode: number;
readonly message: string;
readonly data: {
readonly list: readonly [
{
readonly game_biz: string;
readonly region: string;
readonly game_uid: string;
readonly nickname: string;
readonly level: number;
readonly is_chosen: boolean;
readonly region_name: string;
readonly is_official: boolean;
},
]
} | null;
};
type SignAPIResponse = {
readonly retcode: number;
readonly message: string;
readonly data: {
readonly code: string;
readonly first_bind?: boolean;
// This was added by Hoyoverse to combat bots signing in.
// Currently only seems to exist in Genshin Impact.
readonly gt_result?: {
readonly risk_code: number;
readonly gt: string;
readonly challenge: string;
readonly success: number;
readonly is_risk: boolean;
}
} | null;
};
// Custom result to allow parsing once message is sent.
type CollectResult = {
readonly uid: string;
readonly error: false;
readonly region_name: string;
readonly nickname: string;
award: {
readonly icon: string;
readonly name: string;
readonly cnt: string;
};
readonly today: string;
readonly total_sign_day: number;
check_in_result: string;
} | {
readonly uid: string;
readonly error: true;
};
// The actual full message as a JSON object.
export type SendMessage = {
readonly accounts: CollectResult[];
readonly name: string; // Name of the game
// Split into sections to send per message
err?: readonly string[];
};
class Sign {
constructor(private readonly cookie: string, private readonly notify: boolean, private readonly uid: string) {}
get header() {
return {
'Accept': 'application/json, text/plain, */*',
'Accept-Encoding': 'gzip, deflate, br',
'Connection': 'keep-alive',
'User-Agent': CONFIG.userAgent,
'Origin': CONFIG.origin,
'Referer': `${CONFIG.origin}/`,
'x-rpc-app_version': '2.34.1',
'x-rpc-client_type': '4',
'Cookie': this.cookie,
};
}
async getAwards() {
return fetch(CONFIG.rewardURL, { headers: this.header })
.then(res => res.json())
.then((data: RewardAPIResponse) => data.data)
.catch(err => {
LOGGER.error('failure in getter awards');
throw err;
});
}
async getInfo() {
const res = await fetch(CONFIG.infoURL, { headers: this.header })
.then(res => res.json() as Promise<InfoAPIResponse>)
.catch(err => {
LOGGER.error('failure in getter info');
throw err;
});
if (res.retcode !== 0) {
LOGGER.error('failure in getter info - likely invalid cookie');
throw res;
}
return res.data!;
}
async getRegion(): Promise<[string, string]> {
const res = await fetch(CONFIG.roleURL, { headers: this.header })
.then(res => res.json() as Promise<RoleAPIResponse>)
.catch(err => {
LOGGER.error('failure in getter region');
throw err;
});
if (res.retcode !== 0) {
LOGGER.error('failure in getter region - likely invalid cookie');
throw res;
}
const characterList = res.data!.list[0];
return [characterList.region_name, characterList.nickname];
}
async run(): Promise<CollectResult | undefined> {
LOGGER.log('Running sign in...');
if (!this.notify) {
return fetch(CONFIG.signURL, {
method: 'POST',
headers: { ...this.header, 'Content-Type': 'application/json' },
body: JSON.stringify({ 'act_id': CONFIG.actID }),
}).then(res => res.json()).then((data: SignAPIResponse) => {
const risk_code = data.data?.gt_result?.risk_code;
if (risk_code && risk_code !== 0) {
// Captcha verification required if risk_code is not 0.
LOGGER.error('Captcha verification required.');
} else {
LOGGER.log('Sign in complete, did not notify user. This can mean failure or success.');
}
return undefined;
});
}
const info = await this.getInfo();
const rewards = await this.getAwards();
const [region_name, nickname] = await this.getRegion();
const total_sign_day = info.is_sign ? info.total_sign_day : info.total_sign_day + 1;
const result: CollectResult = {
uid: this.uid,
error: false,
region_name,
nickname,
award: rewards.awards[info.total_sign_day],
today: info.today,
total_sign_day,
check_in_result: '✅',
};
// Skip sign in if any of these are true.
if (info.is_sign) {
result.check_in_result = '❎ Already checked in today';
result.award = rewards.awards[info.total_sign_day - 1];
return result;
} else if (info.first_bind) {
result.check_in_result = '> Please check in manually once ❎';
return result;
}
const res = await fetch(CONFIG.signURL, {
method: 'POST',
headers: { ...this.header, 'Content-Type': 'application/json' },
body: JSON.stringify({ 'act_id': CONFIG.actID }),
}).then(res => res.json() as Promise<SignAPIResponse>);
// Checking for last minute failures/anti-bot
const risk_code = res.data?.gt_result?.risk_code;
if (res.retcode !== 0) {
// Usually cookie error or something similar
LOGGER.error('Error in check-in.');
on_account_error(res, getUID(this.cookie), this.cookie);
return { uid: this.uid, error: true };
} else if (risk_code && risk_code !== 0) {
// Captcha verification required if risk_code is not 0.
LOGGER.error('Captcha verification required.');
result.check_in_result = 'Anti-bot detected. Please check-in manually until the captcha is gone ❎';
}
return result;
}
}
type CheckinType = 'none' | 'checkin' | 'notify';
type HoyolabAccount = {
readonly id: string;
readonly cookie: string;
readonly genshin: CheckinType;
readonly honkai: CheckinType;
readonly star_rail: CheckinType;
};
async function collect() {
const accounts = await client.query<HoyolabAccount>(
`SELECT *
FROM hoyolab_cookies_list
WHERE ${process.env.type} <> $1`,
['none'],
).then(res => res.rows);
const message: SendMessage = {
accounts: [],
name: process.env.displayName!,
};
for (const account of accounts) {
const aid = getUID(account.cookie);
LOGGER.log(`Checking into account ${aid}`);
const gameType = process.env.type as 'genshin' | 'honkai' | 'star_rail';
const sign = new Sign(account.cookie, account[gameType] === 'notify', account.id);
const result = await sign.run().catch(error => {
LOGGER.error('Error in sign-in.');
on_account_error(error, aid, account.id);
return { uid: account.id, error: true } as CollectResult;
});
if (result) {
message.accounts.push(result);
}
}
LOGGER.log('Completed collection!');
if (ret.length > 1 || ret[0] !== '') {
message.err = ret;
LOGGER.error('With errors...');
}
// Send message to be received by index.ts
return fetch(`http://localhost:${config.port}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(message),
});
}
(async () => {
try {
LOGGER.start();
await client.connect();
await collect();
} catch (e) {
LOGGER.error(e);
add('I encountered a really bad error... save me...\n```\n' + inspect(e) + '```');
} finally {
await client.end().catch(() => { });
LOGGER.end();
}
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment