Files
tab-downloader/songsterr_dl.ts

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();