mirror of
https://github.com/daydreamer-json/ak-endfield-api-archive.git
synced 2026-02-04 06:15:04 +01:00
Hello
This commit is contained in:
76
src/cmd.ts
Normal file
76
src/cmd.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
import yargs from 'yargs';
|
||||
import { hideBin } from 'yargs/helpers';
|
||||
import cmds from './cmds.js';
|
||||
import * as TypesLogLevels from './types/LogLevels.js';
|
||||
import argvUtils from './utils/argv.js';
|
||||
import appConfig from './utils/config.js';
|
||||
import configEmbed from './utils/configEmbed.js';
|
||||
import exitUtils from './utils/exit.js';
|
||||
import logger from './utils/logger.js';
|
||||
|
||||
if (configEmbed.VERSION_NUMBER === null) throw new Error('Embed VERSION_NUMBER is null');
|
||||
|
||||
function wrapHandler(handler: (argv: any) => Promise<void>) {
|
||||
return async (argv: any) => {
|
||||
try {
|
||||
await handler(argv);
|
||||
await exitUtils.exit(0);
|
||||
} catch (error) {
|
||||
logger.error('Error caught:', error);
|
||||
await exitUtils.exit(1);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function parseCommand() {
|
||||
const yargsInstance = yargs(hideBin(process.argv));
|
||||
await yargsInstance
|
||||
.command(
|
||||
['test'],
|
||||
'Test command',
|
||||
(yargs) => {
|
||||
yargs.options({
|
||||
'output-dir': {
|
||||
alias: ['o'],
|
||||
desc: 'Output root directory',
|
||||
default: appConfig.file.outputDirPath,
|
||||
normalize: true,
|
||||
type: 'string',
|
||||
},
|
||||
});
|
||||
},
|
||||
wrapHandler(cmds.test),
|
||||
)
|
||||
.options({
|
||||
'log-level': {
|
||||
desc: 'Set log level (' + TypesLogLevels.LOG_LEVELS_NUM.join(', ') + ')',
|
||||
default: appConfig.logger.logLevel,
|
||||
type: 'number',
|
||||
coerce: (arg: number): TypesLogLevels.LogLevelString => {
|
||||
if (arg < TypesLogLevels.LOG_LEVELS_NUM[0] || arg > TypesLogLevels.LOG_LEVELS_NUM.slice(-1)[0]!) {
|
||||
throw new Error(`Invalid log level: ${arg} (Expected: ${TypesLogLevels.LOG_LEVELS_NUM.join(', ')})`);
|
||||
} else {
|
||||
return TypesLogLevels.LOG_LEVELS[arg as TypesLogLevels.LogLevelNumber];
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
.middleware(async (argv) => {
|
||||
argvUtils.setArgv(argv);
|
||||
logger.level = argvUtils.getArgv()['logLevel'];
|
||||
logger.trace('Process started: ' + `${configEmbed.APPLICATION_NAME} v${configEmbed.VERSION_NUMBER}`);
|
||||
})
|
||||
.scriptName(configEmbed.APPLICATION_NAME)
|
||||
.version(String(configEmbed.VERSION_NUMBER))
|
||||
.usage('$0 <command> [argument] [option]')
|
||||
.help()
|
||||
.alias('help', 'h')
|
||||
.alias('help', '?')
|
||||
.alias('version', 'V')
|
||||
.demandCommand(1)
|
||||
.strict()
|
||||
.recommendCommands()
|
||||
.parse();
|
||||
}
|
||||
|
||||
export default parseCommand;
|
||||
5
src/cmds.ts
Normal file
5
src/cmds.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
import test from './cmds/test.js';
|
||||
|
||||
export default {
|
||||
test,
|
||||
};
|
||||
236
src/cmds/test.ts
Normal file
236
src/cmds/test.ts
Normal file
@@ -0,0 +1,236 @@
|
||||
import path from 'node:path';
|
||||
import { DateTime } from 'luxon';
|
||||
import semver from 'semver';
|
||||
import apiUtils from '../utils/api.js';
|
||||
import argvUtils from '../utils/argv.js';
|
||||
import appConfig from '../utils/config.js';
|
||||
import logger from '../utils/logger.js';
|
||||
|
||||
async function mainCmdHandler() {
|
||||
const cfg = appConfig.network.api.akEndfield;
|
||||
|
||||
await (async () => {
|
||||
const channel = appConfig.network.api.akEndfield.channel.osWinRel;
|
||||
logger.debug('apiAkEndfield.launcher.latestGame fetching ...');
|
||||
console.log(
|
||||
await apiUtils.apiAkEndfield.launcher.latestGame(
|
||||
appConfig.network.api.akEndfield.appCode.osWinRel,
|
||||
appConfig.network.api.akEndfield.launcherAppCode.osWinRel,
|
||||
channel,
|
||||
channel,
|
||||
channel,
|
||||
null,
|
||||
),
|
||||
);
|
||||
logger.debug('apiAkEndfield.launcher.latestLauncher fetching ...');
|
||||
console.log(
|
||||
await apiUtils.apiAkEndfield.launcher.latestLauncher(
|
||||
appConfig.network.api.akEndfield.launcherAppCode.osWinRel,
|
||||
channel,
|
||||
channel,
|
||||
null,
|
||||
null,
|
||||
),
|
||||
);
|
||||
logger.debug('apiAkEndfield.launcher.web fetching ...');
|
||||
for (const fn of [
|
||||
apiUtils.apiAkEndfield.launcher.web.sidebar,
|
||||
apiUtils.apiAkEndfield.launcher.web.singleEnt,
|
||||
apiUtils.apiAkEndfield.launcher.web.mainBgImage,
|
||||
apiUtils.apiAkEndfield.launcher.web.banner,
|
||||
apiUtils.apiAkEndfield.launcher.web.announcement,
|
||||
]) {
|
||||
console.dir(await fn(appConfig.network.api.akEndfield.appCode.osWinRel, channel, channel, 'ja-jp'), {
|
||||
depth: null,
|
||||
});
|
||||
}
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
const rsp = await apiUtils.apiAkEndfield.launcher.latestGame(
|
||||
cfg.appCode.osWinRel,
|
||||
cfg.launcherAppCode.osWinRel,
|
||||
cfg.channel.osWinRel,
|
||||
cfg.channel.osWinRel,
|
||||
cfg.channel.osWinRel,
|
||||
null,
|
||||
);
|
||||
const prettyRsp = {
|
||||
req: {
|
||||
appCode: cfg.appCode.osWinRel,
|
||||
launcherAppCode: cfg.launcherAppCode.osWinRel,
|
||||
channel: cfg.channel.osWinRel,
|
||||
subChannel: cfg.channel.osWinRel,
|
||||
launcherSubChannel: cfg.channel.osWinRel,
|
||||
},
|
||||
rsp,
|
||||
};
|
||||
const filePathBase = path.join(
|
||||
argvUtils.getArgv()['outputDir'],
|
||||
'akEndfield',
|
||||
'launcher',
|
||||
'game',
|
||||
String(cfg.channel.osWinRel),
|
||||
);
|
||||
for (const filePath of [path.join(filePathBase, 'latest.json'), path.join(filePathBase, `v${rsp.version}.json`)]) {
|
||||
if (
|
||||
(await Bun.file(filePath).exists()) === false ||
|
||||
JSON.stringify(await Bun.file(filePath).json()) !== JSON.stringify(prettyRsp)
|
||||
) {
|
||||
await Bun.write(filePath, JSON.stringify(prettyRsp, null, 2));
|
||||
}
|
||||
}
|
||||
await (async () => {
|
||||
let needWrite: boolean = true;
|
||||
const tmp: ({ updatedAt: string } & typeof prettyRsp)[] = await Bun.file(
|
||||
path.join(filePathBase, 'all.json'),
|
||||
).json();
|
||||
for (const dataStr of tmp.map((e) => JSON.stringify({ req: e.req, rsp: e.rsp }))) {
|
||||
if (dataStr === JSON.stringify(prettyRsp)) {
|
||||
needWrite = false;
|
||||
}
|
||||
}
|
||||
if (needWrite) {
|
||||
tmp.push({ updatedAt: DateTime.now().toISO(), ...prettyRsp });
|
||||
await Bun.write(path.join(filePathBase, 'all.json'), JSON.stringify(tmp, null, 2));
|
||||
}
|
||||
})();
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
const versionInfoList = (
|
||||
(
|
||||
await Bun.file(
|
||||
path.join(
|
||||
argvUtils.getArgv()['outputDir'],
|
||||
'akEndfield',
|
||||
'launcher',
|
||||
'game',
|
||||
String(cfg.channel.osWinRel),
|
||||
'all.json',
|
||||
),
|
||||
).json()
|
||||
).map((e: any) => e.rsp) as Awaited<ReturnType<typeof apiUtils.apiAkEndfield.launcher.latestGame>>[]
|
||||
)
|
||||
.map((e) => ({
|
||||
version: e.version,
|
||||
versionMinor: semver.major(e.version) + '.' + semver.minor(e.version),
|
||||
randStr: /_([^/]+)\/.+?$/.exec(e.pkg.file_path)![1],
|
||||
}))
|
||||
.sort((a, b) => semver.compare(b.version, a.version));
|
||||
const filePathBase = path.join(
|
||||
argvUtils.getArgv()['outputDir'],
|
||||
'akEndfield',
|
||||
'launcher',
|
||||
'game_resources',
|
||||
String(cfg.channel.osWinRel),
|
||||
);
|
||||
let isLatestWrote: boolean = false;
|
||||
for (const versionInfoEntry of versionInfoList) {
|
||||
if (!versionInfoEntry.randStr) throw new Error('version rand_str not found');
|
||||
const rsp = await apiUtils.apiAkEndfield.launcher.latestGameResources(
|
||||
cfg.appCode.osWinRel,
|
||||
versionInfoEntry.versionMinor,
|
||||
versionInfoEntry.version,
|
||||
versionInfoEntry.randStr,
|
||||
);
|
||||
const prettyRsp = {
|
||||
req: {
|
||||
appCode: cfg.appCode.osWinRel,
|
||||
gameVersion: versionInfoEntry.versionMinor,
|
||||
version: versionInfoEntry.version,
|
||||
randStr: versionInfoEntry.randStr,
|
||||
},
|
||||
rsp,
|
||||
};
|
||||
if (isLatestWrote === false) {
|
||||
if (
|
||||
(await Bun.file(path.join(filePathBase, 'latest.json')).exists()) === false ||
|
||||
JSON.stringify(await Bun.file(path.join(filePathBase, 'latest.json')).json()) !== JSON.stringify(prettyRsp)
|
||||
) {
|
||||
await Bun.write(path.join(filePathBase, 'latest.json'), JSON.stringify(prettyRsp, null, 2));
|
||||
}
|
||||
isLatestWrote = true;
|
||||
}
|
||||
for (const filePath of [path.join(filePathBase, `v${versionInfoEntry.version}.json`)]) {
|
||||
if (
|
||||
(await Bun.file(filePath).exists()) === false ||
|
||||
JSON.stringify(await Bun.file(filePath).json()) !== JSON.stringify(prettyRsp)
|
||||
) {
|
||||
await Bun.write(filePath, JSON.stringify(prettyRsp, null, 2));
|
||||
}
|
||||
}
|
||||
await (async () => {
|
||||
let needWrite: boolean = true;
|
||||
const tmp: ({ updatedAt: string } & typeof prettyRsp)[] = await Bun.file(
|
||||
path.join(filePathBase, 'all.json'),
|
||||
).json();
|
||||
for (const dataStr of tmp.map((e) => JSON.stringify({ req: e.req, rsp: e.rsp }))) {
|
||||
if (dataStr === JSON.stringify(prettyRsp)) needWrite = false;
|
||||
}
|
||||
if (needWrite) {
|
||||
tmp.push({ updatedAt: DateTime.now().toISO(), ...prettyRsp });
|
||||
await Bun.write(path.join(filePathBase, 'all.json'), JSON.stringify(tmp, null, 2));
|
||||
}
|
||||
})();
|
||||
}
|
||||
})();
|
||||
|
||||
await (async () => {
|
||||
const launcherTargetAppList = ['EndField', 'official'] as const;
|
||||
for (const launcherTargetAppEntry of launcherTargetAppList) {
|
||||
const rsp = await apiUtils.apiAkEndfield.launcher.latestLauncher(
|
||||
cfg.launcherAppCode.osWinRel,
|
||||
cfg.channel.osWinRel,
|
||||
cfg.channel.osWinRel,
|
||||
null,
|
||||
launcherTargetAppEntry === 'official' ? null : launcherTargetAppEntry,
|
||||
);
|
||||
const prettyRsp = {
|
||||
req: {
|
||||
appCode: cfg.launcherAppCode.osWinRel,
|
||||
channel: cfg.channel.osWinRel,
|
||||
subChannel: cfg.channel.osWinRel,
|
||||
targetApp: launcherTargetAppEntry === 'official' ? null : launcherTargetAppEntry,
|
||||
},
|
||||
rsp,
|
||||
};
|
||||
const filePathBase = path.join(
|
||||
argvUtils.getArgv()['outputDir'],
|
||||
'akEndfield',
|
||||
'launcher',
|
||||
'launcher',
|
||||
launcherTargetAppEntry,
|
||||
String(cfg.channel.osWinRel),
|
||||
);
|
||||
for (const filePath of [
|
||||
path.join(filePathBase, 'latest.json'),
|
||||
path.join(filePathBase, `v${rsp.version}.json`),
|
||||
]) {
|
||||
if (
|
||||
(await Bun.file(filePath).exists()) === false ||
|
||||
JSON.stringify(await Bun.file(filePath).json()) !== JSON.stringify(prettyRsp)
|
||||
) {
|
||||
await Bun.write(filePath, JSON.stringify(prettyRsp, null, 2));
|
||||
}
|
||||
}
|
||||
await (async () => {
|
||||
let needWrite: boolean = true;
|
||||
const tmp: ({ updatedAt: string } & typeof prettyRsp)[] = await Bun.file(
|
||||
path.join(filePathBase, 'all.json'),
|
||||
).json();
|
||||
for (const dataStr of tmp.map((e) => JSON.stringify({ req: e.req, rsp: e.rsp }))) {
|
||||
if (dataStr === JSON.stringify(prettyRsp)) {
|
||||
needWrite = false;
|
||||
}
|
||||
}
|
||||
if (needWrite) {
|
||||
tmp.push({ updatedAt: DateTime.now().toISO(), ...prettyRsp });
|
||||
await Bun.write(path.join(filePathBase, 'all.json'), JSON.stringify(tmp, null, 2));
|
||||
}
|
||||
})();
|
||||
}
|
||||
})();
|
||||
}
|
||||
|
||||
export default mainCmdHandler;
|
||||
18
src/main.ts
Normal file
18
src/main.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import childProcess from 'node:child_process';
|
||||
import util from 'node:util';
|
||||
import parseCommand from './cmd.js';
|
||||
import exitUtils from './utils/exit.js';
|
||||
|
||||
async function main(): Promise<void> {
|
||||
try {
|
||||
process.platform === 'win32' ? await util.promisify(childProcess.exec)('chcp 65001') : undefined;
|
||||
await parseCommand();
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
exitUtils.pressAnyKeyToExit(1);
|
||||
}
|
||||
}
|
||||
|
||||
await main();
|
||||
15
src/types/LogLevels.ts
Normal file
15
src/types/LogLevels.ts
Normal file
@@ -0,0 +1,15 @@
|
||||
const LOG_LEVELS = {
|
||||
0: 'trace',
|
||||
1: 'debug',
|
||||
2: 'info',
|
||||
3: 'warn',
|
||||
4: 'error',
|
||||
5: 'fatal',
|
||||
} as const;
|
||||
const LOG_LEVELS_NUM = [0, 1, 2, 3, 4, 5] as const;
|
||||
|
||||
type LogLevelNumber = keyof typeof LOG_LEVELS;
|
||||
type LogLevelString = (typeof LOG_LEVELS)[LogLevelNumber];
|
||||
|
||||
export type { LogLevelNumber, LogLevelString };
|
||||
export { LOG_LEVELS, LOG_LEVELS_NUM };
|
||||
118
src/types/api/akEndfield/Api.ts
Normal file
118
src/types/api/akEndfield/Api.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
type LauncherLatestGame = {
|
||||
action: number;
|
||||
version: string; // x.y.z
|
||||
request_version: string; // x.y.z or blank
|
||||
pkg: {
|
||||
packs: {
|
||||
url: string;
|
||||
md5: string;
|
||||
package_size: string;
|
||||
}[];
|
||||
total_size: string;
|
||||
file_path: string;
|
||||
url: string;
|
||||
md5: string;
|
||||
package_size: string;
|
||||
file_id: string;
|
||||
sub_channel: string;
|
||||
game_files_md5: string;
|
||||
};
|
||||
patch: unknown;
|
||||
state: number;
|
||||
launcher_action: number;
|
||||
};
|
||||
|
||||
type LauncherLatestGameResources = {
|
||||
resources: {
|
||||
name: string;
|
||||
version: string;
|
||||
path: string;
|
||||
}[];
|
||||
configs: string; // json str
|
||||
res_version: string;
|
||||
patch_index_path: string;
|
||||
domain: string;
|
||||
};
|
||||
|
||||
type LauncherLatestLauncher = {
|
||||
action: number;
|
||||
version: string; // x.y.z
|
||||
request_version: string; // x.y.z or blank
|
||||
zip_package_url: string;
|
||||
md5: string;
|
||||
package_size: string;
|
||||
total_size: string;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type LauncherWebSidebar = {
|
||||
data_version: string;
|
||||
sidebars: {
|
||||
display_type: 'DisplayType_RESERVE';
|
||||
media: string;
|
||||
pic: { url: string; md5: string; description: string } | null;
|
||||
sidebar_labels: { content: string; jump_url: string; need_token: boolean }[];
|
||||
grid_info: null;
|
||||
jump_url: string;
|
||||
need_token: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
type LauncherWebSingleEnt = {
|
||||
single_ent: {
|
||||
version_url: string;
|
||||
version_md5: string;
|
||||
jump_url: string;
|
||||
button_url: string;
|
||||
button_md5: string;
|
||||
button_hover_url: string;
|
||||
button_hover_md5: string;
|
||||
need_token: boolean;
|
||||
};
|
||||
};
|
||||
|
||||
type LauncherWebMainBgImage = {
|
||||
data_version: string;
|
||||
main_bg_image: {
|
||||
url: string;
|
||||
md5: string;
|
||||
video_url: string;
|
||||
};
|
||||
};
|
||||
|
||||
type LauncherWebBanner = {
|
||||
data_version: string;
|
||||
banners: {
|
||||
url: string;
|
||||
md5: string;
|
||||
jump_url: string;
|
||||
id: string;
|
||||
need_token: boolean;
|
||||
}[];
|
||||
};
|
||||
|
||||
type LauncherWebAnnouncement = {
|
||||
data_version: string;
|
||||
tabs: {
|
||||
tabName: string;
|
||||
announcements: {
|
||||
content: string;
|
||||
jump_url: string;
|
||||
start_ts: string; // example: 1768969800000
|
||||
id: string;
|
||||
need_token: boolean;
|
||||
}[];
|
||||
tab_id: string;
|
||||
}[];
|
||||
};
|
||||
|
||||
export type {
|
||||
LauncherLatestGame,
|
||||
LauncherLatestGameResources,
|
||||
LauncherLatestLauncher,
|
||||
LauncherWebSidebar,
|
||||
LauncherWebSingleEnt,
|
||||
LauncherWebMainBgImage,
|
||||
LauncherWebBanner,
|
||||
LauncherWebAnnouncement,
|
||||
};
|
||||
252
src/utils/api.ts
Normal file
252
src/utils/api.ts
Normal file
@@ -0,0 +1,252 @@
|
||||
import ky from 'ky';
|
||||
import semver from 'semver';
|
||||
import * as TypesApiAkEndfield from '../types/api/akEndfield/api.js';
|
||||
import appConfig from './config.js';
|
||||
|
||||
const defaultKySettings = {
|
||||
headers: {
|
||||
'User-Agent': appConfig.network.userAgent.minimum,
|
||||
},
|
||||
timeout: appConfig.network.timeout,
|
||||
retry: { limit: appConfig.network.retryCount },
|
||||
};
|
||||
|
||||
const launcherWebLang = [
|
||||
'de-de',
|
||||
'en-us',
|
||||
'es-mx',
|
||||
'fr-fr',
|
||||
'id-id',
|
||||
'it-it',
|
||||
'ja-jp',
|
||||
'ko-kr',
|
||||
'pt-br',
|
||||
'ru-ru',
|
||||
'th-th',
|
||||
'vi-vn',
|
||||
'zh-cn',
|
||||
'zh-tw',
|
||||
] as const;
|
||||
|
||||
export default {
|
||||
defaultKySettings,
|
||||
apiAkEndfield: {
|
||||
launcher: {
|
||||
latestGame: async (
|
||||
appCode: string,
|
||||
launcherAppCode: string,
|
||||
channel: number,
|
||||
subChannel: number,
|
||||
launcherSubChannel: number,
|
||||
version: string | null,
|
||||
): Promise<TypesApiAkEndfield.LauncherLatestGame> => {
|
||||
if (version !== null && !semver.valid(version)) throw new Error(`Invalid version string (${version})`);
|
||||
const rsp = await ky
|
||||
.get(`https://${appConfig.network.api.akEndfield.base.launcher}/game/get_latest`, {
|
||||
...defaultKySettings,
|
||||
searchParams: {
|
||||
appcode: appCode,
|
||||
launcher_appcode: launcherAppCode,
|
||||
channel,
|
||||
sub_channel: subChannel,
|
||||
launcher_sub_channel: launcherSubChannel,
|
||||
version: version ?? undefined,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
return rsp as TypesApiAkEndfield.LauncherLatestGame;
|
||||
},
|
||||
latestGameResources: async (
|
||||
appCode: string,
|
||||
gameVersion: string, // example: 1.0
|
||||
version: string,
|
||||
randStr: string,
|
||||
platform: 'Windows' = 'Windows',
|
||||
): Promise<TypesApiAkEndfield.LauncherLatestGameResources> => {
|
||||
if (!semver.valid(version)) throw new Error(`Invalid version string (${version})`);
|
||||
const rsp = await ky
|
||||
.get(`https://${appConfig.network.api.akEndfield.base.launcher}/game/get_latest_resources`, {
|
||||
...defaultKySettings,
|
||||
searchParams: {
|
||||
appcode: appCode,
|
||||
game_version: gameVersion,
|
||||
version: version,
|
||||
platform,
|
||||
rand_str: randStr,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
return rsp as TypesApiAkEndfield.LauncherLatestGameResources;
|
||||
},
|
||||
latestLauncher: async (
|
||||
appCode: string,
|
||||
channel: number,
|
||||
subChannel: number,
|
||||
version: string | null,
|
||||
targetApp: 'EndField' | null,
|
||||
): Promise<TypesApiAkEndfield.LauncherLatestLauncher> => {
|
||||
if (version !== null && !semver.valid(version)) throw new Error(`Invalid version string (${version})`);
|
||||
const rsp = await ky
|
||||
.get(`https://${appConfig.network.api.akEndfield.base.launcher}/launcher/get_latest`, {
|
||||
...defaultKySettings,
|
||||
searchParams: {
|
||||
appcode: appCode,
|
||||
channel,
|
||||
sub_channel: subChannel,
|
||||
version: version ?? undefined,
|
||||
targetApp: targetApp ?? undefined,
|
||||
},
|
||||
})
|
||||
.json();
|
||||
return rsp as TypesApiAkEndfield.LauncherLatestLauncher;
|
||||
},
|
||||
web: {
|
||||
sidebar: async (
|
||||
appCode: string,
|
||||
channel: number,
|
||||
subChannel: number,
|
||||
language: (typeof launcherWebLang)[number],
|
||||
platform: 'Windows' = 'Windows',
|
||||
): Promise<TypesApiAkEndfield.LauncherWebSidebar> => {
|
||||
const rsp = await ky
|
||||
.post(`https://${appConfig.network.api.akEndfield.base.launcher}/proxy/web/batch_proxy`, {
|
||||
...defaultKySettings,
|
||||
json: {
|
||||
proxy_reqs: [
|
||||
{
|
||||
kind: 'get_sidebar',
|
||||
get_sidebar_req: {
|
||||
appcode: appCode,
|
||||
channel: String(channel),
|
||||
sub_channel: String(subChannel),
|
||||
language,
|
||||
platform,
|
||||
source: 'launcher',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.json();
|
||||
return (rsp as any).proxy_rsps[0].get_sidebar_rsp as TypesApiAkEndfield.LauncherWebSidebar;
|
||||
},
|
||||
singleEnt: async (
|
||||
appCode: string,
|
||||
channel: number,
|
||||
subChannel: number,
|
||||
language: (typeof launcherWebLang)[number],
|
||||
platform: 'Windows' = 'Windows',
|
||||
): Promise<TypesApiAkEndfield.LauncherWebSingleEnt> => {
|
||||
const rsp = await ky
|
||||
.post(`https://${appConfig.network.api.akEndfield.base.launcher}/proxy/web/batch_proxy`, {
|
||||
...defaultKySettings,
|
||||
json: {
|
||||
proxy_reqs: [
|
||||
{
|
||||
kind: 'get_single_ent',
|
||||
get_single_ent_req: {
|
||||
appcode: appCode,
|
||||
channel: String(channel),
|
||||
sub_channel: String(subChannel),
|
||||
language,
|
||||
platform,
|
||||
source: 'launcher',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.json();
|
||||
return (rsp as any).proxy_rsps[0].get_single_ent_rsp as TypesApiAkEndfield.LauncherWebSingleEnt;
|
||||
},
|
||||
mainBgImage: async (
|
||||
appCode: string,
|
||||
channel: number,
|
||||
subChannel: number,
|
||||
language: (typeof launcherWebLang)[number],
|
||||
platform: 'Windows' = 'Windows',
|
||||
): Promise<TypesApiAkEndfield.LauncherWebMainBgImage> => {
|
||||
const rsp = await ky
|
||||
.post(`https://${appConfig.network.api.akEndfield.base.launcher}/proxy/web/batch_proxy`, {
|
||||
...defaultKySettings,
|
||||
json: {
|
||||
proxy_reqs: [
|
||||
{
|
||||
kind: 'get_main_bg_image',
|
||||
get_main_bg_image_req: {
|
||||
appcode: appCode,
|
||||
channel: String(channel),
|
||||
sub_channel: String(subChannel),
|
||||
language,
|
||||
platform,
|
||||
source: 'launcher',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.json();
|
||||
return (rsp as any).proxy_rsps[0].get_main_bg_image_rsp as TypesApiAkEndfield.LauncherWebMainBgImage;
|
||||
},
|
||||
banner: async (
|
||||
appCode: string,
|
||||
channel: number,
|
||||
subChannel: number,
|
||||
language: (typeof launcherWebLang)[number],
|
||||
platform: 'Windows' = 'Windows',
|
||||
): Promise<TypesApiAkEndfield.LauncherWebBanner> => {
|
||||
const rsp = await ky
|
||||
.post(`https://${appConfig.network.api.akEndfield.base.launcher}/proxy/web/batch_proxy`, {
|
||||
...defaultKySettings,
|
||||
json: {
|
||||
proxy_reqs: [
|
||||
{
|
||||
kind: 'get_banner',
|
||||
get_banner_req: {
|
||||
appcode: appCode,
|
||||
channel: String(channel),
|
||||
sub_channel: String(subChannel),
|
||||
language,
|
||||
platform,
|
||||
source: 'launcher',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.json();
|
||||
return (rsp as any).proxy_rsps[0].get_banner_rsp as TypesApiAkEndfield.LauncherWebBanner;
|
||||
},
|
||||
announcement: async (
|
||||
appCode: string,
|
||||
channel: number,
|
||||
subChannel: number,
|
||||
language: (typeof launcherWebLang)[number],
|
||||
platform: 'Windows' = 'Windows',
|
||||
): Promise<TypesApiAkEndfield.LauncherWebAnnouncement> => {
|
||||
const rsp = await ky
|
||||
.post(`https://${appConfig.network.api.akEndfield.base.launcher}/proxy/web/batch_proxy`, {
|
||||
...defaultKySettings,
|
||||
json: {
|
||||
proxy_reqs: [
|
||||
{
|
||||
kind: 'get_announcement',
|
||||
get_announcement_req: {
|
||||
appcode: appCode,
|
||||
channel: String(channel),
|
||||
sub_channel: String(subChannel),
|
||||
language,
|
||||
platform,
|
||||
source: 'launcher',
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
})
|
||||
.json();
|
||||
return (rsp as any).proxy_rsps[0].get_announcement_rsp as TypesApiAkEndfield.LauncherWebAnnouncement;
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
13
src/utils/argv.ts
Normal file
13
src/utils/argv.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
type argvType = {
|
||||
[prop: string]: any;
|
||||
};
|
||||
let argv: argvType | null = null;
|
||||
export default {
|
||||
setArgv: (argvIn: object) => {
|
||||
argv = argvIn;
|
||||
},
|
||||
getArgv: () => {
|
||||
if (argv === null) throw new Error('argv is null');
|
||||
return argv;
|
||||
},
|
||||
};
|
||||
120
src/utils/config.ts
Normal file
120
src/utils/config.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import path from 'node:path';
|
||||
import deepmerge from 'deepmerge';
|
||||
import YAML from 'yaml';
|
||||
// import * as TypesGameEntry from '../types/GameEntry.js';
|
||||
import * as TypesLogLevels from '../types/LogLevels.js';
|
||||
|
||||
type Freeze<T> = Readonly<{
|
||||
[P in keyof T]: T[P] extends object ? Freeze<T[P]> : T[P];
|
||||
}>;
|
||||
type AllRequired<T> = Required<{
|
||||
[P in keyof T]: T[P] extends object ? Freeze<T[P]> : T[P];
|
||||
}>;
|
||||
|
||||
type ConfigType = AllRequired<
|
||||
Freeze<{
|
||||
file: {
|
||||
outputDirPath: string;
|
||||
};
|
||||
network: {
|
||||
api: {
|
||||
akEndfield: {
|
||||
appCode: { osWinRel: string };
|
||||
launcherAppCode: { osWinRel: string };
|
||||
channel: { osWinRel: number };
|
||||
base: {
|
||||
accountService: string;
|
||||
launcher: string;
|
||||
u8: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
userAgent: {
|
||||
// UA to hide the fact that the access is from this tool
|
||||
minimum: string;
|
||||
chromeWindows: string;
|
||||
curl: string;
|
||||
ios: string;
|
||||
};
|
||||
timeout: number; // Network timeout
|
||||
retryCount: number; // Number of retries for access failure
|
||||
};
|
||||
threadCount: {
|
||||
// Upper limit on the number of threads for parallel processing
|
||||
network: number; // network access
|
||||
};
|
||||
cli: {
|
||||
autoExit: boolean; // Whether to exit the tool without waiting for key input when the exit code is 0
|
||||
};
|
||||
logger: {
|
||||
// log4js-node logger settings
|
||||
logLevel: TypesLogLevels.LogLevelNumber;
|
||||
useCustomLayout: boolean;
|
||||
customLayoutPattern: string;
|
||||
};
|
||||
}>
|
||||
>;
|
||||
|
||||
const initialConfig: ConfigType = {
|
||||
file: {
|
||||
outputDirPath: path.resolve('output'),
|
||||
},
|
||||
network: {
|
||||
api: {
|
||||
akEndfield: {
|
||||
appCode: { osWinRel: 'YDUTE5gscDZ229CW' },
|
||||
launcherAppCode: { osWinRel: 'TiaytKBUIEdoEwRT' },
|
||||
channel: { osWinRel: 6 },
|
||||
base: {
|
||||
accountService: 'YXMuZ3J5cGhsaW5lLmNvbQ==',
|
||||
launcher: 'bGF1bmNoZXIuZ3J5cGhsaW5lLmNvbS9hcGk=',
|
||||
u8: 'dTguZ3J5cGhsaW5lLmNvbQ==',
|
||||
},
|
||||
},
|
||||
},
|
||||
userAgent: {
|
||||
minimum: 'Mozilla/5.0',
|
||||
chromeWindows:
|
||||
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/143.0.0.0 Safari/537.36',
|
||||
curl: 'curl/8.4.0',
|
||||
ios: 'Mozilla/5.0 (iPhone; CPU iPhone OS 18_0 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/18.0 Mobile/15E148 Safari/604.1',
|
||||
},
|
||||
timeout: 20000,
|
||||
retryCount: 5,
|
||||
},
|
||||
threadCount: { network: 16 },
|
||||
cli: { autoExit: false },
|
||||
logger: {
|
||||
logLevel: 0,
|
||||
useCustomLayout: true,
|
||||
customLayoutPattern: '%[%d{hh:mm:ss.SSS} %-5.0p >%] %m',
|
||||
},
|
||||
};
|
||||
|
||||
const deobfuscator = (input: ConfigType): ConfigType => {
|
||||
const newConfig = JSON.parse(JSON.stringify(input)) as any;
|
||||
const api = newConfig.network.api.akEndfield;
|
||||
for (const key of Object.keys(api.base) as (keyof typeof api.base)[]) {
|
||||
api.base[key] = atob(api.base[key]);
|
||||
}
|
||||
return newConfig as ConfigType;
|
||||
};
|
||||
|
||||
const filePath = 'config/config.yaml';
|
||||
|
||||
if ((await Bun.file(filePath).exists()) === false) {
|
||||
await Bun.write(filePath, YAML.stringify(initialConfig));
|
||||
}
|
||||
|
||||
const config: ConfigType = await (async () => {
|
||||
const rawFileData: ConfigType = YAML.parse(await Bun.file(filePath).text()) as ConfigType;
|
||||
const mergedConfig = deepmerge(initialConfig, rawFileData, {
|
||||
arrayMerge: (_destinationArray, sourceArray) => sourceArray,
|
||||
});
|
||||
if (JSON.stringify(rawFileData) !== JSON.stringify(mergedConfig)) {
|
||||
await Bun.write(filePath, YAML.stringify(mergedConfig));
|
||||
}
|
||||
return deobfuscator(mergedConfig);
|
||||
})();
|
||||
|
||||
export default config;
|
||||
6
src/utils/configEmbed.ts
Normal file
6
src/utils/configEmbed.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import semver from 'semver';
|
||||
|
||||
export default {
|
||||
APPLICATION_NAME: 'ak-endfield-api-archive',
|
||||
VERSION_NUMBER: semver.valid('0.1.0'),
|
||||
};
|
||||
50
src/utils/exit.ts
Normal file
50
src/utils/exit.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import readline from 'node:readline';
|
||||
import appConfig from './config.js';
|
||||
|
||||
async function pressAnyKeyToExit(errorCode: number): Promise<void> {
|
||||
if (errorCode !== 0) console.error('An error occurred');
|
||||
if (!process.stdin.isTTY || (appConfig.cli.autoExit && errorCode === 0)) {
|
||||
console.log(`Exiting with code ${errorCode} ...`);
|
||||
process.exit(errorCode);
|
||||
}
|
||||
process.stdout.write('Press any key to exit ...');
|
||||
return new Promise((resolve) => {
|
||||
readline.emitKeypressEvents(process.stdin);
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.once('data', () => {
|
||||
process.stdin.setRawMode(false);
|
||||
process.stdin.pause();
|
||||
process.stdout.write(` Exiting with code ${errorCode} ...\n`);
|
||||
resolve(); // Promiseを解決
|
||||
process.exit(errorCode); // その後、プロセスを終了
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async function exit(errorCode: number, str: string | null = null, printText: boolean = true): Promise<void> {
|
||||
if (errorCode !== 0 && printText) console.error('An error occurred' + (str ? ': ' + str : ''));
|
||||
printText ? process.stdout.write(`Exiting with code ${errorCode} ...\n`) : null;
|
||||
process.exit(errorCode);
|
||||
}
|
||||
|
||||
async function pressAnyKeyToContinue(printText: boolean = true): Promise<void> {
|
||||
printText ? process.stdout.write('Press any key to continue ...') : null;
|
||||
return new Promise((resolve) => {
|
||||
readline.emitKeypressEvents(process.stdin);
|
||||
process.stdin.setRawMode(true);
|
||||
process.stdin.resume();
|
||||
process.stdin.once('data', () => {
|
||||
process.stdin.setRawMode(false);
|
||||
process.stdin.pause();
|
||||
printText ? process.stdout.write(`\n`) : null;
|
||||
resolve(); // Promiseを解決
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export default {
|
||||
pressAnyKeyToExit,
|
||||
exit,
|
||||
pressAnyKeyToContinue,
|
||||
};
|
||||
117
src/utils/file.ts
Normal file
117
src/utils/file.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import stream from 'node:stream';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import util from 'node:util';
|
||||
import configEmbed from './configEmbed.js';
|
||||
|
||||
function getAppDataDir(): string {
|
||||
switch (process.platform) {
|
||||
case 'win32':
|
||||
return path.join(
|
||||
process.env['APPDATA'] || path.join(os.homedir(), 'AppData', 'Roaming'),
|
||||
configEmbed.APPLICATION_NAME,
|
||||
);
|
||||
case 'darwin':
|
||||
return path.join(os.homedir(), 'Library', 'Application Support', configEmbed.APPLICATION_NAME);
|
||||
case 'linux':
|
||||
return path.join(
|
||||
process.env['XDG_CONFIG_HOME'] || path.join(os.homedir(), '.config'),
|
||||
configEmbed.APPLICATION_NAME,
|
||||
);
|
||||
default:
|
||||
return path.resolve('config');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect app is standalone or not
|
||||
*/
|
||||
function isAppCompiledSA(): boolean {
|
||||
return Boolean(
|
||||
/^file:\/\/\/\$bunfs\/root\//g.test(import.meta.url) || /^file:\/\/\/B:\/%7EBUN\/root\//g.test(import.meta.url),
|
||||
);
|
||||
}
|
||||
|
||||
function getAppRootDir(): string {
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
if (isAppCompiledSA()) {
|
||||
// app is standalone
|
||||
return path.dirname(process.execPath);
|
||||
}
|
||||
// app is not standalone
|
||||
let currentDir = __dirname;
|
||||
|
||||
while (currentDir !== path.parse(currentDir).root) {
|
||||
const basename = path.basename(currentDir);
|
||||
if (basename === 'dist' || basename === 'src') {
|
||||
return path.dirname(currentDir);
|
||||
}
|
||||
currentDir = path.dirname(currentDir);
|
||||
}
|
||||
|
||||
return __dirname; // fallback
|
||||
}
|
||||
|
||||
async function checkFolderExists(folderPath: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fs.promises.stat(folderPath);
|
||||
return stats.isDirectory();
|
||||
} catch (error: any) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function checkFileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
const stats = await fs.promises.stat(filePath);
|
||||
return stats.isFile();
|
||||
} catch (error: any) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function getFileList(dirPath: string): Promise<string[]> {
|
||||
const absoluteDirPath = path.resolve(dirPath);
|
||||
const filePaths: string[] = [];
|
||||
async function walk(currentDir: string): Promise<void> {
|
||||
const entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
|
||||
for (const entry of entries) {
|
||||
const fullPath = path.join(currentDir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await walk(fullPath);
|
||||
} else if (entry.isFile()) {
|
||||
filePaths.push(fullPath);
|
||||
}
|
||||
}
|
||||
}
|
||||
await walk(absoluteDirPath);
|
||||
return filePaths;
|
||||
}
|
||||
|
||||
async function readFileAsArrayBuffer(filePath: string): Promise<ArrayBuffer> {
|
||||
const buffer = await fs.promises.readFile(filePath);
|
||||
return buffer.buffer.slice(buffer.byteOffset, buffer.byteOffset + buffer.byteLength);
|
||||
}
|
||||
|
||||
async function copyFileWithStream(srcPath: string, destPath: string): Promise<void> {
|
||||
const pipelineAsync = util.promisify(stream.pipeline);
|
||||
try {
|
||||
await pipelineAsync(fs.createReadStream(srcPath), fs.createWriteStream(destPath));
|
||||
} catch (err) {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
getAppDataDir,
|
||||
isAppCompiledSA,
|
||||
getAppRootDir,
|
||||
checkFolderExists,
|
||||
checkFileExists,
|
||||
getFileList,
|
||||
readFileAsArrayBuffer,
|
||||
copyFileWithStream,
|
||||
};
|
||||
25
src/utils/logger.ts
Normal file
25
src/utils/logger.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import log4js from 'log4js';
|
||||
import * as TypesLogLevels from '../types/LogLevels.js';
|
||||
import appConfig from './config.js';
|
||||
|
||||
log4js.configure({
|
||||
appenders: {
|
||||
System: {
|
||||
type: 'stdout',
|
||||
layout: {
|
||||
type: appConfig.logger.useCustomLayout ? 'pattern' : 'colored',
|
||||
pattern: appConfig.logger.useCustomLayout ? appConfig.logger.customLayoutPattern : '',
|
||||
},
|
||||
},
|
||||
},
|
||||
categories: {
|
||||
default: {
|
||||
appenders: ['System'],
|
||||
level: TypesLogLevels.LOG_LEVELS[appConfig.logger.logLevel],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const logger: log4js.Logger = log4js.getLogger('System');
|
||||
|
||||
export default logger;
|
||||
90
src/utils/math.ts
Normal file
90
src/utils/math.ts
Normal file
@@ -0,0 +1,90 @@
|
||||
export default {
|
||||
arrayMax(array: Array<number>) {
|
||||
return array.reduce((a, b) => Math.max(a, b));
|
||||
},
|
||||
|
||||
arrayMin(array: Array<number>) {
|
||||
return array.reduce((a, b) => Math.min(a, b));
|
||||
},
|
||||
|
||||
arrayTotal(array: Array<number>) {
|
||||
return array.reduce((acc, f) => acc + f, 0);
|
||||
},
|
||||
|
||||
arrayAvg(array: Array<number>) {
|
||||
return this.arrayTotal(array) / array.length;
|
||||
},
|
||||
|
||||
rounder(method: 'floor' | 'ceil' | 'round', num: number, n: number) {
|
||||
const pow = Math.pow(10, n);
|
||||
let result: number;
|
||||
switch (method) {
|
||||
case 'floor':
|
||||
result = Math.floor(num * pow) / pow;
|
||||
break;
|
||||
case 'ceil':
|
||||
result = Math.ceil(num * pow) / pow;
|
||||
break;
|
||||
case 'round':
|
||||
result = Math.round(num * pow) / pow;
|
||||
break;
|
||||
}
|
||||
return {
|
||||
orig: result,
|
||||
padded: result.toFixed(n),
|
||||
};
|
||||
},
|
||||
|
||||
formatFileSize(
|
||||
bytes: number,
|
||||
options: {
|
||||
decimals: number;
|
||||
decimalPadding: boolean;
|
||||
useBinaryUnit: boolean;
|
||||
useBitUnit: boolean;
|
||||
unitVisible: boolean;
|
||||
unit: 'B' | 'K' | 'M' | 'G' | 'T' | 'P' | 'E' | 'Z' | 'Y' | null;
|
||||
},
|
||||
) {
|
||||
const k = options.useBinaryUnit ? 1024 : 1000;
|
||||
const dm = options.decimals < 0 ? 0 : options.decimals;
|
||||
|
||||
const baseUnits = ['B', 'K', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y'];
|
||||
const binaryUnitSuffix = options.useBitUnit ? 'ib' : 'iB';
|
||||
const siUnitSuffix = options.useBitUnit ? 'b' : 'B';
|
||||
|
||||
const getUnitString = (i: number) => {
|
||||
if (i === 0) return options.useBitUnit ? 'b' : 'B';
|
||||
return baseUnits[i] + (options.useBinaryUnit ? binaryUnitSuffix : siUnitSuffix);
|
||||
};
|
||||
|
||||
let value = bytes < 0 ? 0 : Math.floor(bytes);
|
||||
if (options.useBitUnit) {
|
||||
value *= 8;
|
||||
}
|
||||
|
||||
let i: number;
|
||||
if (options.unit !== null) {
|
||||
i = baseUnits.indexOf(options.unit);
|
||||
if (i === -1) throw new Error(`Invalid unit: ${options.unit}`);
|
||||
} else {
|
||||
if (value === 0) {
|
||||
i = 0;
|
||||
} else {
|
||||
i = Math.floor(Math.log(value) / Math.log(k));
|
||||
i = Math.max(0, Math.min(baseUnits.length - 1, i)); // clamp
|
||||
}
|
||||
}
|
||||
|
||||
const resultValue = value / Math.pow(k, i);
|
||||
|
||||
let formattedValue: string;
|
||||
if (options.decimalPadding) {
|
||||
formattedValue = resultValue.toFixed(dm);
|
||||
} else {
|
||||
formattedValue = resultValue.toFixed(dm).replace(/\.?0+$/, '');
|
||||
}
|
||||
|
||||
return formattedValue + (options.unitVisible ? ' ' + getUnitString(i) : '');
|
||||
},
|
||||
};
|
||||
125
src/utils/omitDeep.ts
Normal file
125
src/utils/omitDeep.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
type Path = Array<string | number>;
|
||||
type Paths = Path | Path[];
|
||||
|
||||
/**
|
||||
* Removes properties from an object at specified paths.
|
||||
* @param obj The target object.
|
||||
* @param paths The paths of the properties to remove.
|
||||
* @returns A new object with the properties removed.
|
||||
*
|
||||
* @example
|
||||
* const user = {
|
||||
* id: 1,
|
||||
* name: 'John',
|
||||
* profile: {
|
||||
* email: 'john@example.com',
|
||||
* settings: {
|
||||
* theme: 'dark',
|
||||
* notifications: true
|
||||
* }
|
||||
* },
|
||||
* tags: ['user', 'premium', 'verified']
|
||||
* };
|
||||
*
|
||||
* // Remove a single nested property
|
||||
* const withoutEmail = omitDeep(user, ['profile', 'email']);
|
||||
*
|
||||
* // Remove multiple properties and array elements at once
|
||||
* const cleanedUser = omitDeep(user, [
|
||||
* ['profile', 'email'],
|
||||
* ['profile', 'settings', 'notifications'],
|
||||
* ['tags', 1] // Remove the second element of the array
|
||||
* ]);
|
||||
*/
|
||||
function omitDeep<T extends object>(obj: T, paths: Paths): T {
|
||||
// Robust deep copy
|
||||
const deepClone = <U>(item: U): U => {
|
||||
if (item === null || typeof item !== 'object') return item;
|
||||
|
||||
if (item instanceof Date) return new Date(item) as unknown as U;
|
||||
if (item instanceof RegExp) return new RegExp(item) as unknown as U;
|
||||
|
||||
if (Array.isArray(item)) {
|
||||
return item.map(deepClone) as unknown as U;
|
||||
}
|
||||
|
||||
const clone = {} as U;
|
||||
for (const key in item) {
|
||||
if (Object.prototype.hasOwnProperty.call(item, key)) {
|
||||
clone[key] = deepClone(item[key]);
|
||||
}
|
||||
}
|
||||
return clone;
|
||||
};
|
||||
|
||||
const result = deepClone(obj);
|
||||
|
||||
// Normalize paths
|
||||
const pathArray: Path[] =
|
||||
Array.isArray(paths) && paths.length > 0 && Array.isArray(paths[0]) ? (paths as Path[]) : [paths as Path];
|
||||
|
||||
// Perform deletion for each path
|
||||
for (const path of pathArray) {
|
||||
if (path.length === 0) continue;
|
||||
|
||||
// Get the parent object/array of the target property
|
||||
const parentInfo = getParentAndKey(result, path);
|
||||
if (!parentInfo) continue;
|
||||
|
||||
const { parent, lastKey } = parentInfo;
|
||||
|
||||
// Delete the property or array element
|
||||
if (Array.isArray(parent)) {
|
||||
// If it's an array
|
||||
const index = Number(lastKey);
|
||||
if (!isNaN(index) && index >= 0 && index < parent.length) {
|
||||
parent.splice(index, 1);
|
||||
}
|
||||
} else if (typeof parent === 'object' && parent !== null) {
|
||||
// If it's an object
|
||||
delete parent[lastKey as string];
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the parent object/array and the final key for a given path.
|
||||
*/
|
||||
function getParentAndKey(obj: any, path: Path): { parent: any; lastKey: string | number } | null {
|
||||
let current: any = obj;
|
||||
|
||||
for (let i = 0; i < path.length - 1; i++) {
|
||||
const key = path[i];
|
||||
|
||||
if (current === null || typeof current !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Handle arrays
|
||||
if (Array.isArray(current)) {
|
||||
const index = Number(key);
|
||||
if (isNaN(index) || index < 0 || index >= current.length) {
|
||||
return null;
|
||||
}
|
||||
current = current[index];
|
||||
} // Handle objects
|
||||
else if (Object.prototype.hasOwnProperty.call(current, key!)) {
|
||||
current = current[key!];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
if (current === null || typeof current !== 'object') {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
parent: current,
|
||||
lastKey: path[path.length - 1]!,
|
||||
};
|
||||
}
|
||||
|
||||
export default omitDeep;
|
||||
Reference in New Issue
Block a user