From 5e2c00dcb82aea64d7a210264101220d9ac1f5e8 Mon Sep 17 00:00:00 2001 From: daydreamer-json Date: Sun, 8 Mar 2026 01:44:25 +0900 Subject: [PATCH] refactor(pages): decompose index.ts into specialized renderers and utils --- pages/src/assets/ts/api.ts | 51 ++ pages/src/assets/ts/index.ts | 673 +----------------- pages/src/assets/ts/renderers/gamePackages.ts | 78 ++ pages/src/assets/ts/renderers/launchers.ts | 137 ++++ pages/src/assets/ts/renderers/overview.ts | 171 +++++ pages/src/assets/ts/renderers/patches.ts | 89 +++ pages/src/assets/ts/renderers/resources.ts | 103 +++ pages/src/assets/ts/types.ts | 11 + pages/src/assets/ts/utils/constants.ts | 45 +- pages/src/assets/ts/utils/ui.ts | 16 + 10 files changed, 712 insertions(+), 662 deletions(-) create mode 100644 pages/src/assets/ts/api.ts create mode 100644 pages/src/assets/ts/renderers/gamePackages.ts create mode 100644 pages/src/assets/ts/renderers/launchers.ts create mode 100644 pages/src/assets/ts/renderers/overview.ts create mode 100644 pages/src/assets/ts/renderers/patches.ts create mode 100644 pages/src/assets/ts/renderers/resources.ts create mode 100644 pages/src/assets/ts/types.ts create mode 100644 pages/src/assets/ts/utils/ui.ts diff --git a/pages/src/assets/ts/api.ts b/pages/src/assets/ts/api.ts new file mode 100644 index 0000000..f43288f --- /dev/null +++ b/pages/src/assets/ts/api.ts @@ -0,0 +1,51 @@ +import ky from 'ky'; +import { BASE_URL, gameTargets, launcherTargets, launcherWebApiLang } from './utils/constants.js'; + +const apiCache = new Map>(); + +export function fetchJson(url: string): Promise { + if (!apiCache.has(url)) { + const promise = ky + .get(url) + .json() + .catch((err) => { + apiCache.delete(url); + throw err; + }); + apiCache.set(url, promise); + } + return apiCache.get(url) as Promise; +} + +export async function preloadData() { + const promises: Promise[] = []; + promises.push(fetchJson(`${BASE_URL}/mirror_file_list.json`)); + const launcherWebApiFolderNames = ['announcement', 'banner', 'main_bg_image', 'sidebar', 'single_ent']; + for (const target of gameTargets) { + promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`)); + promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`)); + for (const apiName of launcherWebApiFolderNames) { + for (const lang of launcherWebApiLang[target.region]) { + promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/web/${target.dirName}/${apiName}/${lang}/all.json`)); + } + } + } + const resTargets = [ + { region: 'os', channel: 6 }, + { region: 'cn', channel: 1 }, + ]; + const platforms = ['Windows', 'Android', 'iOS', 'PlayStation']; + for (const target of resTargets) { + for (const platform of platforms) { + promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/game_resources/${target.channel}/${platform}/all.json`)); + } + } + for (const region of launcherTargets) { + for (const app of region.apps) { + promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`)); + promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`)); + } + } + + await Promise.all(promises); +} diff --git a/pages/src/assets/ts/index.ts b/pages/src/assets/ts/index.ts index 95d2ebb..161668b 100644 --- a/pages/src/assets/ts/index.ts +++ b/pages/src/assets/ts/index.ts @@ -1,93 +1,18 @@ -// import * as bootstrap from 'bootstrap'; -import ky from 'ky'; -import { DateTime } from 'luxon'; -import * as semver from 'semver'; -import math from './utils/math.js'; +import { fetchJson, preloadData } from './api.js'; +import { renderGamePackages } from './renderers/gamePackages.js'; +import { renderLaunchers } from './renderers/launchers.js'; +import { renderOverview } from './renderers/overview.js'; +import { renderPatches } from './renderers/patches.js'; +import { renderResources } from './renderers/resources.js'; +import type { MirrorFileEntry } from './types.js'; +import { BASE_URL } from './utils/constants.js'; document.addEventListener('DOMContentLoaded', () => { main(); }); -const BASE_URL = 'https://raw.githubusercontent.com/daydreamer-json/ak-endfield-api-archive/refs/heads/main/output'; - -const FILE_SIZE_OPTS = { - decimals: 2, - decimalPadding: true, - useBinaryUnit: true, - useBitUnit: false, - unitVisible: true, - unit: null, -}; - -interface MirrorFileEntry { - orig: string; - mirror: string; - origStatus: boolean; -} - -interface StoredData { - req: any; - rsp: T; - updatedAt: string; -} - -const gameTargets = [ - { name: 'Official', region: 'os', dirName: '6', channel: 6 }, - { name: 'Epic', region: 'os', dirName: '801', channel: 6 }, - { name: 'Google Play', region: 'os', dirName: '802', channel: 6 }, - { name: 'Official', region: 'cn', dirName: '1', channel: 1 }, - { name: 'Bilibili', region: 'cn', dirName: '2', channel: 2 }, -]; - -const launcherTargets = [ - { id: 'os', apps: ['EndField', 'Official'], channel: 6 }, - { id: 'cn', apps: ['EndField', 'Arknights', 'Official'], channel: 1 }, -]; - -const apiCache = new Map>(); - -function fetchJson(url: string): Promise { - if (!apiCache.has(url)) { - const promise = ky - .get(url) - .json() - .catch((err) => { - apiCache.delete(url); - throw err; - }); - apiCache.set(url, promise); - } - return apiCache.get(url) as Promise; -} - let mirrorFileDb: MirrorFileEntry[] = []; -async function preloadData() { - const promises: Promise[] = []; - promises.push(fetchJson(`${BASE_URL}/mirror_file_list.json`)); - for (const target of gameTargets) { - promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`)); - promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`)); - } - const resTargets = [ - { region: 'os', channel: 6 }, - { region: 'cn', channel: 1 }, - ]; - const platforms = ['Windows', 'Android', 'iOS', 'PlayStation']; - for (const target of resTargets) { - for (const platform of platforms) { - promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/game_resources/${target.channel}/${platform}/all.json`)); - } - } - for (const region of launcherTargets) { - for (const app of region.apps) { - promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`)); - promises.push(fetchJson(`${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`)); - } - } - await Promise.all(promises); -} - async function main() { const contentDiv = document.getElementById('content'); if (!contentDiv) return; @@ -119,584 +44,10 @@ async function main() { contentDiv.innerHTML = tabsHtml; await Promise.all([ - renderOverview(document.getElementById('tab-overview')!), - renderGamePackages(document.getElementById('tab-game')!), - renderPatches(document.getElementById('tab-patch')!), + renderOverview(document.getElementById('tab-overview')!, mirrorFileDb), + renderGamePackages(document.getElementById('tab-game')!, mirrorFileDb), + renderPatches(document.getElementById('tab-patch')!, mirrorFileDb), renderResources(document.getElementById('tab-resources')!), - renderLaunchers(document.getElementById('tab-launcher')!), + renderLaunchers(document.getElementById('tab-launcher')!, mirrorFileDb), ]); } - -async function renderOverview(container: HTMLElement) { - const mirrorOrigSet = new Set(); - for (const m of mirrorFileDb) { - try { - const u = new URL(m.orig); - u.search = ''; - mirrorOrigSet.add(u.toString()); - } catch {} - } - - const countedUrls = new Set(); - let totalMirrorSize = 0; - - const checkAndAddSize = (url: string, size: number) => { - if (!url || isNaN(size)) return; - try { - const u = new URL(url); - u.search = ''; - const cleanUrl = u.toString(); - if (countedUrls.has(cleanUrl)) return; - if (mirrorOrigSet.has(cleanUrl)) { - totalMirrorSize += size; - countedUrls.add(cleanUrl); - } - } catch {} - }; - - const section = document.createElement('div'); - const sectionIn = document.createElement('div'); - section.className = 'card mb-3'; - sectionIn.className = 'card-body'; - sectionIn.innerHTML = ` -

Latest Game Packages

-

- ${await ( - async () => { - const url = `${BASE_URL}/akEndfield/launcher/game/6/all.json`; - const dat = await fetchJson[]>(url); - return dat.at(-1)?.rsp.version; - } - )()}
- Latest Version (Global) -

-

- ${await ( - async () => { - const url = `${BASE_URL}/akEndfield/launcher/game/1/all.json`; - const dat = await fetchJson[]>(url); - return dat.at(-1)?.rsp.version; - } - )()}
- Latest Version (China) -

- `; - - const tableWrapper = document.createElement('div'); - tableWrapper.className = 'table-responsive'; - - const table = document.createElement('table'); - table.className = 'table table-striped table-bordered table-sm align-middle text-nowrap'; - table.innerHTML = ` - - - Region - Channel - Version - Packed - Unpacked - - - - `; - const tbody = table.querySelector('tbody')!; - tableWrapper.appendChild(table); - sectionIn.appendChild(tableWrapper); - section.appendChild(sectionIn); - container.appendChild(section); - - // 1. Game Packages - for (const target of gameTargets) { - const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`; - try { - const data = await fetchJson[]>(url); - if (!data || data.length === 0) continue; - - const latest = data[data.length - 1]; - if (!latest) continue; - const version = latest.rsp.version; - const packedSize = math.arrayTotal(latest.rsp.pkg.packs.map((f: any) => parseInt(f.package_size))); - const totalSize = parseInt(latest.rsp.pkg.total_size); - const unpackedSize = totalSize - packedSize; - - const row = document.createElement('tr'); - row.innerHTML = ` - ${target.region === 'cn' ? 'China' : 'Global'} - ${target.name} - ${version} - ${math.formatFileSize(packedSize, FILE_SIZE_OPTS)} - ${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)} - `; - tbody.appendChild(row); - - for (const entry of data) { - if (entry.rsp.pkg && entry.rsp.pkg.packs) { - for (const pack of entry.rsp.pkg.packs) { - checkAndAddSize(pack.url, parseInt(pack.package_size)); - } - } - } - } catch (e) { - console.warn('Overview: Failed to fetch game data', target.name, e); - } - } - - // 2. Patches - for (const target of gameTargets) { - const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`; - try { - const data = await fetchJson[]>(url); - for (const entry of data) { - if (!entry.rsp.patch) continue; - if (entry.rsp.patch.url) { - checkAndAddSize(entry.rsp.patch.url, parseInt(entry.rsp.patch.package_size)); - } - if (entry.rsp.patch.patches) { - for (const p of entry.rsp.patch.patches) { - checkAndAddSize(p.url, parseInt(p.package_size)); - } - } - } - } catch (e) {} - } - - // 4. Launchers - for (const region of launcherTargets) { - for (const app of region.apps) { - try { - const urlZip = `${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`; - const dataZip = await fetchJson[]>(urlZip); - for (const e of dataZip) { - checkAndAddSize(e.rsp.zip_package_url, parseInt(e.rsp.package_size)); - } - } catch (e) {} - try { - const urlExe = `${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`; - const dataExe = await fetchJson[]>(urlExe); - for (const e of dataExe) { - checkAndAddSize(e.rsp.exe_url, parseInt(e.rsp.exe_size)); - } - } catch (e) {} - } - } - - const mirrorSection = document.createElement('div'); - mirrorSection.className = 'card'; - mirrorSection.innerHTML = ` -
-

Mirror Statistics

-

- ${math.formatFileSize(totalMirrorSize, { ...FILE_SIZE_OPTS, unit: 'G' })}
- uploaded to mirror -

-
- `; - container.appendChild(mirrorSection); -} - -async function renderGamePackages(container: HTMLElement) { - for (const target of gameTargets) { - const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`; - try { - const data = await fetchJson[]>(url); - const section = document.createElement('div'); - section.className = 'mb-5'; - section.innerHTML = `

${target.region === 'cn' ? 'China' : 'Global'}, ${target.name}

`; - - const accordion = document.createElement('div'); - accordion.className = 'accordion'; - accordion.id = `accordion-game-${target.dirName}`; - - // Reverse order to show latest first - const list = [...data].reverse(); - for (let i = 0; i < list.length; i++) { - const e = list[i]; - if (!e) continue; - const version = e.rsp.version; - const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'); - const packedSize = math.arrayTotal(e.rsp.pkg.packs.map((f: any) => parseInt(f.package_size))); - const unpackedSize = parseInt(e.rsp.pkg.total_size) - packedSize; - - let rows = ''; - const fileName = (f: any) => new URL(f.url).pathname.split('/').pop() ?? ''; - for (const f of e.rsp.pkg.packs) { - rows += ` - ${fileName(f)} - ${f.md5} - ${math.formatFileSize(parseInt(f.package_size), FILE_SIZE_OPTS)} - ${generateDownloadLinks(f.url)} - `; - } - - const itemId = `game-${target.dirName}-${i}`; - // const isExpanded = i === 0; - const isExpanded = false; - const item = document.createElement('div'); - item.className = 'accordion-item'; - item.innerHTML = ` -

- -

-
-
- - - -
Unpacked Size${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)}
Packed Size${math.formatFileSize(packedSize, FILE_SIZE_OPTS)}
-
- - - ${rows} -
FileMD5 ChecksumSizeDL
-
-
-
- `; - accordion.appendChild(item); - } - section.appendChild(accordion); - container.appendChild(section); - } catch (err) { - // Ignore 404 or errors - } - } -} - -async function renderPatches(container: HTMLElement) { - for (const target of gameTargets) { - const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`; - try { - const data = await fetchJson[]>(url); - if (data.length === 0) continue; - - const section = document.createElement('div'); - section.className = 'mb-5'; - section.innerHTML = `

${target.region === 'cn' ? 'China' : 'Global'}, ${target.name}

`; - - const accordion = document.createElement('div'); - accordion.className = 'accordion'; - accordion.id = `accordion-patch-${target.dirName}`; - - let itemIndex = 0; - for (const e of [...data].reverse()) { - if (!e.rsp.patch) continue; - const version = e.rsp.version; - const reqVersion = e.rsp.request_version; - const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'); - const packedSize = math.arrayTotal(e.rsp.patch.patches.map((f: any) => parseInt(f.package_size))); - const unpackedSize = parseInt(e.rsp.patch.total_size) - packedSize; - - let rows = ''; - const fileName = (url: string) => new URL(url).pathname.split('/').pop() ?? ''; - if (e.rsp.patch.url) { - rows += ` - ${fileName(e.rsp.patch.url)} - ${e.rsp.patch.md5} - ${math.formatFileSize(parseInt(e.rsp.patch.package_size), FILE_SIZE_OPTS)} - ${generateDownloadLinks(e.rsp.patch.url)} - `; - } - for (const f of e.rsp.patch.patches) { - rows += ` - ${fileName(f.url)} - ${f.md5} - ${math.formatFileSize(parseInt(f.package_size), FILE_SIZE_OPTS)} - ${generateDownloadLinks(f.url)} - `; - } - - const itemId = `patch-${target.dirName}-${itemIndex}`; - // const isExpanded = itemIndex === 0; - const isExpanded = false; - itemIndex++; - - const item = document.createElement('div'); - item.className = 'accordion-item'; - item.innerHTML = ` -

- -

-
-
- - - -
Unpacked Size${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)}
Packed Size${math.formatFileSize(packedSize, FILE_SIZE_OPTS)}
-
- - - ${rows} -
FileMD5 ChecksumSizeDL
-
-
-
- `; - accordion.appendChild(item); - } - section.appendChild(accordion); - container.appendChild(section); - } catch (err) { - // Ignore - } - } -} - -async function renderResources(container: HTMLElement) { - const platforms = ['Windows', 'Android', 'iOS', 'PlayStation']; - // Filter unique channels (OS: 6, CN: 1), excluding Bilibili (2) as per archive.ts logic - const targets = [ - { region: 'os', channel: 6 }, - { region: 'cn', channel: 1 }, - ]; - - for (const target of targets) { - const section = document.createElement('div'); - section.className = 'mb-5'; - section.innerHTML = `

${target.region === 'cn' ? 'China' : 'Global'}

`; - - const accordion = document.createElement('div'); - accordion.className = 'accordion'; - accordion.id = `accordion-res-${target.region}-${target.channel}`; - let itemIndex = 0; - - for (const platform of platforms) { - const url = `${BASE_URL}/akEndfield/launcher/game_resources/${target.channel}/${platform}/all.json`; - try { - const data = await fetchJson[]>(url); - - // Group by res_version - const resVersionMap = new Map; versions: Set }>(); - for (const e of data) { - const resVer = e.rsp.res_version; - if (!resVersionMap.has(resVer)) { - resVersionMap.set(resVer, { rsp: e, versions: new Set() }); - } - resVersionMap.get(resVer)!.versions.add(e.req.version); - } - - const resVersionSet = Array.from(resVersionMap.values()).map((d) => ({ - resVersion: d.rsp.rsp.res_version, - rsp: d.rsp, - versions: Array.from(d.versions).sort(semver.rcompare), - })); - - let rows = ''; - for (const item of resVersionSet.reverse()) { - // Newest first - const dateStr = DateTime.fromISO(item.rsp.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'); - const initialRes = item.rsp.rsp.resources.find((e: any) => e.name === 'initial'); - const mainRes = item.rsp.rsp.resources.find((e: any) => e.name === 'main'); - const isKick = JSON.parse(item.rsp.rsp.configs).kick_flag === true; - - rows += ` - ${dateStr} - ${initialRes.version} - ${mainRes.version} - ${isKick ? '✅' : ''} - ${item.versions.join(', ')} - `; - } - - const itemId = `res-${target.region}-${target.channel}-${platform}`; - // const isExpanded = itemIndex === 0; - const isExpanded = false; - itemIndex++; - - const item = document.createElement('div'); - item.className = 'accordion-item'; - item.innerHTML = ` -

- -

-
-
-
- - - - - - - - - - - ${rows} -
DateInitialMainKickGame version
-
-
-
- `; - accordion.appendChild(item); - } catch (err) { - // Ignore - } - } - if (accordion.childElementCount > 0) { - section.appendChild(accordion); - container.appendChild(section); - } - } -} - -async function renderLaunchers(container: HTMLElement) { - for (const region of launcherTargets) { - for (const app of region.apps) { - const section = document.createElement('div'); - section.className = 'mb-5'; - section.innerHTML = `

${region.id.toUpperCase()} ${app}

`; - - const accordion = document.createElement('div'); - accordion.className = 'accordion'; - accordion.id = `accordion-launcher-${region.id}-${app}`; - let itemIndex = 0; - - // Zip - try { - const urlZip = `${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`; - const dataZip = await fetchJson[]>(urlZip); - - let rows = ''; - for (const e of [...dataZip].reverse()) { - const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'); - const fileName = new URL(e.rsp.zip_package_url).pathname.split('/').pop() ?? ''; - const unpacked = parseInt(e.rsp.total_size) - parseInt(e.rsp.package_size); - - rows += ` - ${dateStr} - ${e.rsp.version} - ${fileName} - ${e.rsp.md5} - ${math.formatFileSize(unpacked, FILE_SIZE_OPTS)} - ${math.formatFileSize(parseInt(e.rsp.package_size), FILE_SIZE_OPTS)} - ${generateDownloadLinks(e.rsp.zip_package_url)} - `; - } - - const itemId = `launcher-zip-${region.id}-${app}`; - // const isExpanded = itemIndex === 0; - const isExpanded = false; - itemIndex++; - - const item = document.createElement('div'); - item.className = 'accordion-item'; - item.innerHTML = ` -

- -

-
-
-
- - - - - - - - - - - - - ${rows} -
DateVersionFileMD5 ChecksumUnpackedPackedDL
-
-
-
- `; - accordion.appendChild(item); - } catch (e) {} - - // Exe - try { - const urlExe = `${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`; - const dataExe = await fetchJson[]>(urlExe); - - let rows = ''; - for (const e of [...dataExe].reverse()) { - const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'); - const fileName = new URL(e.rsp.exe_url).pathname.split('/').pop() ?? ''; - - rows += ` - ${dateStr} - ${e.rsp.version} - ${fileName} - ${math.formatFileSize(parseInt(e.rsp.exe_size), FILE_SIZE_OPTS)} - ${generateDownloadLinks(e.rsp.exe_url)} - `; - } - - const itemId = `launcher-exe-${region.id}-${app}`; - // const isExpanded = itemIndex === 0; - const isExpanded = false; - itemIndex++; - - const item = document.createElement('div'); - item.className = 'accordion-item'; - item.innerHTML = ` -

- -

-
-
-
- - - - - - - - - - - ${rows} -
DateVersionFileSizeDL
-
-
-
- `; - accordion.appendChild(item); - } catch (e) {} - - if (accordion.childElementCount > 0) { - section.appendChild(accordion); - container.appendChild(section); - } - } - } -} - -// --- Utils --- - -function generateDownloadLinks(url: string) { - const cleanUrl = new URL(url); - cleanUrl.search = ''; - const mirrorEntry = mirrorFileDb.find((g) => g.orig.includes(cleanUrl.toString())); - - const links: string[] = []; - if (!mirrorEntry || mirrorEntry.origStatus === true) { - links.push(`Orig`); - } - if (mirrorEntry) { - links.push(`Mirror`); - } - return links.join(' / '); -} diff --git a/pages/src/assets/ts/renderers/gamePackages.ts b/pages/src/assets/ts/renderers/gamePackages.ts new file mode 100644 index 0000000..7186f3b --- /dev/null +++ b/pages/src/assets/ts/renderers/gamePackages.ts @@ -0,0 +1,78 @@ +import { DateTime } from 'luxon'; +import { fetchJson } from '../api.js'; +import type { MirrorFileEntry, StoredData } from '../types.js'; +import { BASE_URL, FILE_SIZE_OPTS, gameTargets } from '../utils/constants.js'; +import math from '../utils/math.js'; +import { generateDownloadLinks } from '../utils/ui.js'; + +export async function renderGamePackages(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) { + for (const target of gameTargets) { + const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`; + try { + const data = await fetchJson[]>(url); + const section = document.createElement('div'); + section.className = 'mb-5'; + section.innerHTML = `

${target.region === 'cn' ? 'China' : 'Global'}, ${target.name}

`; + + const accordion = document.createElement('div'); + accordion.className = 'accordion'; + accordion.id = `accordion-game-${target.dirName}`; + + // Reverse order to show latest first + const list = [...data].reverse(); + for (let i = 0; i < list.length; i++) { + const e = list[i]; + if (!e) continue; + const version = e.rsp.version; + const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'); + const packedSize = math.arrayTotal(e.rsp.pkg.packs.map((f: any) => parseInt(f.package_size))); + const unpackedSize = parseInt(e.rsp.pkg.total_size) - packedSize; + + let rows = ''; + const fileName = (f: any) => new URL(f.url).pathname.split('/').pop() ?? ''; + for (const f of e.rsp.pkg.packs) { + rows += ` + ${fileName(f)} + ${f.md5} + ${math.formatFileSize(parseInt(f.package_size), FILE_SIZE_OPTS)} + ${generateDownloadLinks(f.url, mirrorFileDb)} + `; + } + + const itemId = `game-${target.dirName}-${i}`; + const isExpanded = false; + const item = document.createElement('div'); + item.className = 'accordion-item'; + item.innerHTML = ` +

+ +

+
+
+ + + +
Unpacked Size${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)}
Packed Size${math.formatFileSize(packedSize, FILE_SIZE_OPTS)}
+
+ + + ${rows} +
FileMD5 ChecksumSizeDL
+
+
+
+ `; + accordion.appendChild(item); + } + section.appendChild(accordion); + container.appendChild(section); + } catch (err) { + // Ignore 404 or errors + } + } +} diff --git a/pages/src/assets/ts/renderers/launchers.ts b/pages/src/assets/ts/renderers/launchers.ts new file mode 100644 index 0000000..b3cc0e6 --- /dev/null +++ b/pages/src/assets/ts/renderers/launchers.ts @@ -0,0 +1,137 @@ +import { DateTime } from 'luxon'; +import { fetchJson } from '../api.js'; +import type { MirrorFileEntry, StoredData } from '../types.js'; +import { BASE_URL, FILE_SIZE_OPTS, launcherTargets } from '../utils/constants.js'; +import math from '../utils/math.js'; +import { generateDownloadLinks } from '../utils/ui.js'; + +export async function renderLaunchers(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) { + for (const region of launcherTargets) { + for (const app of region.apps) { + const section = document.createElement('div'); + section.className = 'mb-5'; + section.innerHTML = `

${region.id.toUpperCase()} ${app}

`; + + const accordion = document.createElement('div'); + accordion.className = 'accordion'; + accordion.id = `accordion-launcher-${region.id}-${app}`; + let itemIndex = 0; + + // Zip + try { + const urlZip = `${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`; + const dataZip = await fetchJson[]>(urlZip); + + let rows = ''; + for (const e of [...dataZip].reverse()) { + const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'); + const fileName = new URL(e.rsp.zip_package_url).pathname.split('/').pop() ?? ''; + const unpacked = parseInt(e.rsp.total_size) - parseInt(e.rsp.package_size); + + rows += ` + ${dateStr} + ${e.rsp.version} + ${fileName} + ${e.rsp.md5} + ${math.formatFileSize(unpacked, FILE_SIZE_OPTS)} + ${math.formatFileSize(parseInt(e.rsp.package_size), FILE_SIZE_OPTS)} + ${generateDownloadLinks(e.rsp.zip_package_url, mirrorFileDb)} + `; + } + + const itemId = `launcher-zip-${region.id}-${app}`; + const isExpanded = false; + itemIndex++; + + const item = document.createElement('div'); + item.className = 'accordion-item'; + item.innerHTML = ` +

+ +

+
+
+
+ + + + + + + + + + + + + ${rows} +
DateVersionFileMD5 ChecksumUnpackedPackedDL
+
+
+
+ `; + accordion.appendChild(item); + } catch (e) {} + + // Exe + try { + const urlExe = `${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`; + const dataExe = await fetchJson[]>(urlExe); + + let rows = ''; + for (const e of [...dataExe].reverse()) { + const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'); + const fileName = new URL(e.rsp.exe_url).pathname.split('/').pop() ?? ''; + + rows += ` + ${dateStr} + ${e.rsp.version} + ${fileName} + ${math.formatFileSize(parseInt(e.rsp.exe_size), FILE_SIZE_OPTS)} + ${generateDownloadLinks(e.rsp.exe_url, mirrorFileDb)} + `; + } + + const itemId = `launcher-exe-${region.id}-${app}`; + const isExpanded = false; + itemIndex++; + + const item = document.createElement('div'); + item.className = 'accordion-item'; + item.innerHTML = ` +

+ +

+
+
+
+ + + + + + + + + + + ${rows} +
DateVersionFileSizeDL
+
+
+
+ `; + accordion.appendChild(item); + } catch (e) {} + + if (accordion.childElementCount > 0) { + section.appendChild(accordion); + container.appendChild(section); + } + } + } +} diff --git a/pages/src/assets/ts/renderers/overview.ts b/pages/src/assets/ts/renderers/overview.ts new file mode 100644 index 0000000..cd39312 --- /dev/null +++ b/pages/src/assets/ts/renderers/overview.ts @@ -0,0 +1,171 @@ +import { fetchJson } from '../api.js'; +import type { MirrorFileEntry, StoredData } from '../types.js'; +import { BASE_URL, FILE_SIZE_OPTS, gameTargets, launcherTargets } from '../utils/constants.js'; +import math from '../utils/math.js'; + +export async function renderOverview(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) { + const mirrorOrigSet = new Set(); + for (const m of mirrorFileDb) { + try { + const u = new URL(m.orig); + u.search = ''; + mirrorOrigSet.add(u.toString()); + } catch {} + } + + const countedUrls = new Set(); + let totalMirrorSize = 0; + + const checkAndAddSize = (url: string, size: number) => { + if (!url || isNaN(size)) return; + try { + const u = new URL(url); + u.search = ''; + const cleanUrl = u.toString(); + if (countedUrls.has(cleanUrl)) return; + if (mirrorOrigSet.has(cleanUrl)) { + totalMirrorSize += size; + countedUrls.add(cleanUrl); + } + } catch {} + }; + + const section = document.createElement('div'); + const sectionIn = document.createElement('div'); + section.className = 'card mb-3'; + sectionIn.className = 'card-body'; + sectionIn.innerHTML = ` +

Latest Game Packages

+

+ ${await ( + async () => { + const url = `${BASE_URL}/akEndfield/launcher/game/6/all.json`; + const dat = await fetchJson[]>(url); + return dat.at(-1)?.rsp.version; + } + )()}
+ Latest Version (Global) +

+

+ ${await ( + async () => { + const url = `${BASE_URL}/akEndfield/launcher/game/1/all.json`; + const dat = await fetchJson[]>(url); + return dat.at(-1)?.rsp.version; + } + )()}
+ Latest Version (China) +

+ `; + + const tableWrapper = document.createElement('div'); + tableWrapper.className = 'table-responsive'; + + const table = document.createElement('table'); + table.className = 'table table-striped table-bordered table-sm align-middle text-nowrap'; + table.innerHTML = ` + + + Region + Channel + Version + Packed + Unpacked + + + + `; + const tbody = table.querySelector('tbody')!; + tableWrapper.appendChild(table); + sectionIn.appendChild(tableWrapper); + section.appendChild(sectionIn); + container.appendChild(section); + + // 1. Game Packages + for (const target of gameTargets) { + const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all.json`; + try { + const data = await fetchJson[]>(url); + if (!data || data.length === 0) continue; + + const latest = data[data.length - 1]; + if (!latest) continue; + const version = latest.rsp.version; + const packedSize = math.arrayTotal(latest.rsp.pkg.packs.map((f: any) => parseInt(f.package_size))); + const totalSize = parseInt(latest.rsp.pkg.total_size); + const unpackedSize = totalSize - packedSize; + + const row = document.createElement('tr'); + row.innerHTML = ` + ${target.region === 'cn' ? 'China' : 'Global'} + ${target.name} + ${version} + ${math.formatFileSize(packedSize, FILE_SIZE_OPTS)} + ${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)} + `; + tbody.appendChild(row); + + for (const entry of data) { + if (entry.rsp.pkg && entry.rsp.pkg.packs) { + for (const pack of entry.rsp.pkg.packs) { + checkAndAddSize(pack.url, parseInt(pack.package_size)); + } + } + } + } catch (e) { + console.warn('Overview: Failed to fetch game data', target.name, e); + } + } + + // 2. Patches + for (const target of gameTargets) { + const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`; + try { + const data = await fetchJson[]>(url); + for (const entry of data) { + if (!entry.rsp.patch) continue; + if (entry.rsp.patch.url) { + checkAndAddSize(entry.rsp.patch.url, parseInt(entry.rsp.patch.package_size)); + } + if (entry.rsp.patch.patches) { + for (const p of entry.rsp.patch.patches) { + checkAndAddSize(p.url, parseInt(p.package_size)); + } + } + } + } catch (e) {} + } + + // 4. Launchers + for (const region of launcherTargets) { + for (const app of region.apps) { + try { + const urlZip = `${BASE_URL}/akEndfield/launcher/launcher/${app}/${region.channel}/all.json`; + const dataZip = await fetchJson[]>(urlZip); + for (const e of dataZip) { + checkAndAddSize(e.rsp.zip_package_url, parseInt(e.rsp.package_size)); + } + } catch (e) {} + try { + const urlExe = `${BASE_URL}/akEndfield/launcher/launcherExe/${app}/${region.channel}/all.json`; + const dataExe = await fetchJson[]>(urlExe); + for (const e of dataExe) { + checkAndAddSize(e.rsp.exe_url, parseInt(e.rsp.exe_size)); + } + } catch (e) {} + } + } + + const mirrorSection = document.createElement('div'); + mirrorSection.className = 'card'; + mirrorSection.innerHTML = ` +
+

Mirror Statistics

+

+ ${math.formatFileSize(totalMirrorSize, { ...FILE_SIZE_OPTS, unit: 'G' })}
+ uploaded to mirror +

+
+ `; + container.appendChild(mirrorSection); +} diff --git a/pages/src/assets/ts/renderers/patches.ts b/pages/src/assets/ts/renderers/patches.ts new file mode 100644 index 0000000..013ecea --- /dev/null +++ b/pages/src/assets/ts/renderers/patches.ts @@ -0,0 +1,89 @@ +import { DateTime } from 'luxon'; +import { fetchJson } from '../api.js'; +import type { MirrorFileEntry, StoredData } from '../types.js'; +import { BASE_URL, FILE_SIZE_OPTS, gameTargets } from '../utils/constants.js'; +import math from '../utils/math.js'; +import { generateDownloadLinks } from '../utils/ui.js'; + +export async function renderPatches(container: HTMLElement, mirrorFileDb: MirrorFileEntry[]) { + for (const target of gameTargets) { + const url = `${BASE_URL}/akEndfield/launcher/game/${target.dirName}/all_patch.json`; + try { + const data = await fetchJson[]>(url); + if (data.length === 0) continue; + + const section = document.createElement('div'); + section.className = 'mb-5'; + section.innerHTML = `

${target.region === 'cn' ? 'China' : 'Global'}, ${target.name}

`; + + const accordion = document.createElement('div'); + accordion.className = 'accordion'; + accordion.id = `accordion-patch-${target.dirName}`; + + let itemIndex = 0; + for (const e of [...data].reverse()) { + if (!e.rsp.patch) continue; + const version = e.rsp.version; + const reqVersion = e.rsp.request_version; + const dateStr = DateTime.fromISO(e.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'); + const packedSize = math.arrayTotal(e.rsp.patch.patches.map((f: any) => parseInt(f.package_size))); + const unpackedSize = parseInt(e.rsp.patch.total_size) - packedSize; + + let rows = ''; + const fileName = (url: string) => new URL(url).pathname.split('/').pop() ?? ''; + if (e.rsp.patch.url) { + rows += ` + ${fileName(e.rsp.patch.url)} + ${e.rsp.patch.md5} + ${math.formatFileSize(parseInt(e.rsp.patch.package_size), FILE_SIZE_OPTS)} + ${generateDownloadLinks(e.rsp.patch.url, mirrorFileDb)} + `; + } + for (const f of e.rsp.patch.patches) { + rows += ` + ${fileName(f.url)} + ${f.md5} + ${math.formatFileSize(parseInt(f.package_size), FILE_SIZE_OPTS)} + ${generateDownloadLinks(f.url, mirrorFileDb)} + `; + } + + const itemId = `patch-${target.dirName}-${itemIndex}`; + const isExpanded = false; + itemIndex++; + + const item = document.createElement('div'); + item.className = 'accordion-item'; + item.innerHTML = ` +

+ +

+
+
+ + + +
Unpacked Size${math.formatFileSize(unpackedSize, FILE_SIZE_OPTS)}
Packed Size${math.formatFileSize(packedSize, FILE_SIZE_OPTS)}
+
+ + + ${rows} +
FileMD5 ChecksumSizeDL
+
+
+
+ `; + accordion.appendChild(item); + } + section.appendChild(accordion); + container.appendChild(section); + } catch (err) { + // Ignore + } + } +} diff --git a/pages/src/assets/ts/renderers/resources.ts b/pages/src/assets/ts/renderers/resources.ts new file mode 100644 index 0000000..378c418 --- /dev/null +++ b/pages/src/assets/ts/renderers/resources.ts @@ -0,0 +1,103 @@ +import { DateTime } from 'luxon'; +import * as semver from 'semver'; +import { fetchJson } from '../api.js'; +import type { StoredData } from '../types.js'; +import { BASE_URL } from '../utils/constants.js'; + +export async function renderResources(container: HTMLElement) { + const platforms = ['Windows', 'Android', 'iOS', 'PlayStation']; + const targets = [ + { region: 'os', channel: 6 }, + { region: 'cn', channel: 1 }, + ]; + + for (const target of targets) { + const section = document.createElement('div'); + section.className = 'mb-5'; + section.innerHTML = `

${target.region === 'cn' ? 'China' : 'Global'}

`; + + const accordion = document.createElement('div'); + accordion.className = 'accordion'; + accordion.id = `accordion-res-${target.region}-${target.channel}`; + let itemIndex = 0; + + for (const platform of platforms) { + const url = `${BASE_URL}/akEndfield/launcher/game_resources/${target.channel}/${platform}/all.json`; + try { + const data = await fetchJson[]>(url); + + // Group by res_version + const resVersionMap = new Map; versions: Set }>(); + for (const e of data) { + const resVer = e.rsp.res_version; + if (!resVersionMap.has(resVer)) { + resVersionMap.set(resVer, { rsp: e, versions: new Set() }); + } + resVersionMap.get(resVer)!.versions.add(e.req.version); + } + + const resVersionSet = Array.from(resVersionMap.values()).map((d) => ({ + resVersion: d.rsp.rsp.res_version, + rsp: d.rsp, + versions: Array.from(d.versions).sort(semver.rcompare), + })); + + let rows = ''; + for (const item of resVersionSet.reverse()) { + // Newest first + const dateStr = DateTime.fromISO(item.rsp.updatedAt).toFormat('yyyy/MM/dd HH:mm:ss'); + const initialRes = item.rsp.rsp.resources.find((e: any) => e.name === 'initial'); + const mainRes = item.rsp.rsp.resources.find((e: any) => e.name === 'main'); + const isKick = JSON.parse(item.rsp.rsp.configs).kick_flag === true; + + rows += ` + ${dateStr} + ${initialRes.version} + ${mainRes.version} + ${isKick ? '✅' : ''} + ${item.versions.join(', ')} + `; + } + + const itemId = `res-${target.region}-${target.channel}-${platform}`; + const isExpanded = false; + itemIndex++; + + const item = document.createElement('div'); + item.className = 'accordion-item'; + item.innerHTML = ` +

+ +

+
+
+
+ + + + + + + + + + + ${rows} +
DateInitialMainKickGame version
+
+
+
+ `; + accordion.appendChild(item); + } catch (err) { + // Ignore + } + } + if (accordion.childElementCount > 0) { + section.appendChild(accordion); + container.appendChild(section); + } + } +} diff --git a/pages/src/assets/ts/types.ts b/pages/src/assets/ts/types.ts new file mode 100644 index 0000000..6647c5d --- /dev/null +++ b/pages/src/assets/ts/types.ts @@ -0,0 +1,11 @@ +export interface MirrorFileEntry { + orig: string; + mirror: string; + origStatus: boolean; +} + +export interface StoredData { + req: any; + rsp: T; + updatedAt: string; +} diff --git a/pages/src/assets/ts/utils/constants.ts b/pages/src/assets/ts/utils/constants.ts index ff8b4c5..7f177e9 100644 --- a/pages/src/assets/ts/utils/constants.ts +++ b/pages/src/assets/ts/utils/constants.ts @@ -1 +1,44 @@ -export default {}; +export const BASE_URL = + 'https://raw.githubusercontent.com/daydreamer-json/ak-endfield-api-archive/refs/heads/main/output'; + +export const FILE_SIZE_OPTS = { + decimals: 2, + decimalPadding: true, + useBinaryUnit: true, + useBitUnit: false, + unitVisible: true, + unit: null, +}; + +export const gameTargets = [ + { name: 'Official', region: 'os' as const, dirName: '6', channel: 6 }, + { name: 'Epic', region: 'os' as const, dirName: '801', channel: 6 }, + { name: 'Google Play', region: 'os' as const, dirName: '802', channel: 6 }, + { name: 'Official', region: 'cn' as const, dirName: '1', channel: 1 }, + { name: 'Bilibili', region: 'cn' as const, dirName: '2', channel: 2 }, +]; + +export const launcherTargets = [ + { id: 'os', apps: ['EndField', 'Official'], channel: 6 }, + { id: 'cn', apps: ['EndField', 'Arknights', 'Official'], channel: 1 }, +]; + +export const launcherWebApiLang = { + os: [ + '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, + cn: ['zh-cn'] as const, +}; diff --git a/pages/src/assets/ts/utils/ui.ts b/pages/src/assets/ts/utils/ui.ts new file mode 100644 index 0000000..9772812 --- /dev/null +++ b/pages/src/assets/ts/utils/ui.ts @@ -0,0 +1,16 @@ +import type { MirrorFileEntry } from '../types.js'; + +export function generateDownloadLinks(url: string, mirrorFileDb: MirrorFileEntry[]) { + const cleanUrl = new URL(url); + cleanUrl.search = ''; + const mirrorEntry = mirrorFileDb.find((g) => g.orig.includes(cleanUrl.toString())); + + const links: string[] = []; + if (!mirrorEntry || mirrorEntry.origStatus === true) { + links.push(`Orig`); + } + if (mirrorEntry) { + links.push(`Mirror`); + } + return links.join(' / '); +}