182 lines
5.8 KiB
TypeScript
182 lines
5.8 KiB
TypeScript
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { SongsterrToAlphaTabConverter } from './songsterr/songsterr-to-alphatab.converter.ts';
|
|
import type { SongsterrStateMetaCurrent, SongsterrRevisionTrackPayload } from './songsterr/types.ts';
|
|
|
|
// Polite standard User-Agent header to prevent aggressive scraper blocks
|
|
const userAgent = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36';
|
|
|
|
/**
|
|
* Fetches the HTML content of a URL using the global fetch API.
|
|
*
|
|
* @param url Target endpoint to fetch.
|
|
* @returns HTML string content.
|
|
*/
|
|
async function fetchHTML(url: string): Promise<string> {
|
|
const res = await fetch(url, {
|
|
headers: {
|
|
'User-Agent': userAgent
|
|
}
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to fetch ${url} (HTTP ${res.status})`);
|
|
}
|
|
return res.text();
|
|
}
|
|
|
|
/**
|
|
* Fetches and parses JSON from a URL using the global fetch API.
|
|
*
|
|
* @param url Target JSON API endpoint.
|
|
* @returns Parsed JSON object.
|
|
*/
|
|
async function fetchJSON(url: string): Promise<any> {
|
|
const res = await fetch(url, {
|
|
headers: {
|
|
'User-Agent': userAgent
|
|
}
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`Failed to fetch JSON ${url} (HTTP ${res.status})`);
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
/**
|
|
* Sanitizes a filename string by replacing illegal OS characters with underscores.
|
|
*
|
|
* @param name File name to clean.
|
|
* @returns Cleaned file name.
|
|
*/
|
|
function sanitizeFilename(name: string): string {
|
|
return name.replace(/[\\/:*?"<>|]/g, '_');
|
|
}
|
|
|
|
/**
|
|
* Main execution entry point for the Songsterr download and conversion helper.
|
|
*/
|
|
async function main() {
|
|
const args = process.argv.slice(2);
|
|
let songUrl = '';
|
|
let outputDir = '.';
|
|
|
|
// Parse custom command-line arguments: --url <songUrl> and --output <outputDir>
|
|
for (let i = 0; i < args.length; i++) {
|
|
if (args[i] === '--url' && i + 1 < args.length) {
|
|
songUrl = args[i + 1];
|
|
i++;
|
|
} else if (args[i] === '--output' && i + 1 < args.length) {
|
|
outputDir = args[i + 1];
|
|
i++;
|
|
}
|
|
}
|
|
|
|
if (!songUrl) {
|
|
console.error('Error: Missing --url parameter');
|
|
process.exit(1);
|
|
}
|
|
|
|
try {
|
|
console.log(`[*] Fetching Songsterr page: ${songUrl}`);
|
|
const html = await fetchHTML(songUrl);
|
|
|
|
// Locate the embedded React hydration state JSON script tag
|
|
const stateMatch = html.match(/<script id="state"[^>]*>([^<]+)<\/script>/);
|
|
if (!stateMatch) {
|
|
throw new Error('Unable to find Songsterr state payload in page HTML');
|
|
}
|
|
|
|
// Decode and parse the JSON string containing comprehensive metadata & tracking hashes
|
|
const stateJSON = decodeURIComponent(stateMatch[1]);
|
|
const parsedState = JSON.parse(stateJSON);
|
|
const current = parsedState?.meta?.current;
|
|
|
|
if (!current?.songId || !current?.revisionId || !current?.image) {
|
|
throw new Error('Songsterr state payload missing required revision fields');
|
|
}
|
|
|
|
// Structure metadata representing active song and its separate tracking divisions
|
|
const stateMeta: SongsterrStateMetaCurrent = {
|
|
songId: current.songId,
|
|
revisionId: current.revisionId,
|
|
image: current.image,
|
|
title: current.title || 'Song',
|
|
artist: current.artist || 'Unknown Artist',
|
|
tracks: Array.isArray(current.tracks) ? current.tracks : []
|
|
};
|
|
|
|
console.log(`[*] Found song: "${stateMeta.title}" by ${stateMeta.artist}`);
|
|
console.log(`[*] Tracks count: ${stateMeta.tracks.length}`);
|
|
|
|
// CloudFront CDNs containing Songsterr's actual track-level musical note data files
|
|
const CDNs = [
|
|
'https://dqsljvtekg760.cloudfront.net',
|
|
'https://d3d3l6a6rcgkaf.cloudfront.net'
|
|
];
|
|
|
|
const revisions: any[] = [];
|
|
|
|
// Fetch individual JSON note revision payloads for each track defined in the metadata
|
|
for (const track of stateMeta.tracks) {
|
|
if (typeof track.partId !== 'number') continue;
|
|
|
|
console.log(`[*] Fetching note payload for track: "${track.name || track.title || 'Track'}" (Part ${track.partId})...`);
|
|
let fetched = false;
|
|
let revisionPayload: SongsterrRevisionTrackPayload | null = null;
|
|
|
|
// Iterate through available CDNs in case of network or mirror failures
|
|
for (const cdn of CDNs) {
|
|
// Build direct URL targeting the JSON payload of this specific track part
|
|
const cdnUrl = `${cdn}/${stateMeta.songId}/${stateMeta.revisionId}/${stateMeta.image}/${track.partId}.json`;
|
|
try {
|
|
revisionPayload = await fetchJSON(cdnUrl);
|
|
fetched = true;
|
|
break; // Stop CDN attempts if fetch succeeds
|
|
} catch (e) {
|
|
// Silent catch: log failure locally and try fallback CDN
|
|
}
|
|
}
|
|
|
|
if (!fetched || !revisionPayload) {
|
|
console.warn(`[!] Warning: Failed to fetch note payload for track part ${track.partId}`);
|
|
continue;
|
|
}
|
|
|
|
revisions.push({
|
|
trackMeta: track,
|
|
revision: revisionPayload
|
|
});
|
|
}
|
|
|
|
if (revisions.length === 0) {
|
|
throw new Error('Failed to fetch note payloads for any of the tracks');
|
|
}
|
|
|
|
// Initiate alphaTab score generation and export as standard Guitar Pro 7 binary
|
|
console.log('[*] Converting notes to Guitar Pro 7 format...');
|
|
const converter = new SongsterrToAlphaTabConverter();
|
|
const result = converter.toGp7({
|
|
meta: stateMeta,
|
|
revisions: revisions
|
|
});
|
|
|
|
const artistSafe = sanitizeFilename(stateMeta.artist);
|
|
const titleSafe = sanitizeFilename(stateMeta.title);
|
|
const fileName = `${artistSafe} - ${titleSafe}.gp`;
|
|
|
|
if (!fs.existsSync(outputDir)) {
|
|
fs.mkdirSync(outputDir, { recursive: true });
|
|
}
|
|
|
|
const outPath = path.join(outputDir, fileName);
|
|
fs.writeFileSync(outPath, result.data);
|
|
|
|
console.log(`[+] Success! Saved tab file to: ${outPath}`);
|
|
} catch (e: any) {
|
|
console.error(`[-] Error: ${e.message}`);
|
|
process.exit(1);
|
|
}
|
|
}
|
|
|
|
main();
|