diff --git a/.gitignore b/.gitignore index b1b921b..95bc2de 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,5 @@ config/config.yaml +memo/ # dependencies (bun install) node_modules diff --git a/bun.lock b/bun.lock index 96b5289..884fd6a 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,7 @@ "": { "name": "ak-endfield-api-archive", "dependencies": { + "@types/prompts": "^2.4.9", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "deepmerge": "^4.3.1", @@ -13,6 +14,7 @@ "luxon": "^3.7.2", "ora": "^9.1.0", "p-queue": "^9.1.0", + "prompts": "^2.4.2", "qs": "^6.14.1", "semver": "^7.7.3", "uuid": "^13.0.0", @@ -91,6 +93,8 @@ "@types/node": ["@types/node@25.0.9", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-/rpCXHlCWeqClNBwUhDcusJxXYDjZTyE8v5oTO7WbL8eij2nKhUeU89/6xgjU7N4/Vh3He0BtyhJdQbDyhiXAw=="], + "@types/prompts": ["@types/prompts@2.4.9", "", { "dependencies": { "@types/node": "*", "kleur": "^3.0.3" } }, "sha512-qTxFi6Buiu8+50/+3DGIWLHM6QuWsEKugJnnP6iv2Mc4ncxE4A/OJkjuVOA+5X0X1S/nq5VJRa8Lu+nwcvbrKA=="], + "@types/qs": ["@types/qs@6.14.0", "", {}, "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ=="], "@types/semver": ["@types/semver@7.7.1", "", {}, "sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA=="], @@ -203,6 +207,8 @@ "jsonfile": ["jsonfile@4.0.0", "", { "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg=="], + "kleur": ["kleur@3.0.3", "", {}, "sha512-eTIzlVOSUR+JxdDFepEYcBMtZ9Qqdef+rnzWdRZuMbOywu5tO2w2N7rqjoANZ5k9vywhL6Br1VRjUIgTQx4E8w=="], + "ky": ["ky@1.14.2", "", {}, "sha512-q3RBbsO5A5zrPhB6CaCS8ZUv+NWCXv6JJT4Em0i264G9W0fdPB8YRfnnEi7Dm7X7omAkBIPojzYJ2D1oHTHqug=="], "log-symbols": ["log-symbols@7.0.1", "", { "dependencies": { "is-unicode-supported": "^2.0.0", "yoctocolors": "^2.1.1" } }, "sha512-ja1E3yCr9i/0hmBVaM0bfwDjnGy8I/s6PP4DFp+yP+a+mrHO4Rm7DtmnqROTUkHIkqffC84YY7AeqX6oFk0WFg=="], @@ -237,6 +243,8 @@ "picomatch": ["picomatch@2.3.1", "", {}, "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA=="], + "prompts": ["prompts@2.4.2", "", { "dependencies": { "kleur": "^3.0.3", "sisteransi": "^1.0.5" } }, "sha512-NxNv/kLguCA7p3jE8oL2aEBsrJWgAakBpgmgK6lpPWV+WuOmY6r2/zbAVnP+T8bQlA0nzHXSJSJW0Hq7ylaD2Q=="], + "pstree.remy": ["pstree.remy@1.1.8", "", {}, "sha512-77DZwxQmxKnu3aR542U+X8FypNzbfJ+C5XQDk3uWjWxn6151aIMGthWYRXTqT1E5oJvg+ljaa2OJi+VfvCOQ8w=="], "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], @@ -261,6 +269,8 @@ "simple-update-notifier": ["simple-update-notifier@2.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w=="], + "sisteransi": ["sisteransi@1.0.5", "", {}, "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg=="], + "stdin-discarder": ["stdin-discarder@0.2.2", "", {}, "sha512-UhDfHmA92YAlNnCfhmq0VeNL5bDbiZGg7sZ2IvPsXubGkiNa9EC+tUTsjBRsYUAz87btI6/1wf4XoVvQ3uRnmQ=="], "streamroller": ["streamroller@3.1.5", "", { "dependencies": { "date-format": "^4.0.14", "debug": "^4.3.4", "fs-extra": "^8.1.0" } }, "sha512-KFxaM7XT+irxvdqSP1LGLgNWbYN7ay5owZ3r/8t77p+EtSUAfUgtl7be3xtqtOmGUl9K9YPO2ca8133RlTjvKw=="], diff --git a/config/config.yaml b/config/config.yaml index 58851b9..c97eb66 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -2,15 +2,23 @@ network: api: akEndfield: appCode: + game: + osWinRel: YDUTE5gscDZ229CW + launcher: + osWinRel: TiaytKBUIEdoEwRT + accountService: + osWinRel: d9f6dbb6bbd6bb33 + u8: + osWinRel: 973bd727dd11cbb6ead8 osWinRel: YDUTE5gscDZ229CW - launcherAppCode: - osWinRel: TiaytKBUIEdoEwRT channel: osWinRel: 6 base: accountService: YXMuZ3J5cGhsaW5lLmNvbQ== launcher: bGF1bmNoZXIuZ3J5cGhsaW5lLmNvbS9hcGk= u8: dTguZ3J5cGhsaW5lLmNvbQ== + launcherAppCode: + osWinRel: TiaytKBUIEdoEwRT userAgent: minimum: Mozilla/5.0 chromeWindows: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 diff --git a/package.json b/package.json index cd53f97..82950a6 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "start": "bun x biome format --write src && bun x oxfmt && bun src/main.ts" }, "dependencies": { + "@types/prompts": "^2.4.9", "chalk": "^5.6.2", "cli-table3": "^0.6.5", "deepmerge": "^4.3.1", @@ -24,6 +25,7 @@ "luxon": "^3.7.2", "ora": "^9.1.0", "p-queue": "^9.1.0", + "prompts": "^2.4.2", "qs": "^6.14.1", "semver": "^7.7.3", "uuid": "^13.0.0", diff --git a/src/cmd.ts b/src/cmd.ts index 7119d99..c1c912a 100644 --- a/src/cmd.ts +++ b/src/cmd.ts @@ -42,6 +42,27 @@ async function parseCommand() { }, wrapHandler(cmds.test), ) + .command( + ['authTest [token] [email] [password]'], + 'Auth test command', + (yargs) => { + yargs + .positional('token', { + describe: 'Gryphline account service token', + type: 'string', + }) + .positional('email', { + describe: 'Gryphline account email address', + type: 'string', + }) + .positional('password', { + describe: 'Gryphline account password', + type: 'string', + }) + .options({}); + }, + wrapHandler(cmds.authTest), + ) .options({ 'log-level': { desc: 'Set log level (' + TypesLogLevels.LOG_LEVELS_NUM.join(', ') + ')', diff --git a/src/cmds.ts b/src/cmds.ts index bfe883f..c6092d9 100644 --- a/src/cmds.ts +++ b/src/cmds.ts @@ -1,5 +1,7 @@ +import authTest from './cmds/authTest.js'; import test from './cmds/test.js'; export default { + authTest, test, }; diff --git a/src/cmds/authTest.ts b/src/cmds/authTest.ts new file mode 100644 index 0000000..49ea39f --- /dev/null +++ b/src/cmds/authTest.ts @@ -0,0 +1,163 @@ +import chalk from 'chalk'; +import CliTable3 from 'cli-table3'; +import { HTTPError } from 'ky'; +import prompts from 'prompts'; +import apiUtils from '../utils/api.js'; +import argvUtils from '../utils/argv.js'; +import appConfig from '../utils/config.js'; +import exitUtils from '../utils/exit.js'; +import logger from '../utils/logger.js'; +import termPrettyUtils from '../utils/termPretty.js'; + +async function mainCmdHandler() { + const cfg = appConfig.network.api.akEndfield; + // const channelStr = String(cfg.channel.osWinRel); + + let needRetrieveToken = false; + let oauth2TokenPreRsp = null; + if (!('token' in argvUtils.getArgv()) || !argvUtils.getArgv()['token']) { + const tokenUserRsp: string = await (async () => { + logger.warn('Gryphline account service token has not been specified. Requesting ...'); + const onCancelFn = () => { + logger.error('Aborted'); + exitUtils.exit(1, null, false); + }; + return ( + await prompts( + { name: 'value', type: 'password', message: 'Enter Gryphline account service token' }, + { onCancel: onCancelFn }, + ) + ).value; + })(); + if (tokenUserRsp === '') { + needRetrieveToken = true; + } else { + argvUtils.setArgv({ ...argvUtils.getArgv(), token: tokenUserRsp }); + } + } + if (needRetrieveToken === false) { + try { + oauth2TokenPreRsp = await apiUtils.apiAkEndfield.accountService.user.oauth2.v2.grant( + cfg.appCode.accountService.osWinRel, + argvUtils.getArgv()['token'], + ); + } catch (err) { + if (err instanceof HTTPError) { + if ((await err.response.json()).status === 3) needRetrieveToken = true; + } else { + throw err; + } + } + } + if (needRetrieveToken) { + await (async () => { + const onCancelFn = () => { + logger.error('Aborted'); + exitUtils.exit(1, null, false); + }; + if (!('email' in argvUtils.getArgv())) { + logger.warn('Gryphline account email has not been specified. Requesting ...'); + const emailRsp: number = ( + await prompts( + { + ...{ name: 'value', type: 'text', message: 'Enter Gryphline account email' }, + validate: (value) => (Boolean(value) ? true : 'Invalid value'), + }, + { onCancel: onCancelFn }, + ) + ).value; + argvUtils.setArgv({ ...argvUtils.getArgv(), email: emailRsp }); + } + if (!('password' in argvUtils.getArgv())) { + // logger.warn('Gryphline account password has not been specified. Requesting ...'); + const pwdRsp: number = ( + await prompts( + { + ...{ name: 'value', type: 'password', message: 'Enter Gryphline account password' }, + validate: (value) => (Boolean(value) ? true : 'Invalid value'), + }, + { onCancel: onCancelFn }, + ) + ).value; + argvUtils.setArgv({ ...argvUtils.getArgv(), password: pwdRsp }); + } + })(); + logger.info('Retrieving account service token ...'); + const accSrvTokenRsp = await apiUtils.apiAkEndfield.accountService.user.auth.v1.tokenByEmailPassword( + argvUtils.getArgv()['email'], + argvUtils.getArgv()['password'], + ); + 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, + argvUtils.getArgv()['token'], + ); + logger.info('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.info('Authentication successful!'); + + logger.info('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 ...'); + const userGameData = await apiUtils.apiAkEndfield.u8.game.server.v1.serverList(u8TokenRsp.data.token); + + logger.info('Data retrieval completed!'); + + (() => { + const table = new CliTable3(termPrettyUtils.cliTableConfig.rounded); + table.push( + ...[ + ['Account ID', userAccData.data.hgId], + ['Email', userAccData.data.realEmail], + ['Nickname', userAccData.data.nickName === '' ? chalk.dim('(none)') : userAccData.data.nickName], + ['Age Region', userAccData.data.ageGate.regionInfo['en-us']], + ].map((e) => [chalk.dim(e[0]), e[1]]), + ); + console.log(table.toString()); + })(); + + (() => { + const table = new CliTable3(termPrettyUtils.cliTableConfig.rounded); + table.push( + ...[ + ['ID', 'Time', 'Name', 'Domain', 'Port'].map((e) => chalk.dim(e)), + ...userGameData.data.serverList.map((e) => [ + e.serverId, + 'UTC' + (JSON.parse(e.extension).offsetSeconds < 0 ? '' : '+') + JSON.parse(e.extension).offsetSeconds / 3600, + e.serverName, + JSON.parse(e.serverDomain)[0].host, + JSON.parse(e.serverDomain)[0].port, + ]), + ], + ); + console.log(table.toString()); + })(); + + (() => { + const table = new CliTable3(termPrettyUtils.cliTableConfig.rounded); + table.push( + ...[ + ['ID', 'UID', 'Level', 'Default'].map((e) => chalk.dim(e)), + ...userGameData.data.serverList.map((e) => [ + e.serverId, + e.roleId, + { hAlign: 'right' as const, content: e.level }, + e.defaultChoose, + ]), + ], + ); + console.log(table.toString()); + })(); +} + +export default mainCmdHandler; diff --git a/src/cmds/test.ts b/src/cmds/test.ts index b735656..a56fe69 100644 --- a/src/cmds/test.ts +++ b/src/cmds/test.ts @@ -81,8 +81,8 @@ async function mainCmdHandler() { await (async () => { logger.debug('Fetching latestGame ...'); const rsp = await apiUtils.apiAkEndfield.launcher.latestGame( - cfg.appCode.osWinRel, - cfg.launcherAppCode.osWinRel, + cfg.appCode.game.osWinRel, + cfg.appCode.launcher.osWinRel, cfg.channel.osWinRel, cfg.channel.osWinRel, cfg.channel.osWinRel, @@ -103,8 +103,8 @@ async function mainCmdHandler() { ); const prettyRsp = { req: { - appCode: cfg.appCode.osWinRel, - launcherAppCode: cfg.launcherAppCode.osWinRel, + appCode: cfg.appCode.game.osWinRel, + launcherAppCode: cfg.appCode.launcher.osWinRel, channel: cfg.channel.osWinRel, subChannel: cfg.channel.osWinRel, launcherSubChannel: cfg.channel.osWinRel, @@ -147,7 +147,7 @@ async function mainCmdHandler() { 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, + cfg.appCode.game.osWinRel, versionInfoEntry.versionMinor, versionInfoEntry.version, versionInfoEntry.randStr, @@ -155,7 +155,7 @@ async function mainCmdHandler() { logger.info(`Fetched latestGameRes: v${versionInfoEntry.version}, ${rsp.res_version}`); const prettyRsp = { req: { - appCode: cfg.appCode.osWinRel, + appCode: cfg.appCode.game.osWinRel, gameVersion: versionInfoEntry.versionMinor, version: versionInfoEntry.version, randStr: versionInfoEntry.randStr, @@ -178,7 +178,7 @@ async function mainCmdHandler() { const launcherTargetAppList = ['EndField', 'official'] as const; for (const launcherTargetAppEntry of launcherTargetAppList) { const rsp = await apiUtils.apiAkEndfield.launcher.latestLauncher( - cfg.launcherAppCode.osWinRel, + cfg.appCode.launcher.osWinRel, cfg.channel.osWinRel, cfg.channel.osWinRel, null, @@ -187,7 +187,7 @@ async function mainCmdHandler() { logger.info(`Fetched latestLauncher: v${rsp.version}, ${launcherTargetAppEntry}`); const prettyRsp = { req: { - appCode: cfg.launcherAppCode.osWinRel, + appCode: cfg.appCode.launcher.osWinRel, channel: cfg.channel.osWinRel, subChannel: cfg.channel.osWinRel, targetApp: launcherTargetAppEntry === 'official' ? null : launcherTargetAppEntry, diff --git a/src/types/api/akEndfield/Api.ts b/src/types/api/akEndfield/Api.ts index 3d10c20..c14dcb0 100644 --- a/src/types/api/akEndfield/Api.ts +++ b/src/types/api/akEndfield/Api.ts @@ -106,6 +106,94 @@ type LauncherWebAnnouncement = { }[]; }; +type AccSrvUserAuthV1TokenByEmail = { + data: { + token: string; + hgId: string; // hypergryph account id + email: string; // obfuscated email + isLatestUserAgreement: boolean; + }; + msg: string; + status: number; + type: string; +}; + +type AccSrvUserInfoV1Basic = { + data: { + hgId: string; // hypergryph account id + email: string; // obfuscated email + realEmail: string; // un-obfuscated email + isLatestUserAgreement: boolean; + nickName: string; + emailSubscription: boolean; // ??? + extension: { firebaseHashedInfo: string }; + ageGate: { + ageAuthState: number; + bindEmail: boolean; + parentAuthState: number; + regionInfo: Record< + | 'de-de' + | 'en-us' // Japan + | 'es-mx' + | 'fr-fr' + | 'id-id' + | 'it-it' + | 'ja-jp' // 日本 + | 'ko-kr' + | 'pt-br' + | 'ru-ru' + | 'th-th' + | 'vi-vn' + | 'zh-cn' // 日本 + | 'zh-tw', + string + >; + regionCode: string; // JP + }; + }; + msg: string; + status: number; + type: string; +}; + +type AccSrvUserOAuth2V2Grant = { + data: { + uid: string; // ??? + code: string; // this is channel token + }; + msg: string; // OK, Login status expired. + status: number; // 0=OK, 3=expired + type: string; +}; + +type U8UserAuthV2ChToken = { + data: { + token: string; + isNew: boolean; + uid: string; // number, game overall uid? + }; + msg: string; + status: number; + type: string; +}; + +type U8GameServerV1ServerList = { + data: { + serverList: { + serverId: string; // number + serverName: string; // Asia + serverDomain: string; // jsonStr [{"host": "beyond-asiapacific.gryphline.com", "port": 30000}] + defaultChoose: boolean; + roleId: string; // the so-called UID elsewhere + level: number; // playerLv + extension: string; // jsonStr {"offsetSeconds": -18000, "monthlyCardOffsetSecond": -18000} + }[]; + }; + msg: string; + status: number; + type: string; +}; + export type { LauncherLatestGame, LauncherLatestGameResources, @@ -115,4 +203,9 @@ export type { LauncherWebMainBgImage, LauncherWebBanner, LauncherWebAnnouncement, + AccSrvUserAuthV1TokenByEmail, + AccSrvUserInfoV1Basic, + AccSrvUserOAuth2V2Grant, + U8UserAuthV2ChToken, + U8GameServerV1ServerList, }; diff --git a/src/utils/api.ts b/src/utils/api.ts index 9a9312c..89a926b 100644 --- a/src/utils/api.ts +++ b/src/utils/api.ts @@ -1,6 +1,6 @@ import ky from 'ky'; import semver from 'semver'; -import * as TypesApiAkEndfield from '../types/api/akEndfield/api.js'; +import * as TypesApiAkEndfield from '../types/api/akEndfield/Api.js'; import appConfig from './config.js'; const defaultKySettings = { @@ -248,5 +248,104 @@ export default { }, }, }, + accountService: { + user: { + auth: { + v1: { + tokenByEmailPassword: async ( + email: string, + password: string, + from: number = 0, + ): Promise => { + const rsp = await ky + .post( + `https://${appConfig.network.api.akEndfield.base.accountService}/user/auth/v1/token_by_email_password`, + { ...defaultKySettings, json: { email, from, password } }, + ) + .json(); + return rsp as TypesApiAkEndfield.AccSrvUserAuthV1TokenByEmail; + }, + }, + }, + oauth2: { + v2: { + grant: async ( + appCode: string, + token: string, + type: number = 0, + ): Promise => { + const rsp = await ky + .post(`https://${appConfig.network.api.akEndfield.base.accountService}/user/oauth2/v2/grant`, { + ...defaultKySettings, + json: { appCode, token, type }, + }) + .json(); + return rsp as TypesApiAkEndfield.AccSrvUserOAuth2V2Grant; + }, + }, + }, + info: { + v1: { + basic: async (appCode: string, token: string): Promise => { + const rsp = await ky + .get(`https://${appConfig.network.api.akEndfield.base.accountService}/user/info/v1/basic`, { + ...defaultKySettings, + searchParams: { appCode, token }, + }) + .json(); + return rsp as TypesApiAkEndfield.AccSrvUserInfoV1Basic; + }, + }, + }, + }, + }, + u8: { + user: { + auth: { + v2: { + tokenByChToken: async ( + appCode: string, + channelMasterId: number, + channelToken: string, + platform: number = 2, + type: number = 0, + ): Promise => { + const rsp = await ky + .post(`https://${appConfig.network.api.akEndfield.base.u8}/u8/user/auth/v2/token_by_channel_token`, { + ...defaultKySettings, + json: { + appCode, + channelMasterId, + channelToken: JSON.stringify({ + code: channelToken, + type: 1, + isSuc: true, + }), + type, + platform, + }, + }) + .json(); + return rsp as TypesApiAkEndfield.U8UserAuthV2ChToken; + }, + }, + }, + }, + game: { + server: { + v1: { + serverList: async (token: string): Promise => { + const rsp = await ky + .post(`https://${appConfig.network.api.akEndfield.base.u8}/game/server/v1/server_list`, { + ...defaultKySettings, + json: { token }, + }) + .json(); + return rsp as TypesApiAkEndfield.U8GameServerV1ServerList; + }, + }, + }, + }, + }, }, }; diff --git a/src/utils/config.ts b/src/utils/config.ts index 770b914..b61e1fb 100644 --- a/src/utils/config.ts +++ b/src/utils/config.ts @@ -15,8 +15,12 @@ type ConfigType = AllRequired< network: { api: { akEndfield: { - appCode: { osWinRel: string }; - launcherAppCode: { osWinRel: string }; + appCode: { + game: { osWinRel: string }; + launcher: { osWinRel: string }; + accountService: { osWinRel: string }; + u8: { osWinRel: string }; + }; channel: { osWinRel: number }; base: { accountService: string; @@ -55,8 +59,12 @@ const initialConfig: ConfigType = { network: { api: { akEndfield: { - appCode: { osWinRel: 'YDUTE5gscDZ229CW' }, - launcherAppCode: { osWinRel: 'TiaytKBUIEdoEwRT' }, + appCode: { + game: { osWinRel: 'YDUTE5gscDZ229CW' }, + launcher: { osWinRel: 'TiaytKBUIEdoEwRT' }, + accountService: { osWinRel: 'd9f6dbb6bbd6bb33' }, + u8: { osWinRel: '973bd727dd11cbb6ead8' }, + }, channel: { osWinRel: 6 }, base: { accountService: 'YXMuZ3J5cGhsaW5lLmNvbQ==', diff --git a/src/utils/string.ts b/src/utils/string.ts new file mode 100644 index 0000000..96f8c31 --- /dev/null +++ b/src/utils/string.ts @@ -0,0 +1,148 @@ +// === Generated by Qwen3-Max === + +/** + * 元となるURLと相対URLから絶対URLを解決します。 + * ベースURLがファイルパスを含む場合でも、そのファイルのディレクトリを基準に解決します。 + * + * @param baseUrl - 元となるURL(絶対URL) + * @param relativeUrl - 相対URLまたは絶対URL + * @returns 解決された絶対URL + * @throws URLが無効な場合にエラーをスローします + */ +function resolveUrl(baseUrl: string, relativeUrl: string): string { + try { + // 相対URLがすでに絶対URLの場合、そのまま返す + if (isAbsoluteUrl(relativeUrl)) { + return new URL(relativeUrl).href; + } + + // baseUrlをURLとしてパース + const base = new URL(baseUrl); + + // ベースURLがファイルパス(末尾が/でない)の場合、 + // ファイル名を除去してディレクトリパスにする + if (!base.pathname.endsWith('/')) { + // 最後のスラッシュ以降を除去(ファイル名を削除) + const lastSlashIndex = base.pathname.lastIndexOf('/'); + if (lastSlashIndex !== -1) { + base.pathname = base.pathname.substring(0, lastSlashIndex + 1); + } else { + // パスにスラッシュが含まれていない稀なケース + base.pathname = '/'; + } + } + + // 相対URLを解決 + const resolved = new URL(relativeUrl, base); + + return resolved.href; + } catch (error) { + throw new Error(`URLの解決に失敗しました: ${error instanceof Error ? error.message : 'Unknown error'}`); + } +} + +/** + * URLが絶対URLかどうかを判定します。 + * + * @param url - 判定するURL文字列 + * @returns 絶対URLの場合はtrue、それ以外はfalse + */ +function isAbsoluteUrl(url: string): boolean { + try { + return Boolean(new URL(url).protocol); + } catch { + return false; + } +} + +/** + * URLフルパスからファイル名を除いた部分を抽出します + * @param url + * @returns + */ +function getBaseUrlWithoutLastSegment(url: string): string { + const u = new URL(url); + const dirPath = u.pathname.split('/').slice(0, -1).join('/'); + return `${u.origin}${dirPath}`; +} + +// ============================== + +function replaceMultiPatterns(replacements: [RegExp, string][], originalString: string): string { + return replacements.reduce((currentString, [pattern, replacement]) => { + return currentString.replace(pattern, replacement); + }, originalString); +} + +function sanitizeFilename(filename: string) { + const invalidCharsMap: Record = { + '\\': '\', + '/': '/', + ':': ':', + '*': '*', + '?': '?', + '"': '"', + '<': '<', + '>': '>', + '|': '|', + '&': '&', + }; + return Array.from(filename) + .map((char) => invalidCharsMap[char] || char) + .join(''); +} + +/** + * 文字列が指定された正規表現パターンにマッチするかを判定する関数 + * + * @param inputStr - チェック対象の文字列 + * @param regexInclude - マッチが必須の正規表現パターン(少なくとも1つマッチする必要あり) + * @param regexExclude - マッチが禁止の正規表現パターン(1つもマッチしてはならない) + * @returns + * includeパターンが空の場合はexcludeにマッチしない限りtrue + * includeパターンがある場合は、少なくとも1つのincludeにマッチし、 + * かつ全てのexcludeにマッチしない場合のみtrue + */ +function filterByRegex( + inputStr: string, + regexInclude: readonly (RegExp | string)[], + regexExclude: readonly (RegExp | string)[], +): boolean { + // 文字列をRegExpに変換するヘルパー関数(グローバルフラグを無効化) + const toRegExp = (pattern: RegExp | string): RegExp => { + if (pattern instanceof RegExp) { + // グローバルフラグ/ステッキーフラグを無効化(test()の状態問題を回避) + const flags = pattern.flags.replace(/[gy]/g, ''); + return new RegExp(pattern.source, flags); + } + return new RegExp(pattern); + }; + + // 正規表現を変換(include/exclude両方) + const includePatterns = regexInclude.map(toRegExp); + const excludePatterns = regexExclude.map(toRegExp); + + // 1. excludeパターンに1つでもマッチしたら即座にfalse + if (excludePatterns.some((pattern) => pattern.test(inputStr))) { + return false; + } + + // 2. includeパターンが空の場合はtrue(excludeを通過した時点でOK) + if (includePatterns.length === 0) { + return true; + } + + // 3. includeパターンに1つでもマッチすればtrue + return includePatterns.some((pattern) => pattern.test(inputStr)); +} + +// ============================== + +export default { + resolveUrl, + isAbsoluteUrl, + getBaseUrlWithoutLastSegment, + replaceMultiPatterns, + sanitizeFilename, + filterByRegex, +}; diff --git a/src/utils/termPretty.ts b/src/utils/termPretty.ts new file mode 100644 index 0000000..0218e3d --- /dev/null +++ b/src/utils/termPretty.ts @@ -0,0 +1,46 @@ +const cliTableConfig = { + rounded: { + chars: { + top: '─', + 'top-mid': '', + 'top-left': '╭', + 'top-right': '╮', + bottom: '─', + 'bottom-mid': '', + 'bottom-left': '╰', + 'bottom-right': '╯', + left: '│', + 'left-mid': '├', + mid: '─', + 'mid-mid': '', + right: '│', + 'right-mid': '┤', + middle: '', + }, + style: { 'padding-left': 1, 'padding-right': 1, head: [''], border: [''], compact: true }, + }, + noBorder: { + chars: { + top: '', + 'top-mid': '', + 'top-left': '', + 'top-right': '', + bottom: '', + 'bottom-mid': '', + 'bottom-left': '', + 'bottom-right': '', + left: '', + 'left-mid': '', + mid: '', + 'mid-mid': '', + right: '', + 'right-mid': '', + middle: ' ', + }, + style: { 'padding-left': 0, 'padding-right': 0 }, + }, +}; + +export default { + cliTableConfig, +};