data: add September event song charts
Publish GitHub Pages output / publish (push) Successful in 46s
Publish GitHub Pages output / publish (push) Successful in 46s
This commit is contained in:
@@ -0,0 +1,121 @@
|
||||
// Download and decrypt selected Hololive Dreams SUS resources from Octo CDN.
|
||||
// Kept dependency-free so chart collection does not depend on a local Python runtime.
|
||||
import { createDecipheriv, createHash } from 'node:crypto';
|
||||
import { mkdir, writeFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const OCTO_API_KEY = 'B46OtKlGGHoz6sxbOWDe3VUvBsagXxr5av38IQIKUKo=';
|
||||
const OCTO_CLIENT_KEY = 'CwFhQ+S5m4nERWVaq5oFZIP0cZLc0j7O/zllG0UYVNo=';
|
||||
const OCTO_LIST_URL = 'https://us.game-hololive-dreams.com/asset/v2/pub/a/5/v/200001/list/0';
|
||||
const resourceMagic = Buffer.from('QUAVMAGIC');
|
||||
|
||||
function readVarint(bytes, offset) {
|
||||
let value = 0;
|
||||
let shift = 0;
|
||||
while (offset < bytes.length) {
|
||||
const current = bytes[offset++];
|
||||
value += (current & 0x7f) * (2 ** shift);
|
||||
if ((current & 0x80) === 0) return [value, offset];
|
||||
shift += 7;
|
||||
}
|
||||
throw new Error('잘린 Octo protobuf입니다.');
|
||||
}
|
||||
|
||||
function protobufFields(bytes) {
|
||||
const fields = [];
|
||||
for (let offset = 0; offset < bytes.length;) {
|
||||
const [tag, afterTag] = readVarint(bytes, offset);
|
||||
offset = afterTag;
|
||||
const field = tag >> 3;
|
||||
const wireType = tag & 7;
|
||||
let value;
|
||||
if (wireType === 0) [value, offset] = readVarint(bytes, offset);
|
||||
else if (wireType === 2) {
|
||||
let length;
|
||||
[length, offset] = readVarint(bytes, offset);
|
||||
value = bytes.subarray(offset, offset + length);
|
||||
offset += length;
|
||||
} else if (wireType === 1) {
|
||||
value = bytes.subarray(offset, offset + 8);
|
||||
offset += 8;
|
||||
} else if (wireType === 5) {
|
||||
value = bytes.subarray(offset, offset + 4);
|
||||
offset += 4;
|
||||
} else throw new Error(`지원하지 않는 protobuf wire type: ${wireType}`);
|
||||
fields.push([field, value]);
|
||||
}
|
||||
return fields;
|
||||
}
|
||||
|
||||
function decodeEntry(bytes) {
|
||||
const entry = {};
|
||||
for (const [field, value] of protobufFields(bytes)) {
|
||||
if (field === 2) entry.name = Buffer.from(value).toString('utf8');
|
||||
else if (field === 7) entry.objectName = Buffer.from(value).toString('utf8');
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
|
||||
function decryptCatalog(encrypted) {
|
||||
const key = createHash('sha256').update(OCTO_API_KEY).digest();
|
||||
const decipher = createDecipheriv('aes-256-cbc', key, encrypted.subarray(0, 16));
|
||||
const plaintext = Buffer.concat([decipher.update(encrypted.subarray(16)), decipher.final()]);
|
||||
const entries = [];
|
||||
let urlFormat = '';
|
||||
let revisionId = null;
|
||||
for (const [field, value] of protobufFields(plaintext)) {
|
||||
if (field === 1) revisionId = value;
|
||||
else if (field === 3) entries.push(decodeEntry(value));
|
||||
else if (field === 4) urlFormat = Buffer.from(value).toString('utf8');
|
||||
}
|
||||
if (!urlFormat || revisionId === null) throw new Error('Octo 카탈로그 형식이 바뀌었습니다.');
|
||||
return { revisionId, urlFormat, entries };
|
||||
}
|
||||
|
||||
function resourceMask(name) {
|
||||
// Exact equivalent of holodori-asset-tools/crypto/mask.py.
|
||||
const mask = Buffer.alloc(name.length * 2);
|
||||
for (let index = 0; index < name.length; index += 1) {
|
||||
const character = name.charCodeAt(index) & 0xff;
|
||||
mask[index * 2] = character;
|
||||
mask[mask.length - 1 - index * 2] = (~character) & 0xff;
|
||||
}
|
||||
let hash = 0x7c;
|
||||
for (const byte of mask) hash = ((((hash & 1) << 7) | (hash >> 1)) ^ byte) & 0xff;
|
||||
return Buffer.from(mask.map((byte) => byte ^ hash));
|
||||
}
|
||||
|
||||
function decryptResource(bytes, name) {
|
||||
if (!bytes.subarray(0, resourceMagic.length).equals(resourceMagic)) return bytes;
|
||||
const mask = resourceMask(name);
|
||||
const output = Buffer.from(bytes.subarray(resourceMagic.length));
|
||||
for (let index = 0; index < Math.min(256, output.length); index += 1) output[index] ^= mask[index % mask.length];
|
||||
return output;
|
||||
}
|
||||
|
||||
const requested = process.argv.slice(2);
|
||||
if (requested.length === 0) throw new Error('SUS 파일명 또는 곡 ID를 지정하세요. 예: m0344 m0348 m0527');
|
||||
const requestedNames = new Set(requested.flatMap((value) =>
|
||||
/^m\d{4}$/.test(value) ? ['easy', 'normal', 'hard', 'expert'].map((difficulty) => `chart_${value}_${difficulty}.sus`) : [value]
|
||||
));
|
||||
|
||||
const catalogResponse = await fetch(OCTO_LIST_URL, { headers: { 'X-OCTO-KEY': OCTO_API_KEY, 'X-APP-OCTO-KEY': OCTO_CLIENT_KEY } });
|
||||
if (!catalogResponse.ok) throw new Error(`Octo 카탈로그 요청 실패: ${catalogResponse.status}`);
|
||||
const catalog = decryptCatalog(Buffer.from(await catalogResponse.arrayBuffer()));
|
||||
const selected = catalog.entries.filter((entry) => requestedNames.has(entry.name));
|
||||
if (selected.length !== requestedNames.size) {
|
||||
const found = new Set(selected.map((entry) => entry.name));
|
||||
throw new Error(`카탈로그에 없는 SUS: ${[...requestedNames].filter((name) => !found.has(name)).join(', ')}`);
|
||||
}
|
||||
|
||||
const outputDirectory = path.resolve('data/raw/sus');
|
||||
await mkdir(outputDirectory, { recursive: true });
|
||||
for (const entry of selected.sort((left, right) => left.name.localeCompare(right.name))) {
|
||||
const response = await fetch(catalog.urlFormat.replace('{o}', entry.objectName));
|
||||
if (!response.ok) throw new Error(`${entry.name} 다운로드 실패: ${response.status}`);
|
||||
const plaintext = decryptResource(Buffer.from(await response.arrayBuffer()), entry.name);
|
||||
if (!plaintext.toString('utf8', 0, 9).startsWith('#CREATE_')) throw new Error(`${entry.name} 복호화 결과가 SUS가 아닙니다.`);
|
||||
await writeFile(path.join(outputDirectory, entry.name), plaintext);
|
||||
console.log(`${entry.name} ${createHash('sha256').update(plaintext).digest('hex')}`);
|
||||
}
|
||||
console.log(`Octo revision ${catalog.revisionId}: ${selected.length}개 SUS 저장 완료`);
|
||||
Reference in New Issue
Block a user