diff --git a/typescript/seeds-scripts/rerunners/iidx/scrape-and-update-sp-tiers.ts b/typescript/seeds-scripts/rerunners/iidx/scrape-and-update-sp-tiers.ts
new file mode 100644
index 000000000..7c3083dfe
--- /dev/null
+++ b/typescript/seeds-scripts/rerunners/iidx/scrape-and-update-sp-tiers.ts
@@ -0,0 +1,366 @@
+import { log } from "#log";
+import * as cheerio from "cheerio";
+import { type AnyNode } from "domhandler";
+import { type SEEDS_ChartDocument, type SEEDS_SongDocument } from "tachi-common";
+import { ReadCollection, WriteCollection } from "../../util";
+
+if (require.main !== module) {
+ throw new Error(`This is a script. It should be ran directly from the command line with bun.`);
+}
+
+const URL_L12_BASE =
+ "https://docs.google.com/spreadsheets/u/0/d/e/2PACX-1vSUdp6iuEzE8Z5AL1hkoxzLexp89nJnLQMmICm6_MC0_UjCp1ImZFzabcZkvCpK7mcWvm_2t6iYoJRg/pubhtml/sheet?headers=false&gid=";
+const URL_L11_BASE =
+ "https://docs.google.com/spreadsheets/u/0/d/e/2PACX-1vSLnjRLdYQ53QgdmKoCvW9zVSgHjBCcGf8iTeEgjiSia4i_4YwR9isSfhxC2kSAq_wWygd_9xGORhk0/pubhtml/sheet?headers=false&gid=";
+
+const TIER_CONFIGS = [
+ {
+ url: URL_L12_BASE + "1184656976",
+ level: 12,
+ gauge: "normal",
+ format: "columnwise",
+ },
+ {
+ url: URL_L12_BASE + "1277599511",
+ level: 12,
+ gauge: "hard",
+ format: "columnwise",
+ },
+ // The 11 sheet is out of date - trying to find a better one.
+ // {
+ // url: URL_L11_BASE + "411493762",
+ // level: 11,
+ // gauge: "normal",
+ // format: "rowwise",
+ // },
+ // {
+ // url: URL_L11_BASE + "1585306050",
+ // level: 11,
+ // gauge: "hard",
+ // format: "rowwise",
+ // },
+];
+
+type songInTier = {
+ title: string;
+ difficulty: string;
+ kojinsa: boolean;
+};
+
+const KOJINSA_CLASS = "s5";
+
+function editDistance(a: string, b: string) {
+ const matrix = [];
+ for (let i = 0; i <= b.length; i++) {
+ matrix[i] = [i];
+ }
+ for (let j = 0; j <= a.length; j++) {
+ matrix[0][j] = j;
+ }
+ for (let i = 1; i <= b.length; i++) {
+ for (let j = 1; j <= a.length; j++) {
+ if (b.charAt(i - 1) === a.charAt(j - 1)) {
+ matrix[i][j] = matrix[i - 1][j - 1];
+ } else {
+ matrix[i][j] = Math.min(
+ matrix[i - 1][j - 1] + 1,
+ matrix[i][j - 1] + 1,
+ matrix[i - 1][j] + 1,
+ );
+ }
+ }
+ }
+ return matrix[b.length][a.length];
+}
+
+function parseTitleAndDiff(text: string) {
+ // L12 sheet uses [L]/[H]; L11 sheet uses (L)/(H).
+ if (text.endsWith("[L]") || text.endsWith("(L)")) {
+ return { title: text.slice(0, -3).trim(), difficulty: "LEGGENDARIA" };
+ }
+ if (text.endsWith("[H]") || text.endsWith("(H)")) {
+ return { title: text.slice(0, -3).trim(), difficulty: "HYPER" };
+ }
+ return { title: text.trim(), difficulty: "ANOTHER" };
+}
+
+// NFKC folds fullwidth ASCII/digits to halfwidth (1 -> 1, + -> +); the regex
+// passes then collapse curly quotes which NFKC leaves as stylistic variants.
+function normalize(s: string) {
+ return s.normalize("NFKC").replace(/[“”]/g, '"').replace(/[‘’]/g, "'");
+}
+
+function findMatchingSong(title: string, songsData: SEEDS_SongDocument<"iidx">[]) {
+ const waveDashed = title.replaceAll("~", "〜");
+ const normalizedClean = normalize(waveDashed);
+
+ for (const song of songsData) {
+ if (normalize(song.title) === normalizedClean) {
+ return song;
+ }
+ }
+
+ const lowerTitle = normalizedClean.toLowerCase();
+ let bestMatch = null;
+ let bestDistance = Infinity;
+ const maxDistance = 4;
+
+ for (const song of songsData) {
+ const distance = editDistance(lowerTitle, normalize(song.title).toLowerCase().trim());
+ if (distance <= maxDistance && distance < bestDistance) {
+ bestDistance = distance;
+ bestMatch = song;
+ }
+ }
+
+ if (bestMatch === null) {
+ return null;
+ }
+ if (bestDistance > 0) {
+ log.info(`Fuzzy match: "${title}" -> "${bestMatch.title}" (distance: ${bestDistance})`);
+ }
+ return bestMatch;
+}
+
+function isRedSpan(node: AnyNode) {
+ if (node.type !== "tag" || node.name !== "span") {
+ return false;
+ }
+ const style = node.attribs?.style || "";
+ return /color\s*:\s*#ff0000/i.test(style);
+}
+
+// Walks a
, returning [{title, difficulty, kojinsa}] split on .
+// Any text encountered inside a red marks its segment kojinsa=true.
+function parseCell(cellEl: AnyNode) {
+ const titles: songInTier[] = [];
+ let buffer = "";
+ let bufferIsRed = false;
+
+ function flush() {
+ const t = buffer.trim();
+ if (t.length > 0) {
+ const { title, difficulty } = parseTitleAndDiff(t);
+ titles.push({ title: title, difficulty: difficulty, kojinsa: bufferIsRed });
+ }
+ buffer = "";
+ bufferIsRed = false;
+ }
+
+ function walk(nodes: AnyNode[], redContext: boolean) {
+ for (const node of nodes) {
+ if (node.type === "text") {
+ buffer += node.data;
+ if (redContext) {
+ bufferIsRed = true;
+ }
+ continue;
+ }
+ if (node.type !== "tag") {
+ continue;
+ }
+ if (node.name === "br") {
+ flush();
+ continue;
+ }
+ if (isRedSpan(node)) {
+ walk(node.children, true);
+ continue;
+ }
+ walk(node.children, redContext);
+ }
+ }
+ if (cellEl.type === "tag") {
+ walk(cellEl.children, false);
+ }
+ flush();
+ return titles;
+}
+
+async function fetchSheet(url: string) {
+ log.info(`Fetching ${url}`);
+ const res = await fetch(url);
+ if (!res.ok) {
+ throw new Error(`HTTP ${res.status} fetching ${url}`);
+ }
+ return res.text();
+}
+
+function cleanTierName(text: string) {
+ return text.trim().replaceAll("+", "+");
+}
+
+// L12 layout: thead shim, then one tier-header row + one data row in tbody.
+// Each data | holds -separated titles; red s mark 個人差.
+function extractTiersColumnwise(html: string) {
+ const $ = cheerio.load(html);
+ const rows = $("tr").toArray();
+ if (rows.length < 3) {
+ throw new Error(`Expected at least 3 rows, got ${rows.length}`);
+ }
+
+ const headerCells = $(rows[1]).find("td").toArray();
+ const tierCells = $(rows[2]).find("td").toArray();
+
+ const colCount = Math.min(headerCells.length, tierCells.length);
+ if (headerCells.length !== tierCells.length) {
+ log.info(
+ `Warning: header has ${headerCells.length} cols, data has ${tierCells.length} cols`,
+ );
+ }
+
+ const tiers = [];
+ for (let k = 0; k < colCount; k++) {
+ const tierName = cleanTierName($(headerCells[k]).text());
+ const songsInTier = parseCell(tierCells[k]);
+ tiers.push({ tier: tierName, songsInTier });
+ }
+ return tiers;
+}
+
+// L11 layout: thead shim, then in tbody: title row, tier-header row, then one
+// song per cell per row. Empty cell = tier ran out of songs. 個人差 cells
+// carry class "s5" on the | .
+function extractTiersRowwise(html: string) {
+ const $ = cheerio.load(html);
+ const rows = $("tr").toArray();
+ if (rows.length < 4) {
+ throw new Error(`Expected at least 4 rows, got ${rows.length}`);
+ }
+
+ const headerCells = $(rows[2]).find("td").toArray();
+ const tiers = headerCells.map((c) => ({
+ tier: cleanTierName($(c).text()),
+ songsInTier: [] as songInTier[],
+ }));
+
+ for (let k = 3; k < rows.length; k++) {
+ const cells = $(rows[k]).find("td").toArray();
+ const colCount = Math.min(tiers.length, cells.length);
+ for (let col = 0; col < colCount; col++) {
+ const cell = $(cells[col]);
+ const text = cell.text().trim();
+ if (text.length === 0) {
+ continue;
+ }
+ const cls = cell.attr("class") || "";
+ const kojinsa = cls.split(/\s+/).includes(KOJINSA_CLASS);
+ const { title, difficulty } = parseTitleAndDiff(text);
+ tiers[col].songsInTier.push({ title: title, difficulty: difficulty, kojinsa: kojinsa });
+ }
+ }
+ return tiers;
+}
+
+function applyTiers(
+ chartsData: SEEDS_ChartDocument<"iidx-sp">[],
+ songsData: SEEDS_SongDocument<"iidx">[],
+ level: number,
+ tiers: {
+ tier: string;
+ songsInTier: songInTier[];
+ }[],
+ field: "hcTier" | "ncTier",
+) {
+ let updated = 0;
+ let notFound = 0;
+
+ function tierToValue(tier: string) {
+ switch (tier) {
+ case "S+":
+ return level + 1.2;
+ case "S":
+ return level + 1.0;
+ case "A+":
+ return level + 0.9;
+ case "A":
+ return level + 0.8;
+ case "B+":
+ return level + 0.7;
+ case "B":
+ return level + 0.6;
+ case "C":
+ return level + 0.4;
+ case "D":
+ return level + 0.2;
+ case "E":
+ return level + 0.0;
+ case "F":
+ return level - 0.2;
+ default:
+ return level;
+ }
+ }
+
+ function isInAc(versions: string[]) {
+ for (const version of ["33", "32", "31", "30"]) {
+ if (versions.includes(version)) return true;
+ }
+ return false;
+ }
+
+ for (const { tier, songsInTier } of tiers) {
+ if (!tier || tier === "未定" || tier === "超個人差") {
+ continue;
+ }
+ for (const { title, difficulty, kojinsa } of songsInTier) {
+ const songMatch = findMatchingSong(title, songsData);
+ if (!songMatch) {
+ log.warn(`No match: ${title}`);
+ notFound++;
+ continue;
+ }
+ let chartFound = false;
+ for (const chart of chartsData) {
+ if (
+ chart.songID === songMatch.id &&
+ chart.difficulty === difficulty &&
+ chart.data["2dxtraSet"] === null &&
+ isInAc(chart.versions)
+ ) {
+ chart.data[field] = {
+ individualDifference: kojinsa,
+ text: level + tier,
+ value: tierToValue(tier),
+ };
+ updated++;
+ chartFound = true;
+ break;
+ }
+ }
+ if (!chartFound) {
+ log.warn(`Chart not found: ${songMatch.title} (SP ${difficulty})`);
+ notFound++;
+ continue;
+ }
+ }
+ }
+ return { updated, notFound };
+}
+
+async function main() {
+ const chartsData = ReadCollection("charts-iidx-sp.json");
+ const songsData = ReadCollection("songs-iidx.json");
+
+ for (const config of TIER_CONFIGS) {
+ log.info(`Processing L${config.level} ${config.gauge}`);
+ const html = await fetchSheet(config.url);
+ const tiers =
+ config.format === "rowwise" ? extractTiersRowwise(html) : extractTiersColumnwise(html);
+ log.info(`Parsed ${tiers.length} tiers: ${tiers.map((t) => t.tier).join(", ")}`);
+ const { updated, notFound } = applyTiers(
+ chartsData,
+ songsData,
+ config.level,
+ tiers,
+ config.gauge === "normal" ? "ncTier" : "hcTier",
+ );
+ log.info(`L${config.level} ${config.gauge}: ${updated} updated, ${notFound} not found`);
+ }
+ WriteCollection("charts-iidx-sp.json", chartsData);
+}
+
+main().catch((err) => {
+ console.error(`ERROR: ${err.message}`);
+ process.exit(1);
+});
|