Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Select an option

  • Save TheKing-OfTime/bb0f918a41a6c6990db8df929e0cabe3 to your computer and use it in GitHub Desktop.

Select an option

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
// СКРИПТ ДЛЯ МГНОВЕННОГО ЭКСПОРТА ПЛЕЙЛИСТОВ ИЗ НОВОЙ ВЕБ ВЕРСИИ ЯНДЕКС МУЗЫКИ
// 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");
@wdlukcer
Copy link

у меня тоже самое( 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)

@TheKing-OfTime
Copy link
Author

Обновил скрипт. Попробуйте новую версию

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment