Last active
February 28, 2026 00:40
-
-
Save TheKing-OfTime/bb0f918a41a6c6990db8df929e0cabe3 to your computer and use it in GitHub Desktop.
Export any Yandex Music Web playlist tracks to a file. Console script
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // СКРИПТ ДЛЯ МГНОВЕННОГО ЭКСПОРТА ПЛЕЙЛИСТОВ ИЗ НОВОЙ ВЕБ ВЕРСИИ ЯНДЕКС МУЗЫКИ | |
| // Created by: TheKingOfTime | |
| // Version: 1.2 | |
| // License: MIT | |
| // Как использовать: | |
| // 1) Откройте веб версию Яндекс Музыки. | |
| // 2) Откройте консоль (F12). | |
| // 3) Вставьте код, нажмите Enter. | |
| // Экспорт будет происходить при заходе в плейлист (не альбом). | |
| /** ========================= | |
| * НАСТРОЙКИ | |
| * ========================= */ | |
| const EXPORT_IMMEDIATELY = true; // true - экспортировать автоматически при загрузке плейлиста | |
| const USE_JSON = false; // true - экспортировать в json, false - в txt | |
| const USE_RICH_DATA = false; // true - включать raw track из API (только если USE_JSON = true) (Может содержать конфиденциальные данные) | |
| const PRETTY_JSON = true; // true - JSON с отступами (удобнее читать) | |
| const INCLUDE_PLAYLIST_TITLE_IN_FILENAME = true; | |
| const DEDUPE_EXPORTS = true; // true - не скачивать одно и то же много раз | |
| const AUTO_REEXPORT_IF_CHANGED = true; // если плейлист изменился (по хэшу) — экспортировать заново | |
| const DURATION_FORMAT = "hms"; // "ms" => mm:ss (минуты могут быть > 59), "hms" => hh:mm:ss | |
| const DEBUG = true; // логировать перехват и состояние | |
| const REPORT_NON_OK = true; // логировать ответы API с response.ok === false | |
| /** ========================= | |
| * ВНУТРЕННЕЕ СОСТОЯНИЕ | |
| * ========================= */ | |
| const __YM_EXPORTER_FLAG__ = "__YM_PLAYLIST_EXPORTER_INSTALLED__"; | |
| if (window[__YM_EXPORTER_FLAG__]) { | |
| console.warn("[YM Exporter] Скрипт уже установлен. Если нужно переустановить — сначала stopYmPlaylistExporter()."); | |
| } else { | |
| window[__YM_EXPORTER_FLAG__] = true; | |
| } | |
| const allSongs = {}; // playlistUuid -> tracks[] | |
| const playlistMeta = {}; // playlistUuid -> { title, lastHash, lastExportedHash, lastUpdatedAt } | |
| const exportedHashes = new Set(); // `${playlistUuid}:${hash}` | |
| /** ========================= | |
| * УТИЛИТЫ | |
| * ========================= */ | |
| function log(...args) { | |
| if (DEBUG) console.log("[YM Exporter]", ...args); | |
| } | |
| function warn(...args) { | |
| console.warn("[YM Exporter]", ...args); | |
| } | |
| function sanitizeFileName(name) { | |
| return String(name ?? "") | |
| .trim() | |
| .replace(/[\\/:*?"<>|]/g, "_") | |
| .replace(/\s+/g, " ") | |
| .slice(0, 160); | |
| } | |
| function pad2(n) { | |
| return String(n).padStart(2, "0"); | |
| } | |
| function msToDuration(ms) { | |
| const totalSeconds = Math.floor((ms || 0) / 1000); | |
| const seconds = totalSeconds % 60; | |
| const minutesTotal = Math.floor(totalSeconds / 60); | |
| if (DURATION_FORMAT === "ms") { | |
| // mm:ss где mm = total minutes (может быть > 59) | |
| return `${pad2(minutesTotal)}:${pad2(seconds)}`; | |
| } | |
| const minutes = minutesTotal % 60; | |
| const hours = Math.floor(minutesTotal / 60); | |
| return `${pad2(hours)}:${pad2(minutes)}:${pad2(seconds)}`; | |
| } | |
| function fnv1a(str) { | |
| let h = 0x811c9dc5; | |
| for (let i = 0; i < str.length; i++) { | |
| h ^= str.charCodeAt(i); | |
| h = (h + ((h << 1) + (h << 4) + (h << 7) + (h << 8) + (h << 24))) >>> 0; | |
| } | |
| return ("00000000" + h.toString(16)).slice(-8); | |
| } | |
| /** ========================= | |
| * ПОДГОТОВКА ДАННЫХ | |
| * ========================= */ | |
| function prepareTxt(tracks) { | |
| return tracks | |
| .map((t, idx) => { | |
| const title = String(t.title ?? "").replace(/\s+/g, " ").trim(); | |
| const artists = String(t.artists ?? "").replace(/\s+/g, " ").trim(); | |
| const duration = String(t.duration ?? "").trim(); | |
| return `${idx + 1}\t${title}\t${artists}\t${duration}`; | |
| }) | |
| .join("\n"); | |
| } | |
| function prepareJsonTracks(tracks) { | |
| const payload = USE_RICH_DATA ? tracks.map(t => t.richData) : tracks; | |
| return JSON.stringify(payload, null, PRETTY_JSON ? 2 : 0); | |
| } | |
| function prepareJsonPlaylists(playlists) { | |
| const playlistsData = {}; | |
| for (const [playlistID, tracks] of Object.entries(playlists)) { | |
| playlistsData[playlistID] = USE_RICH_DATA ? tracks.map(t => t.richData) : tracks; | |
| } | |
| return JSON.stringify(playlistsData, null, PRETTY_JSON ? 2 : 0); | |
| } | |
| function downloadBlob(blob, fileName) { | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement("a"); | |
| a.href = url; | |
| a.download = fileName; | |
| a.rel = "noopener"; | |
| a.click(); | |
| setTimeout(() => URL.revokeObjectURL(url), 2000); | |
| log("Список песен экспортирован в файл:", fileName); | |
| } | |
| /** ========================= | |
| * ЭКСПОРТ | |
| * ========================= */ | |
| function exportToFile(playlistID = undefined) { | |
| const keys = Object.keys(allSongs || {}); | |
| if (!keys.length) { | |
| warn("Нет плейлистов для экспорта"); | |
| return; | |
| } | |
| if (playlistID && !allSongs[playlistID]) { | |
| warn(`Плейлист ${playlistID} не найден для экспорта`); | |
| return; | |
| } | |
| // Если не указан playlistID и формат не JSON — экспортируем каждый плейлист отдельно | |
| if (!playlistID && !USE_JSON) { | |
| keys.forEach(k => exportToFile(k)); | |
| return; | |
| } | |
| let data; | |
| let fileName; | |
| if (!playlistID && USE_JSON) { | |
| data = prepareJsonPlaylists(allSongs); | |
| fileName = `songs_list_all.json`; | |
| } else { | |
| const tracks = allSongs[playlistID]; | |
| const meta = playlistMeta[playlistID] || {}; | |
| const safeTitle = INCLUDE_PLAYLIST_TITLE_IN_FILENAME ? sanitizeFileName(meta.title || "") : ""; | |
| const titlePart = safeTitle ? `${safeTitle}_` : ""; | |
| const ext = USE_JSON ? "json" : "txt"; | |
| fileName = `songs_${titlePart}${playlistID}.${ext}`; | |
| data = USE_JSON ? prepareJsonTracks(tracks) : prepareTxt(tracks); | |
| } | |
| const mime = USE_JSON ? "application/json;charset=utf-8" : "text/plain;charset=utf-8"; | |
| const blob = new Blob([data], { type: mime }); | |
| downloadBlob(blob, fileName); | |
| } | |
| window.exportToFile = exportToFile; | |
| window.allSongs = allSongs; | |
| /** ========================= | |
| * ОБРАБОТКА API-ОТВЕТА | |
| * ========================= */ | |
| function handleTracks(fetchedData) { | |
| const playlistUuid = fetchedData?.playlistUuid; | |
| if (!playlistUuid) return; | |
| const title = fetchedData?.title ?? "playlist"; | |
| const rawTracks = fetchedData?.tracks ?? []; | |
| const tracks = rawTracks.map(playlistTrack => { | |
| const track = playlistTrack?.track || {}; | |
| const artistsArr = Array.isArray(track.artists) ? track.artists : []; | |
| return { | |
| id: playlistTrack?.id, | |
| artists: artistsArr.map(a => a?.name).filter(Boolean).join(", "), | |
| duration: msToDuration(track.durationMs), | |
| title: track.title, | |
| richData: USE_RICH_DATA ? track : undefined, | |
| }; | |
| }); | |
| allSongs[playlistUuid] = tracks; | |
| // Хэш для дедупликации: ids + count | |
| const idsStr = tracks.map(t => String(t.id ?? "")).join("|"); | |
| const hash = fnv1a(`${tracks.length}#${idsStr}`); | |
| playlistMeta[playlistUuid] = { | |
| title, | |
| lastHash: hash, | |
| lastUpdatedAt: Date.now(), | |
| lastExportedHash: playlistMeta[playlistUuid]?.lastExportedHash, | |
| }; | |
| log(`Плейлист "${title}" сохранён: ${playlistUuid}, треков: ${tracks.length}, hash: ${hash}`); | |
| if (!EXPORT_IMMEDIATELY) return; | |
| if (!DEDUPE_EXPORTS) { | |
| exportToFile(playlistUuid); | |
| return; | |
| } | |
| const key = `${playlistUuid}:${hash}`; | |
| const alreadyExported = exportedHashes.has(key); | |
| if (alreadyExported && !AUTO_REEXPORT_IF_CHANGED) { | |
| log(`Экспорт пропущен (уже экспортировалось): ${key}`); | |
| return; | |
| } | |
| if (alreadyExported) { | |
| log(`Экспорт пропущен (без изменений): ${key}`); | |
| return; | |
| } | |
| exportedHashes.add(key); | |
| playlistMeta[playlistUuid].lastExportedHash = hash; | |
| exportToFile(playlistUuid); | |
| } | |
| /** ========================= | |
| * ПАТЧ FETCH | |
| * ========================= */ | |
| const API_HOST_RE = /^api\.music\.yandex\.(ru|net|kz|by|com)$/i; | |
| function isPlaylistApiUrl(u) { | |
| try { | |
| const url = new URL(u); | |
| return API_HOST_RE.test(url.hostname) && url.pathname.startsWith("/playlist/"); | |
| } catch { | |
| return false; | |
| } | |
| } | |
| function withRichTracks(u) { | |
| const url = new URL(u); | |
| if (url.searchParams.get("richTracks") !== "true") { | |
| url.searchParams.set("richTracks", "true"); | |
| } | |
| return url.toString(); | |
| } | |
| const __originalFetch = window.fetch; | |
| if (!window.__ymExporterOriginalFetch) { | |
| window.__ymExporterOriginalFetch = __originalFetch; | |
| } | |
| window.fetch = async function (input, init) { | |
| let urlStr = null; | |
| let isReq = false; | |
| if (typeof input === "string") { | |
| if (isPlaylistApiUrl(input)) urlStr = input; | |
| } else if (input instanceof Request) { | |
| isReq = true; | |
| if (isPlaylistApiUrl(input.url)) urlStr = input.url; | |
| } | |
| if (!urlStr) { | |
| return __originalFetch.call(this, input, init); | |
| } | |
| const newUrl = withRichTracks(urlStr); | |
| if (newUrl !== urlStr) log("Fetch перехвачен, URL изменён:", newUrl); | |
| let response; | |
| if (isReq) { | |
| const req = new Request(newUrl, input); | |
| response = await __originalFetch.call(this, req); | |
| } else { | |
| response = await __originalFetch.call(this, newUrl, init); | |
| } | |
| try { | |
| if (REPORT_NON_OK && !response.ok) { | |
| warn(`API вернул не-OK статус: ${response.status} ${response.statusText}`); | |
| } | |
| const ct = response.headers.get("content-type") || ""; | |
| if (!ct.includes("application/json")) { | |
| return response; | |
| } | |
| const clone = response.clone(); | |
| const data = await clone.json(); | |
| handleTracks(data); | |
| } catch (e) { | |
| warn("Не удалось прочитать/обработать ответ:", e); | |
| } | |
| return response; | |
| }; | |
| window.stopYmPlaylistExporter = function () { | |
| if (window.__ymExporterOriginalFetch) { | |
| window.fetch = window.__ymExporterOriginalFetch; | |
| delete window.__ymExporterOriginalFetch; | |
| } | |
| window[__YM_EXPORTER_FLAG__] = false; | |
| log("Остановлено. fetch восстановлен."); | |
| }; | |
| log("Установлено ✅ Функции: exportToFile(playlistUuid?), stopYmPlaylistExporter(), доступ к данным: allSongs"); |
Author
Обновил скрипт. Попробуйте новую версию
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
у меня тоже самое( VM1640:65 [Fetch ответ] Не удалось прочитать тело: TypeError: Cannot read properties of undefined (reading 'map')
at :161:50
at Array.map ()
at handleTracks (:158:63)
at window.fetch (:62:13)
at async o (5395.aeb7b5e9f3c1cbb1.js:1:4239)
at async R._retry (5395.aeb7b5e9f3c1cbb1.js:1:8866)
at async P.request (1186.f2143e19920ac335.js:1:16932)
at async Z.getPlaylist (1186.f2143e19920ac335.js:1:53462)