feat: propogate lyko's fixes from lr2ir-dataset (#1644)

This commit is contained in:
zk
2026-06-06 15:34:37 +01:00
committed by GitHub
parent c405ebcb56
commit 0391fbacf0
13 changed files with 1012 additions and 582 deletions
+1 -1
View File
@@ -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
View File
File diff suppressed because it is too large Load Diff
+16 -16
View File
@@ -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",
+1
View File
@@ -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",
+1
View File
@@ -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 };
}
-1
View File
@@ -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();
+1 -1
View File
@@ -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();
+9 -119
View File
@@ -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.");