Копируем содержимое Яндекс.Диск к себе

—

от автора

Если ваши данные лежат в «облаке» — это не ваши данные.

Итак, вводные:

  • есть Яндекс.Диск объемом ~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 offfor /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 (видимо, в зависимости от конкретного ЦОД).

Текущая скорость загрузки файлов с Диска

Текущая скорость загрузки файлов с Диска

ссылка на оригинал статьи https://habr.com/ru/articles/1101968/