feat: implement china region launcher api support

This commit is contained in:
daydreamer-json
2026-02-12 01:46:46 +09:00
parent 37d01d3766
commit f1d7b4416a
44 changed files with 930 additions and 64 deletions

View File

@@ -7,6 +7,9 @@ import argvUtils from '../utils/argv.js';
import appConfig from '../utils/config.js';
import logger from '../utils/logger.js';
import mathUtils from '../utils/math.js';
import webArchiveOrg from '../utils/webArchiveOrg.js';
let waybackCred: { user: string; sig: string } | null = null;
const formatBytes = (size: number) =>
mathUtils.formatFileSize(size, {
@@ -21,6 +24,7 @@ const formatBytes = (size: number) =>
type LatestGameResponse = Awaited<ReturnType<typeof apiUtils.akEndfield.launcher.latestGame>>;
type LatestGameResourcesResponse = Awaited<ReturnType<typeof apiUtils.akEndfield.launcher.latestGameResources>>;
// type LatestLauncherResponse = Awaited<ReturnType<typeof apiUtils.akEndfield.launcher.latestLauncher>>;
// type LatestLauncherExeResponse = Awaited<ReturnType<typeof apiUtils.akEndfield.launcher.latestLauncherExe>>;
interface StoredData<T> {
req: any;
@@ -36,23 +40,42 @@ interface GameTarget {
dirName: string;
}
function getObjectDiff(obj1: any, obj2: any) {
function getObjectDiff(
obj1: any,
obj2: any,
ignoreRules: {
path: string[];
pattern: RegExp;
}[] = [],
currentPath: string[] = [],
) {
const diff: any = {};
const keys = new Set([...Object.keys(obj1 || {}), ...Object.keys(obj2 || {})]);
for (const key of keys) {
const val1 = obj1?.[key];
const val2 = obj2?.[key];
if (JSON.stringify(val1) !== JSON.stringify(val2)) {
if (typeof val1 === 'object' && val1 !== null && typeof val2 === 'object' && val2 !== null) {
const nestedDiff = getObjectDiff(val1, val2);
if (Object.keys(nestedDiff).length > 0) {
diff[key] = nestedDiff;
}
} else {
diff[key] = { old: val1, new: val2 };
}
const fullPath = [...currentPath, key];
if (JSON.stringify(val1) === JSON.stringify(val2)) continue;
const rule = ignoreRules.find(
(r) => r.path.length === fullPath.length && r.path.every((p, i) => p === fullPath[i]),
);
if (rule && typeof val1 === 'string' && typeof val2 === 'string') {
const normalized1 = val1.replace(rule.pattern, '');
const normalized2 = val2.replace(rule.pattern, '');
if (normalized1 === normalized2) continue;
}
if (typeof val1 === 'object' && val1 !== null && typeof val2 === 'object' && val2 !== null) {
const nestedDiff = getObjectDiff(val1, val2, ignoreRules, fullPath);
if (Object.keys(nestedDiff).length > 0) diff[key] = nestedDiff;
} else {
diff[key] = { old: val1, new: val2 };
}
}
return diff;
}
@@ -61,6 +84,7 @@ async function saveResult<T>(
version: string,
data: { req: any; rsp: T },
saveLatest: boolean = true,
ignoreRules: Parameters<typeof getObjectDiff>[2] = [],
) {
const outputDir = argvUtils.getArgv()['outputDir'];
const filePathBase = path.join(outputDir, ...subPaths);
@@ -71,20 +95,20 @@ async function saveResult<T>(
}
const dataStr = JSON.stringify(data, null, 2);
const dataMinified = JSON.stringify(data);
for (const filePath of filesToCheck) {
const file = Bun.file(filePath);
const exists = await file.exists();
let currentData: any = null;
if (exists) {
currentData = await file.json();
}
if (!exists || JSON.stringify(currentData) !== dataMinified) {
if (exists) {
logger.trace(`Diff detected in ${filePath}:`, JSON.stringify(getObjectDiff(currentData, data), null, 2));
}
if (!exists) {
await Bun.write(filePath, dataStr);
} else {
const currentData = await file.json();
const diff = getObjectDiff(currentData, data, ignoreRules);
if (Object.keys(diff).length > 0) {
logger.trace(`Diff detected in ${filePath}:`, JSON.stringify(diff, null, 2));
await Bun.write(filePath, dataStr);
}
}
}
@@ -95,7 +119,10 @@ async function saveResult<T>(
allData = await allFile.json();
}
const exists = allData.some((e) => JSON.stringify({ req: e.req, rsp: e.rsp }) === dataMinified);
const exists = allData.some((e) => {
const diff = getObjectDiff({ req: e.req, rsp: e.rsp }, data, ignoreRules);
return Object.keys(diff).length === 0;
});
if (!exists) {
allData.push({ updatedAt: DateTime.now().toISO(), ...data });
@@ -303,6 +330,93 @@ async function generateResourceListMd(channelStr: string) {
);
}
async function generateLauncherMd(type: 'zip' | 'exe') {
const cfg = appConfig.network.api.akEndfield;
const outputDir = argvUtils.getArgv()['outputDir'];
const settings = {
zip: {
subdir: 'launcher',
title: 'Launcher Packages (zip)',
headers: ['Date', 'Version', 'File', 'MD5 Checksum', 'Unpacked', 'Packed'],
align: ['---', '---', '---', '---', '--:', '--:'],
},
exe: {
subdir: 'launcherExe',
title: 'Launcher Packages (Installer)',
headers: ['Date', 'Version', 'File', 'Size'],
align: ['---', '---', '---', '--:'],
},
}[type];
const regions = [
{ id: 'os' as const, apps: ['EndField', 'Official'] as const, channel: cfg.channel.osWinRel },
{ id: 'cn' as const, apps: ['EndField', 'Arknights', 'Official'] as const, channel: cfg.channel.cnWinRel },
];
const mdTexts: string[] = [
`# ${settings.title}\n`,
...regions.flatMap((r) =>
r.apps.map((app) => `- [${r.id.toUpperCase()} ${app}](#launcher-${r.id}-${app.toLowerCase()})`),
),
'\n',
];
const waybackDb = await (async () => {
const localJsonPath = path.join(outputDir, 'wayback_machine.json');
return (await Bun.file(localJsonPath).json()) as string[];
})();
for (const region of regions) {
for (const appName of region.apps) {
const jsonPath = path.join(
outputDir,
'akEndfield',
'launcher',
settings.subdir,
appName,
String(region.channel),
'all.json',
);
const jsonData = (await Bun.file(jsonPath).json()) as StoredData<any>[];
mdTexts.push(
`<h2 id="launcher-${region.id}-${appName.toLowerCase()}">${region.id.toUpperCase()} ${appName}</h2>\n`,
`|${settings.headers.join('|')}|`,
`|${settings.align.join('|')}|`,
);
for (const e of jsonData) {
const url = type === 'zip' ? e.rsp.zip_package_url : e.rsp.exe_url;
const fileName = new URL(url).pathname.split('/').pop() ?? '';
const cleanUrl = new URL(url);
cleanUrl.search = '';
const waybackUrl = waybackDb.find((f) => f.includes(cleanUrl.toString()));
const fileLink = waybackUrl ? `${fileName} [Orig](${url}) / [Mirror](${waybackUrl})` : `[${fileName}](${url})`;
const date = DateTime.fromISO(e.updatedAt, { setZone: true }).setZone('UTC+8').toFormat('yyyy/MM/dd HH:mm:ss');
const row =
type === 'zip'
? [
date,
e.rsp.version,
fileLink,
`\`${e.rsp.md5}\``,
formatBytes(parseInt(e.rsp.total_size) - parseInt(e.rsp.package_size)),
formatBytes(parseInt(e.rsp.package_size)),
]
: [date, e.rsp.version, fileLink, formatBytes(parseInt(e.rsp.exe_size))];
mdTexts.push(`|${row.join('|')}|`);
}
mdTexts.push('');
}
}
await Bun.write(path.join(outputDir, 'akEndfield', 'launcher', settings.subdir, 'list.md'), mdTexts.join('\n'));
}
// 実行例
// await generateLauncherMd('zip');
// await generateLauncherMd('exe');
async function fetchAndSaveLatestGames(cfg: typeof appConfig.network.api.akEndfield, gameTargets: GameTarget[]) {
for (const target of gameTargets) {
logger.debug(`Fetching latestGame (${target.name}) ...`);
@@ -489,37 +603,68 @@ async function fetchAndSaveLatestGameResources(cfg: typeof appConfig.network.api
}
}
async function fetchAndSaveLatestLauncher(cfg: typeof appConfig.network.api.akEndfield, channelStr: string) {
async function fetchAndSaveLatestLauncher(cfg: typeof appConfig.network.api.akEndfield) {
logger.debug('Fetching latestLauncher ...');
const launcherTargetAppList = ['EndField', 'official'] as const;
for (const launcherTargetAppEntry of launcherTargetAppList) {
const rsp = await apiUtils.akEndfield.launcher.latestLauncher(
cfg.appCode.launcher.osWinRel,
cfg.channel.osWinRel,
cfg.channel.osWinRel,
null,
launcherTargetAppEntry === 'official' ? null : launcherTargetAppEntry,
);
logger.info(`Fetched latestLauncher: v${rsp.version}, ${launcherTargetAppEntry}`);
const prettyRsp = {
req: {
appCode: cfg.appCode.launcher.osWinRel,
channel: cfg.channel.osWinRel,
subChannel: cfg.channel.osWinRel,
targetApp: launcherTargetAppEntry === 'official' ? null : launcherTargetAppEntry,
},
rsp,
};
const regions = [
{
id: 'os' as const,
apps: ['EndField', 'Official'] as const,
code: cfg.appCode.launcher.osWinRel,
channel: cfg.channel.osWinRel,
},
{
id: 'cn' as const,
apps: ['EndField', 'Arknights', 'Official'] as const,
code: cfg.appCode.launcher.cnWinRel,
channel: cfg.channel.cnWinRel,
},
];
const removeQueryStr = (url: string): string => {
const urlObj = new URL(url);
urlObj.search = '';
return urlObj.toString();
};
const saveToWM = async (url: string): Promise<void> => {
if (waybackCred === null) throw new Error('Wayback Machine auth is null');
const localJsonPath = path.join(argvUtils.getArgv()['outputDir'], 'wayback_machine.json');
const localJson: string[] = await Bun.file(localJsonPath).json();
if (!localJson.find((e) => e.includes(removeQueryStr(url)))) {
await webArchiveOrg.savePage(url, waybackCred);
}
};
await saveResult(
['akEndfield', 'launcher', 'launcher', launcherTargetAppEntry, channelStr],
rsp.version,
prettyRsp,
);
for (const { id, apps, code, channel } of regions) {
for (const app of apps) {
const apiArgs = [code, channel, channel, null] as const;
const rsp = await apiUtils.akEndfield.launcher.latestLauncher(...apiArgs, app, id);
const prettyRsp = { req: { appCode: code, channel, subChannel: channel, targetApp: app }, rsp };
const appLower = app.toLowerCase();
const rspExe = await apiUtils.akEndfield.launcher.latestLauncherExe(...apiArgs, appLower, id);
const prettyRspExe = { req: { appCode: code, channel, subChannel: channel, ta: appLower }, rsp: rspExe };
logger.info(`Fetched latestLauncher: v${rsp.version}, ${id}, ${app}`);
const basePath = ['akEndfield', 'launcher'];
const channelStr = String(channel);
const ignoreRules = [
{ path: ['rsp', 'zip_package_url'], pattern: /\?auth_key=.*$/ },
{ path: ['rsp', 'exe_url'], pattern: /\?auth_key=.*$/ },
];
if (rsp.zip_package_url.includes('?auth_key')) await saveToWM(rsp.zip_package_url);
if (rspExe.exe_url.includes('?auth_key')) await saveToWM(rspExe.exe_url);
await saveResult([...basePath, 'launcher', app, channelStr], rsp.version, prettyRsp, true, ignoreRules);
await saveResult([...basePath, 'launcherExe', app, channelStr], rspExe.version, prettyRspExe, true, ignoreRules);
}
}
}
async function mainCmdHandler() {
waybackCred = await (async () => {
logger.debug('Fetching credentials for Wayback Machine ...');
const authTextData = await Bun.file('config/config_auth.txt').json();
const result = await webArchiveOrg.login(authTextData[0], authTextData[1]);
logger.info('Logged in to Wayback Machine');
return result;
})();
const cfg = appConfig.network.api.akEndfield;
const channelStr = String(cfg.channel.osWinRel);
@@ -550,13 +695,15 @@ async function mainCmdHandler() {
await fetchAndSaveLatestGames(cfg, gameTargets);
await fetchAndSaveLatestGamePatches(cfg, gameTargets);
await fetchAndSaveLatestGameResources(cfg, channelStr);
await fetchAndSaveLatestLauncher(cfg, channelStr);
await fetchAndSaveLatestLauncher(cfg);
for (const target of gameTargets) {
await generateGameListMd(target);
await generatePatchListMd(target);
}
await generateResourceListMd(channelStr);
await generateLauncherMd('zip');
await generateLauncherMd('exe');
}
export default mainCmdHandler;

View File

@@ -56,6 +56,14 @@ type LauncherLatestLauncher = {
description: string;
};
type LauncherLatestLauncherExe = {
action: number;
version: string; // x.y.z
request_version: string; // x.y.z or blank
exe_url: string;
exe_size: string;
};
type LauncherWebSidebar = {
data_version: string;
sidebars: {
@@ -355,6 +363,7 @@ export type {
LauncherLatestGame,
LauncherLatestGameResources,
LauncherLatestLauncher,
LauncherLatestLauncherExe,
LauncherWebSidebar,
LauncherWebSingleEnt,
LauncherWebMainBgImage,

View File

@@ -58,22 +58,54 @@ export default {
channel: number,
subChannel: number,
version: string | null,
targetApp: 'EndField' | null,
targetApp: 'EndField' | 'Arknights' | 'Official',
region: 'os' | 'cn',
): Promise<TypesApiAkEndfield.LauncherLatestLauncher> => {
if (version !== null && !semver.valid(version)) throw new Error(`Invalid version string (${version})`);
const domain =
region === 'cn'
? appConfig.network.api.akEndfield.base.launcherCN
: appConfig.network.api.akEndfield.base.launcher;
const rsp = await ky
.get(`https://${appConfig.network.api.akEndfield.base.launcher}/launcher/get_latest`, {
.get(`https://${domain}/launcher/get_latest`, {
...defaultSettings.ky,
searchParams: {
appcode: appCode,
channel,
sub_channel: subChannel,
version: version ?? undefined,
target_app: targetApp ?? undefined,
target_app: targetApp,
},
})
.json();
return rsp as TypesApiAkEndfield.LauncherLatestLauncher;
},
latestLauncherExe: async (
appCode: string,
channel: number,
subChannel: number,
version: string | null,
targetApp: 'endfield' | 'official' | string,
region: 'os' | 'cn',
): Promise<TypesApiAkEndfield.LauncherLatestLauncherExe> => {
if (version !== null && !semver.valid(version)) throw new Error(`Invalid version string (${version})`);
const domain =
region === 'cn'
? appConfig.network.api.akEndfield.base.launcherCN
: appConfig.network.api.akEndfield.base.launcher;
const rsp = await ky
.get(`https://${domain}/launcher/get_latest_launcher`, {
...defaultSettings.ky,
searchParams: {
appcode: appCode,
channel,
sub_channel: subChannel,
version: version ?? undefined,
ta: targetApp,
},
})
.json();
return rsp as TypesApiAkEndfield.LauncherLatestLauncherExe;
},
web: launcherWeb,
};

View File

@@ -1,5 +1,7 @@
import akEndfield from './akEndfield/index.js';
import webArchiveOrg from './webArchiveOrg/index.js';
export default {
akEndfield,
webArchiveOrg,
};

View File

@@ -0,0 +1,11 @@
import appConfig from '../../config.js';
export default {
ky: {
headers: {
'User-Agent': appConfig.network.userAgent.chromeWindows,
},
timeout: appConfig.network.timeout,
retry: { limit: appConfig.network.retryCount },
},
};

View File

@@ -0,0 +1,85 @@
import cookie from 'cookie';
import ky from 'ky';
import defaultSettings from './defaultSettings.js';
export default {
login: {
getLoginToken: async (): Promise<string> => {
const rsp: any = await ky.get('https://archive.org/services/account/login/', defaultSettings.ky).json();
if (!rsp.value.token) throw new Error('Failed to get wayback machine login token');
return rsp.value.token;
},
login: async (username: string, password: string, token: string, remember: boolean = true) => {
const rsp = await ky.post('https://archive.org/services/account/login/', {
...defaultSettings.ky,
json: {
username,
password,
remember: String(remember),
t: token,
},
});
if (!((await rsp.json()) as any).success) throw new Error('Wayback Machine login error: ' + rsp);
return rsp.headers.getSetCookie().map((e) => cookie.parseSetCookie(e));
},
},
save: async (url: string, auth: { user: string; sig: string }): Promise<string> => {
const params = new URLSearchParams();
params.append('url', url);
params.append('capture_all', 'on');
const rsp = await ky
.post('https://web.archive.org/save/' + url, {
...defaultSettings.ky,
headers: {
...defaultSettings.ky.headers,
Cookie: cookie.stringifyCookie({ 'logged-in-sig': auth.sig, 'logged-in-user': auth.user }),
},
body: params,
})
.text();
const match = rsp.match(/spn\.watchJob\("([^"]+)"/);
if (match && match[1]) {
return match[1]; // spn2-xxxxxxxxxxxxxxxxxxx
}
throw new Error('Wayback Machine save job id not found');
},
saveStatus: async (
jobId: string,
auth: { user: string; sig: string },
): Promise<
| {
status: 'success';
job_id: string;
resources: [];
download_size: number;
total_size: number;
timestamp: string;
original_url: string;
duration_sec: number;
counters: {
outlinks: number;
embeds: number;
};
http_status: number;
first_archive: boolean;
}
| {
status: 'pending';
job_id: string;
resources: [];
download_size?: number;
total_size?: number;
}
> => {
const rsp = await ky
.get('https://web.archive.org/save/status/' + jobId, {
...defaultSettings.ky,
headers: {
...defaultSettings.ky.headers,
Cookie: cookie.stringifyCookie({ 'logged-in-sig': auth.sig, 'logged-in-user': auth.user }),
},
})
.json();
return rsp as any;
},
};

View File

@@ -16,16 +16,17 @@ type ConfigType = AllRequired<
api: {
akEndfield: {
appCode: {
game: { osWinRel: string };
launcher: { osWinRel: string; osWinRelEpic: string };
game: { osWinRel: string; cnWinRel: string };
launcher: { osWinRel: string; osWinRelEpic: string; cnWinRel: string };
accountService: { osWinRel: string; skport: string; binding: string };
u8: { osWinRel: string };
};
channel: { osWinRel: number };
subChannel: { osWinRel: number; osWinRelEpic: number, osWinRelGooglePlay: number };
channel: { osWinRel: number; cnWinRel: number };
subChannel: { osWinRel: number; osWinRelEpic: number; osWinRelGooglePlay: number; cnWinRel: number };
base: {
accountService: string;
launcher: string;
launcherCN: string;
u8: string;
binding: string;
webview: string;
@@ -63,16 +64,17 @@ const initialConfig: ConfigType = {
api: {
akEndfield: {
appCode: {
game: { osWinRel: 'YDUTE5gscDZ229CW' },
launcher: { osWinRel: 'TiaytKBUIEdoEwRT', osWinRelEpic: 'BBWoqCzuZ2bZ1Dro' },
game: { osWinRel: 'YDUTE5gscDZ229CW', cnWinRel: '6LL0KJuqHBVz33WK' },
launcher: { osWinRel: 'TiaytKBUIEdoEwRT', osWinRelEpic: 'BBWoqCzuZ2bZ1Dro', cnWinRel: 'abYeZZ16BPluCFyT' },
accountService: { osWinRel: 'd9f6dbb6bbd6bb33', skport: '6eb76d4e13aa36e6', binding: '3dacefa138426cfe' },
u8: { osWinRel: '973bd727dd11cbb6ead8' },
},
channel: { osWinRel: 6 },
subChannel: { osWinRel: 6, osWinRelEpic: 801, osWinRelGooglePlay: 802 },
channel: { osWinRel: 6, cnWinRel: 1 },
subChannel: { osWinRel: 6, osWinRelEpic: 801, osWinRelGooglePlay: 802, cnWinRel: 1 },
base: {
accountService: 'YXMuZ3J5cGhsaW5lLmNvbQ==',
launcher: 'bGF1bmNoZXIuZ3J5cGhsaW5lLmNvbS9hcGk=',
launcherCN: 'bGF1bmNoZXIuaHlwZXJncnlwaC5jb20vYXBp',
u8: 'dTguZ3J5cGhsaW5lLmNvbQ==',
binding: 'YmluZGluZy1hcGktYWNjb3VudC1wcm9kLmdyeXBobGluZS5jb20=',
webview: 'ZWYtd2Vidmlldy5ncnlwaGxpbmUuY29t',

View File

@@ -0,0 +1,65 @@
import path from 'node:path';
import { HTTPError } from 'ky';
import api from './api/index.js';
import argvUtils from './argv.js';
import logger from './logger.js';
import mathUtils from './math.js';
function formatBytes(bytes: number) {
return mathUtils.formatFileSize(bytes, {
decimals: 2,
decimalPadding: true,
useBinaryUnit: true,
useBitUnit: false,
unitVisible: true,
unit: null,
});
}
async function login(username: string, password: string): Promise<{ user: string; sig: string }> {
const token = await api.webArchiveOrg.login.getLoginToken();
const loginRet = await api.webArchiveOrg.login.login(username, password, token, true);
const credential = {
user: loginRet.find((e) => e.name === 'logged-in-user')?.value,
sig: loginRet.find((e) => e.name === 'logged-in-sig')?.value,
};
if (credential.sig && credential.user) return credential as { user: string; sig: string };
throw new Error('Wayback Machine auth error');
}
async function savePage(url: string, auth: { user: string; sig: string }): Promise<string> {
const jobId = await api.webArchiveOrg.save(url, auth);
const result = await (async () => {
while (true) {
try {
const status = await api.webArchiveOrg.saveStatus(jobId, auth);
if (status.download_size && status.total_size)
logger.debug(
`Wayback Machine save: ${formatBytes(status.download_size)} / ${formatBytes(status.total_size)}`,
);
if (status.status === 'success') return status;
await new Promise((resolve) => setTimeout(resolve, 1000));
} catch (err) {
if (err instanceof HTTPError) {
throw new Error(
`Wayback Machine save: HTTP ${err.response.status} ${err.response.statusText}: ${await err.response.text()}`,
);
}
}
}
})();
if (result.http_status >= 400) {
throw new Error('Wayback Machine save: http ' + result.http_status + ' error');
}
const resultUrl = `https://web.archive.org/web/${result.timestamp}/${result.original_url}`;
const localJsonPath = path.join(argvUtils.getArgv()['outputDir'], 'wayback_machine.json');
const localJson: string[] = await Bun.file(localJsonPath).json();
if (localJson.includes(resultUrl) === false) localJson.push(resultUrl);
await Bun.write(localJsonPath, JSON.stringify(localJson, null, 2));
return resultUrl;
}
export default {
login,
savePage,
};