Estimated reading time : 7 minutes
Tags: javascript reverse engineering
Table of contents:
I wanted to download a full album from Khinsider, but the website has a paywall that prevents you from downloading more than 1 song at a time.
(of course, I bought the album physically, but I don't have a CD player anymore, and I don't want to buy it again in a digital format)
So I decided to bypass the paywall, and download the full album in a single click, in the highest quality available.
When arriving on an album page, the website loads these things :
Unfortunately, clicking the download
button will open a new tab, and two links to click again.
However, clicking the play
button will instantly play the song, without opening a new tab.
And observing all the scripts, I noticed that they were obfuscated (and I don't want to spend hours deobfuscating them).
When clicking the play
button for each lines in the table, it swaps the src
attribute of the <audio>
tag with the link to the song.
Unfortunately (again), the song played is always the mp3
version, and I want the flac
version (when available).
But when trying to simulate a download, it seems like we just have to replace the mp3
extension with flac
to get the flac
version of the song :^).
The ripping script will have to do the following things :
flac
version of the songtr
) in the tableplay
buttonsrc
attribute of the <audio>
tagmp3
extension with flac
(if there is a flac
version)ZipWriter
objectI'll use Tampermonkey to inject the script on the page.
To do that, a simple method would be to check whether we have the string FLAC
in the download button.
document.querySelector("div.albumMassDownload").innerText.includes("FLAC")
To mute the player, it seems like we can just click on the mute
button
document.querySelector("div.audioplayerVolume").click()
To get all rows, we can use the querySelectorAll
method, and select all div.playTrack
elements.
let songsPlayBtns = document.querySelectorAll('div.playTrack');
console.log("Found " + songsPlayBtns.length + " songs.");
let urls = [];
for (let btn of songsPlayBtns) {
btn.click();
urls.push(document.querySelector("audio#audio1").src);
}
mp3
extension with flac
If the song is available in flac
, we can just replace the mp3
extension with flac
.
if (hasFlac) {
urls = urls.map(url => url.replace(".mp3", ".flac"));
}
// Create our custom download button
let div = document.querySelector("div.albumMassDownload").cloneNode(true);
div.style.marginTop = "10px";
div.style.marginBottom = "10px";
// Set the href to # so the cursor stays as a pointer, but nothing happens when clicked
div.querySelectorAll("a").forEach(a => a.setAttribute("href", "#"));
// Change the text, and inject our a tag
div.childNodes[2].textContent = "Rip all songs at once : ";
div.childNodes[2].insertAdjacentHTML("afterend", "<a href='#'> click here (" + (hasFlac ? "FLAC" : "MP3") + " zip)</a><span style='margin-left: 4px;' id='progress'></span>");
div.querySelectorAll("a").forEach(a => a.addEventListener("click", () => downloadToZip(urls)));
// Add the button to the page above div#nowPlay
document.querySelector("div#nowPlay").insertAdjacentElement("beforebegin", div);
We will use the JSZip
library to download the songs to a zip file, we have to inject it in the page.
// Inject the jszip library
let script = document.createElement("script");
script.src = "https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js";
document.head.appendChild(script);
// Wait for the script to load before continuing
script.onload = () => {
And then we can use our functions to download songs and create the zip file.
function urlToBlob(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({ // Using GM_xmlhttpRequest instead of fetch because of CORS issues
method: "GET",
url: url,
responseType: "blob",
onload: function (response) {
resolve(response.response);
},
onerror: function (error) {
reject(error);
}
});
});
}
function saveAs(blob, filename) {
// Use GM_download if available
if (typeof GM_download === "function") {
GM_download({
url: URL.createObjectURL(blob),
name: filename,
onload: function () {
console.log("Downloaded " + filename);
},
onerror: function (error) {
console.error(error);
}
});
} else {
// Create a link and click it
let link = document.createElement("a");
link.href = URL.createObjectURL(blob);
link.download = filename;
link.click();
}
}
And this to download all the songs to our zip file.
let zip = new JSZip();
// Add all the files to the zip
let downloaded = 0;
for (let url of urls) {
let filename = decodeURIComponent(url.split("/").pop());
urlToBlob(url).then(blob => {
downloaded++;
progressElement.innerText = downloaded + "/" + urls.length + " downloaded so far.";
zip.file(filename, blob);
if (downloaded === urls.length) {
progressElement.innerText = "Generating zip...";
// Generate the zip
let name = decodeURIComponent(document.querySelector("div#pageContent > h2").innerText);
zip.generateAsync({ type: "blob" })
.then(function (content) {
// Download the zip
saveAs(content, name + ".zip");
progressElement.innerText = "Done!";
});
}
});
}
Which gives us this final script which despite being a bit messy, works perfectly (at least on what I tested).
This entire process took me ~1 hour and has a lot of room for improvement, but it works, and that's what matters :^)
Feel free to use this script if you want to download an album from Khinsider, and edit it to your needs !
(and of course, download only the albums you bought)