Files
HolodoriCalc/scripts/collect-exact-charts.mjs
T

201 lines
8.1 KiB
JavaScript

import { mkdir, readFile, writeFile } from 'node:fs/promises';
import path from 'node:path';
const sourceOrigin = process.argv[2] ?? 'https://dreams.wf-calc.net';
const outputDirectory = path.resolve('static/data/charts');
const catalogPath = path.resolve('src/lib/data/exact-chart-catalog.json');
const selectedDifficulties = new Set(['HARD', 'EXPERT']);
const chartScoreWeightCoefficient = 0.3713416563352296;
const musicMaster = JSON.parse(await readFile(path.resolve('data/raw/holodoridb/kor/Music.json'), 'utf8'));
const musicById = new Map(musicMaster.map((row) => [row.data?.id ?? row.id, row.data ?? row]));
const singerMetadataOverrides = {
m0334: { singerType: 'solo', singerCharacterIds: ['chr-04001'] }
};
function singerType(value) {
return typeof value === 'string' ? value.split('_').at(-1)?.toLowerCase() ?? null : null;
}
async function fetchText(url) {
const response = await fetch(url);
if (!response.ok) throw new Error(`${url} 요청 실패: ${response.status}`);
return response.text();
}
function requiredMatch(text, pattern, label) {
const match = text.match(pattern);
if (!match) throw new Error(`${label}을 찾을 수 없습니다.`);
return match[1];
}
function decodeTemplateLiteral(value) {
return value.replaceAll('\\\\', '\\').replaceAll('\\`', '`').replaceAll('\\${', '${');
}
function decodeSingleQuotedLiteral(value) {
let decoded = '';
for (let index = 0; index < value.length; index += 1) {
const character = value[index];
if (character !== '\\') {
decoded += character;
continue;
}
const escaped = value[index + 1];
index += 1;
if (escaped === '\\' || escaped === "'") decoded += escaped;
else if (escaped === 'n') decoded += '\n';
else if (escaped === 'r') decoded += '\r';
else if (escaped === 't') decoded += '\t';
else if (escaped === 'b') decoded += '\b';
else if (escaped === 'f') decoded += '\f';
else if (escaped === 'u') {
const code = value.slice(index + 1, index + 5);
decoded += String.fromCodePoint(Number.parseInt(code, 16));
index += 4;
} else decoded += escaped;
}
return decoded;
}
function extractJsonParseArgument(moduleText) {
const prefix = "JSON.parse('";
const start = moduleText.indexOf(prefix);
if (start < 0) throw new Error('채보 모듈에서 JSON 데이터를 찾을 수 없습니다.');
const valueStart = start + prefix.length;
for (let index = valueStart; index < moduleText.length; index += 1) {
if (moduleText[index] !== "'") continue;
let slashCount = 0;
for (let cursor = index - 1; cursor >= valueStart && moduleText[cursor] === '\\'; cursor -= 1) {
slashCount += 1;
}
if (slashCount % 2 === 0) {
return decodeSingleQuotedLiteral(moduleText.slice(valueStart, index));
}
}
throw new Error('채보 JSON 문자열의 끝을 찾을 수 없습니다.');
}
function flattenEvents(chartData) {
const noteEvents = chartData.note_events ?? {};
const green = (noteEvents.green_blocks ?? noteEvents.green ?? [])
.flatMap((block) => Array.isArray(block) ? block : [block]);
return [
...(noteEvents.blue ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...green.map((note) => ({ ...note, type: note.note_type ?? 'regular' })),
...(noteEvents.pink ?? []).map((note) => ({ ...note, type: note.note_type ?? 'regular', is_flick: true }))
]
.map((note) => ({
timeSeconds: note.time_seconds,
type: note.type === 'mid' ? 'mid' : 'regular',
isFlick: note.is_flick === true
}))
.filter((note) => Number.isFinite(note.timeSeconds))
.sort((left, right) => left.timeSeconds - right.timeSeconds || (left.type === 'mid' ? 1 : -1));
}
function scoreRatioFromNotes(notes) {
const countedMidTimes = new Set();
let weight = 0;
for (const note of notes) {
if (note.type === 'mid') {
if (countedMidTimes.has(note.timeSeconds)) continue;
countedMidTimes.add(note.timeSeconds);
weight += 0.1;
} else {
weight += note.isFlick ? 1.05 : 1;
}
}
return weight * chartScoreWeightCoefficient;
}
const home = await fetchText(sourceOrigin);
const mainAsset = requiredMatch(home, /<script type="module" crossorigin src="([^"]+)"/, '메인 스크립트');
const mainModule = await fetchText(new URL(mainAsset, sourceOrigin));
const simulatorAsset = requiredMatch(mainModule, /\.\/UnitSimulatorPage-([^"']+\.js)/, '점수 시뮬레이터 모듈');
const simulatorModule = await fetchText(new URL(`/assets/UnitSimulatorPage-${simulatorAsset}`, sourceOrigin));
const catalogPrefix = 'us=JSON.parse(`';
const catalogStart = simulatorModule.indexOf(catalogPrefix);
const catalogEnd = simulatorModule.indexOf('`)', catalogStart + catalogPrefix.length);
if (catalogStart < 0 || catalogEnd < 0) throw new Error('채보 카탈로그를 찾을 수 없습니다.');
const sourceCatalog = JSON.parse(
decodeTemplateLiteral(simulatorModule.slice(catalogStart + catalogPrefix.length, catalogEnd))
);
const catalogByKey = new Map(sourceCatalog.map((chart) => [chart.key, chart]));
const imports = [];
const importPattern = /charts\/([^"/]+\.json)"[^\n]{0,180}?import\("\.\/([^"/]+\.js)"/g;
for (const match of simulatorModule.matchAll(importPattern)) {
const [, chartFile, assetFile] = match;
const [songId, difficultyAndLevel] = chartFile.split('.', 2);
const difficulty = difficultyAndLevel.split('-')[0].toUpperCase();
if (selectedDifficulties.has(difficulty)) imports.push({ songId, difficulty, chartFile, assetFile });
}
if (imports.length === 0) throw new Error('수집할 Hard/Expert 채보 모듈을 찾지 못했습니다.');
await mkdir(outputDirectory, { recursive: true });
let completed = 0;
const normalizedCharts = [];
const queue = [...imports];
async function worker() {
while (queue.length > 0) {
const item = queue.shift();
if (!item) return;
const moduleText = await fetchText(new URL(`/assets/${item.assetFile}`, sourceOrigin));
const chartData = JSON.parse(extractJsonParseArgument(moduleText));
const sourceEntry = catalogByKey.get(`${item.songId}::${item.difficulty}`);
const music = musicById.get(item.songId);
const singerOverride = singerMetadataOverrides[item.songId];
const notes = flattenEvents(chartData);
const output = {
schemaVersion: 1,
songId: item.songId,
difficulty: item.difficulty.toLowerCase(),
durationSeconds: sourceEntry?.durationSeconds ?? Math.ceil(notes.at(-1)?.timeSeconds ?? 0),
scoreRatio: scoreRatioFromNotes(notes),
scoreCalibrationSource: 'measured-formula-v1',
notes,
specialStartSeconds: (chartData.sp_activation_events ?? [])
.sort((left, right) => left.slot - right.slot)
.map((event) => event.time_seconds),
singerType: singerOverride?.singerType ?? singerType(music?.musicSingerType),
singerCharacterIds: singerOverride?.singerCharacterIds ?? music?.characterIds ?? [],
source: {
page: sourceOrigin,
transcriptionStatus: chartData.transcription_status,
susSha256: chartData.source?.sha256 ?? null
}
};
const fileName = `${item.songId}.${item.difficulty.toLowerCase()}.json`;
await writeFile(path.join(outputDirectory, fileName), JSON.stringify(output), 'utf8');
normalizedCharts.push({
songId: item.songId,
title: sourceEntry?.title ?? item.songId,
difficulty: item.difficulty.toLowerCase(),
durationSeconds: output.durationSeconds,
scoreRatio: output.scoreRatio,
totalNotes: notes.length,
singerType: output.singerType,
singerCharacterIds: output.singerCharacterIds,
path: `/data/charts/${fileName}`
});
completed += 1;
if (completed % 20 === 0 || completed === imports.length) {
console.log(`${completed}/${imports.length} 채보 수집 완료`);
}
}
}
await Promise.all(Array.from({ length: 6 }, () => worker()));
normalizedCharts.sort((left, right) => left.songId.localeCompare(right.songId) || left.difficulty.localeCompare(right.difficulty));
await writeFile(catalogPath, `${JSON.stringify({
schemaVersion: 1,
source: sourceOrigin,
charts: normalizedCharts
}, null, 2)}\n`, 'utf8');
console.log(`${normalizedCharts.length}개 정확 채보를 저장했습니다.`);