new version
This commit is contained in:
@@ -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 секунд.
|
||||
|
||||
## Коды завершения
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user