mirror of
https://github.com/zkldi/Tachi.git
synced 2026-09-22 23:18:05 +03:00
feat: propogate lyko's fixes from lr2ir-dataset (#1644)
This commit is contained in:
@@ -133,6 +133,7 @@
|
||||
"name": "tachi-common",
|
||||
"version": "2.4.0-dev",
|
||||
"dependencies": {
|
||||
"bms-table-loader": "catalog:",
|
||||
"prudence": "catalog:",
|
||||
"zod": "catalog:",
|
||||
},
|
||||
@@ -293,7 +294,6 @@
|
||||
"@types/lodash.get": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"binary-parser": "catalog:",
|
||||
"bms-table-loader": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"cheerio": "catalog:",
|
||||
"commander": "catalog:",
|
||||
|
||||
+648
-304
File diff suppressed because it is too large
Load Diff
+16
-16
@@ -149,7 +149,7 @@
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"description": "The Arm-Shougakkou table is A gachi and gachi-ish practice table, Ude0 is approximately equivalent to an sl0",
|
||||
"description": "The Arm-Shougakkou table is a gachi and gachi-ish practice table. Ude0 is approximately equivalent to an sl0",
|
||||
"folders": [
|
||||
"armshougakkou-0",
|
||||
"armshougakkou-1",
|
||||
@@ -293,23 +293,23 @@
|
||||
},
|
||||
{
|
||||
"default": false,
|
||||
"description": "Gachimjoy is a gachi practice table. 双1 is approximately equivalent to an ★★1",
|
||||
"description": "Gachimijoy is a gachi practice table. 双1 is approximately equivalent to an ★★1",
|
||||
"folders": [
|
||||
"gachimjoy-0",
|
||||
"gachimjoy-1",
|
||||
"gachimjoy-2",
|
||||
"gachimjoy-3",
|
||||
"gachimjoy-4",
|
||||
"gachimjoy-5",
|
||||
"gachimjoy-6",
|
||||
"gachimjoy-7",
|
||||
"gachimjoy-8"
|
||||
"gachimijoy-0",
|
||||
"gachimijoy-1",
|
||||
"gachimijoy-2",
|
||||
"gachimijoy-3",
|
||||
"gachimijoy-4",
|
||||
"gachimijoy-5",
|
||||
"gachimijoy-6",
|
||||
"gachimijoy-7",
|
||||
"gachimijoy-8"
|
||||
],
|
||||
"game": "bms-7k",
|
||||
"id": "T19d35f0d592e33c0d44",
|
||||
"id": "T19e9d2d406937f13eb2",
|
||||
"inactive": false,
|
||||
"legacyTableID": "bms-7K-gachimjoy",
|
||||
"title": "Gachimjoy"
|
||||
"legacyTableID": "bms-7K-gachimijoy",
|
||||
"title": "Gachimijoy"
|
||||
},
|
||||
{
|
||||
"default": true,
|
||||
@@ -501,7 +501,6 @@
|
||||
"starlight-present",
|
||||
"lnoverjoy-present",
|
||||
"luminous-present",
|
||||
"gachimjoy-present",
|
||||
"delayjoy-present",
|
||||
"armshougakkou-present",
|
||||
"exoplanet-present",
|
||||
@@ -510,7 +509,8 @@
|
||||
"solar-present",
|
||||
"supernova-present",
|
||||
"csst-present",
|
||||
"cssl-present"
|
||||
"cssl-present",
|
||||
"gachimijoy-present"
|
||||
],
|
||||
"game": "bms-7k",
|
||||
"id": "T19d35f0d592d728c226",
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
"author": "zk",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"bms-table-loader": "catalog:",
|
||||
"prudence": "catalog:",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
|
||||
@@ -32,7 +32,7 @@ export const BMS_TABLES: Array<BMSTableInfo> = [
|
||||
name: "Insane",
|
||||
game: "bms-7k",
|
||||
description: "The 7K GENOSIDE insane table.",
|
||||
url: "https://darksabun.github.io/table/archive/insane1/",
|
||||
url: "https://darksabun.club/table/archive/insane1",
|
||||
prefix: "★",
|
||||
asciiPrefix: "insane",
|
||||
colour: COLOUR_SET.red,
|
||||
@@ -41,7 +41,7 @@ export const BMS_TABLES: Array<BMSTableInfo> = [
|
||||
name: "Normal",
|
||||
game: "bms-7k",
|
||||
description: "The 7K GENOSIDE normal table.",
|
||||
url: "https://darksabun.github.io/table/archive/normal1/",
|
||||
url: "https://darksabun.club/table/archive/normal1",
|
||||
prefix: "☆",
|
||||
asciiPrefix: "normal",
|
||||
colour: COLOUR_SET.paleGreen,
|
||||
@@ -113,7 +113,7 @@ export const BMS_TABLES: Array<BMSTableInfo> = [
|
||||
name: "DP Satellite",
|
||||
description: "The 14K Satellite table.",
|
||||
game: "bms-14k",
|
||||
prefix: "sl",
|
||||
prefix: "DPsl",
|
||||
asciiPrefix: "dpSatellite",
|
||||
url: "https://stellabms.xyz/dp/table.html",
|
||||
colour: COLOUR_SET.vibrantBlue,
|
||||
@@ -175,14 +175,14 @@ export const BMS_TABLES: Array<BMSTableInfo> = [
|
||||
colour: COLOUR_SET.purple,
|
||||
},
|
||||
{
|
||||
name: "Gachimjoy",
|
||||
name: "Gachimijoy",
|
||||
prefix: "双",
|
||||
asciiPrefix: "gachimjoy",
|
||||
asciiPrefix: "gachimijoy",
|
||||
game: "bms-7k",
|
||||
url: "http://su565fx.web.fc2.com/Gachimijoy/gachimijoy.html",
|
||||
url: "https://yeslyko.github.io/gachimijoy-mirror/",
|
||||
notDefault: true,
|
||||
description:
|
||||
"Gachimjoy is a gachi practice table. 双1 is approximately equivalent to an ★★1",
|
||||
"Gachimijoy is a gachi practice table. 双1 is approximately equivalent to an ★★1",
|
||||
},
|
||||
{
|
||||
name: "delayjoy",
|
||||
|
||||
@@ -7,6 +7,7 @@ export * from "./constants/game";
|
||||
export * from "./constants/grade-boundaries";
|
||||
export * as StaticConfig from "./constants/import-types";
|
||||
export * from "./constants/permissions";
|
||||
export * from "./lib/bmstable-load";
|
||||
export * from "./lib/folder-slug";
|
||||
export * as Schemas from "./lib/schemas";
|
||||
export * from "./lib/zod-schemas";
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { type BmstableFetch, resolveBMSTableUrl } from "./bmstable-load";
|
||||
|
||||
function mockFetch(response: {
|
||||
body: string;
|
||||
contentType?: string;
|
||||
ok?: boolean;
|
||||
status?: number;
|
||||
url?: string;
|
||||
}): BmstableFetch {
|
||||
return async (url) => ({
|
||||
ok: response.ok ?? true,
|
||||
status: response.status ?? 200,
|
||||
url: response.url ?? url,
|
||||
headers: { get: () => response.contentType ?? "text/html" },
|
||||
text: async () => response.body,
|
||||
});
|
||||
}
|
||||
|
||||
describe("resolveBmstableTableUrl", () => {
|
||||
it("accepts HTML with a bmstable meta tag", async () => {
|
||||
const html = '<html><head><meta name="bmstable" content="{}"></head></html>';
|
||||
|
||||
await expect(
|
||||
resolveBMSTableUrl(
|
||||
"https://example.com/table/",
|
||||
mockFetch({ body: html, url: "https://example.com/table/final" }),
|
||||
),
|
||||
).resolves.toBe("https://example.com/table/final");
|
||||
});
|
||||
|
||||
it("accepts JSON table headers", async () => {
|
||||
await expect(
|
||||
resolveBMSTableUrl(
|
||||
"https://example.com/header.json",
|
||||
mockFetch({
|
||||
body: '{"symbol":"★"}',
|
||||
contentType: "application/json",
|
||||
}),
|
||||
),
|
||||
).resolves.toBe("https://example.com/header.json");
|
||||
});
|
||||
|
||||
it("rejects HTML without a bmstable meta tag", async () => {
|
||||
await expect(
|
||||
resolveBMSTableUrl(
|
||||
"https://example.com/moved/",
|
||||
mockFetch({ body: "<html><script>window.location.replace('x')</script></html>" }),
|
||||
),
|
||||
).rejects.toThrow(/no bmstable meta tag/u);
|
||||
});
|
||||
|
||||
it("returns the input URL when skipRedirect is set", async () => {
|
||||
await expect(
|
||||
resolveBMSTableUrl("https://example.com/table/", mockFetch({ body: "" }), {
|
||||
skipRedirect: true,
|
||||
}),
|
||||
).resolves.toBe("https://example.com/table/");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import { type BMSTable, LoadBMSTable } from "bms-table-loader";
|
||||
|
||||
import type { BMSTableInfo } from "../constants/bms-tables";
|
||||
|
||||
const BMS_TABLE_META_RE = /<meta[\s]+name="bmstable"/u;
|
||||
|
||||
export type BmstableFetchResult = {
|
||||
headers: { get(name: string): string | null };
|
||||
ok: boolean;
|
||||
status: number;
|
||||
text(): Promise<string>;
|
||||
url: string;
|
||||
};
|
||||
|
||||
export type BmstableFetch = (url: string) => Promise<BmstableFetchResult>;
|
||||
|
||||
export type LoadBmstableTableOptions = {
|
||||
skipRedirect?: boolean;
|
||||
};
|
||||
|
||||
function isJsonTableHeader(contentType: string | null, text: string): boolean {
|
||||
if (contentType?.includes("application/json")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return text.trimStart().startsWith("{");
|
||||
}
|
||||
|
||||
function responseLooksLikeBmstablePage(text: string): boolean {
|
||||
return BMS_TABLE_META_RE.test(text);
|
||||
}
|
||||
|
||||
/** Fetch a BMS table URL (following HTTP redirects) and verify the response is a BMS table. */
|
||||
export async function resolveBMSTableUrl(
|
||||
url: string,
|
||||
fetchFn: BmstableFetch,
|
||||
opts?: LoadBmstableTableOptions,
|
||||
): Promise<string> {
|
||||
if (opts?.skipRedirect) {
|
||||
return url;
|
||||
}
|
||||
|
||||
const res = await fetchFn(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch BMS table URL ${url}: HTTP ${res.status}.`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const resolvedHttpUrl = res.url;
|
||||
|
||||
if (
|
||||
isJsonTableHeader(res.headers.get("content-type"), text) ||
|
||||
responseLooksLikeBmstablePage(text)
|
||||
) {
|
||||
return resolvedHttpUrl;
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`BMS table URL ${url} (resolved to ${resolvedHttpUrl}) has no bmstable meta tag.`,
|
||||
);
|
||||
}
|
||||
|
||||
/** Resolve redirects, load a BMS table, and verify its symbol matches {@link BMSTableInfo.prefix}. */
|
||||
export async function ParseAndLoadBMSTable(
|
||||
tableInfo: BMSTableInfo,
|
||||
fetchFn: BmstableFetch,
|
||||
opts?: LoadBmstableTableOptions,
|
||||
): Promise<{ loadUrl: string; table: BMSTable }> {
|
||||
const loadUrl = await resolveBMSTableUrl(tableInfo.url, fetchFn, opts);
|
||||
const table = await LoadBMSTable(loadUrl);
|
||||
|
||||
if (table.head.symbol !== tableInfo.prefix) {
|
||||
throw new Error(
|
||||
`Table ${tableInfo.name} (${tableInfo.url}) has unexpected symbol: expected ${JSON.stringify(tableInfo.prefix)}, got ${JSON.stringify(table.head.symbol)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return { loadUrl, table };
|
||||
}
|
||||
@@ -24,7 +24,6 @@
|
||||
"@types/lodash.get": "catalog:",
|
||||
"@types/node": "catalog:",
|
||||
"binary-parser": "catalog:",
|
||||
"bms-table-loader": "catalog:",
|
||||
"chalk": "catalog:",
|
||||
"cheerio": "catalog:",
|
||||
"commander": "catalog:",
|
||||
|
||||
@@ -1,128 +1,219 @@
|
||||
import { log } from "#log";
|
||||
import { LoadBMSTable } from "bms-table-loader";
|
||||
import nodeFetch from "node-fetch";
|
||||
import {
|
||||
BMS_TABLES,
|
||||
type BMSGames,
|
||||
type BMSTableInfo,
|
||||
type FolderDocument,
|
||||
type TableDocument,
|
||||
type BmstableFetch,
|
||||
computeFolderSlug,
|
||||
type SEEDS_FolderDocument,
|
||||
type SEEDS_TableDocument,
|
||||
ParseAndLoadBMSTable,
|
||||
} from "tachi-common";
|
||||
|
||||
import { CreateLegacyFolderIDFromFolder, MutateCollection, ReadCollection } from "../../util";
|
||||
import { Random20Hex } from "../../../server/src/utils/misc";
|
||||
import { CreateFolderID, MutateCollection, ReadCollection } from "../../util";
|
||||
|
||||
const existsTables = ReadCollection("tables.json").map((e) => e.tableID);
|
||||
const existsFolders = ReadCollection("folders.json").map((e) => e.folderID);
|
||||
const fetchBMSTable: BmstableFetch = async (url) => {
|
||||
const res = await nodeFetch(url);
|
||||
return {
|
||||
ok: res.ok,
|
||||
status: res.status,
|
||||
url: res.url,
|
||||
headers: { get: (name) => res.headers.get(name) },
|
||||
text: () => res.text(),
|
||||
};
|
||||
};
|
||||
|
||||
async function UpdateTable(tableInfo: BMSTableInfo) {
|
||||
const tableID = `bms-${tableInfo.game}-${tableInfo.asciiPrefix}`;
|
||||
function bmsPlaytype(game: BMSGames): "7K" | "14K" {
|
||||
return game === "bms-7k" ? "7K" : "14K";
|
||||
}
|
||||
|
||||
if (existsTables.includes(tableID)) {
|
||||
return;
|
||||
function legacyTableID(tableInfo: BMSTableInfo): string {
|
||||
return `bms-${bmsPlaytype(tableInfo.game)}-${tableInfo.asciiPrefix}`;
|
||||
}
|
||||
|
||||
function escapeSqlStringLiteral(value: string): string {
|
||||
return value.replaceAll("'", "''");
|
||||
}
|
||||
|
||||
function levelWhere(prefix: string, level: string | number): string {
|
||||
const escapedPrefix = escapeSqlStringLiteral(prefix);
|
||||
const escapedLevel = escapeSqlStringLiteral(String(level));
|
||||
return `(chart.data->'tableFolders'->>'${escapedPrefix}') = '${escapedLevel}'`;
|
||||
}
|
||||
|
||||
function presentWhere(prefix: string): string {
|
||||
const escapedPrefix = escapeSqlStringLiteral(prefix);
|
||||
return `(chart.data->'tableFolders') ? '${escapedPrefix}'`;
|
||||
}
|
||||
|
||||
function levelSearchTerm(tableInfo: BMSTableInfo, level: string | number): string {
|
||||
return `${tableInfo.name} ${level}`;
|
||||
}
|
||||
|
||||
function isExcludedSubLevel(level: string | number, slug: string): boolean {
|
||||
const levelText = String(level);
|
||||
return levelText.includes("sub") || slug.includes("sub");
|
||||
}
|
||||
|
||||
type FolderSyncFields = Pick<SEEDS_FolderDocument, "searchTerms" | "title" | "where">;
|
||||
|
||||
function levelFolderFields(tableInfo: BMSTableInfo, level: string | number): FolderSyncFields {
|
||||
return {
|
||||
searchTerms: [levelSearchTerm(tableInfo, level)],
|
||||
title: `${tableInfo.prefix}${level}`,
|
||||
where: levelWhere(tableInfo.prefix, level),
|
||||
};
|
||||
}
|
||||
|
||||
function presentFolderFields(tableInfo: BMSTableInfo): FolderSyncFields {
|
||||
return {
|
||||
searchTerms: [tableInfo.asciiPrefix],
|
||||
title: tableInfo.name,
|
||||
where: presentWhere(tableInfo.prefix),
|
||||
};
|
||||
}
|
||||
|
||||
function buildLevelFolder(tableInfo: BMSTableInfo, level: string | number): SEEDS_FolderDocument {
|
||||
const fields = levelFolderFields(tableInfo, level);
|
||||
const folder: SEEDS_FolderDocument = {
|
||||
game: tableInfo.game,
|
||||
id: CreateFolderID(),
|
||||
inactive: false,
|
||||
legacyFolderID: Random20Hex(),
|
||||
slug: "",
|
||||
...fields,
|
||||
};
|
||||
folder.slug = computeFolderSlug(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
function buildPresentFolder(tableInfo: BMSTableInfo): SEEDS_FolderDocument {
|
||||
const fields = presentFolderFields(tableInfo);
|
||||
const folder: SEEDS_FolderDocument = {
|
||||
game: tableInfo.game,
|
||||
id: CreateFolderID(),
|
||||
inactive: false,
|
||||
legacyFolderID: Random20Hex(),
|
||||
slug: "",
|
||||
...fields,
|
||||
};
|
||||
folder.slug = computeFolderSlug(folder);
|
||||
return folder;
|
||||
}
|
||||
|
||||
function applyFolderSyncFields(existing: SEEDS_FolderDocument, fields: FolderSyncFields): boolean {
|
||||
const unchanged =
|
||||
existing.title === fields.title &&
|
||||
existing.where === fields.where &&
|
||||
existing.searchTerms.length === fields.searchTerms.length &&
|
||||
existing.searchTerms.every((term, i) => term === fields.searchTerms[i]);
|
||||
|
||||
if (unchanged) {
|
||||
return false;
|
||||
}
|
||||
|
||||
log.info(`Fetching ${tableInfo.url} (${tableInfo.name})...`);
|
||||
const table = await LoadBMSTable(tableInfo.url);
|
||||
log.info(`Fetched.`);
|
||||
existing.title = fields.title;
|
||||
existing.where = fields.where;
|
||||
existing.searchTerms = fields.searchTerms;
|
||||
return true;
|
||||
}
|
||||
|
||||
const levels = table.getLevelOrder();
|
||||
function folderKey(game: string, slug: string): string {
|
||||
return `${game}:${slug}`;
|
||||
}
|
||||
|
||||
const folders: Array<FolderDocument> = [];
|
||||
function isBmsGame(game: BMSTableInfo["game"]): game is BMSGames {
|
||||
return game === "bms-7k" || game === "bms-14k";
|
||||
}
|
||||
|
||||
for (const level of levels) {
|
||||
const f: Omit<FolderDocument, "folderID"> = {
|
||||
title: `${tableInfo.prefix}${level}`,
|
||||
playtype: tableInfo.game === "bms-7k" ? "7K" : "14K",
|
||||
game: "bms",
|
||||
searchTerms: [],
|
||||
type: "charts",
|
||||
data: {
|
||||
"data¬tableFolders": {
|
||||
"~elemMatch": {
|
||||
level: level.toString(),
|
||||
table: tableInfo.prefix,
|
||||
},
|
||||
},
|
||||
},
|
||||
inactive: false,
|
||||
};
|
||||
async function syncBmsTableFolders(): Promise<void> {
|
||||
const folders = ReadCollection("folders.json") as Array<SEEDS_FolderDocument>;
|
||||
const tables = ReadCollection("tables.json") as Array<SEEDS_TableDocument>;
|
||||
|
||||
const folderID = CreateLegacyFolderIDFromFolder(f);
|
||||
const folderByKey = new Map(folders.map((f) => [folderKey(f.game, f.slug), f]));
|
||||
const tableByLegacyId = new Map(tables.map((t) => [t.legacyTableID, t]));
|
||||
|
||||
const realFolder = {
|
||||
...f,
|
||||
folderID,
|
||||
} as FolderDocument;
|
||||
|
||||
if (existsFolders.includes(folderID)) {
|
||||
for (const tableInfo of BMS_TABLES) {
|
||||
if (!isBmsGame(tableInfo.game)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
folders.push(realFolder);
|
||||
try {
|
||||
log.info(`Fetching ${tableInfo.url} (${tableInfo.name})...`);
|
||||
const { loadUrl, table } = await ParseAndLoadBMSTable(tableInfo, fetchBMSTable);
|
||||
if (loadUrl !== tableInfo.url) {
|
||||
log.info(`Resolved ${tableInfo.name} URL: ${tableInfo.url} -> ${loadUrl}`);
|
||||
}
|
||||
|
||||
log.info(`Inserted new folder ${tableInfo.prefix}${level}.`);
|
||||
}
|
||||
const existingTable = tableByLegacyId.get(legacyTableID(tableInfo));
|
||||
const isNewTable = existingTable === undefined;
|
||||
const curatedFolderSlugs = new Set(existingTable?.folders ?? []);
|
||||
|
||||
MutateCollection("folders.json", (f) => {
|
||||
f.push(...folders);
|
||||
return f;
|
||||
});
|
||||
for (const level of table.getLevelOrder()) {
|
||||
const fields = levelFolderFields(tableInfo, level);
|
||||
const slug = computeFolderSlug({
|
||||
game: tableInfo.game,
|
||||
id: "",
|
||||
slug: "",
|
||||
...fields,
|
||||
});
|
||||
if (isExcludedSubLevel(level, slug)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
MutateCollection("tables.json", (t: Array<TableDocument>) => {
|
||||
t.push({
|
||||
folders: folders.map((e) => e.folderID),
|
||||
game: "bms",
|
||||
default: false,
|
||||
playtype: tableInfo.game === "bms-7k" ? "7K" : "14K",
|
||||
inactive: false,
|
||||
description: tableInfo.description,
|
||||
title: tableInfo.name,
|
||||
tableID: tableID,
|
||||
});
|
||||
return t;
|
||||
});
|
||||
const key = folderKey(tableInfo.game, slug);
|
||||
const existing = folderByKey.get(key);
|
||||
const inCuratedTable = isNewTable || curatedFolderSlugs.has(slug);
|
||||
|
||||
log.info(`Bumped table ${tableInfo.name}.`);
|
||||
if (existing && inCuratedTable) {
|
||||
if (applyFolderSyncFields(existing, fields)) {
|
||||
log.info(`Updated folder ${existing.slug} (${existing.title}).`);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
log.info(`Checking meta-folder...`);
|
||||
|
||||
const f = {
|
||||
title: tableInfo.name,
|
||||
playtype: tableInfo.game === "bms-7k" ? "7K" : "14K",
|
||||
game: "bms",
|
||||
searchTerms: [tableInfo.asciiPrefix],
|
||||
type: "charts",
|
||||
data: {
|
||||
"data¬tableFolders¬table": tableInfo.prefix,
|
||||
},
|
||||
inactive: false,
|
||||
};
|
||||
|
||||
const folderID = CreateLegacyFolderIDFromFolder(f);
|
||||
|
||||
const realFolder = {
|
||||
...f,
|
||||
folderID,
|
||||
} as FolderDocument;
|
||||
|
||||
// add this to meta table.
|
||||
if (!existsFolders.includes(folderID)) {
|
||||
MutateCollection("tables.json", (tables) => {
|
||||
for (const table of tables) {
|
||||
if (table.tableID === `bms-${tableInfo.game}-meta`) {
|
||||
table.folders.push(folderID);
|
||||
if (!existing && inCuratedTable) {
|
||||
const candidate = buildLevelFolder(tableInfo, level);
|
||||
folders.push(candidate);
|
||||
folderByKey.set(key, candidate);
|
||||
log.info(`Inserted folder ${candidate.slug} (${candidate.title}).`);
|
||||
}
|
||||
}
|
||||
|
||||
return tables;
|
||||
});
|
||||
const presentFields = presentFolderFields(tableInfo);
|
||||
const presentSlug = computeFolderSlug({
|
||||
game: tableInfo.game,
|
||||
id: "",
|
||||
slug: "",
|
||||
...presentFields,
|
||||
});
|
||||
const presentKey = folderKey(tableInfo.game, presentSlug);
|
||||
const existingPresent = folderByKey.get(presentKey);
|
||||
const inCuratedPresent = isNewTable || curatedFolderSlugs.has(presentSlug);
|
||||
|
||||
MutateCollection("folders.json", (folders) => [...folders, realFolder]);
|
||||
if (existingPresent && inCuratedPresent) {
|
||||
if (applyFolderSyncFields(existingPresent, presentFields)) {
|
||||
log.info(
|
||||
`Updated meta folder ${existingPresent.slug} (${existingPresent.title}).`,
|
||||
);
|
||||
}
|
||||
} else if (!existingPresent && inCuratedPresent) {
|
||||
const presentCandidate = buildPresentFolder(tableInfo);
|
||||
folders.push(presentCandidate);
|
||||
folderByKey.set(presentKey, presentCandidate);
|
||||
log.info(
|
||||
`Inserted meta folder ${presentCandidate.slug} (${presentCandidate.title}).`,
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
log.error(`Failed to sync ${tableInfo.name} (${tableInfo.url}): ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
log.info(`Done.`);
|
||||
MutateCollection("folders.json", () => folders);
|
||||
log.info("Done.");
|
||||
}
|
||||
|
||||
(async () => {
|
||||
for (const table of BMS_TABLES) {
|
||||
await UpdateTable(table);
|
||||
}
|
||||
})();
|
||||
void syncBmsTableFolders();
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"allowJs": true,
|
||||
"strict": false,
|
||||
"paths": {
|
||||
"#*": ["./src/*"]
|
||||
"#*": ["./*", "./*.ts", "./*.js"]
|
||||
}
|
||||
},
|
||||
"exclude": ["node_modules", "rerunners/**"]
|
||||
|
||||
@@ -4,7 +4,7 @@ import { seedUser } from "#test-utils/pg-fixtures";
|
||||
import { CreateChartID } from "tachi-common";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { ACTION_BMSTableSync, extractBmstableRedirectTarget } from "./bms-table-sync";
|
||||
import { ACTION_BMSTableSync } from "./bms-table-sync";
|
||||
|
||||
const { testMd5, mockLoadBMSTable, fakeTable } = vi.hoisted(() => {
|
||||
const md5 = "c".repeat(32);
|
||||
@@ -34,42 +34,6 @@ vi.mock("tachi-common", async (importOriginal) => {
|
||||
};
|
||||
});
|
||||
|
||||
describe("extractBmstableRedirectTarget", () => {
|
||||
it("resolves darksabun github.io migration stubs to darksabun.club", () => {
|
||||
const html = `<!doctype html>
|
||||
<script>
|
||||
var newDomain = "https://darksabun.club";
|
||||
window.location.replace(newDomain + window.location.pathname);
|
||||
</script>`;
|
||||
|
||||
expect(
|
||||
extractBmstableRedirectTarget(
|
||||
"https://darksabun.github.io/table/archive/insane1/",
|
||||
html,
|
||||
),
|
||||
).toBe("https://darksabun.club/table/archive/insane1/");
|
||||
});
|
||||
|
||||
it("preserves the source path for bare-domain meta refresh targets", () => {
|
||||
const html = '<meta http-equiv="refresh" content="0; url=https://darksabun.club" />';
|
||||
|
||||
expect(
|
||||
extractBmstableRedirectTarget(
|
||||
"https://darksabun.github.io/table/archive/normal1/",
|
||||
html,
|
||||
),
|
||||
).toBe("https://darksabun.club/table/archive/normal1/");
|
||||
});
|
||||
|
||||
it("follows absolute window.location.replace targets", () => {
|
||||
const html = `window.location.replace("https://example.com/table/header.json");`;
|
||||
|
||||
expect(extractBmstableRedirectTarget("https://old.example/table/", html)).toBe(
|
||||
"https://example.com/table/header.json",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("ACTION_BMSTableSync", () => {
|
||||
const songNewID = "song-bms-action-test";
|
||||
const chartId = CreateChartID();
|
||||
|
||||
@@ -11,7 +11,7 @@ import { FormatBMSTables } from "#utils/misc";
|
||||
import { FindBMSChartOnHashInGame } from "#utils/queries/charts";
|
||||
import { IsUserAdmin } from "#utils/user";
|
||||
import { ExpectedErr } from "bliss";
|
||||
import { type BMSTableEntry, LoadBMSTable } from "bms-table-loader";
|
||||
import { type BMSTableEntry } from "bms-table-loader";
|
||||
import { sql } from "kysely";
|
||||
import _ from "lodash";
|
||||
import {
|
||||
@@ -21,96 +21,13 @@ import {
|
||||
type ChartDocument,
|
||||
type ChartDocumentData,
|
||||
GameToGameGroup,
|
||||
ParseAndLoadBMSTable,
|
||||
} from "tachi-common";
|
||||
|
||||
const UPDATE_CHUNK = 500;
|
||||
|
||||
const BMS_TABLE_META_RE = /<meta[\s]+name="bmstable"/u;
|
||||
|
||||
function isJsonTableHeader(contentType: string | null, text: string): boolean {
|
||||
if (contentType?.includes("application/json")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return text.trimStart().startsWith("{");
|
||||
}
|
||||
|
||||
function responseLooksLikeBmstablePage(text: string): boolean {
|
||||
return BMS_TABLE_META_RE.test(text);
|
||||
}
|
||||
|
||||
/**
|
||||
* Best-effort extraction of redirect targets from HTML migration stubs.
|
||||
* Handles meta refresh, `window.location.*` literals, and `newDomain + pathname` patterns.
|
||||
*/
|
||||
export function extractBmstableRedirectTarget(sourceUrl: string, html: string): string | null {
|
||||
const newDomainMatch = /(?:var|let|const)\s+newDomain\s*=\s*["']([^"']+)["']/u.exec(html);
|
||||
if (newDomainMatch?.[1]) {
|
||||
const source = new URL(sourceUrl);
|
||||
return new URL(source.pathname + source.search + source.hash, newDomainMatch[1]).href;
|
||||
}
|
||||
|
||||
const locationAssignMatch =
|
||||
/window\.location\.(?:replace|href\s*=)\(\s*["']([^"']+)["']\s*\)/u.exec(html) ??
|
||||
/window\.location\.(?:replace|href\s*=)\s*=\s*["']([^"']+)["']/u.exec(html);
|
||||
if (locationAssignMatch?.[1]) {
|
||||
return new URL(locationAssignMatch[1], sourceUrl).href;
|
||||
}
|
||||
|
||||
const refreshMatch =
|
||||
/<meta[^>]*http-equiv=["']refresh["'][^>]*content=["']([^"']+)["']/iu.exec(html) ??
|
||||
/<meta[^>]*content=["']([^"']+)["'][^>]*http-equiv=["']refresh["']/iu.exec(html);
|
||||
if (refreshMatch?.[1]) {
|
||||
const urlPart = refreshMatch[1].match(/url\s*=\s*(.+)$/iu)?.[1]?.trim();
|
||||
if (urlPart) {
|
||||
const target = new URL(urlPart.replace(/^['"]|['"]$/gu, ""), sourceUrl);
|
||||
const source = new URL(sourceUrl);
|
||||
if ((target.pathname === "/" || target.pathname === "") && source.pathname !== "/") {
|
||||
target.pathname = source.pathname;
|
||||
target.search = source.search;
|
||||
target.hash = source.hash;
|
||||
}
|
||||
return target.href;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Follow HTTP redirects and HTML/JS migration stubs until a BMS table page is reachable. */
|
||||
async function resolveBmstableTableUrl(url: string, hopsLeft = 5): Promise<string> {
|
||||
if (Env.NODE_ENV === "test") {
|
||||
return url;
|
||||
}
|
||||
|
||||
if (hopsLeft <= 0) {
|
||||
throw new Error(`Too many redirects while resolving BMS table URL ${url}.`);
|
||||
}
|
||||
|
||||
const res = await fetch(url);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to fetch BMS table URL ${url}: HTTP ${res.status}.`);
|
||||
}
|
||||
|
||||
const text = await res.text();
|
||||
const resolvedHttpUrl = res.url;
|
||||
|
||||
if (
|
||||
isJsonTableHeader(res.headers.get("content-type"), text) ||
|
||||
responseLooksLikeBmstablePage(text)
|
||||
) {
|
||||
return resolvedHttpUrl;
|
||||
}
|
||||
|
||||
const redirectTarget = extractBmstableRedirectTarget(url, text);
|
||||
if (!redirectTarget || redirectTarget === url) {
|
||||
return resolvedHttpUrl;
|
||||
}
|
||||
|
||||
log.info({ from: url, to: redirectTarget }, "Following BMS table redirect.");
|
||||
return resolveBmstableTableUrl(redirectTarget, hopsLeft - 1);
|
||||
}
|
||||
|
||||
/**
|
||||
* When `LoadBMSTable` fails, re-fetch the URL and log response shape hints
|
||||
* (redirect stubs, missing bmstable meta tag, etc.).
|
||||
@@ -121,27 +38,6 @@ async function logBmstableLoadFailureDebug(tableInfo: BMSTableInfo, err: unknown
|
||||
const text = await res.text();
|
||||
const contentType = res.headers.get("content-type");
|
||||
const hasBmstableMeta = BMS_TABLE_META_RE.test(text);
|
||||
const looksLikeJsRedirect = /window\.location\.(?:replace|href)/u.test(text);
|
||||
const looksLikeMovedPage =
|
||||
/site has moved|사이트가 이전되었습니다|Redirecting\.\.\./iu.test(text);
|
||||
|
||||
const hints: Array<string> = [];
|
||||
if (res.url !== tableInfo.url) {
|
||||
hints.push(`HTTP redirect resolved to ${res.url}.`);
|
||||
}
|
||||
if (!hasBmstableMeta && looksLikeJsRedirect) {
|
||||
hints.push(
|
||||
"Response looks like a JavaScript redirect page, but no redirect target could be resolved.",
|
||||
);
|
||||
}
|
||||
if (!hasBmstableMeta && looksLikeMovedPage) {
|
||||
hints.push(
|
||||
"Response looks like a site-migration landing page without a bmstable meta tag.",
|
||||
);
|
||||
}
|
||||
if (!hasBmstableMeta && !looksLikeJsRedirect && !looksLikeMovedPage) {
|
||||
hints.push("Response is HTML but has no bmstable meta tag.");
|
||||
}
|
||||
|
||||
log.error(
|
||||
{
|
||||
@@ -155,9 +51,6 @@ async function logBmstableLoadFailureDebug(tableInfo: BMSTableInfo, err: unknown
|
||||
contentType,
|
||||
responseBytes: text.length,
|
||||
hasBmstableMeta,
|
||||
looksLikeJsRedirect,
|
||||
looksLikeMovedPage,
|
||||
hints,
|
||||
responsePreview: text.slice(0, 500).replace(/\s+/gu, " "),
|
||||
},
|
||||
`BMS table load diagnostics for ${tableInfo.name} (${tableInfo.url}).`,
|
||||
@@ -353,25 +246,21 @@ async function ImportTableLevels(
|
||||
export async function UpdateTable(tableInfo: BMSTableInfo) {
|
||||
let table;
|
||||
try {
|
||||
const loadUrl = await resolveBmstableTableUrl(tableInfo.url);
|
||||
if (loadUrl !== tableInfo.url) {
|
||||
const result = await ParseAndLoadBMSTable(tableInfo, fetch, {
|
||||
skipRedirect: Env.NODE_ENV === "test",
|
||||
});
|
||||
if (result.loadUrl !== tableInfo.url) {
|
||||
log.info(
|
||||
{ tableName: tableInfo.name, from: tableInfo.url, to: loadUrl },
|
||||
{ tableName: tableInfo.name, from: tableInfo.url, to: result.loadUrl },
|
||||
"Resolved BMS table URL after redirect.",
|
||||
);
|
||||
}
|
||||
table = await LoadBMSTable(loadUrl);
|
||||
table = result.table;
|
||||
} catch (err) {
|
||||
await logBmstableLoadFailureDebug(tableInfo, err);
|
||||
throw err;
|
||||
}
|
||||
|
||||
if (table.head.symbol !== tableInfo.prefix) {
|
||||
throw new Error(
|
||||
`Table ${tableInfo.name} (${tableInfo.url}) has unexpected symbol: expected ${JSON.stringify(tableInfo.prefix)}, got ${JSON.stringify(table.head.symbol)}.`,
|
||||
);
|
||||
}
|
||||
|
||||
log.info(`Bumping levels...`);
|
||||
await ImportTableLevels(table.body, tableInfo.prefix, tableInfo.game);
|
||||
log.info(`Levels bumped.`);
|
||||
@@ -396,6 +285,7 @@ export async function SyncBMSTables() {
|
||||
await syncBmsTablesCore();
|
||||
}
|
||||
|
||||
// Surprisingly, this action doesn't add new folders - just updates levels.
|
||||
export const ACTION_BMSTableSync = MakeAction("BMS_TABLE_SYNC", async (taker, _input) => {
|
||||
if (!(await IsUserAdmin(taker.acct.id))) {
|
||||
throw new ExpectedErr(403, "You are not authorized to perform this action.");
|
||||
|
||||
Reference in New Issue
Block a user