-
-
Save TheKing-OfTime/bb0f918a41a6c6990db8df929e0cabe3 to your computer and use it in GitHub Desktop.
| // СКРИПТ ДЛЯ МГНОВЕННОГО ЭКСПОРТА ПЛЕЙЛИСТОВ ИЗ НОВОЙ ВЕБ ВЕРСИИ ЯНДЕКС МУЗЫКИ | |
| // 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"); |
у меня тоже самое( 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)
Обновил скрипт. Попробуйте новую версию
а что делать после того как я вбил в консоль все
а что делать после того как я вбил в консоль все
Перейдите на страницу плейлиста который вы хотите экспортировать
спасибо, все сработало
Content-Security-Policy: Параметры страницы заблокировали загрузку ресурса wss://mc.yandex.ru/solid.ws («connect-src»).
такое сыпется, что делать? подскажите
Content-Security-Policy: Параметры страницы заблокировали загрузку ресурса wss://mc.yandex.ru/solid.ws («connect-src»). такое сыпется, что делать? подскажите
У вас блокировщик рекламы блокирует отправку метрики самой Яндекс Музыки. Это не должно мешать скрипту
Не удалось прочитать тело: TypeError: Cannot read properties of undefined (reading 'map')
at :178:50
at Array.map ()
at handleTracks (:175:63)
at window.fetch (:79:13)
at async o (5395.aeb7b5e9f3c1cbb1.js:1:4239)
at async R._retry (5395.aeb7b5e9f3c1cbb1.js:1:8866)
at async P.request (1186.989de96c3ca05dfa.js:1:16841)
at async Z.getPlaylist (1186.989de96c3ca05dfa.js:1:53371)