feat: integrate game patch data processing

This commit is contained in:
daydreamer-json
2026-01-26 08:00:33 +09:00
parent 1e2f91eb6c
commit 45be4f3067
11 changed files with 1031 additions and 18 deletions

View File

@@ -1,6 +1,7 @@
import chalk from 'chalk';
import CliTable3 from 'cli-table3';
import { HTTPError } from 'ky';
import { DateTime } from 'luxon';
import prompts from 'prompts';
import apiUtils from '../utils/api.js';
import argvUtils from '../utils/argv.js';
@@ -35,8 +36,10 @@ async function mainCmdHandler() {
argvUtils.setArgv({ ...argvUtils.getArgv(), token: tokenUserRsp });
}
}
logger.info('Authorization in progress ...');
if (needRetrieveToken === false) {
try {
logger.debug('Retrieving account service OAuth 2.0 code ...');
oauth2TokenPreRsp = await apiUtils.apiAkEndfield.accountService.user.oauth2.v2.grant(
cfg.appCode.accountService.osWinRel,
argvUtils.getArgv()['token'],
@@ -82,7 +85,7 @@ async function mainCmdHandler() {
argvUtils.setArgv({ ...argvUtils.getArgv(), password: pwdRsp });
}
})();
logger.info('Retrieving account service token ...');
logger.debug('Retrieving account service token ...');
const accSrvTokenRsp = await apiUtils.apiAkEndfield.accountService.user.auth.v1.tokenByEmailPassword(
argvUtils.getArgv()['email'],
argvUtils.getArgv()['password'],
@@ -90,37 +93,116 @@ async function mainCmdHandler() {
argvUtils.setArgv({ ...argvUtils.getArgv(), token: accSrvTokenRsp.data.token });
}
logger.info('Retrieving account service OAuth 2.0 token ...');
const oauth2TokenRsp = await apiUtils.apiAkEndfield.accountService.user.oauth2.v2.grant(
cfg.appCode.accountService.osWinRel,
oauth2TokenPreRsp === null ? logger.debug('Retrieving account service OAuth 2.0 code ...') : undefined;
const oauth2TokenRsp =
oauth2TokenPreRsp === null
? await apiUtils.apiAkEndfield.accountService.user.oauth2.v2.grant(
cfg.appCode.accountService.osWinRel,
argvUtils.getArgv()['token'],
)
: oauth2TokenPreRsp;
const oauth2TokenBindRsp = await apiUtils.apiAkEndfield.accountService.user.oauth2.v2.grant(
cfg.appCode.accountService.binding,
argvUtils.getArgv()['token'],
1,
);
logger.info('Retrieving u8 access token ...');
logger.debug('Retrieving u8 access token ...');
const u8TokenRsp = await apiUtils.apiAkEndfield.u8.user.auth.v2.tokenByChToken(
cfg.appCode.u8.osWinRel,
cfg.channel.osWinRel,
oauth2TokenRsp.data.code,
);
// logger.debug('Retrieving u8 OAuth 2.0 code ...');
// const u8OAuth2Rsp = await apiUtils.apiAkEndfield.u8.user.auth.v2.grant(u8TokenRsp.data.token);
logger.info('Authentication successful!');
logger.info('Retrieving user account data ...');
logger.info('Retrieving user information data ...');
logger.debug('Retrieving user account data ...');
const userAccData = await apiUtils.apiAkEndfield.accountService.user.info.v1.basic(
cfg.appCode.accountService.osWinRel,
argvUtils.getArgv()['token'],
);
logger.info('Retrieving user game server data ...');
logger.debug('Retrieving user game server data ...');
const userGameData = await apiUtils.apiAkEndfield.u8.game.server.v1.serverList(u8TokenRsp.data.token);
const userGameBindingData = await apiUtils.apiAkEndfield.binding.account.binding.v1.bindingList(
oauth2TokenBindRsp.data.token,
);
logger.debug('Retrieving gacha record ...');
const selectedServerId = await (async () => {
const selectedServerAccData = userGameBindingData.data.list
.find((f) => f.appCode === 'endfield')!
.bindingList[0]!.roles.filter((e) => e.isBanned === false)
.sort((a, b) => b.level - a.level)[0];
if (!selectedServerAccData) throw new Error('Game account not found');
const id = selectedServerAccData.serverId;
logger.debug('Confirming server availability ...');
const confirmServerRsp = await apiUtils.apiAkEndfield.u8.game.role.v1.confirmServer(
u8TokenRsp.data.token,
parseInt(id),
);
if (confirmServerRsp.status !== 0)
throw new Error('Game server availability error: ' + JSON.stringify(confirmServerRsp));
return id;
})();
const gachaRecordRsp = await (async () => {
const overallRsp = await (async () => {
const poolTypeList = [
'E_CharacterGachaPoolType_Standard',
'E_CharacterGachaPoolType_Beginner',
'E_CharacterGachaPoolType_Special',
] as const;
const recordArr = [];
for (const poolTypeEntry of poolTypeList) {
let seqId: string | null = null;
while (true) {
const rsp = await apiUtils.apiAkEndfield.webview.record.char(
u8TokenRsp.data.token,
parseInt(selectedServerId),
poolTypeEntry,
seqId,
);
recordArr.push(...rsp.data.list.map((e) => ({ poolType: poolTypeEntry, ...e })));
logger.trace(`Loaded: ${poolTypeEntry}, ${recordArr.length} entries, hasMore=${rsp.data.hasMore}`);
if (rsp.data.hasMore === false) break;
if (!rsp.data.list.at(-1)) break;
seqId = rsp.data.list.at(-1)!.seqId;
}
}
return recordArr;
})();
return overallRsp.toReversed();
})();
const gachaPoolInfoList = await (async () => {
logger.debug('Retrieving gacha pool info ...');
const arr = [];
const poolIdList = [...new Set(gachaRecordRsp.map((e) => e.poolId))];
for (const poolId of poolIdList) {
const rsp = await apiUtils.apiAkEndfield.webview.content(parseInt(selectedServerId), poolId, 'ja-jp');
arr.push({ poolId, ...rsp.data.pool });
}
return arr;
})();
logger.info('Data retrieval completed!');
(() => {
const table = new CliTable3(termPrettyUtils.cliTableConfig.rounded);
table.push(
// [{ colSpan: 2, hAlign: 'center', content: chalk.bold('Account Info') }],
...[
['Account ID', userAccData.data.hgId],
['Hypergryph ID', userAccData.data.hgId],
['OAuth Grant ID', oauth2TokenRsp.data.uid],
['Game Overall UID', userGameBindingData.data.list.find((e) => e.appCode === 'endfield')!.bindingList[0]!.uid],
['Email', userAccData.data.realEmail],
['Nickname', userAccData.data.nickName === '' ? chalk.dim('(none)') : userAccData.data.nickName],
['Age Region', userAccData.data.ageGate.regionInfo['en-us']],
[
'Registered',
DateTime.fromSeconds(
userGameBindingData.data.list.find((e) => e.appCode === 'endfield')!.bindingList[0]!.registerTs,
).toFormat('yyyy/MM/dd HH:mm:ss'),
],
].map((e) => [chalk.dim(e[0]), e[1]]),
);
console.log(table.toString());
@@ -147,17 +229,108 @@ async function mainCmdHandler() {
const table = new CliTable3(termPrettyUtils.cliTableConfig.rounded);
table.push(
...[
['ID', 'UID', 'Level', 'Default'].map((e) => chalk.dim(e)),
['ID', 'UID', 'Lv', 'Found', 'Default', 'Nickname', 'Registered'].map((e) => chalk.dim(e)),
...userGameData.data.serverList.map((e) => [
e.serverId,
e.roleId,
{ hAlign: 'right' as const, content: e.level },
Boolean(
userGameBindingData.data.list
.find((f) => f.appCode === 'endfield')!
.bindingList[0]!.roles.find((f) => f.serverId === e.serverId),
),
e.defaultChoose,
userGameBindingData.data.list
.find((f) => f.appCode === 'endfield')!
.bindingList[0]!.roles.find((f) => f.serverId === e.serverId)?.nickName,
userGameBindingData.data.list
.find((f) => f.appCode === 'endfield')!
.bindingList[0]!.roles.find((f) => f.serverId === e.serverId)?.registerTs
? DateTime.fromSeconds(
userGameBindingData.data.list
.find((f) => f.appCode === 'endfield')!
.bindingList[0]!.roles.find((f) => f.serverId === e.serverId)?.registerTs!,
).toFormat('yyyy/MM/dd HH:mm:ss')
: '',
]),
],
);
console.log(table.toString());
})();
(() => {
const table = new CliTable3(termPrettyUtils.cliTableConfig.rounded);
table.push(
...[
['Pool ID', 'Pool Name', 'Pulls', '*6', '*5', '*4', 'Pity *6', '*5', 'Latest'].map((e) => chalk.dim(e)),
...gachaPoolInfoList.map((e) => [
e.poolId,
e.pool_name,
...[
gachaRecordRsp.filter((f) => f.poolId === e.poolId).length,
gachaRecordRsp.filter((f) => f.poolId === e.poolId && f.rarity === 6).length,
gachaRecordRsp.filter((f) => f.poolId === e.poolId && f.rarity === 5).length,
gachaRecordRsp.filter((f) => f.poolId === e.poolId && f.rarity === 4).length,
(() => {
let counter = 0;
for (const pullEntry of gachaRecordRsp.filter((f) => f.poolId === e.poolId).toReversed()) {
if (pullEntry.rarity >= 6) break;
counter++;
}
return String(counter);
})(),
(() => {
let counter = 0;
for (const pullEntry of gachaRecordRsp.filter((f) => f.poolId === e.poolId).toReversed()) {
if (pullEntry.rarity >= 5) break;
counter++;
}
return String(counter);
})(),
].map((f) => ({ hAlign: 'right' as const, content: f })),
(() => {
const latestRecord = gachaRecordRsp.filter((f) => f.poolId === e.poolId).at(-1)!;
const color = latestRecord.rarity === 6 ? chalk.yellow : chalk.magenta;
return color(`*${latestRecord.rarity} ${latestRecord.charName}`);
})(),
]),
],
);
console.log(table.toString());
})();
(() => {
const tableData: (string | number)[][] = [];
tableData.push(['Pool ID', 'Pool Name', 'Pulled', 'Pity', 'Character'].map((e) => chalk.dim(e)));
for (const [gachaPoolInfoIndex, gachaPoolInfoEntry] of Object.entries(gachaPoolInfoList)) {
const records = gachaRecordRsp.filter((e) => e.poolId === gachaPoolInfoEntry.poolId);
const tableSubData: typeof tableData = [];
let pityR6: number = 0;
let pityR5: number = 0;
for (const record of records) {
pityR6++;
pityR5++;
if (record.rarity >= 5) {
tableSubData.push([
gachaPoolInfoEntry.poolId,
gachaPoolInfoEntry.pool_name,
DateTime.fromMillis(parseInt(record.gachaTs)).toFormat('yyyy/MM/dd hh:mm:ss'),
record.rarity === 6 ? pityR6 : pityR5,
record.rarity === 6
? chalk.yellow(`*${record.rarity} ${record.charName}`)
: chalk.magenta(`*${record.rarity} ${record.charName}`),
]);
if (record.rarity === 6) pityR6 = 0;
pityR5 = 0;
}
}
tableData.push(...tableSubData.toReversed());
if (parseInt(gachaPoolInfoIndex) < gachaPoolInfoList.length - 1) tableData.push(Array(4).fill(''));
}
const table = new CliTable3(termPrettyUtils.cliTableConfig.rounded);
table.push(...tableData);
console.log(table.toString());
})();
}
export default mainCmdHandler;

View File

@@ -1,5 +1,6 @@
import path from 'node:path';
import { DateTime } from 'luxon';
import PQueue from 'p-queue';
import semver from 'semver';
import apiUtils from '../utils/api.js';
import argvUtils from '../utils/argv.js';
@@ -115,6 +116,76 @@ async function mainCmdHandler() {
await saveResult(['akEndfield', 'launcher', 'game', channelStr], rsp.version, prettyRsp);
})();
await (async () => {
logger.debug('Fetching latestGame (patch) ...');
const gameAllJson = await Bun.file(
path.join(argvUtils.getArgv()['outputDir'], 'akEndfield', 'launcher', 'game', channelStr, 'all.json'),
).json();
const patchAllJson = await Bun.file(
path.join(argvUtils.getArgv()['outputDir'], 'akEndfield', 'launcher', 'game', channelStr, 'all_patch.json'),
).json();
const versionList = ([...new Set(gameAllJson.map((e: any) => e.rsp.version))] as string[])
.sort((a, b) => semver.compare(b, a))
.slice(1);
let needWrite: boolean = false;
const queue = new PQueue({ concurrency: appConfig.threadCount.network });
for (const ver of versionList) {
queue.add(async () => {
const rsp = await apiUtils.apiAkEndfield.launcher.latestGame(
cfg.appCode.game.osWinRel,
cfg.appCode.launcher.osWinRel,
cfg.channel.osWinRel,
cfg.channel.osWinRel,
cfg.channel.osWinRel,
ver,
);
const prettyRsp = {
req: {
appCode: cfg.appCode.game.osWinRel,
launcherAppCode: cfg.appCode.launcher.osWinRel,
channel: cfg.channel.osWinRel,
subChannel: cfg.channel.osWinRel,
launcherSubChannel: cfg.channel.osWinRel,
version: ver,
},
rsp,
};
if (rsp.patch === null) return;
if (
patchAllJson
.map((e: any) => JSON.stringify({ req: e.req, rsp: e.rsp }))
.includes(JSON.stringify(prettyRsp)) === false
) {
logger.debug(
`Fetched latestGame (patch): v${rsp.request_version} -> v${rsp.version}, ${mathUtils.formatFileSize(
parseInt(rsp.patch.total_size) - parseInt(rsp.patch.package_size),
{
decimals: 2,
decimalPadding: true,
unitVisible: true,
useBinaryUnit: true,
useBitUnit: false,
unit: null,
},
)}`,
);
patchAllJson.push({
updatedAt: DateTime.now().toISO(),
...prettyRsp,
});
needWrite = true;
}
});
}
await queue.onIdle();
if (needWrite) {
await Bun.write(
path.join(argvUtils.getArgv()['outputDir'], 'akEndfield', 'launcher', 'game', channelStr, 'all_patch.json'),
JSON.stringify(patchAllJson, null, 2),
);
}
})();
await (async () => {
logger.debug('Fetching latestGameRes ...');
@@ -202,6 +273,147 @@ async function mainCmdHandler() {
);
}
})();
await (async () => {
//* Markdown generate
await (async () => {
const gameAllJson = await Bun.file(
path.join(argvUtils.getArgv()['outputDir'], 'akEndfield', 'launcher', 'game', channelStr, 'all.json'),
).json();
const mdTexts: string[] = [];
mdTexts.push(
...[
'# Game Packages\n',
...gameAllJson.map(
(e: any) =>
`- [${e.rsp.version} (${DateTime.fromISO(e.updatedAt, { setZone: true }).setZone('UTC+8').toFormat('yyyy/MM/dd HH:mm:ss')})](#ver-${e.rsp.version}-${Math.ceil(DateTime.fromISO(e.updatedAt).toSeconds())})`,
),
'',
],
...gameAllJson.map((e: any) =>
[
`<h2 id="ver-${e.rsp.version}-${Math.ceil(DateTime.fromISO(e.updatedAt).toSeconds())}">${e.rsp.version} (${DateTime.fromISO(e.updatedAt, { setZone: true }).setZone('UTC+8').toFormat('yyyy/MM/dd HH:mm:ss')})</h2>\n`,
`<table>`,
` <tr><td>Unpacked Size</td><td style="text-align: right;"><b>${mathUtils.formatFileSize(
e.rsp.pkg.total_size - mathUtils.arrayTotal(e.rsp.pkg.packs.map((f: any) => parseInt(f.package_size))),
{
decimals: 2,
decimalPadding: true,
unitVisible: true,
useBinaryUnit: true,
useBitUnit: false,
unit: null,
},
)}</b></td></tr>`,
` <tr><td>Packed Size</td><td style="text-align: right;"><b>${mathUtils.formatFileSize(mathUtils.arrayTotal(e.rsp.pkg.packs.map((f: any) => parseInt(f.package_size))), { decimals: 2, decimalPadding: true, unitVisible: true, useBinaryUnit: true, useBitUnit: false, unit: null })}</b></td></tr>`,
`</table>\n`,
`|File|MD5 Checksum|Size|`,
`|:--|:--|--:|`,
...e.rsp.pkg.packs.map((f: any) => [
`|[${new URL(f.url).pathname.split('/').pop() ?? ''}](${f.url})|\`${f.md5}\`|${mathUtils.formatFileSize(parseInt(f.package_size), { decimals: 2, decimalPadding: true, unitVisible: true, useBinaryUnit: true, useBitUnit: false, unit: null })}|`,
]),
'',
].join('\n'),
),
);
await Bun.write(
path.join(argvUtils.getArgv()['outputDir'], 'akEndfield', 'launcher', 'game', channelStr, 'list.md'),
mdTexts.join('\n'),
);
})();
await (async () => {
const gameAllJson = await Bun.file(
path.join(argvUtils.getArgv()['outputDir'], 'akEndfield', 'launcher', 'game', channelStr, 'all_patch.json'),
).json();
const mdTexts: string[] = [];
mdTexts.push(
...[
'# Game Patch Packages\n',
...gameAllJson.map(
(e: any) =>
`- [${e.rsp.request_version}${e.rsp.version} (${DateTime.fromISO(e.updatedAt, { setZone: true }).setZone('UTC+8').toFormat('yyyy/MM/dd HH:mm:ss')})](#ver-${e.rsp.request_version}-${e.rsp.version}-${Math.ceil(DateTime.fromISO(e.updatedAt).toSeconds())})`,
),
'',
],
...gameAllJson.map((e: any) =>
[
`<h2 id="ver-${e.rsp.request_version}-${e.rsp.version}-${Math.ceil(DateTime.fromISO(e.updatedAt).toSeconds())}">${e.rsp.request_version}${e.rsp.version} (${DateTime.fromISO(e.updatedAt, { setZone: true }).setZone('UTC+8').toFormat('yyyy/MM/dd HH:mm:ss')})</h2>\n`,
`<table>`,
` <tr><td>Unpacked Size</td><td style="text-align: right;"><b>${mathUtils.formatFileSize(
e.rsp.patch.total_size -
mathUtils.arrayTotal(e.rsp.patch.patches.map((f: any) => parseInt(f.package_size))),
{
decimals: 2,
decimalPadding: true,
unitVisible: true,
useBinaryUnit: true,
useBitUnit: false,
unit: null,
},
)}</b></td></tr>`,
` <tr><td>Packed Size</td><td style="text-align: right;"><b>${mathUtils.formatFileSize(mathUtils.arrayTotal(e.rsp.patch.patches.map((f: any) => parseInt(f.package_size))), { decimals: 2, decimalPadding: true, unitVisible: true, useBinaryUnit: true, useBitUnit: false, unit: null })}</b></td></tr>`,
`</table>\n`,
`|File|MD5 Checksum|Size|`,
`|:--|:--|--:|`,
...(e.rsp.patch.url
? [
`|[${new URL(e.rsp.patch.url).pathname.split('/').pop() ?? ''}](${e.rsp.patch.url})|\`${e.rsp.patch.md5}\`|${mathUtils.formatFileSize(parseInt(e.rsp.patch.package_size), { decimals: 2, decimalPadding: true, unitVisible: true, useBinaryUnit: true, useBitUnit: false, unit: null })}|`,
]
: []),
...e.rsp.patch.patches.map((f: any) => [
`|[${new URL(f.url).pathname.split('/').pop() ?? ''}](${f.url})|\`${f.md5}\`|${mathUtils.formatFileSize(parseInt(f.package_size), { decimals: 2, decimalPadding: true, unitVisible: true, useBinaryUnit: true, useBitUnit: false, unit: null })}|`,
]),
'',
].join('\n'),
),
);
await Bun.write(
path.join(argvUtils.getArgv()['outputDir'], 'akEndfield', 'launcher', 'game', channelStr, 'list_patch.md'),
mdTexts.join('\n'),
);
})();
await (async () => {
const gameAllJson = await Bun.file(
path.join(argvUtils.getArgv()['outputDir'], 'akEndfield', 'launcher', 'game_resources', channelStr, 'all.json'),
).json();
const resVersionSet: {
resVersion: string;
rsp: { rsp: Awaited<ReturnType<typeof apiUtils.apiAkEndfield.launcher.latestGameResources>> };
versions: string[];
}[] = (() => {
const resVersions: string[] = [...new Set(gameAllJson.map((e: any) => e.rsp.res_version))] as string[];
const arr: { resVersion: string; rsp: any; versions: string[] }[] = [];
for (const resVersion of resVersions) {
arr.push({
resVersion,
rsp: gameAllJson.find((e: any) => e.rsp.res_version === resVersion),
versions: [
...new Set(
gameAllJson.filter((e: any) => e.rsp.res_version === resVersion).map((e: any) => e.req.version),
),
] as string[],
});
}
return arr;
})();
const mdTexts: string[] = [];
mdTexts.push(
'# Game Resources\n',
'|Res version|Initial|Main|Game version|',
'|--|--|--|--|',
...resVersionSet.map(
(resVerObj) =>
`|\`${resVerObj.rsp.rsp.res_version}\`|[${resVerObj.rsp.rsp.resources.find((e) => e.name === 'initial')!.version}](${resVerObj.rsp.rsp.resources.find((e) => e.name === 'initial')!.path})|[${resVerObj.rsp.rsp.resources.find((e) => e.name === 'main')!.version}](${resVerObj.rsp.rsp.resources.find((e) => e.name === 'main')!.path})|${resVerObj.versions.join(', ')}|`,
),
);
await Bun.write(
path.join(argvUtils.getArgv()['outputDir'], 'akEndfield', 'launcher', 'game_resources', channelStr, 'list.md'),
mdTexts.join('\n'),
);
})();
})();
}
export default mainCmdHandler;