Если ваши данные лежат в "облаке" - это не ваши данные.
Итак, вводные:
есть Яндекс.Диск объемом ~500GB, заполненный в основном фото (RAW и JPEG) и видео
большинство файлов успешно скачивается через web-интерфейс на полной скорости
есть задача сохранить все его содержимое на локальный диск, насколько это возможно, учитывая последние события
Вроде бы ровно для этого и нужен официальный клиент - но в реальных условиях он повел себя крайне странно:
не позволил выбрать целевую папку ни на каком локальном диске, кроме C:
несмотря на позитивные новости, регулярно натыкался на какой-то недоступный файл и впадал в ступор
скорость загрузки оставляла желать лучшего
Пришлось брать дело в свои руки, а заодно плотнее познакомиться с node:streams...
Как все устроено "для браузера"
Пробовать API Диска после попыток использовать "родной" клиент не хотелось совсем - лучше посмотрим, как работает web-клиент в браузере, и постараемся прикинуться им.
В общем-то от нас никто ничего не скрывает, и нам потребуется реализовать всего несколько типов операций.
Листинг раздела
Чтобы получить список объектов любого раздела, включая корневой, используется POST-запрос по адресу https://disk.yandex.ru/models-v2?m=mpfs/resources.
Аргументы:
{ sort : 'name' // сортировка по имени , order : '1' , idContext : '/disk' // path до объекта , amount : 40 // количество элементов на странице , offset : 0 // стартовый по порядку элемент }
Web-клиент запрашивает списки файлов страницами по 40 штук, и мы не будем нарушать эту традицию.
В ответ получаем JSON-массив примерно таких объектов с вполне прозрачной структурой:
{ type : "dir" // тип объекта: 'dir' или 'file' , id : "/disk/My Folder/" // полный путь к объекту на Диске , name : "My Folder" // имя объекта , path : "/disk/My Folder" // полный путь к объекту на Диске, еще раз , meta : { size : 123456 // размер - для файла } // время создания, изменения, ... в Unix Timestamp , ctime : ... , mtime : ... , utime : ... }
Путь к файлу
Еще один POST-запрос, теперь по адресу https://disk.yandex.ru/models-v2?m=mpfs/url с единственным аргументом path помогает нам получить URL для загрузки файла:
{ file : "//downloader.disk.yandex.ru/disk/..." }
Тело файла
Обратившись к полученному URL, теперь уже GET-запросом, мы получим HTTP 302-редирект с адресом конкретного хранилища в заголовке:
location : https://mds.storage.yandex.net/rdisk/...
Собственно, там и находится искомый контент.
Загрузчик
Для того, чтобы "притворяться браузером" условно-корректно, нам потребуются заголовки любого из упомянутых выше POST-запроса - их легко скопировать прямо из браузера:

Нам потребуются сами заголовки (headers) и два параметра, передаваемых в body: sk и connection_id.
Данные сессии (
sk / connection_id) "протухают" примерно через сутки после получения. Поэтому если контента у вас много - загрузка отвалится, и необходимо будет взять из браузера актуальные реквизиты.
Я постарался обойтись минимумом внешних библиотек. Понадобилась всего одна - utimes для удобной простановки атрибутов времени на файлах и каталогах под Windows.
Основной алгоритм весьма прост:
обрабатываем очередь задач
tasks, пока она не опустеет, начиная с "корня"/disk(если вам более приоритетна конкретная папка - подставьте ее)для задачи-каталога (
type == 'dir') страницами по 40 получаем все лежащие внутри элементы и добавляем в начало очереди задачдля задачи-файла (
type == 'file') пытаемся несколько раз получить его контент, и если загруженный размер совпадает с ожидаемым - выкладываем в целевое хранилищев некоторых случаях при доступе к файлу случаются таймауты, HTTP 500 или просто обрыв в ходе загрузки - поэтому делаем пару попыток, пока не удалось загрузить весь ожидаемый объем файла
В моем случае целевой диск находится на медленном сетевом хранилище, поэтому тело файла сначала потоково "чанками" пишется во временный файл на локальном диске, а потом целиком копируется куда надо.
// yad-dl.js // очередь задач const tasks = [ { id : '/disk/' , type : 'dir' , path : '/disk' // откуда начинаем сканирование , name : 'disk' , depth : 0 , target : 'Z:\\#ya.dsk.dump' // целевая папка на локальном диске } ]; // реквизиты сессии const headers = { ..., "Referer": "https://disk.yandex.ru/client/disk" }; const reqJSON = { sk : '...:...' , connection_id : '...' }; const path = require('node:path'); const fs = require('node:fs'); const {Readable, Transform} = require('node:stream'); const {pipeline} = require('node:stream/promises'); const {utimes} = require('utimes'); // форматируем таймстамп в убодное представление Date.prototype.toTimeStringEx = function() { return `${this.getFullYear()}-${(this.getMonth() + 1).toString().padStart(2, '0')}-${this.getDate().toString().padStart(2, '0')} ${this.getHours().toString().padStart(2, '0')}:${this.getMinutes().toString().padStart(2, '0')}:${this.getSeconds().toString().padStart(2, '0')}.${this.getMilliseconds().toString().padStart(3, '0')}`; }; // статистика обработки const filestat = { skip : 0 , dump : 0 , error : 0 }; // создаем временную папку const temp = fs.mkdtempSync('yad-dl'); (async () => { // обрабатываем задачи из очереди по обной do { const task = tasks.shift(); if (!task) { break; } task.name = task.name.replaceAll(/[\:\?\"]/g, '+'); // убираем недопустимые символы из имени task.target = path.join(task.target, task.name); console.log(`${new Date().toTimeStringEx()} | -- ${task.type == 'file' ? 'f' : 'd'} : ${JSON.stringify(task.path)}`); console.log(`${new Date().toTimeStringEx()} | -- --- ${JSON.stringify(task.target)}`); // обработка папки if (task.type == 'dir') { // если целевой папки еще нет - создаем ее и проставляем ей атрибуты времени if (!fs.existsSync(task.target)) { try { fs.mkdirSync(task.target); } catch (e) { console.log(`${new Date().toTimeStringEx()} | mkdir`, e); } await utimes(task.target, { btime : (task.ctime ?? 0) * 1000 , mtime : (task.mtime ?? 0) * 1000 , atime : (task.utime ?? 0) * 1000 }); } else { console.log(`${new Date().toTimeStringEx()} | -- -- : skip mkdir`); } // загружаем вложенные элементы "страницами" по 40 штук const nested = []; const amount = 40; do { console.log(`${new Date().toTimeStringEx()} | -- -- : get offset : ${nested.length.toString().padStart(9)}`); const res = await fetch( 'https://disk.yandex.ru/models-v2?m=mpfs/resources' , { headers , method : 'POST' , body : JSON.stringify({ ...reqJSON , apiMethod : 'mpfs/resources' , requestParams : { sort : 'name' , order : '1' , idContext : task.path , amount , offset : nested.length , with_share : '1' } }) } ); const {resources} = await res.json(); console.log(`${new Date().toTimeStringEx()} | -- -- : segment-sz : ${resources.length.toString().padStart(9)}`); nested.push(...resources); if (resources.length < amount) { break; } } while (true); // прописываем всем "дочерним" задачам целевой каталог и добавляем в начало очереди nested.forEach(item => item.target = task.target); tasks.unshift(...nested); } // обработка файла if (task.type == 'file') { console.log(`${new Date().toTimeStringEx()} | -- -- : vol = ${task.meta.size.toString().padStart(10)}`); // пропускаем, если целевой файл уже есть, и нужного размера let need = !fs.existsSync(task.target); if (!need) { const {size} = fs.statSync(task.target); need ||= size != task.meta.size; if (need) { fs.rmSync(task.target); // если размер не совпал - зачищаем } else { console.log(`${new Date().toTimeStringEx()} | -- -- : skip file`); filestat.skip++; } } if (need) { console.log(`${new Date().toTimeStringEx()} | -- -- : get storage : ${JSON.stringify(task.path)}`); // получаем URL для доступа к файлу const res = await fetch( 'https://disk.yandex.ru/models-v2?m=mpfs/url' , { headers , method : 'POST' , body : JSON.stringify({ ...reqJSON , apiMethod : 'mpfs/url' , requestParams : { path : task.path } }) } ); const data = await res.json(); if (data.file) { let retry = 2; // до 2 попыток загрузки let success; do { console.log(`${new Date().toTimeStringEx()} | -- -- : rcv storage : ${data.file}`); const res = await fetch('https:' + data.file, { headers , method : 'GET' }) .catch(e => { console.log(`${new Date().toTimeStringEx()} | GET`, e); }); if (res?.status == 200) { const tempfile = path.join(temp, task.name); console.log(`${new Date().toTimeStringEx()} | -- -- : rcv data | ${tempfile}`); // считаем полученный объем файла и выводим в лог раз в секунду const tsb = Date.now(); let size = 0; let tsl = tsb; const monitor = new Transform({ transform(chunk, encoding, callback) { size += chunk.length; const tse = Date.now(); if (tse >= tsl + 1000 || size == task.meta.size) { console.log(`${new Date().toTimeStringEx()} | -- -- : rcv = ${size.toString().padStart(10)} (${(size/task.meta.size * 100).toFixed(2).padStart(6)}%) | ${(size/((tse - tsb)/1000)/1024/1024).toFixed(2).padStart(6)} MB/s | ETA = ${new Date(tse + (tse - tsb)/size * (task.meta.size - size)).toTimeStringEx()}`); tsl = tse; } this.push(chunk); callback(); } }); try { // перенаправляем поток загружаемого файла через наш монитор во временный файл await pipeline( Readable.fromWeb(res.body) .on('error', e => { console.log(`${new Date().toTimeStringEx()} | bodyStream`, e); }) , monitor , fs.createWriteStream(tempfile, {flags : 'wx'}) ) .catch(e => { console.log(`${new Date().toTimeStringEx()} | finished`, e); }); // если загруженный размер совпадает с ожидаемым - перекопируем в целевое хранилище if (size == task.meta.size) { console.log(`${new Date().toTimeStringEx()} | -- -- : cpy = ${task.target}`); fs.copyFileSync(tempfile, task.target); await utimes(task.target, { btime : task.ctime * 1000 , mtime : task.mtime * 1000 , atime : task.utime * 1000 }); const tse = Date.now(); console.log(`${new Date().toTimeStringEx()} | -- -- : mov = ${size.toString().padStart(10)} (${(size/task.meta.size * 100).toFixed(2).padStart(6)}%) | ${(size/((tse - tsl)/1000)/1024/1024).toFixed(2).padStart(6)} MB/s |`); success = true; filestat.dump++; } else { console.log(`${new Date().toTimeStringEx()} | -- -- : neq sizes : ${size} != ${task.meta.size}`); } } catch (e) { console.log(`${new Date().toTimeStringEx()} | catch`, e); } fs.rmSync(tempfile); } else { console.log(`${new Date().toTimeStringEx()} | -- -- : ${res?.status} - ${res?.statusText}`); } } while (!success && --retry); if (!success) { // файл, который не удалось получить, выводится в STDERR console.error(`${new Date().toTimeStringEx()} | ${task.path}`); filestat.error++; } } else { console.log(data); } } } } while (true); console.log(`${new Date().toTimeStringEx()} | finish | skip : ${filestat.skip}, dump : ${filestat.dump}, error : ${filestat.error}`); })();
Поскольку часть файлов все еще недоступна, но постепенно "открывается", рекомендуется периодически перезапускать скрипт. Я для себя сделал следующий bat-файл, который формирует новую пару log/err-файлов при каждом запуске, чтобы можно было сразу увидеть, что еще не загрузилось в этот раз:
@echo off for /f "tokens=2 delims==" %%i in ('wmic OS Get localdatetime /value') do set "dt=%%i" set "timestamp=%dt:~0,14%" node yad-dl.js >_log-%timestamp% 2>_err-%timestamp%
Вместо заключения
Практически каждый новый запуск скрипт приносит сейчас новые, недоступные на предыдущих итерациях, файлы - так что, благодаря коллегам из Яндекса, ситуация постепенно улучшается.
Возможно, вам будет достаточно и официального клиента, но у меня он так и не смог показать утилизацию канала выше 1Mbps. Эксперименты же со скриптом показали, что сейчас файлы отдаются хоть и на разных уровнях скорости, зато от 1.5Mbps до 50Mbps (видимо, в зависимости от конкретного ЦОД).


