308 lines
8.6 KiB
TypeScript
Executable File
308 lines
8.6 KiB
TypeScript
Executable File
import { readFile } from "node:fs/promises";
|
|
import { inArray, sql } from "drizzle-orm";
|
|
import { cdnify, checkCdnRefs } from "@/lib/api";
|
|
import { AmberElementMap } from "@/lib/const";
|
|
import { db } from "@/lib/db";
|
|
import { cdn, characters, versions } from "@/lib/db/schema";
|
|
|
|
const exclude = ["Traveler", "Manekin", "Manekina"];
|
|
const extra: Avatar[] = [
|
|
{
|
|
id: "10000005-pyro",
|
|
rank: 5,
|
|
name: "Traveler Pyro",
|
|
element: "Fire",
|
|
weaponType: "WEAPON_SWORD_ONE_HAND",
|
|
region: "MAINACTOR",
|
|
specialProp: "FIGHT_PROP_ATTACK_PERCENT",
|
|
bodyType: "BOY",
|
|
icon: "custom/traveler_pyro.webp:image/webp",
|
|
birthday: [0, 0],
|
|
route: "Pyro Traveler Boy",
|
|
release: 1735758000,
|
|
},
|
|
{
|
|
id: "10000005-hydro",
|
|
rank: 5,
|
|
name: "Traveler Hydro",
|
|
element: "Water",
|
|
weaponType: "WEAPON_SWORD_ONE_HAND",
|
|
region: "MAINACTOR",
|
|
specialProp: "FIGHT_PROP_ATTACK_PERCENT",
|
|
bodyType: "BOY",
|
|
icon: "custom/traveler_hydro.webp:image/webp",
|
|
birthday: [0, 0],
|
|
route: "Hydro Traveler Boy",
|
|
release: 1690146000,
|
|
},
|
|
{
|
|
id: "10000005-anemo",
|
|
rank: 5,
|
|
name: "Traveler Anemo",
|
|
element: "Wind",
|
|
weaponType: "WEAPON_SWORD_ONE_HAND",
|
|
region: "MAINACTOR",
|
|
specialProp: "FIGHT_PROP_ATTACK_PERCENT",
|
|
bodyType: "BOY",
|
|
icon: "custom/traveler_anemo.webp:image/webp",
|
|
birthday: [0, 0],
|
|
route: "Anemo Traveler Boy",
|
|
release: 1601244000,
|
|
},
|
|
{
|
|
id: "10000005-geo",
|
|
rank: 5,
|
|
name: "Traveler Geo",
|
|
element: "Rock",
|
|
weaponType: "WEAPON_SWORD_ONE_HAND",
|
|
region: "MAINACTOR",
|
|
specialProp: "FIGHT_PROP_ATTACK_PERCENT",
|
|
bodyType: "BOY",
|
|
icon: "custom/traveler_geo.webp:image/webp",
|
|
birthday: [0, 0],
|
|
route: "Geo Traveler Boy",
|
|
release: 1601244000,
|
|
},
|
|
{
|
|
id: "10000005-electro",
|
|
rank: 5,
|
|
name: "Traveler Electro",
|
|
element: "Electric",
|
|
weaponType: "WEAPON_SWORD_ONE_HAND",
|
|
region: "MAINACTOR",
|
|
specialProp: "FIGHT_PROP_ATTACK_PERCENT",
|
|
bodyType: "BOY",
|
|
icon: "custom/traveler_electro.webp:image/webp",
|
|
birthday: [0, 0],
|
|
route: "Electro Traveler Boy",
|
|
release: 1626814800,
|
|
},
|
|
{
|
|
id: "10000005-dendro",
|
|
rank: 5,
|
|
name: "Traveler Dendro",
|
|
element: "Grass",
|
|
weaponType: "WEAPON_SWORD_ONE_HAND",
|
|
region: "MAINACTOR",
|
|
specialProp: "FIGHT_PROP_ATTACK_PERCENT",
|
|
bodyType: "BOY",
|
|
icon: "custom/traveler_dendro.webp:image/webp",
|
|
birthday: [0, 0],
|
|
route: "Dendro Traveler Boy",
|
|
release: 1657659600,
|
|
},
|
|
{
|
|
id: "10000005-cyro",
|
|
rank: 5,
|
|
name: "Traveler Cyro",
|
|
element: "Ice",
|
|
weaponType: "WEAPON_SWORD_ONE_HAND",
|
|
region: "MAINACTOR",
|
|
specialProp: "FIGHT_PROP_ATTACK_PERCENT",
|
|
bodyType: "BOY",
|
|
icon: "custom/traveler_cyro.webp:image/webp",
|
|
birthday: [0, 0],
|
|
route: "Cyro Traveler Boy",
|
|
release: 1786496400,
|
|
},
|
|
];
|
|
|
|
async function main() {
|
|
console.log("Fetching Amber data...");
|
|
const [changelog, avatars]: [ChangelogApiResponse, AvatarApiResponse] =
|
|
await Promise.all([
|
|
await fetch("https://buzz.sudloh.com/api/amber/log").then((e) =>
|
|
e.json(),
|
|
),
|
|
await fetch("https://buzz.sudloh.com/api/amber/char").then((e) =>
|
|
e.json(),
|
|
),
|
|
]);
|
|
return await db.transaction(async (tx) => {
|
|
// console.log("Deleting existing versions...");
|
|
// await tx.delete(versions);
|
|
console.log("Creating new versions...");
|
|
const amberVersions = Object.entries(changelog.data).filter(
|
|
(v) => "avatar" in v[1].items,
|
|
);
|
|
const existingVersions = (await tx.select().from(versions)).map(
|
|
(v) => v.id,
|
|
);
|
|
const newVersions = [
|
|
{
|
|
id: "base",
|
|
name: `Base 1.0`,
|
|
},
|
|
...amberVersions.map(([id, ver], i) => ({
|
|
id,
|
|
name: `Amber ${ver.version}`,
|
|
from: amberVersions[i - 1]?.[0] || "base",
|
|
})),
|
|
].filter((v) => !existingVersions.includes(v.id));
|
|
const createdVersions = newVersions.length
|
|
? await tx
|
|
.insert(versions)
|
|
.values(newVersions)
|
|
.returning({ id: versions.id })
|
|
.then((v) => v.map((v) => v.id))
|
|
: [];
|
|
console.log(
|
|
createdVersions.map((v) => `- ${v}`).join("\n") || "- None created.",
|
|
);
|
|
process.stdout.write("Calculating characters... ");
|
|
const existingChars = await tx
|
|
.select({ name: characters.name })
|
|
.from(characters)
|
|
.then((l) => l.map((c) => c.name));
|
|
existingChars.push(...exclude);
|
|
const newChars = [...Object.values(avatars.data.items), ...extra].filter(
|
|
(c) => !existingChars.includes(c.name),
|
|
);
|
|
process.stdout.write(
|
|
`${newChars.length} new characters to be added.\nSyncing characters...`,
|
|
);
|
|
const total = Object.keys(newChars).length;
|
|
var done = 0;
|
|
const pros = [];
|
|
function progress<K>(p: K): K {
|
|
done += 1 / 3;
|
|
process.stdout.write(
|
|
`\rSyncing characters... ${Math.round(done)}/${total} (${Math.floor(
|
|
(done / total) * 100,
|
|
)}%)`,
|
|
);
|
|
return p;
|
|
}
|
|
async function handle(char: Avatar, order: number) {
|
|
const customFile = char.icon.match(customImageRegex);
|
|
await tx
|
|
.insert(characters)
|
|
.values({
|
|
amber: char.id.toString(),
|
|
image: await cdnify(
|
|
customFile
|
|
? new Blob(
|
|
[
|
|
new Uint8Array(
|
|
await readFile(`${__dirname}/img/${customFile[1]}`).then(
|
|
progress,
|
|
),
|
|
),
|
|
],
|
|
{
|
|
type: customFile[2] || "image/png",
|
|
},
|
|
)
|
|
: await fetch(
|
|
`https://gi.yatta.moe/assets/UI/${
|
|
char.icon || "UI_AvatarIcon_PlayerBoy"
|
|
}.png`,
|
|
)
|
|
.then((r) =>
|
|
r.ok
|
|
? progress(r)
|
|
: Promise.reject(
|
|
new Error(`Failed to fetch image for ${char.name}`),
|
|
),
|
|
)
|
|
.then(toFile),
|
|
{ tx, name: customFile?.[1] },
|
|
).then(progress),
|
|
name: char.name,
|
|
vision: AmberElementMap[char.element],
|
|
stars: char.rank as 4 | 5,
|
|
version:
|
|
amberVersions.find(([, d]) =>
|
|
d.items.avatar
|
|
.map((e) => e.toString())
|
|
.includes(char.id.toString()),
|
|
)?.[0] || "base",
|
|
weapon: char.weaponType,
|
|
order,
|
|
id: char.name.toLowerCase().replace(/ /g, "_"),
|
|
})
|
|
.onConflictDoNothing();
|
|
}
|
|
let [{ maxOrder: i }] = await db
|
|
.select({ maxOrder: sql<number>`MAX(${characters.order})` })
|
|
.from(characters);
|
|
for (const char of newChars) {
|
|
i += 10;
|
|
process.stdout.write(
|
|
`\rSyncing characters... ${char.name} `,
|
|
);
|
|
pros.push(handle(char, i).then(progress));
|
|
}
|
|
await Promise.all(pros);
|
|
|
|
console.log("\nListing dangling CDN images...");
|
|
const files = await tx.select({ id: cdn.id }).from(cdn);
|
|
const pendingDelete: string[] = [];
|
|
for (const file of files) {
|
|
if ((await checkCdnRefs(file.id, tx)).length) continue;
|
|
console.log("-", file.id);
|
|
pendingDelete.push(file.id);
|
|
}
|
|
if (!pendingDelete.length) console.log("- Found none.");
|
|
console.log("Deleting dangling CDN images...");
|
|
await tx.delete(cdn).where(inArray(cdn.id, pendingDelete));
|
|
|
|
console.log("\nDone!");
|
|
});
|
|
}
|
|
|
|
async function toFile(res: Response) {
|
|
return new File(
|
|
[await res.blob()],
|
|
res.headers
|
|
.get("Content-Disposition")
|
|
?.match(/^attachment;.*filename="([a-zA-Z0-9-_ .]+)"/)?.[1] ||
|
|
res.url.match(/\/([a-zA-Z0-9-_ .]+)$/)?.[1] ||
|
|
`file.${res.headers.get("Content-Type")?.split("/")[1] || "dat"}`,
|
|
);
|
|
}
|
|
|
|
const customImageRegex = /^custom\/([a-z0-9_.-]+)(?::([a-z]+\/[a-z]+))/im;
|
|
|
|
if (import.meta.main) main();
|
|
|
|
export interface AvatarApiResponse {
|
|
response: number;
|
|
data: AvatarApiData;
|
|
}
|
|
|
|
export interface AvatarApiData {
|
|
props: { [key: string]: string };
|
|
types: { [key: string]: string };
|
|
items: { [key: string]: Avatar };
|
|
}
|
|
|
|
export interface Avatar {
|
|
id: number | string;
|
|
rank: number;
|
|
name: string;
|
|
element: string;
|
|
weaponType: string;
|
|
region: string;
|
|
specialProp: string;
|
|
bodyType: string;
|
|
icon: string;
|
|
birthday: number[];
|
|
release: number;
|
|
route: string;
|
|
}
|
|
|
|
export interface ChangelogApiResponse {
|
|
response: number;
|
|
data: { [ver: string]: ChangelogVersion };
|
|
}
|
|
|
|
export interface ChangelogVersion {
|
|
items: {
|
|
avatar: Avatar["id"][];
|
|
};
|
|
version: string;
|
|
}
|
|
|
|
export const syncAmber = main;
|