add initial version of tab-downloader
This commit is contained in:
181
songsterr_dl.ts
Normal file
181
songsterr_dl.ts
Normal file
@@ -0,0 +1,181 @@
|
||||
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();
|
||||
Reference in New Issue
Block a user