Как все начиналось


Дело было вечером, делать было нечего… Точнее, я просто хотел загрузить аудиокнигу перед парами и тут меня ждал сюрприз. Кэш в кейт мобайле отключили. Как так? Что делать? Конечно же писать свое приложение с кэшем и аудиозаписями. Но для начала нужно понять, как вк превращает ссылки вида audio%user_id%_%track_id% в прямые ссылки на mp3. Что из этого вышло приложения я не написал и как скачать определенный плейлист можно прочитать под катом.

Дебажим js


Начинаем с очевидного — открываем вкладку аудиозаписей и смотрим код. Видим onclick на кнопке у каждой аудиозаписи:

onclick="return getAudioPlayer().toggleAudio(this, event)"

Открываем audioplayer.js, включаем Pretty print и ищем функцию toggleAudio().

toggleAudio()
AudioPlayer.prototype.toggleAudio = function(t, e) {
        if (vk && vk.widget && !vk.id && window.Widgets)
            return Widgets.oauth(),
            !1;
        if (domClosest("_audio_row__tt", e.target))
            return cancelEvent(e);
        var i = domClosest("_audio_row", t)
          , o = AudioUtils.getAudioFromEl(i, !0);
        if (window.getSelection && window.getSelection().rangeCount) {
            var a = window.getSelection().getRangeAt(0);
            if (a && a.startOffset != a.endOffset)
                return !1
        }
        if (e && hasClass(e.target, "mem_link"))
            return nav.go(attr(e.target, "href"), e, {
                navigateToUploader: !0
            }),
            cancelEvent(e);
        if (hasClass(e.target, "_audio_row__title_inner") && o.lyrics && !o.isInAttach)
            return AudioUtils.toggleAudioLyrics(i, o),
            cancelEvent(e);
        if (hasClass(e.target, "audio_row__performer"))
            return checkEvent(e) || vk.widget ? !0 : (AudioUtils.audioSearchPerformer(e.target, o.performer, e),
            cancelEvent(e));
        var s = cur.cancelClick || e && (hasClass(e.target, "audio_lyrics") || domClosest("_audio_duration_wrap", e.target) || domClosest("_audio_inline_player", e.target) || domClosest("audio_performer", e.target));
        if (cur._sliderMouseUpNowEl && cur._sliderMouseUpNowEl == geByClass1("audio_inline_player_progress", i) && (s = !0),
        delete cur.cancelClick,
        delete cur._sliderMouseUpNowEl,
        s)
            return !0;
        if (AudioUtils.isClaimedAudio(o) || o.isReplaceable) {
            var r = AudioUtils.getAudioExtra(o)
              , l = r.claim;
            if (l)
                return void (hasClass(i, "no_actions") || o.isInEditBox || showAudioClaimWarning(o, l, AudioUtils.replaceWithOriginal.bind(AudioUtils, i, o)))
        }
        if (o.isPlaying)
            this.pause();
        else {
            var n = AudioUtils.getContextPlaylist(i);
            this.play(o.fullId, n.playlist, o.context || n.context),
            cur.audioPage && cur.audioPage.onUserAction(o, n.playlist)
        }
        AudioUtils.onRowOver(i, !1, !0)
    }


Ставим брейкпоинт на первую инструкцию и нажимаем на кнопку. Входим в режим отладки и начинаем выполнять код по шагам.

Видим, что переменная o через несколько шагов содержит кучу полезной информации: исполнитель, название, хэши, всякие айди. Но не содержит главного свойства — url.
Идем дальше, выходим из toggleAudio(), попадаем в файл common.js в функцию, работающую с эвентами. Ага, значит ссылку мы получим асинхронно, надо бы посматривать на сетевую активность, что ж, шагаем дальше. Через n шагов замечаем, что во вкладке network появляется интересный запрос:

Request URL:https://vk.com/al_audio.php
Request Method:POST

Form Data:
act:reload_audio
al:1
ids: список id вида %user_id%_%track_id%, которые нужно обновить


И не менее интересный ответ:

4089188939145<!><!>0<!>6854<!>0<!><!json>[[456239119,%my_id%,"https:\/\/vk.com\/mp3\/audio_api_unavailable.mp3?extra=ofvLohaZvtDKnOPmEtHWl2rJCLLMrJiZy1i4D3blohn4AZLflLqZtMn3utbJmeTrCdq2Be1LyJ1HDvqTBKnUne8YrJfzzKrVENKXzL9JmMHgEgz3u3nbDwfuBMDiywLJBOrfl2fQwJDRmvrFzwDbwtGWvwThDxy6lxLHt1KOlvDODhbTAgjOzffOzdvTBOvOms9nvO9Ix1bxrNqUwgnfAfLWnwfLDLb0CLLxAvCVr2r4ExbHzhPTlKS2p3zcodu3yxm4Ea#CWS1mZi","Сон смешного человека ","Ф.М. Достоевский",4705,0,0,"",0,2,"","[]","6adb4186ee0c1d3ad0\/5102a312745ae505a7\/08eb0e4bd407e74e76\/d313ec4b6051942649\/","",[]],[456239118,%my_id%,"https:\/\/vk.com\/mp3\/audio_api_unavailable.mp3?extra=AdbkuuLOywjuou0Zme1yrdzJsI9Tm2qYvIO2AJaWnMnIztKOzgLxmMfbu3rgyJ8Tme5MBNrKlMLpChPIBLLFngjvu3zZAxztuOXJztfgrK9vq1rznLCYDJznBMrMCMT4rO9PytD3oc5kAhyYm1jMmZeZlxqZsKfWyKO9DNGUmvfuBtfOudjXrvbmyZLVltvICvPIver5zg5Zm2jWmJvWsue5nuXWzgPnBZq6l2n3x30OCgCVC2SVmxjqzvD6Egrlnc1zyq#CWSYmdC","автостопом в москву","црвених цветова",359,0,0,"",0,18,"","[]","57c59cffa93d47effa\/836d457cff34e02fa0\/6374a8e457c763a8c6\/96db3ddc8c210b1fb0\/","",[]],[456239117,%my_id%,"https:\/\/vk.com\/mp3\/audio_api_unavailable.mp3?extra=AgDYnY1TmwmOm2vTos5Ylu9Juwzkugv4zMzZyJbVlKjcqI9LB2vdzhe6DufVqY9KA1uZy3bfnhm4AgDOAwHIzMDztMTxsLjHn2K9veXuttvKq2fxogTWxY1fzJvumJrADLDlCdnLAv8UAfPiB2zxEKTrttLTrtK2ntrLCLnOww5rtI1Rq2vzsgr2vMnYp1P5BeOOx3rIEs8WBI10yLntCNzOCKrLBtznBMzRC29ewMOOC3uTueuYl29OztnWDhCXoffz#CWSYntC","Sal sér hon standa (Völuspá, 64-66)","Nytt Land",272,0,0,"",0,82,"","[]","4881448c55978a3374\/40a707901f551a4572\/778fe59467e6a629a9\/02a308905303098496\/","https:\/\/pp.userapi.com\/c837628\/v837628453\/829d0\/kLoB-0G_r78.jpg,https:\/\/pp.userapi.com\/c837628\/v837628453\/829cf\/C39pJ5b-t-w.jpg",80],[456239116,%my_id%,"https:\/\/vk.com\/mp3\/audio_api_unavailable.mp3?extra=l1yUmI1MCc8Vsxq2qxHYoM1Ku2i9C1y\/EdzWzZfADhrpDgGYrwvJwwTRtZzHDwL3A2HZqKXXrOfHtZDVDKzZn1zIrKLJD3PVBKqOzxLylKDjvZuYqKf5qLG3rhn4nJnHs1rlt1PUy29mCvLuuxD6DJHJAtGXvNfjrMzkt29Wme93mKuWze55x1PotO01lMqZnuHfzJLgBM1Jluzqogvkr3KYs1fNmtn6qODYyx0XnxDZDNnvvO9Lsgn0tdrKDc9Kowm2#CWSOmJy","After Dark "," Mr.Kitty",351,0,0,"",0,18,"","[]","353d934506a14abed5\/5745fb63e4e5f4abb4\/2ed8c9b81df35317d7\/01404a5db986cf16b8\/","",[]],[456239115,%my_id%,"https:\/\/vk.com\/mp3\/audio_api_unavailable.mp3?extra=mJfWyY1SBMm\/m1HYDc5YzgO5BwvdndC5nZDyvNzWyMvsEgfwyMvmsJzXm2fqEhq5mJvXvw50qIO4xZKTEefVsxq3yMfkltLpnJvWzfDrCZHgywiXEgzZqwntsJvMn21Nss9psKDkBKHKlY1LzI1vlJPyEuu3l3nyEhjsBNuWAhrlq3rezZfxmJn4A2zwtZbowhq3B1CWlZe4ytHZnhzhu19Lt29fvJDkCfLOzgvewI43rtjWmd1YBKLysOe2uMfKztbr#CWSZmJG","Storm","Godspeed You! Black Emperor",1352,0,0,"",0,34,"","[]","81b9d46c10d9df8d03\/5299d303c627944df7\/5ec696a0453d27253e\/ce7a1e0600cff40c53\/","",[]]]<!><!bool><!>7212f741260c90ab47

Понимаем, что это json, распаковываем

JSON одного из элементов

Отлично, теперь у нас есть хоть какая-то ссылка, но выглядит она не очень рабочей, что делать дальше?

Расшифровываем ссылку


Шагаем по скрипту дальше и понимаем, что ничего не выходит, на каком-то этапе ссылка просто превращается из audio_api_unavailable в ссылку на mp3. Значит где-то что-то происходит!
Но где именно? В одной из функций setUrl() вызывается функция с говорящим именем audioUnmaskSource()

Код функции
    function i() {
        return window.wbopen && ~(window.open + "").indexOf("wbopen")
    }
    function o(t) {
        if (!i() && ~t.indexOf("audio_api_unavailable")) {
            var e = t.split("?extra=")[1].split("#")
              , o = "" === e[1] ? "" : a(e[1]);
            if (e = a(e[0]),
            "string" != typeof o || !e)
                return t;
            o = o ? o.split(String.fromCharCode(9)) : [];
            for (var s, r, n = o.length; n--; ) {
                if (r = o[n].split(String.fromCharCode(11)),
                s = r.splice(0, 1, e)[0],
                !l[s])
                    return t;
                e = l[s].apply(null, r)
            }
            if (e && "http" === e.substr(0, 4))
                return e
        }
        return t
    }
    function a(t) {
        if (!t || t.length % 4 == 1)
            return !1;
        for (var e, i, o = 0, a = 0, s = ""; i = t.charAt(a++); )
            i = r.indexOf(i),
            ~i && (e = o % 4 ? 64 * e + i : i,
            o++ % 4) && (s += String.fromCharCode(255 & e >> (-2 * o & 6)));
        return s
    }
    function s(t, e) {
        var i = t.length
          , o = [];
        if (i) {
            var a = i;
            for (e = Math.abs(e); a--; )
                o[a] = (e += e * (a + i) / e) % i | 0
        }
        return o
    }
    Object.defineProperty(e, "__esModule", {
        value: !0
    }),
    e.audioUnmaskSource = o;
    var r = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMN0PQRSTUVWXYZO123456789+/="
      , l = {
        v: function(t) {
            return t.split("").reverse().join("")
        },
        r: function(t, e) {
            t = t.split("");
            for (var i, o = r + r, a = t.length; a--; )
                i = o.indexOf(t[a]),
                ~i && (t[a] = o.substr(i - e, 1));
            return t.join("")
        },
        s: function(t, e) {
            var i = t.length;
            if (i) {
                var o = s(t, e)
                  , a = 0;
                for (t = t.split(""); ++a < i; )
                    t[a] = t.splice(o[i - 1 - a], 1, t[a])[0];
                t = t.join("")
            }
            return t
        },
        x: function(t, e) {
            var i = [];
            return e = e.charCodeAt(0),
            each(t.split(""), function(t, o) {
                i.push(String.fromCharCode(o.charCodeAt(0) ^ e))
            }),
            i.join("")
        }
    }
}


Ставим брейкпоинт на o(t) и смотрим, что приходит и что уходит. t — наша ссылка вида audio_api..., в ?extra= содержатся два параметра. Насколько я понял, один содержит зашифрованную ссылку, а второй — это что-то вроде ключа. Можно зареверсить алгоритм, понять, как именно он все это шифрует, а можно просто вызвать o('https://...audio_api_...'). Так я и решил сделать, и получил на выходе прямую ссылку на mp3

Получаем зашифрованные ссылки


Расшифровывать ссылки научились, метод для получения зашифрованн��й ссылки знаем. Как же нам теперь получить айди, которые нужно передать методу, возвращающему зашифрованные ссылки? Идем смотреть сетевую активность. Как мы поняли ранее, взаимодействие с audio API происходит через al_audio.php. Ставим фильтр на данный запрос, загружаем страницу с аудиозаписями заново и видим уже новый запрос

Request URL:https://vk.com/al_audio.php
Request Method:POST
Status Code:200
Form Data:
access_hash:
act:load_section
al:1
claim:0
offset:30
owner_id:my_id
playlist_id:-1
type:playlist


И в ответ получаем большой json в том же виде, что и раньше, который содержит данные о плейлисте. Поле offset отвечает за смещение, начиная с которого, мы будем получать данные о плейлисте. Кроме того, ответ содержит полезные данные: поля hasMore и nextOffset.

Собираем все вместе


Мы знаем как получить данные о плейлисте, как получить зашифрованные ссылки и как их расшифровать. Осталось собрать все воедино. Вот пример того, что получилось у меня. Проверил работоспособность на node.js v8.1.3.

UPD:


Как заметил Veber, с Оперой вк работает как-то по-особому, поэтому куки лучше брать из хрома/огнелиса.

UPD 2: вк обновил способ маскинга ссылки, теперь там код ксорится с айди, подробнее в коммите от 17.12.17