diff --git a/README.md b/README.md index eba1630..9abfa44 100644 --- a/README.md +++ b/README.md @@ -2,22 +2,69 @@ Скрипт для рекурсивного скачивания большой публичной папки Яндекс Диска, защищённой паролем. -Скрипт использует cookie `passToken`, скачивает каждый файл отдельно и не пытается сформировать единый ZIP-архив. +Он получает список объектов через веб-API Яндекс Диска, сохраняет структуру каталогов и скачивает каждый файл отдельно через `wget`. ## Возможности -* скачивание защищённой публичной папки; -* рекурсивный обход вложенных каталогов; -* сохранение исходной структуры папок; -* скачивание файлов по одному; -* продолжение прерванной загрузки через `wget --continue`; -* автоматический пропуск полностью скачанных файлов; -* проверка размера скачанного файла; -* автоматическая пагинация списка файлов; -* остановка при появлении CAPTCHA; -* сохранение ссылки CAPTCHA в текстовый файл; -* паузы между запросами для снижения вероятности блокировки; -* повторный запуск без потери уже скачанных данных. +- чтение всех пользовательских настроек из файла `.env` рядом со скриптом; +- использование готового cookie `passToken` либо автоматическое получение токена по паролю через Chromium и Playwright; +- постоянный профиль Chromium с повторным использованием cookies; +- рекурсивный обход вложенных каталогов; +- двухэтапная работа: сначала полный список, затем скачивание; +- сохранение полного списка в `yandex-download-manifest.jsonl`; +- исключение служебного элемента текущего каталога из расчёта `offset`; +- сохранение исходной структуры каталогов; +- продолжение прерванной загрузки через `wget --continue`; +- автоматический пропуск файлов, размер которых уже совпадает с серверным; +- проверка итогового размера каждого скачанного файла; +- паузы между запросами прямых ссылок и периодические длительные паузы; +- повторные HTTP-запросы при временных ошибках `429`, `500`, `502`, `503` и `504`; + +## Как работает двухэтапная загрузка + +### Этап 1 — получение полного списка + +Скрипт быстро обходит страницы каталога: + +```text +0 → 40 → 80 → 120 → ... → completed=True +``` + +На этом этапе: + +- `download-url` не запрашивается; +- `wget` не запускается; +- файлы не скачиваются; +- для каждого файла сохраняются серверные метаданные и относительный каталог; +- вложенные каталоги также обходятся полностью; +- служебное описание текущей папки не учитывается при увеличении `offset`. + +После успешного завершения обхода создаётся: + +```text +OUTPUT_DIR/yandex-download-manifest.jsonl +``` + +Manifest записывается атомарно: сначала создаётся временный файл `.tmp`, после чего он заменяет предыдущий manifest. + +Пример одной строки: + +```json +{"relative_directory":".","item":{"name":"FL_9J1915234BP_1654_40013103010803_V001_S.frf","type":"file","size":793122,"path":"public_hash:/ODIS/Flashdaten/Brand-A/FL_9J1915234BP_1654_40013103010803_V001_S.frf"}} +``` + +### Этап 2 — скачивание файлов + +После получения `completed=True` скрипт начинает скачивание по полностью собранному плану: + +1. проверяет наличие локального файла; +2. сравнивает его размер с серверным; +3. пропускает полностью скачанный файл; +4. для отсутствующего или неполного файла получает временный `download-url`; +5. запускает `wget --continue`; +6. проверяет размер после завершения. + +Текущая версия сохраняет manifest для контроля и последующего анализа, но при новом запуске всё равно заново выполняет этап 1 и получает актуальный список с Яндекса. ## Требования @@ -29,38 +76,36 @@ sudo apt install -y python3 python3-requests wget ``` +Playwright и Chromium для автоматического получения `passToken`: + +```bash +python3 -m pip install --user playwright +python3 -m playwright install chromium +``` + ## Настройка -Все основные параметры находятся в начале файла: +Все основные параметры находятся в файле sample.env -```python -TARGET_URL = ( - "https://disk.yandex.ru/d/" - "cWn4RgSdFcSZXw/ODIS/Flashdaten/Brand-V" -) +### `TARGET_URL` -PASS_TOKEN = "" +Полная публичная ссылка на нужный каталог: -OUTPUT_DIR = "/mnt/hdd1-4TB/ODIS/Flashdaten/Brand-V" +```dotenv +TARGET_URL="https://disk.yandex.ru/d/cWn4RgSdFcSZXw/ODIS/Flashdaten/Brand-A" ``` -### TARGET_URL +Для вложенного каталога необходимо указывать полный путь после идентификатора публичной ссылки. -Полная ссылка на скачиваемую папку Яндекс Диска: +### `PUBLIC_LINK_PASSWORD` -```python -TARGET_URL = "https://disk.yandex.ru/d/cWn4RgSdFcSZXw/ODIS/Flashdaten/Brand-V" +Пароль публичной ссылки: + +```dotenv +PUBLIC_LINK_PASSWORD="реальный пароль публичной ссылки" ``` -### PASS_TOKEN - -Значение cookie `passToken`, полученное после ввода пароля в браузере. - -Можно указать только значение: - -```python -PASS_TOKEN = "значение_cookie_passToken" -``` +Если оставить значение пустым, программа попытается использовать cookies из постоянного профиля Chromium. Если действующего `passToken` нет и запуск производится из терминала, пароль будет запрошен без отображения введённых символов. ### OUTPUT_DIR @@ -71,31 +116,131 @@ OUTPUT_DIR = "/mnt/hdd1-4TB/ODIS/Flashdaten/Brand-V" ``` Каталог будет создан автоматически, если у пользователя есть права на его создание. +Внутри него также могут появиться: + +```text +yandex-download-manifest.jsonl +yandex-captcha-url.txt +``` + +### Настройки Playwright + +```dotenv +PLAYWRIGHT_PROFILE_DIR="./playwright-profile" +PLAYWRIGHT_HEADLESS="false" +PLAYWRIGHT_TIMEOUT="120" +``` + +- `PLAYWRIGHT_PROFILE_DIR` — постоянный профиль Chromium; +- `PLAYWRIGHT_HEADLESS=false` — показывает окно браузера и позволяет вручную пройти CAPTCHA; +- `PLAYWRIGHT_HEADLESS=true` — запускает Chromium без окна; при CAPTCHA программа остановится; +- `PLAYWRIGHT_TIMEOUT` — максимальное время ожидания получения `passToken`, в секундах. + +Относительный путь профиля вычисляется относительно каталога, где находится `.env` и скрипт. + +### Задержки между страницами + +```dotenv +PAGE_DELAY_MIN="0.5" +PAGE_DELAY_MAX="1.5" +``` + +Эти задержки используются только на этапе 1 между запросами `fetch-list`. + +После перехода на двухэтапную схему страницы перечисляются подряд и больше не разделяются длительным скачиванием файлов. Поэтому состояние пагинации живёт значительно меньше времени. + +### Задержки между файлами + +```dotenv +FILE_DELAY_MIN="1.5" +FILE_DELAY_MAX="3.5" +``` + +Случайная пауза перед каждым запросом `download-url` на этапе 2. + +### Длительные паузы + +```dotenv +COOLDOWN_EVERY_FILES="100" +COOLDOWN_MIN="60" +COOLDOWN_MAX="120" +``` + +После каждых 100 запросов `download-url` программа дополнительно ждёт от 60 до 120 секунд. + +Чтобы отключить длительные паузы: + +```dotenv +COOLDOWN_EVERY_FILES="0" +COOLDOWN_MIN="0" +COOLDOWN_MAX="0" +``` + +### HTTP-таймауты и повторы + +```dotenv +CONNECT_TIMEOUT="30" +READ_TIMEOUT="120" +HTTP_RETRIES="5" +``` + +- `CONNECT_TIMEOUT` — таймаут установки соединения; +- `READ_TIMEOUT` — таймаут чтения ответа API; +- `HTTP_RETRIES` — число автоматических повторов временных ошибок. ## Пример вывода +### Получение токена + +```text +[AUTH] Открываю защищённую ссылку в Chromium... +[AUTH] Используется passToken из сохранённого профиля. +``` + +Либо: + +```text +[AUTH] Поле пароля найдено (input[type="password"]). +[AUTH] Ввожу пароль публичной ссылки... +[AUTH] Пароль введён; отправляю форму... +[AUTH] passToken получен. +``` + +### Этап 1 + ```text [INFO] Открываю исходную ссылку... [INFO] Папка открыта. -[INFO] Корневой hash: public_hash: +[INFO] Корневой hash: public_hash:/ODIS/Flashdaten/Brand-A +[INFO] Этап 1/2: получаю полный список файлов... -[DIR ] /mnt/hdd1-4TB/ODIS/Flashdaten/Brand-V -[GET ] /mnt/hdd1-4TB/ODIS/Flashdaten/Brand-V/038997016C__7000.sgo (1.75 MiB) -[WAIT] 2.4 с перед запросом ссылки -2026-07-19 URL:https://downloader.disk.yandex.ru/... [1835008/1835008] -[PAGE] получено=41, следующий offset=41, completed=False -[WAIT] 4.2 с перед следующей страницей +[DIR ] /mnt/hdd1-4TB/ODIS/Flashdaten/Brand-A +[PAGE] получено API=41, дочерних=40, служебных=1, offset=0->40, completed=False +[WAIT] 0.8 с перед следующей страницей +[PAGE] получено API=10, дочерних=9, служебных=1, offset=12080->12089, completed=True +[INFO] Полный список получен: 12089 файлов. +[INFO] Manifest сохранён: /mnt/hdd1-4TB/ODIS/Flashdaten/Brand-A/yandex-download-manifest.jsonl ``` -После завершения выводится статистика: +### Этап 2 + +```text +[INFO] Этап 2/2: начинаю скачивание по сохранённому списку... +[SKIP] /mnt/hdd1-4TB/ODIS/Flashdaten/Brand-A/FL_0DL300014F_3123_idMA_sw.frf (2.14 MiB) +[GET ] /mnt/hdd1-4TB/ODIS/Flashdaten/Brand-A/FL_9J1915234BP_1654_40013103010803_V001_S.frf (774.53 KiB) +[WAIT] 2.6 с перед запросом ссылки +2026-08-03 URL:https://s684vla.storage.yandex.net/rdisk/... [793122/793122] +``` + +### Итоговая статистика ```text ================ РЕЗУЛЬТАТ ================ -Найдено файлов: 4500 -Скачано сейчас: 250 -Уже было скачано: 4250 +Найдено файлов: 12089 +Скачано сейчас: 529 +Уже было скачано: 11560 Ошибок: 0 -Известный общий объём: 115.40 GiB +Известный общий объём: 49.04 GiB =========================================== ``` @@ -129,48 +274,26 @@ https://disk.yandex.ru/showcaptcha?... /mnt/hdd1-4TB/ODIS/Flashdaten/Brand-V/yandex-captcha-url.txt ``` -## Настройка задержек +## Ошибка `409 Conflict` -### FILE_DELAY_MIN и FILE_DELAY_MAX +Внутренний API `/public/api/fetch-list` может вернуть `409 Conflict`, если серверное состояние пагинации изменилось или стало недействительным. -Случайная задержка перед запросом прямой ссылки на каждый файл: +Двухэтапная схема значительно снижает вероятность такой ошибки, потому что все страницы каталога запрашиваются до начала скачивания файлов. -```python -FILE_DELAY_MIN = 1.5 -FILE_DELAY_MAX = 3.5 +`409` не следует считать признаком конца каталога. Если он всё же возник на этапе 1: + +1. не удаляйте уже скачанные файлы; +2. повторно запустите программу; +3. скрипт получит новый `sk` и начнёт перечисление заново; +4. на этапе 2 существующие файлы будут пропущены. + +Полным считается только обход, завершившийся серверным признаком: + +```text +completed=True ``` -Скрипт будет ждать от 1.5 до 3.5 секунды перед каждым запросом `download-url`. -### PAGE_DELAY_MIN и PAGE_DELAY_MAX - -Задержка между страницами списка файлов: - -```python -PAGE_DELAY_MIN = 3.0 -PAGE_DELAY_MAX = 6.0 -``` - -### COOLDOWN_EVERY_FILES - -Количество запросов файлов, после которого выполняется длинная пауза: - -```python -COOLDOWN_EVERY_FILES = 100 -``` - -При значении `100` длинная пауза выполняется после каждых 100 запросов. - -### COOLDOWN_MIN и COOLDOWN_MAX - -Диапазон длительной паузы: - -```python -COOLDOWN_MIN = 60.0 -COOLDOWN_MAX = 120.0 -``` - -После каждых 100 запросов скрипт будет ждать от 60 до 120 секунд. ## Коды завершения diff --git a/yandex_disk_folder_downloader.py b/yandex_disk_folder_downloader.py index a86da36..4468b3d 100644 --- a/yandex_disk_folder_downloader.py +++ b/yandex_disk_folder_downloader.py @@ -1161,6 +1161,10 @@ class YandexProtectedFolderDownloader: self.visited_directories: set[str] = set() self.seen_files: set[str] = set() + self.planned_file_hashes: set[str] = set() + self.planned_files: list[ + tuple[dict[str, Any], Path] + ] = [] self.file_count = 0 self.downloaded_count = 0 @@ -1883,17 +1887,10 @@ class YandexProtectedFolderDownloader: self.seen_files.add(resource_hash) - self.file_count += 1 - expected_size = self.get_file_size( item ) - if expected_size is not None: - self.total_known_bytes += ( - expected_size - ) - destination_directory = ( self.output_dir / relative_directory @@ -2016,14 +2013,11 @@ class YandexProtectedFolderDownloader: relative_directory: Path, ) -> list[tuple[str, str]]: """ - Обработать страницу. + Добавить файлы страницы в план скачивания. - Возвращает список вложенных каталогов: - - [ - (hash_каталога, локальное_имя), - ... - ] + На первом этапе прямые ссылки не запрашиваются + и wget не запускается. Метод только сохраняет + описание файлов и возвращает вложенные каталоги. """ subdirectories: list[ @@ -2064,35 +2058,126 @@ class YandexProtectedFolderDownloader: continue - if item_type == "file": - try: - self.download_file( - item, - relative_directory, - ) + if item_type != "file": + continue - except CaptchaRequired: - # CAPTCHA должна остановить - # весь процесс, а не один файл. - raise + resource_hash = ( + item.get("path") + or item.get("hash") + ) - except Exception as exc: - local_path = ( - self.output_dir - / relative_directory - / name - ) + if ( + not isinstance(resource_hash, str) + or not resource_hash + ): + print( + "[WARN] У файла " + f"{name!r} отсутствует path/hash.", + file=sys.stderr, + ) + self.failed_count += 1 + continue - print( - f"[ERR ] {local_path}: " - f"{exc}", - file=sys.stderr, - ) + if resource_hash in self.planned_file_hashes: + continue - self.failed_count += 1 + self.planned_file_hashes.add( + resource_hash + ) + + self.planned_files.append( + ( + item, + relative_directory, + ) + ) + + self.file_count += 1 + + expected_size = self.get_file_size( + item + ) + + if expected_size is not None: + self.total_known_bytes += ( + expected_size + ) return subdirectories + def save_manifest(self) -> Path: + """Атомарно сохранить полный план скачивания в JSONL.""" + + manifest_path = ( + self.output_dir + / "yandex-download-manifest.jsonl" + ) + + temporary_path = manifest_path.with_suffix( + manifest_path.suffix + ".tmp" + ) + + with temporary_path.open( + "w", + encoding="utf-8", + ) as manifest_file: + for item, relative_directory in self.planned_files: + record = { + "relative_directory": ( + relative_directory.as_posix() + ), + "item": item, + } + + manifest_file.write( + json.dumps( + record, + ensure_ascii=False, + separators=(",", ":"), + ) + + "\n" + ) + + temporary_path.replace( + manifest_path + ) + + return manifest_path + + def download_planned_files(self) -> None: + """Скачать файлы из полностью собранного плана.""" + + for item, relative_directory in self.planned_files: + name = safe_component( + str( + item.get("name") + or "unnamed-file" + ) + ) + + try: + self.download_file( + item, + relative_directory, + ) + + except CaptchaRequired: + raise + + except Exception as exc: + local_path = ( + self.output_dir + / relative_directory + / name + ) + + print( + f"[ERR ] {local_path}: {exc}", + file=sys.stderr, + ) + + self.failed_count += 1 + def split_fetch_list_items( self, items: list[dict[str, Any]], @@ -2451,11 +2536,34 @@ class YandexProtectedFolderDownloader: f"{self.root_hash}" ) + print( + "[INFO] Этап 1/2: получаю полный список файлов..." + ) + self.crawl_directory( self.root_hash, Path("."), ) + manifest_path = self.save_manifest() + + print( + "[INFO] Полный список получен: " + f"{self.file_count} файлов." + ) + + print( + "[INFO] Manifest сохранён: " + f"{manifest_path}" + ) + + print( + "[INFO] Этап 2/2: начинаю скачивание " + "по сохранённому списку..." + ) + + self.download_planned_files() + self.print_summary() def print_summary(self) -> None: