201 lines
8.1 KiB
JavaScript
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}개 정확 채보를 저장했습니다.`);
|