From d597a5e1c5fd1ccdf8dfeddf687b017544980b48 Mon Sep 17 00:00:00 2001 From: vrubelroman Date: Sun, 26 Jul 2026 00:31:16 +0000 Subject: [PATCH] =?UTF-8?q?fix(downloaders):=20Deno=20runtime,=20=D1=81?= =?UTF-8?q?=D0=B2=D0=B5=D0=B6=D0=B8=D0=B9=20yt-dlp,=20=D1=87=D0=B5=D1=81?= =?UTF-8?q?=D1=82=D0=BD=D1=8B=D0=B9=20health-check,=20=D1=81=D0=BC=D0=BE?= =?UTF-8?q?=D1=83=D0=BA-=D1=82=D0=B5=D1=81=D1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Возрастные YouTube-видео не скачивались: с cookies yt-dlp отбрасывает клиент android и остаётся web, которому нужно решить n-challenge, а в образе стоял Node 20 при требуемом минимуме 22 ("JS runtimes: node-20.19.2 (unsupported)"). Ставим Deno — рекомендованный yt-dlp рантайм и один статический бинарник. Instagram падал с "empty media response" одинаково с cookies и без них — дело было не в сессии, а в устаревшем экстракторе: слой pip был закеширован на yt-dlp 2026.06.09. Поднимаем нижнюю границу до 2026.7.4. /cookies/check помечал проблемой с cookies ЛЮБОЙ сбой, из-за чего на поломку JS-рантайма прилетел алерт про протухшие cookies и увёл разбор не туда. Теперь ответ содержит status: ok | cookies_invalid | extraction_failed | no_cookies, и админ-бот шлёт разные сообщения. Разбор ответа в bot.py сохраняет совместимость со старым форматом без поля status. Добавлен smoke_test.py — гоняет реальные ссылки (включая обе регрессии выше) через запущенные сервисы и печатает таблицу. Запускать после каждой правки. Схема получения cookies переведена с крона на разовый ручной экспорт: cookies-cron/ -> cookies/, удалены скрипты с анти-паттерном `--cookies-from-browser BROWSER --cookies FILE`, который wiki yt-dlp прямо запрещает и который сам ломал YouTube-сессию ротацией. Ключевые cookies живут около года, поэтому обновление по расписанию не нужно — триггером служит алерт health-check. deliver_cookies.sh только доставляет файлы по scp. Co-Authored-By: Claude Opus 5 --- ARCHITECTURE.md | 4 +- README.md | 23 +- bot.py | 43 +++- cookies-cron/README.md | 36 --- cookies-cron/get_instagram_cookies.sh | 76 ------ cookies-cron/get_youtube_cookies.sh | 217 ------------------ cookies/README.md | 74 ++++++ .../deliver_cookies.sh | 71 +++--- .../INSTAGRAM_COOKIES_INSTRUCTIONS.md | 50 +--- instagram-downloader/README.md | 73 ++---- instagram-downloader/app.py | 24 +- instagram-downloader/get_instagram_cookies.sh | 79 ------- instagram-downloader/requirements.txt | 2 +- .../update_instagram_cookies.sh | 47 ---- smoke_test.py | 128 +++++++++++ youtube-downloader/Dockerfile | 17 +- youtube-downloader/app.py | 37 ++- youtube-downloader/get_youtube_cookies.sh | 103 --------- youtube-downloader/requirements.txt | 2 +- 19 files changed, 373 insertions(+), 733 deletions(-) delete mode 100644 cookies-cron/README.md delete mode 100755 cookies-cron/get_instagram_cookies.sh delete mode 100755 cookies-cron/get_youtube_cookies.sh create mode 100644 cookies/README.md rename cookies-cron/update_cookies.sh => cookies/deliver_cookies.sh (55%) delete mode 100755 instagram-downloader/get_instagram_cookies.sh delete mode 100755 instagram-downloader/update_instagram_cookies.sh create mode 100755 smoke_test.py delete mode 100755 youtube-downloader/get_youtube_cookies.sh diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 337c711..d19594f 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -182,7 +182,7 @@ CREATE TABLE stats ( - (YouTube) `./youtube_cookies.txt:/app/youtube_cookies.txt` - (Instagram) `./instagram_cookies.txt:/app/instagram_cookies.txt` -Cookies монтируются с хоста (не read-only — внешний крон на отдельной машине обновляет файл по `scp`), а не запекаются в образ — это позволяет обновлять их без пересборки/передеплоя сервиса. +Cookies монтируются с хоста (не read-only — файл обновляется вручную по `scp` с машины, где есть браузер), а не запекаются в образ — это позволяет обновлять их без пересборки/передеплоя сервиса. Процедура — в `cookies/README.md`. ## Потоки данных @@ -235,7 +235,7 @@ YAPFILES_DOWNLOADER_URL=http://:5558 ## Безопасность - Токен бота в `.env` (не коммитится) -- Cookies YouTube/Instagram закоммичены в репозиторий как fallback внутри образа; актуальные версии приходят на хост кроном и монтируются поверх (см. `README.md#cookies-на-проде`) +- Cookies YouTube/Instagram закоммичены в репозиторий как fallback внутри образа; актуальные версии кладутся на хост вручную и монтируются поверх (см. `README.md#cookies-на-проде` и `cookies/README.md`) - Сервисы доступны только по указанным URL - Можно добавить API key между сервисами diff --git a/README.md b/README.md index 8906f0a..3325c99 100644 --- a/README.md +++ b/README.md @@ -76,14 +76,9 @@ TIKTOK_DOWNLOADER_URL=http://localhost:5559 Если планируете скачивать видео с Instagram: -1. Экспортируйте cookies из браузера (см. `instagram-downloader/INSTAGRAM_COOKIES_INSTRUCTIONS.md`) +1. Экспортируйте cookies из браузера расширением для `cookies.txt` — процедура в [`cookies/README.md`](cookies/README.md) 2. Сохраните файл как `instagram_cookies.txt` в папке `instagram-downloader/` -```bash -cd instagram-downloader -./get_instagram_cookies.sh -``` - ### 4. Запуск сервисов Все 7 сервисов (бот, admin-бот, 5 загрузчиков) собираются и запускаются одной командой из корня проекта — единый `docker-compose.yml` уже содержит build-контексты для всех подпапок: @@ -174,7 +169,21 @@ Content-Type: application/json В проде `youtube_cookies.txt`/`instagram_cookies.txt` монтируются в контейнеры с хоста (`docker-compose.prod.yml`), а не запекаются в образ — это позволяет обновлять их без пересборки/передеплоя. `yt-dlp`/`app.py` читают файл с диска при каждом запросе — рестарт контейнера не требуется. -Куки получает по крону (раз в 20 минут) отдельная машина с графическим окружением и реальным залогиненным браузером (на самом проде это сделать нельзя — там нет GUI/браузера), и разливает результат по `scp` сразу на прод и на тестовый стенд. Скрипты для этого, требования к cron-машине и инструкция по развёртыванию на новой машине — в [`cookies-cron/`](cookies-cron/README.md). Это копия того, что реально крутится на cron-машине; `youtube-downloader/get_youtube_cookies.sh` и `instagram-downloader/get_instagram_cookies.sh` в подпапках сервисов — более старые версии тех же скриптов, с тех пор разошедшиеся. +Куки обновляются **вручную и редко** — по алерту `🍪⚠️ COOKIES ПРОТУХЛИ` из админ-бота, а не по расписанию. Ключевые куки живут около года, поэтому регулярный экспорт не нужен; за состоянием следит фоновая проверка в `bot.py` (`check_cookies_health`, раз в 30 минут дёргает `POST /cookies/check` у youtube- и instagram-downloader). + +Экспорт делается с машины с браузером (на проде нет GUI) и требует точной процедуры — для YouTube только через приватное окно, иначе сессия ротируется и куки протухают. Пошаговая инструкция и скрипт доставки — в [`cookies/`](cookies/README.md). + +## Смоук-тест + +После любой правки, до коммита, на поднятом тестовом контуре: + +```bash +python3 smoke_test.py +``` + +Гоняет реальные ссылки через запущенные сервисы и печатает таблицу: `/cookies/check` обоих загрузчиков, `/formats` на возрастном YouTube-видео (ловит отсутствие рабочего JS-рантайма) и реальные скачивания YouTube и Instagram. Код возврата `0` — всё прошло, `1` — есть падения. Адреса переопределяются через `YOUTUBE_DOWNLOADER_URL`/`INSTAGRAM_DOWNLOADER_URL`. + +Если сервисы пересобирались — поднимать их через `docker compose up -d --force-recreate `: без флага `up -d` просто стартует старый контейнер, и правка в проверку не попадёт. ## Обновление diff --git a/bot.py b/bot.py index f15f677..9fb6e00 100644 --- a/bot.py +++ b/bot.py @@ -617,8 +617,13 @@ def _save_cookie_state(state: dict): logger.error(f"Не удалось сохранить состояние cookie-health: {e}") -async def notify_admin_cookie_alert(service: str, healthy: bool, detail: str): - """Проактивное уведомление о состоянии cookies (health-check), не связано с ошибкой конкретного пользователя""" +async def notify_admin_cookie_alert(service: str, status: str, detail: str): + """Проактивное уведомление от health-check, не связано с ошибкой конкретного пользователя. + + status: 'ok' | 'cookies_invalid' | 'extraction_failed' + Тексты разные намеренно: сбой извлечения (версия yt-dlp, JS-рантайм) не должен + выглядеть как протухшие cookies, иначе разбор уходит не туда. + """ if not ADMIN_BOT_TOKEN: return admin_chat_id = get_admin_chat_id() @@ -634,15 +639,24 @@ async def notify_admin_cookie_alert(service: str, healthy: bool, detail: str): ) admin_bot = Bot(token=ADMIN_BOT_TOKEN, request=request) - if healthy: - text = f"✅ Cookies восстановлены\n\n🍪 Сервис: {service}\nCookies снова рабочие." + if status == 'ok': + text = f"✅ Снова работает\n\n🍪 Сервис: {service}\nПроверка проходит успешно." + elif status == 'cookies_invalid': + text = ( + f"🍪⚠️ COOKIES ПРОТУХЛИ\n\n🍪 Сервис: {service}\n❗ {detail}\n\n" + "Обновить по инструкции в cookies/README.md " + "(для YouTube — только через приватное окно)." + ) else: - text = f"🍪⚠️ ПРОАКТИВНАЯ ПРОВЕРКА COOKIES\n\n🍪 Сервис: {service}\n❗ {detail}" + text = ( + f"🛠⚠️ СЕРВИС НЕ МОЖЕТ ИЗВЛЕЧЬ ВИДЕО\n\n🍪 Сервис: {service}\n❗ {detail}\n\n" + "Это НЕ cookies — проверить версию yt-dlp и логи сервиса." + ) if len(text) > 4000: text = text[:4000] + "…" await admin_bot.send_message(chat_id=admin_chat_id, text=text) - logger.info(f"Уведомление о состоянии cookies ({service}) отправлено админ боту") + logger.info(f"Уведомление health-check ({service}, {status}) отправлено админ боту") except Exception as e: logger.error(f"Ошибка при отправке уведомления о cookies админ боту: {e}") @@ -664,24 +678,31 @@ async def check_cookies_health(): logger.warning(f"Проверка cookies {service} не удалась (сервис недоступен?): {e}") continue # не меняем состояние при сетевой ошибке — только при чистом ответе cookies_valid:false - if data.get('cookies_valid') is None: + # Обратная совместимость: старый ответ без 'status' различал только cookies_valid + status = data.get('status') + if status is None: + valid = data.get('cookies_valid') + status = 'no_cookies' if valid is None else ('ok' if valid else 'cookies_invalid') + + if status == 'no_cookies': continue # куки для этого сервиса не настроены — нечего проверять - currently_healthy = bool(data.get('cookies_valid')) + detail = data.get('detail', '') + currently_healthy = status == 'ok' was_healthy = state.get(service, 'healthy') == 'healthy' if not currently_healthy and was_healthy: state[service] = 'broken' state.setdefault('last_reminder_ts', {})[service] = time.time() - await notify_admin_cookie_alert(service, healthy=False, detail=data.get('detail', '')) + await notify_admin_cookie_alert(service, status, detail) elif not currently_healthy and not was_healthy: last = state.get('last_reminder_ts', {}).get(service, 0) if time.time() - last >= COOKIE_REMINDER_INTERVAL: state.setdefault('last_reminder_ts', {})[service] = time.time() - await notify_admin_cookie_alert(service, healthy=False, detail=data.get('detail', '')) + await notify_admin_cookie_alert(service, status, detail) elif currently_healthy and not was_healthy: state[service] = 'healthy' - await notify_admin_cookie_alert(service, healthy=True, detail='') + await notify_admin_cookie_alert(service, 'ok', '') _save_cookie_state(state) diff --git a/cookies-cron/README.md b/cookies-cron/README.md deleted file mode 100644 index 2506e6b..0000000 --- a/cookies-cron/README.md +++ /dev/null @@ -1,36 +0,0 @@ -# cookies-cron - -Скрипты для регулярного обновления `youtube_cookies.txt`/`instagram_cookies.txt` из реального авторизованного браузера и доставки их по `scp` на прод и тестовые стенды. - -Это **точная копия** скриптов, которые сейчас реально работают по крону на отдельной машине (не на проде — на проде GUI/браузера нет в принципе). Здесь они лежат для справки и на случай, если cron-машину придётся поднимать заново. Ничего в этом репозитории/CI их не запускает и не использует автоматически. - -## Где это должно стоять и работать - -- Машина с **графическим окружением и реальным браузером** (используется Firefox, см. `BROWSER="firefox"` в обоих скриптах) — обычный десктоп/ноутбук, не сервер. -- В этом браузере должны быть **активные авторизованные сессии** на youtube.com и на instagram.com — скрипты не логинятся сами, они просто экспортируют куки из уже залогиненного браузера через `yt-dlp --cookies-from-browser`. Если сессии нет (вышел из аккаунта/куки протухли) — экспортируется пустой/неполный набор куков, и видео с этими доменами перестают скачиваться (в Instagram это проявляется как `login required` без всякой ошибки в самом cron-скрипте). -- На машине должен быть установлен **`yt-dlp`** и доступен в `PATH` для cron-окружения (не только для интерактивного шелла — у cron urezанный `PATH`). Скрипты исторически не проверяют, что `yt-dlp` реально нашёлся и отработал — при отсутствии `yt-dlp` они тихо «успешно завершаются», просто переиспользуя старый файл cookies. Если куки на проде не обновляются неделями без видимых ошибок — первым делом проверьте `which yt-dlp` на cron-машине. - -## Файлы - -- **`get_instagram_cookies.sh`** / **`get_youtube_cookies.sh`** — извлекают куки из Firefox в локальные `instagram_cookies.txt`/`youtube_cookies.txt` рядом со скриптом. -- **`update_cookies.sh`** — оркестратор: гоняет оба скрипта выше, затем разливает результат по `scp` на: - - прод (`REMOTE_HOST`/`REMOTE_INSTAGRAM_PATH`/`REMOTE_YOUTUBE_PATH`) — обязательный шаг, при ошибке скрипт падает с `error_exit`; - - тестовый стенд (`DEV_HOST`/`DEV_INSTAGRAM_PATH`/`DEV_YOUTUBE_PATH`) — необязательный, при недоступности стенда просто пишет warning и продолжает. - - Значения этих переменных сейчас захардкожены под текущую инфраструктуру (SSH-алиасы `germany`/`hermesDev` из `~/.ssh/config` той машины, где крон запущен) — при разворачивании на новой cron-машине их нужно поправить под актуальные хосты/пути. - -## Установка на новую cron-машину - -```bash -# 1. yt-dlp должен быть в PATH (включая cron-окружение) -sudo dnf install -y yt-dlp # или: pip install --user yt-dlp / pipx install yt-dlp - -# 2. Залогиниться в Firefox на youtube.com и instagram.com - -# 3. Прописать актуальные REMOTE_HOST/DEV_HOST и пути в update_cookies.sh - -# 4. Добавить в crontab -*/20 * * * * /bin/bash -c '/path/to/cookies-cron/update_cookies.sh' >> /path/to/cookies-cron/cron_error.log 2>&1 -``` - -На принимающей стороне (прод и тестовый стенд) cookies монтируются в контейнеры с хоста — см. `docker-compose.prod.yml`/`docker-compose.yml` и раздел «Cookies на проде» в корневом `README.md`. Перезапуск контейнеров после доставки не требуется — `yt-dlp` читает файл с диска при каждом запросе. diff --git a/cookies-cron/get_instagram_cookies.sh b/cookies-cron/get_instagram_cookies.sh deleted file mode 100755 index bf5dcdf..0000000 --- a/cookies-cron/get_instagram_cookies.sh +++ /dev/null @@ -1,76 +0,0 @@ -#!/bin/bash -# Скрипт для получения cookies Instagram через yt-dlp из Firefox - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -COOKIES_FILE="$SCRIPT_DIR/instagram_cookies.txt" -BROWSER="firefox" - -# Если запущено с sudo, используем HOME реального пользователя -if [ -n "$SUDO_USER" ]; then - REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6) - export HOME="$REAL_HOME" - echo "⚠️ Обнаружен sudo, использую домашнюю директорию пользователя: $HOME" -fi - -echo "Получение cookies Instagram из Firefox..." -echo "Файл cookies будет сохранен в: $COOKIES_FILE" -echo "Получаю cookies..." - -# Извлекаем cookies из браузера напрямую из базы данных (быстро) -# --cookies-from-browser извлекает все cookies для домена -# Используем один запрос - все cookies для instagram.com будут извлечены -echo "Получаю cookies из браузера..." -timeout 10 yt-dlp \ - --cookies-from-browser "$BROWSER" \ - --cookies "$COOKIES_FILE" \ - --no-download \ - --quiet \ - --socket-timeout 5 \ - "https://www.instagram.com" 2>&1 | head -20 || true - -# Проверяем, был ли создан файл cookies (не проверяем EXIT_CODE, т.к. команды могут завершиться с ошибками, но cookies могут быть получены) - -if [ -f "$COOKIES_FILE" ]; then - # Проверяем, что файл содержит данные (не только заголовки) - COOKIE_LINES=$(grep -v '^#' "$COOKIES_FILE" | grep -v '^$' | wc -l) - - if [ "$COOKIE_LINES" -gt 0 ]; then - echo "" - echo "✅ Cookies успешно сохранены в $COOKIES_FILE" - echo " Найдено строк с cookies: $COOKIE_LINES" - echo "" - echo "Теперь перезапустите instagram-downloader:" - echo " docker compose -f instagram-downloader/docker-compose.yml restart" - echo "" - echo "Или перезапустите все сервисы:" - echo " ./stop_all.sh && ./start_all.sh" - else - echo "" - echo "❌ Ошибка: файл cookies создан, но не содержит данных" - rm -f "$COOKIES_FILE" - exit 1 - fi -else - echo "" - echo "❌ Ошибка: файл cookies не был создан" - echo "" - echo "Возможные причины:" - echo "1. Firefox не найден или недоступен" - echo "2. Проблемы с правами доступа к файлу cookies браузера" - echo "3. Cookies не найдены в браузере" - echo "" - echo "Проверка:" - if [ -n "$SUDO_USER" ]; then - REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6) - FIREFOX_DIR="$REAL_HOME/.mozilla/firefox" - else - FIREFOX_DIR="$HOME/.mozilla/firefox" - fi - if [ -d "$FIREFOX_DIR" ]; then - echo " ✓ Директория Firefox найдена: $FIREFOX_DIR" - else - echo " ✗ Директория Firefox не найдена: $FIREFOX_DIR" - echo " Попробуйте запустить скрипт БЕЗ sudo: ./get_instagram_cookies.sh" - fi - exit 1 -fi diff --git a/cookies-cron/get_youtube_cookies.sh b/cookies-cron/get_youtube_cookies.sh deleted file mode 100755 index 03a5f65..0000000 --- a/cookies-cron/get_youtube_cookies.sh +++ /dev/null @@ -1,217 +0,0 @@ -#!/bin/bash -# Скрипт для получения cookies YouTube через yt-dlp из Firefox -# Предназначен для запуска на Ubuntu 24.04 с авторизованным Firefox -# Результат копируется через cron на серверы со службами скачивания - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -COOKIES_FILE="$SCRIPT_DIR/youtube_cookies.txt" -BROWSER="firefox" -TEMP_FILE="${COOKIES_FILE}.tmp" - -# Определяем HOME пользователя (работает для sudo и обычного запуска) -if [ -n "$SUDO_USER" ]; then - REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6) - export HOME="$REAL_HOME" -elif [ -z "$HOME" ] || [ "$HOME" = "/root" ]; then - # Если HOME не установлен или это root, пытаемся найти реального пользователя - REAL_USER=$(whoami) - if [ "$REAL_USER" != "root" ]; then - export HOME=$(getent passwd "$REAL_USER" | cut -d: -f6) - fi -fi - -# Проверка для cron: если запущен без терминала, меньше вывода -if [ -t 0 ]; then - VERBOSE=1 -else - VERBOSE=0 -fi - -if [ "$VERBOSE" = "1" ]; then - echo "Получение cookies YouTube из Firefox..." - echo "Файл cookies будет сохранен в: $COOKIES_FILE" -fi - -# Проверяем наличие yt-dlp -if ! command -v yt-dlp &> /dev/null; then - echo "ERROR: yt-dlp не найден в системе" >&2 - echo "Установите: sudo apt install yt-dlp (Ubuntu) или pip install yt-dlp" >&2 - exit 1 -fi - -if [ "$VERBOSE" = "1" ]; then - echo "Получаю cookies..." -fi - -# Удаляем старый временный файл -rm -f "$TEMP_FILE" - -# Извлекаем cookies из браузера напрямую из базы данных -# --cookies-from-browser извлекает cookies для всех доменов, связанных с URL -# Используем один запрос с основным URL - cookies для всех поддоменов будут извлечены -if [ "$VERBOSE" = "1" ]; then - echo "Получаю cookies из браузера..." - timeout 10 yt-dlp \ - --cookies-from-browser "$BROWSER" \ - --cookies "$TEMP_FILE" \ - --no-download \ - --quiet \ - --socket-timeout 5 \ - "https://www.youtube.com" > /dev/null 2>&1 || true -else - timeout 10 yt-dlp \ - --cookies-from-browser "$BROWSER" \ - --cookies "$TEMP_FILE" \ - --no-download \ - --quiet \ - --socket-timeout 5 \ - "https://www.youtube.com" > /dev/null 2>&1 || true -fi - -# Функция проверки валидности куков -check_cookies() { - local file="$1" - local current_time=$(date +%s) - - # Проверяем существование файла - if [ ! -f "$file" ]; then - return 1 - fi - - # Проверяем, что файл содержит данные (не только заголовки) - local cookie_lines=$(grep -v '^#' "$file" | grep -v '^$' | wc -l) - if [ "$cookie_lines" -eq 0 ]; then - return 1 - fi - - # Проверяем наличие YouTube-куков - local youtube_cookies=$(grep -E '\.youtube\.com|youtube\.com' "$file" | grep -v '^#' | wc -l) - if [ "$youtube_cookies" -eq 0 ]; then - return 1 - fi - - # Проверяем срок действия куков (должен быть хотя бы один не просроченный) - local valid_cookies=0 - local expired_cookies=0 - - # Используем awk для более надежного парсинга - while IFS=$'\t' read -r domain flag path secure expiration name value; do - # Пропускаем комментарии и пустые строки - [[ "$domain" =~ ^#.*$ ]] && continue - [[ -z "$domain" ]] && continue - - # Проверяем только YouTube-куки - if [[ "$domain" =~ youtube\.com ]]; then - if [ "$expiration" = "0" ] || [ -z "$expiration" ]; then - # Сессионные куки (expiration=0) считаем валидными - valid_cookies=$((valid_cookies + 1)) - else - # Проверяем срок действия - if [ "$expiration" -gt "$current_time" ] 2>/dev/null; then - valid_cookies=$((valid_cookies + 1)) - else - expired_cookies=$((expired_cookies + 1)) - fi - fi - fi - done < "$file" - - # Должен быть хотя бы один валидный YouTube-куки - if [ "$valid_cookies" -eq 0 ]; then - return 1 - fi - - # Если есть просроченные куки, выводим предупреждение, но не считаем критичным - if [ "$expired_cookies" -gt 0 ] && [ "$VERBOSE" = "1" ]; then - echo "⚠️ Предупреждение: найдено $expired_cookies просроченных YouTube-куков" >&2 - fi - - return 0 -} - -# Проверяем результат -if [ -f "$TEMP_FILE" ] && check_cookies "$TEMP_FILE"; then - # Куки валидны, переносим во временный файл в финальный - mv "$TEMP_FILE" "$COOKIES_FILE" - - # Собираем статистику для вывода - COOKIE_LINES=$(grep -v '^#' "$COOKIES_FILE" | grep -v '^$' | wc -l) - YOUTUBE_COOKIES=$(grep -E '\.youtube\.com|youtube\.com' "$COOKIES_FILE" | grep -v '^#' | wc -l) - IMPORTANT_COOKIES=$(grep -E 'VISITOR_INFO1_LIVE|__Secure-3PSID|PREF|__Secure-YNID' "$COOKIES_FILE" | grep -v '^#' | wc -l) - - if [ "$VERBOSE" = "1" ]; then - echo "" - echo "✅ Cookies успешно сохранены в $COOKIES_FILE" - echo " Всего строк с cookies: $COOKIE_LINES" - echo " YouTube-куков: $YOUTUBE_COOKIES" - echo " Важных куков: $IMPORTANT_COOKIES" - echo "" - echo "Файл готов к копированию на серверы через cron" - else - # Минимальный вывод для cron (можно логировать в файл) - echo "$(date '+%Y-%m-%d %H:%M:%S') - YouTube cookies updated: $YOUTUBE_COOKIES cookies, $IMPORTANT_COOKIES important" - fi - - exit 0 -else - # Очищаем временный файл при ошибке - rm -f "$TEMP_FILE" - - if [ "$VERBOSE" = "1" ]; then - echo "" - echo "❌ Ошибка: не удалось получить валидные YouTube-куки" - echo "" - echo "Возможные причины:" - echo "1. В Firefox нет куков для YouTube" - echo "2. Откройте YouTube в Firefox и войдите в аккаунт" - echo "3. Все куки просрочены - обновите их в браузере" - echo "" - else - echo "$(date '+%Y-%m-%d %H:%M:%S') - ERROR: Failed to get valid YouTube cookies" >&2 - fi - - if [ ! -f "$COOKIES_FILE" ]; then - # Если файла не было, это критичная ошибка - exit 1 - else - # Если файл был, просто оставляем старый (лучше работать со старыми куками чем без них) - if [ "$VERBOSE" = "1" ]; then - echo "⚠️ Оставляю существующий файл cookies без изменений" - fi - exit 0 - fi -fi - -if [ ! -f "$TEMP_FILE" ] && [ ! -f "$COOKIES_FILE" ]; then - if [ "$VERBOSE" = "1" ]; then - echo "" - echo "❌ Ошибка: файл cookies не был создан" - echo "" - echo "Возможные причины:" - echo "1. Firefox не найден или недоступен" - echo "2. Проблемы с правами доступа к файлу cookies браузера" - echo "3. Cookies не найдены в браузере" - echo "" - echo "Проверка:" - FIREFOX_DIR="$HOME/.mozilla/firefox" - if [ -d "$FIREFOX_DIR" ]; then - echo " ✓ Директория Firefox найдена: $FIREFOX_DIR" - else - echo " ✗ Директория Firefox не найдена: $FIREFOX_DIR" - echo " Убедитесь что Firefox установлен и вы авторизованы на YouTube" - fi - else - echo "$(date '+%Y-%m-%d %H:%M:%S') - ERROR: Cookies file was not created" >&2 - fi - - # Если был старый файл, оставляем его - if [ -f "$COOKIES_FILE" ]; then - if [ "$VERBOSE" = "1" ]; then - echo "⚠️ Оставляю существующий файл cookies без изменений" - fi - exit 0 - else - exit 1 - fi -fi - diff --git a/cookies/README.md b/cookies/README.md new file mode 100644 index 0000000..942e04e --- /dev/null +++ b/cookies/README.md @@ -0,0 +1,74 @@ +# cookies + +Как обновлять `youtube_cookies.txt`/`instagram_cookies.txt` для прода и тестового стенда. + +Куки обновляются **вручную и редко** — по алерту от проактивной проверки, а не по расписанию. Раньше здесь жил крон, который каждые 20–30 минут экспортировал куки из постоянно запущенного браузера; эта схема убрана, ниже объяснено почему. + +## Почему больше нет крона и постоянно открытого браузера + +1. **Экспорт через `--cookies-from-browser` ломает YouTube-сессию.** Wiki yt-dlp прямо запрещает связку `--cookies COOKIEFILE --cookies-from-browser BROWSER` — именно она даёт ошибку `The provided YouTube account cookies are no longer valid. They have likely been rotated in the browser as a security measure`. Пока браузер держит живую сессию, YouTube её ротирует, и любой снятый снимок куков протухает. + +2. **Обновлять раз в 30 минут нечего.** Ключевые куки живут около года: Instagram `sessionid` и YouTube `SID`/`__Secure-1PSID` на момент разбора имели срок ≈344–376 дней. Крон сотни раз в сутки копировал одни и те же значения. + +3. **Открытый браузер сам провоцировал блокировки.** Вкладка Instagram, висящая сутками, непрерывно ходит в сеть (лента, сторис, автоплей, телеметрия) — тысячи запросов, которые повышают шанс, что Instagram пометит сессию. Это ровно тот симптом, который мы ловили: `Main webpage is locked behind the login page` при формально валидных по сроку куках. + +## Когда обновлять + +Когда админ-бот пришлёт `🍪⚠️ ПРОАКТИВНАЯ ПРОВЕРКА COOKIES` по нужному сервису. Проверка живёт в `bot.py` (`check_cookies_health`, раз в 30 минут дёргает `POST /cookies/check` у youtube- и instagram-downloader) и шлёт алерт при переходе healthy→broken, потом не чаще раза в сутки, а при восстановлении — `✅ Cookies восстановлены`. + +Проверить состояние вручную в любой момент: + +```bash +ssh germany "curl -s -X POST localhost:5557/cookies/check" # YouTube +ssh germany "curl -s -X POST localhost:5556/cookies/check" # Instagram +``` + +## Экспорт cookies + +Нужен браузер с графическим окружением и расширение для экспорта в формате `cookies.txt` (Netscape). На проде это сделать нельзя — там нет GUI. + +### YouTube — обязательно через приватное окно + +Официальный способ из wiki yt-dlp, именно он снимает ротацию: + +1. Открыть **приватное окно** и залогиниться на YouTube. +2. В той же вкладке перейти на `https://www.youtube.com/robots.txt`. Это должна быть **единственная** открытая приватная вкладка. +3. Экспортировать куки `youtube.com` расширением в `youtube_cookies.txt`. +4. **Сразу закрыть приватное окно, не разлогиниваясь.** Сессия больше никогда не открывается — значит, YouTube её не ротирует, и куки живут месяцами. + +Не использовать `--cookies-from-browser`: он заберёт обычные куки профиля, а не куки приватной сессии. + +### Instagram + +Экспортировать куки `instagram.com` расширением в `instagram_cookies.txt`, после чего вкладку закрыть и сессию в браузере не трогать. Формальной гарантии, как у YouTube, документация не даёт, но принцип тот же: сессию, которой браузер не пользуется, не ротируют и не помечают за подозрительную активность. + +В файле должен быть `sessionid` — без него Instagram отдаёт `login required`: + +```bash +grep -c sessionid instagram_cookies.txt # должно быть >= 1 +``` + +### После экспорта + +Браузер не оставлять запущенным. Профиль на диске для этой схемы больше не нужен — куки уже лежат в файлах. + +## Доставка + +Положить оба файла рядом с `deliver_cookies.sh` и запустить: + +```bash +./deliver_cookies.sh +``` + +Скрипт проверит, что файлы непустые, и разольёт их по `scp`: + +- на прод (`REMOTE_HOST=germany`) — обязательный шаг, при ошибке падает; +- на тестовый стенд (`DEV_HOST=hermesDev`) — необязательный, при недоступности пишет warning и продолжает. + +Хосты и пути захардкожены под текущую инфраструктуру (SSH-алиасы из `~/.ssh/config` машины, где запускаете) — при переезде поправить переменные в начале скрипта. + +Перезапускать контейнеры после доставки не нужно: `yt-dlp` читает файл с диска при каждом запросе. Монтирование в контейнеры — см. `docker-compose.prod.yml`/`docker-compose.yml`. + +## Проверка результата + +После доставки дёрнуть `/cookies/check` (команды выше) — ожидаем `"cookies_valid": true` у обоих сервисов. На следующем цикле проверки в админ-бот придёт `✅ Cookies восстановлены`. diff --git a/cookies-cron/update_cookies.sh b/cookies/deliver_cookies.sh similarity index 55% rename from cookies-cron/update_cookies.sh rename to cookies/deliver_cookies.sh index 99b4871..f556d91 100755 --- a/cookies-cron/update_cookies.sh +++ b/cookies/deliver_cookies.sh @@ -1,6 +1,10 @@ #!/bin/bash -# Скрипт для обновления cookies Instagram и YouTube через cron -# Запускает скрипты получения cookies и копирует их на удаленный хост +# Доставка cookies на прод и тестовый стенд. +# +# Запускается ВРУЧНУЮ после разового экспорта cookies из браузера — не по крону. +# Как правильно экспортировать cookies, см. README.md рядом с этим скриптом. +# Автоматический экспорт через `yt-dlp --cookies-from-browser` убран намеренно: +# он ломает YouTube-сессию ("cookies have likely been rotated in the browser"). set -euo pipefail @@ -9,8 +13,6 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" cd "$SCRIPT_DIR" # Настройки -INSTAGRAM_SCRIPT="$SCRIPT_DIR/get_instagram_cookies.sh" -YOUTUBE_SCRIPT="$SCRIPT_DIR/get_youtube_cookies.sh" INSTAGRAM_COOKIES="$SCRIPT_DIR/instagram_cookies.txt" YOUTUBE_COOKIES="$SCRIPT_DIR/youtube_cookies.txt" REMOTE_HOST="germany" @@ -19,7 +21,7 @@ REMOTE_YOUTUBE_PATH="/root/services/videoDownloadTGbot/youtube_cookies.txt" DEV_HOST="hermesDev" DEV_INSTAGRAM_PATH="services/videoDownloadTGbot/instagram-downloader/instagram_cookies.txt" DEV_YOUTUBE_PATH="services/videoDownloadTGbot/youtube-downloader/youtube_cookies.txt" -LOG_FILE="$SCRIPT_DIR/cron.log" +LOG_FILE="$SCRIPT_DIR/deliver.log" # Функция логирования log() { @@ -32,48 +34,28 @@ error_exit() { exit 1 } -log "=== Начало обновления cookies ===" +# Проверяет, что файл существует и содержит хотя бы одну строку с cookie +check_cookies_file() { + local file="$1" + local name="$2" -# Проверяем наличие скриптов -if [ ! -f "$INSTAGRAM_SCRIPT" ]; then - error_exit "Скрипт не найден: $INSTAGRAM_SCRIPT" -fi - -if [ ! -f "$YOUTUBE_SCRIPT" ]; then - error_exit "Скрипт не найден: $YOUTUBE_SCRIPT" -fi - -# Проверяем, что скрипты исполняемые -chmod +x "$INSTAGRAM_SCRIPT" "$YOUTUBE_SCRIPT" - -# Получаем cookies Instagram -log "Получение cookies Instagram..." -if bash "$INSTAGRAM_SCRIPT" >> "$LOG_FILE" 2>&1; then - if [ -f "$INSTAGRAM_COOKIES" ]; then - log "✅ Cookies Instagram успешно получены" - else - error_exit "Файл cookies Instagram не был создан" + if [ ! -f "$file" ]; then + error_exit "Файл не найден: $file. Сначала экспортируйте cookies $name — см. README.md" fi -else - error_exit "Ошибка при получении cookies Instagram (код возврата: $?)" -fi -# Получаем cookies YouTube -log "Получение cookies YouTube..." -if bash "$YOUTUBE_SCRIPT" >> "$LOG_FILE" 2>&1; then - if [ -f "$YOUTUBE_COOKIES" ]; then - log "✅ Cookies YouTube успешно получены" - else - error_exit "Файл cookies YouTube не был создан" + local lines + lines=$(grep -cv -e '^#' -e '^$' "$file" || true) + if [ "$lines" -eq 0 ]; then + error_exit "Файл $file пустой (0 строк с cookies). Экспорт $name не удался — см. README.md" fi -else - # YouTube скрипт может завершиться с кодом 0, даже если новый файл не создан (использует старый) - if [ -f "$YOUTUBE_COOKIES" ]; then - log "✅ Cookies YouTube (возможно использован старый файл)" - else - error_exit "Файл cookies YouTube не найден" - fi -fi + + log "✅ $name: $file ($lines строк с cookies)" +} + +log "=== Начало доставки cookies ===" + +check_cookies_file "$INSTAGRAM_COOKIES" "Instagram" +check_cookies_file "$YOUTUBE_COOKIES" "YouTube" # Копируем cookies на удаленный хост (прод) log "Копирование cookies на удаленный хост $REMOTE_HOST..." @@ -107,5 +89,6 @@ else log "⚠️ Не удалось скопировать YouTube cookies на $DEV_HOST (продолжаем)" fi -log "=== Обновление cookies завершено успешно ===" +log "=== Доставка cookies завершена успешно ===" +log "Проверить результат: ssh ${REMOTE_HOST} \"curl -s -X POST localhost:5557/cookies/check; curl -s -X POST localhost:5556/cookies/check\"" exit 0 diff --git a/instagram-downloader/INSTAGRAM_COOKIES_INSTRUCTIONS.md b/instagram-downloader/INSTAGRAM_COOKIES_INSTRUCTIONS.md index 52caf9c..853dd89 100644 --- a/instagram-downloader/INSTAGRAM_COOKIES_INSTRUCTIONS.md +++ b/instagram-downloader/INSTAGRAM_COOKIES_INSTRUCTIONS.md @@ -1,51 +1,7 @@ # Инструкция по получению cookies для Instagram -## Вариант 1: Использование расширения браузера (рекомендуется) +Актуальная инструкция живёт в одном месте — [`cookies/README.md`](../cookies/README.md). -1. Установите расширение для экспорта cookies: - - Chrome: [Get cookies.txt LOCALLY](https://chrome.google.com/webstore/detail/get-cookiestxt-locally/cclelndahbckbenkjhflpdbgdldlbecc) - - Firefox: [cookies.txt](https://addons.mozilla.org/en-US/firefox/addon/cookies-txt/) - -2. Откройте Instagram и войдите в свой аккаунт: https://www.instagram.com - -3. Кликните на расширение и выберите "Export cookies.txt" - -4. Сохраните файл как `instagram_cookies.txt` в корень проекта (там же, где docker-compose.yml) - -## Вариант 2: Ручной экспорт через DevTools - -1. Откройте Instagram в браузере и войдите: https://www.instagram.com - -2. Откройте DevTools (F12) → вкладка Application/Storage → Cookies → https://www.instagram.com - -3. Скопируйте нужные cookies в формате Netscape: - ``` - # Netscape HTTP Cookie File - .instagram.com TRUE / FALSE 1735689600 sessionid YOUR_SESSION_ID - .instagram.com TRUE / FALSE 1735689600 csrftoken YOUR_CSRF_TOKEN - ``` - -4. Сохраните в файл `instagram_cookies.txt` - -## Вариант 3: Использование yt-dlp для экспорта - -```bash -# Экспорт cookies из браузера Chrome -yt-dlp --cookies-from-browser chrome --cookies instagram_cookies.txt https://www.instagram.com - -# Или из Firefox -yt-dlp --cookies-from-browser firefox --cookies instagram_cookies.txt https://www.instagram.com -``` - -## Важно! - -- Файл должен называться `instagram_cookies.txt` -- Разместите его в корне проекта (рядом с docker-compose.yml) -- Cookies имеют срок действия - возможно, потребуется обновлять их периодически -- Не коммитьте файл в git (он уже добавлен в .gitignore) - -После добавления файла перезапустите контейнер: -```bash -docker compose restart bot -``` +Там описано: когда обновлять (по алерту `🍪⚠️ ПРОАКТИВНАЯ ПРОВЕРКА COOKIES` из админ-бота, а не по расписанию), как правильно экспортировать куки расширением для `cookies.txt` и как доставить их на прод через `deliver_cookies.sh`. +**Не используйте `yt-dlp --cookies-from-browser ... --cookies file.txt`** — этот способ раньше был описан здесь, но wiki yt-dlp его прямо запрещает: он ломает сессию, и куки протухают («cookies have likely been rotated in the browser as a security measure»). diff --git a/instagram-downloader/README.md b/instagram-downloader/README.md index 27d2da7..adea10d 100644 --- a/instagram-downloader/README.md +++ b/instagram-downloader/README.md @@ -11,27 +11,9 @@ ### 1. Настройка cookies -Перед запуском сервиса необходимо получить cookies Instagram. Есть несколько способов: +Перед запуском сервиса нужно положить `instagram_cookies.txt` в папку `instagram-downloader/`. -#### Способ 1: Через скрипт (рекомендуется) - -```bash -cd instagram-downloader -./get_instagram_cookies.sh -``` - -Скрипт попросит выбрать браузер и автоматически извлечет cookies. - -#### Способ 2: Обновление существующих cookies - -```bash -cd instagram-downloader -./update_instagram_cookies.sh -``` - -#### Способ 3: Вручную - -См. подробные инструкции в `INSTAGRAM_COOKIES_INSTRUCTIONS.md` +Куки экспортируются вручную из браузера расширением для `cookies.txt` — пошаговая процедура в [`cookies/README.md`](../cookies/README.md). Скрипты автоматического экспорта через `yt-dlp --cookies-from-browser` убраны: этот способ ломает сессию, куки после него быстро протухают. ### 2. Запуск сервиса @@ -71,6 +53,21 @@ curl http://localhost:5556/health } ``` +### POST /cookies/check + +Проверка, работают ли cookies прямо сейчас: делает лёгкий `extract_info` (без скачивания) по публичному тестовому ролику. Используется фоновым мониторингом в `bot.py`. + +**Ответ:** +```json +{ + "cookies_present": true, + "cookies_valid": false, + "detail": "текст ошибки от yt-dlp" +} +``` + +`cookies_valid: null` означает, что файл с куками не найден. Тестовый URL переопределяется переменной `INSTAGRAM_COOKIE_TEST_URL`. + ### POST /download/stream Скачивание видео с Instagram. @@ -93,40 +90,22 @@ curl http://localhost:5556/health ## Обновление cookies -Cookies Instagram имеют ограниченный срок действия. Рекомендуется обновлять их раз в несколько недель. +Ключевые куки Instagram живут около года, поэтому обновлять их по расписанию не нужно. Триггер на обновление — алерт `🍪⚠️ ПРОАКТИВНАЯ ПРОВЕРКА COOKIES` из админ-бота (за состоянием следит фоновая проверка в `bot.py`, которая раз в 30 минут дёргает `POST /cookies/check`). -Для обновления: - -```bash -cd instagram-downloader -./update_instagram_cookies.sh -``` - -После обновления перезапустите сервис: - -```bash -docker compose restart instagram-downloader -``` +Процедура экспорта и доставки — в [`cookies/README.md`](../cookies/README.md). Перезапускать сервис после обновления не нужно: `yt-dlp` читает файл с диска при каждом запросе. ## Troubleshooting ### Сервис не может скачать видео -1. Проверьте наличие файла `instagram_cookies.txt` в папке `instagram-downloader/` -2. Проверьте срок действия cookies (они могут истечь) -3. Обновите cookies через скрипт `update_instagram_cookies.sh` +1. Проверьте состояние куков: `curl -s -X POST localhost:5556/cookies/check` +2. Проверьте наличие файла `instagram_cookies.txt` в папке `instagram-downloader/` и что в нём есть `sessionid` +3. Если `cookies_valid: false` — обновите куки по инструкции в [`cookies/README.md`](../cookies/README.md) 4. Проверьте логи: `docker compose logs instagram-downloader` -### Cookies истекли +### Cookies есть и не истекли по сроку, но всё равно `login required` -Если видите ошибку "Instagram cookies истекли", выполните: - -```bash -cd instagram-downloader -./update_instagram_cookies.sh -``` - -Затем перезапустите сервис. +Instagram умеет блокировать сессию на своей стороне — тогда в логах видно `Main webpage is locked behind the login page` при формально валидном `sessionid`. Лечится только повторным экспортом куков. Провоцирует такую блокировку активная автоматическая работа с аккаунтом (например, постоянно открытая вкладка Instagram, непрерывно ходящая в сеть), поэтому после экспорта браузер лучше закрывать. ## Структура файлов @@ -136,9 +115,7 @@ instagram-downloader/ ├── Dockerfile # Образ Docker ├── docker-compose.yml # Конфигурация для отдельного запуска ├── requirements.txt # Python зависимости -├── get_instagram_cookies.sh # Скрипт для получения cookies -├── update_instagram_cookies.sh # Скрипт для обновления cookies -├── INSTAGRAM_COOKIES_INSTRUCTIONS.md # Подробные инструкции по cookies +├── INSTAGRAM_COOKIES_INSTRUCTIONS.md # Отсылка к cookies/README.md └── README.md # Этот файл ``` diff --git a/instagram-downloader/app.py b/instagram-downloader/app.py index ded7b8d..b82c2ca 100644 --- a/instagram-downloader/app.py +++ b/instagram-downloader/app.py @@ -206,12 +206,24 @@ INSTAGRAM_COOKIE_TEST_URL = os.getenv( ) +# Маркеры, по которым сбой однозначно относится к cookies/авторизации. +# Всё остальное считаем поломкой извлечения (версия yt-dlp, изменения на стороне +# Instagram) и про cookies ничего не утверждаем — иначе мониторинг уводит +# диагностику не туда: например, "empty media response" воспроизводится и без cookies. +INSTAGRAM_COOKIE_INVALID_MARKERS = ( + 'login required', + 'locked behind the login page', + 'rate-limit reached', +) + + @app.route('/cookies/check', methods=['POST']) def cookies_check(): """Проверяет, рабочие ли сейчас Instagram cookies (для проактивного мониторинга).""" cookies_file = Path(os.getenv('INSTAGRAM_COOKIES_FILE', 'instagram_cookies.txt')) if not cookies_file.exists(): return jsonify({'cookies_present': False, 'cookies_valid': None, + 'status': 'no_cookies', 'detail': 'cookies file missing'}), 200 ydl_opts = { @@ -224,10 +236,16 @@ def cookies_check(): try: with yt_dlp.YoutubeDL(ydl_opts) as ydl: ydl.extract_info(INSTAGRAM_COOKIE_TEST_URL, download=False) - return jsonify({'cookies_present': True, 'cookies_valid': True, 'detail': ''}), 200 + return jsonify({'cookies_present': True, 'cookies_valid': True, + 'status': 'ok', 'detail': ''}), 200 except Exception as e: - return jsonify({'cookies_present': True, 'cookies_valid': False, - 'detail': str(e)[-500:]}), 200 + err = str(e) + detail = err[-500:] + if any(marker in err.lower() for marker in INSTAGRAM_COOKIE_INVALID_MARKERS): + return jsonify({'cookies_present': True, 'cookies_valid': False, + 'status': 'cookies_invalid', 'detail': detail}), 200 + return jsonify({'cookies_present': True, 'cookies_valid': None, + 'status': 'extraction_failed', 'detail': detail}), 200 @app.route('/download/stream', methods=['POST']) diff --git a/instagram-downloader/get_instagram_cookies.sh b/instagram-downloader/get_instagram_cookies.sh deleted file mode 100755 index b6413e7..0000000 --- a/instagram-downloader/get_instagram_cookies.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/bin/bash -# Скрипт для получения cookies Instagram через yt-dlp из Firefox - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -COOKIES_FILE="$SCRIPT_DIR/instagram_cookies.txt" -BROWSER="firefox" - -# Если запущено с sudo, используем HOME реального пользователя -if [ -n "$SUDO_USER" ]; then - REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6) - export HOME="$REAL_HOME" - echo "⚠️ Обнаружен sudo, использую домашнюю директорию пользователя: $HOME" -fi - -echo "Получение cookies Instagram из Firefox..." -echo "Файл cookies будет сохранен в: $COOKIES_FILE" -echo "Получаю cookies..." - -# Используем таймаут для предотвращения зависания -timeout 15 yt-dlp \ - --cookies-from-browser "$BROWSER" \ - --cookies "$COOKIES_FILE" \ - --no-download \ - --quiet \ - "https://www.instagram.com" 2>&1 | head -20 - -EXIT_CODE=$? - -if [ $EXIT_CODE -eq 124 ]; then - echo "" - echo "⚠️ Процесс получения cookies превысил таймаут (15 сек)" - echo " Проверяю, был ли создан файл cookies..." -elif [ $EXIT_CODE -ne 0 ] && [ $EXIT_CODE -ne 124 ]; then - echo "" - echo "⚠️ Процесс завершился с кодом $EXIT_CODE" - echo " Проверяю, был ли создан файл cookies..." -fi - -if [ -f "$COOKIES_FILE" ]; then - # Проверяем, что файл содержит данные (не только заголовки) - COOKIE_LINES=$(grep -v '^#' "$COOKIES_FILE" | grep -v '^$' | wc -l) - - if [ "$COOKIE_LINES" -gt 0 ]; then - echo "" - echo "✅ Cookies успешно сохранены в $COOKIES_FILE" - echo " Найдено строк с cookies: $COOKIE_LINES" - echo "" - echo "Если cookies смонтированы с хоста (см. docker-compose.yml/docker-compose.prod.yml)," - echo "перезапуск не нужен — yt-dlp читает файл заново при каждом запросе." - else - echo "" - echo "❌ Ошибка: файл cookies создан, но не содержит данных" - rm -f "$COOKIES_FILE" - exit 1 - fi -else - echo "" - echo "❌ Ошибка: файл cookies не был создан" - echo "" - echo "Возможные причины:" - echo "1. Firefox не найден или недоступен" - echo "2. Проблемы с правами доступа к файлу cookies браузера" - echo "3. Cookies не найдены в браузере" - echo "" - echo "Проверка:" - if [ -n "$SUDO_USER" ]; then - REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6) - FIREFOX_DIR="$REAL_HOME/.mozilla/firefox" - else - FIREFOX_DIR="$HOME/.mozilla/firefox" - fi - if [ -d "$FIREFOX_DIR" ]; then - echo " ✓ Директория Firefox найдена: $FIREFOX_DIR" - else - echo " ✗ Директория Firefox не найдена: $FIREFOX_DIR" - echo " Попробуйте запустить скрипт БЕЗ sudo: ./get_instagram_cookies.sh" - fi - exit 1 -fi diff --git a/instagram-downloader/requirements.txt b/instagram-downloader/requirements.txt index 36f57c1..35837d5 100644 --- a/instagram-downloader/requirements.txt +++ b/instagram-downloader/requirements.txt @@ -1,5 +1,5 @@ Flask==3.0.0 flask-cors==4.0.0 -yt-dlp>=2024.12.13 +yt-dlp>=2026.7.4 gunicorn==21.2.0 diff --git a/instagram-downloader/update_instagram_cookies.sh b/instagram-downloader/update_instagram_cookies.sh deleted file mode 100755 index 2f1a0ee..0000000 --- a/instagram-downloader/update_instagram_cookies.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/bin/bash -# Обновление cookies Instagram через браузер - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -COOKIES_FILE="$SCRIPT_DIR/instagram_cookies.txt" - -echo "Обновление cookies Instagram..." -echo "" -echo "Выберите браузер:" -echo "1) Chrome" -echo "2) Firefox" -echo "3) Edge" -echo "4) Opera" -read -p "Введите номер (1-4): " browser - -case "$browser" in - 1) BROWSER="chrome" ;; - 2) BROWSER="firefox" ;; - 3) BROWSER="edge" ;; - 4) BROWSER="opera" ;; - *) - echo "Неверный выбор" - exit 1 - ;; -esac - -echo "" -echo "ВАЖНО: Перед обновлением cookies убедитесь, что вы авторизованы в Instagram в выбранном браузере!" -echo "" -read -p "Нажмите Enter для продолжения или Ctrl+C для отмены..." - -echo "Обновляю cookies из $BROWSER..." -echo "Файл cookies будет сохранен в: $COOKIES_FILE" - -yt-dlp --cookies-from-browser "$BROWSER" --cookies "$COOKIES_FILE" --no-download https://www.instagram.com 2>&1 | head -10 - -if [ -f "$COOKIES_FILE" ]; then - echo "" - echo "✅ Cookies успешно обновлены в $COOKIES_FILE" - echo "" - echo "Для применения изменений перезапустите instagram-downloader сервис:" - echo " cd $(cd "$SCRIPT_DIR/.." && pwd) && docker compose restart instagram-downloader" -else - echo "" - echo "❌ Ошибка: файл cookies не был создан" - exit 1 -fi diff --git a/smoke_test.py b/smoke_test.py new file mode 100755 index 0000000..bbde9fa --- /dev/null +++ b/smoke_test.py @@ -0,0 +1,128 @@ +#!/usr/bin/env python3 +"""Смоук-тест загрузчиков: гоняет реальные ссылки через запущенные сервисы. + +Запускать после КАЖДОЙ правки, до коммита: + + python3 smoke_test.py + +Ссылки подобраны не случайно — это реальные регрессии, которые уже ломались: + * возрастное YouTube-видео ловит отсутствие рабочего JS-рантайма (n-challenge); + * Instagram-ролик ловит устаревший экстрактор yt-dlp; + * обычное видео проверяет, что базовый путь скачивания цел. + +Зависимостей нет — только стандартная библиотека, чтобы запускалось на любом хосте. +Код возврата: 0 — все проверки прошли, 1 — есть падения. +""" +import json +import os +import sys +import time +import urllib.error +import urllib.request + +YOUTUBE_URL = os.getenv('YOUTUBE_DOWNLOADER_URL', 'http://localhost:5557') +INSTAGRAM_URL = os.getenv('INSTAGRAM_DOWNLOADER_URL', 'http://localhost:5556') + +YT_SMALL = 'https://www.youtube.com/watch?v=jNQXAC9IVRw' # «Me at the zoo», 19 секунд +YT_AGE_RESTRICTED = 'https://youtu.be/UMyoCr2MnpM' # требует cookies + решения n-challenge +IG_REEL = 'https://www.instagram.com/reel/Dak0fDTMv_i/' # обычный публичный reel + +MIN_VIDEO_BYTES = 100 * 1024 # меньше — значит пришло не видео, а заглушка/ошибка + + +def _post(url, payload=None, timeout=120): + """POST с JSON-телом. Возвращает (http_code, body_bytes).""" + data = json.dumps(payload or {}).encode() + req = urllib.request.Request( + url, data=data, headers={'Content-Type': 'application/json'}, method='POST' + ) + try: + with urllib.request.urlopen(req, timeout=timeout) as resp: + return resp.status, resp.read() + except urllib.error.HTTPError as e: + return e.code, e.read() + except Exception as e: + return None, str(e).encode() + + +def check_cookies(service, base_url): + """/cookies/check должен вернуть status=ok.""" + code, body = _post(f'{base_url}/cookies/check', timeout=180) + if code != 200: + return False, f'HTTP {code}' + try: + data = json.loads(body) + except ValueError: + return False, 'ответ не JSON' + status = data.get('status', '?') + if status == 'ok': + return True, 'status=ok' + return False, f"status={status}: {(data.get('detail') or '')[:120]}" + + +def check_formats(url): + """/formats у YouTube — лёгкая проверка: cookies + JS-рантайм без скачивания.""" + code, body = _post(f'{YOUTUBE_URL}/formats', {'url': url}, timeout=180) + if code != 200: + detail = body.decode('utf-8', 'replace')[:150] + return False, f'HTTP {code}: {detail}' + try: + formats = json.loads(body).get('formats', []) + except ValueError: + return False, 'ответ не JSON' + if not formats: + return False, 'форматы не найдены' + return True, f'форматов: {len(formats)}' + + +def check_download(base_url, url): + """/download/stream — полный путь до реального файла.""" + code, body = _post(f'{base_url}/download/stream', {'url': url}, timeout=600) + if code != 200: + detail = body.decode('utf-8', 'replace')[:150] + return False, f'HTTP {code}: {detail}' + size = len(body) + if size < MIN_VIDEO_BYTES: + return False, f'подозрительно мало: {size} байт' + return True, f'{size / 1024:.0f} КБ' + + +CHECKS = [ + ('cookies YouTube', lambda: check_cookies('youtube', YOUTUBE_URL)), + ('cookies Instagram', lambda: check_cookies('instagram', INSTAGRAM_URL)), + ('YouTube возрастное', lambda: check_formats(YT_AGE_RESTRICTED)), + ('YouTube скачивание', lambda: check_download(YOUTUBE_URL, YT_SMALL)), + ('Instagram скачивание', lambda: check_download(INSTAGRAM_URL, IG_REEL)), +] + + +def main(): + print(f'Смоук-тест: {YOUTUBE_URL} / {INSTAGRAM_URL}\n') + + results = [] + for name, fn in CHECKS: + started = time.monotonic() + try: + ok, detail = fn() + except Exception as e: # проверка не должна ронять весь прогон + ok, detail = False, f'исключение: {e}' + results.append((name, ok, detail, time.monotonic() - started)) + + width = max(len(name) for name, *_ in results) + print(f'| {"Проверка".ljust(width)} | Итог | Время | Детали') + print(f'|{"-" * (width + 2)}|------|-------|--------') + for name, ok, detail, elapsed in results: + mark = ' OK ' if ok else 'FAIL' + print(f'| {name.ljust(width)} | {mark} | {elapsed:5.1f}с | {detail}') + + failed = [name for name, ok, *_ in results if not ok] + print() + if failed: + print(f'ИТОГ: провалено {len(failed)} из {len(results)} — {", ".join(failed)}') + return 1 + print(f'ИТОГ: все проверки прошли ({len(results)} из {len(results)})') + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/youtube-downloader/Dockerfile b/youtube-downloader/Dockerfile index 0d076b5..478db41 100644 --- a/youtube-downloader/Dockerfile +++ b/youtube-downloader/Dockerfile @@ -1,5 +1,20 @@ FROM python:3.11-slim -RUN apt-get update && apt-get install -y ffmpeg wget curl aria2 nodejs npm && rm -rf /var/lib/apt/lists/* +RUN apt-get update && apt-get install -y ffmpeg wget curl aria2 unzip && rm -rf /var/lib/apt/lists/* +# Deno — рекомендованный yt-dlp рантайм для решения n-challenge YouTube. +# Node из Debian bookworm — версия 20, а yt-dlp требует минимум 22 (иначе +# "JS runtimes: node-20.x (unsupported)" и "No video formats found" на путях с cookies). +RUN set -eux; \ + case "$(uname -m)" in \ + x86_64) DENO_ARCH=x86_64-unknown-linux-gnu ;; \ + aarch64) DENO_ARCH=aarch64-unknown-linux-gnu ;; \ + *) echo "unsupported arch: $(uname -m)" >&2; exit 1 ;; \ + esac; \ + curl -fsSL --retry 5 --retry-all-errors -o /tmp/deno.zip \ + "https://github.com/denoland/deno/releases/latest/download/deno-${DENO_ARCH}.zip"; \ + unzip -q /tmp/deno.zip -d /usr/local/bin; \ + rm /tmp/deno.zip; \ + chmod +x /usr/local/bin/deno; \ + deno --version WORKDIR /app COPY requirements.txt . RUN pip install --no-cache-dir -r requirements.txt diff --git a/youtube-downloader/app.py b/youtube-downloader/app.py index 9f9a32d..1cb1087 100644 --- a/youtube-downloader/app.py +++ b/youtube-downloader/app.py @@ -143,7 +143,7 @@ def _build_ytdlp_base_cmd() -> list: YTDLP_CMD, '--socket-timeout', '15', '--extractor-args', EXTRACTOR_ARGS, - '--js-runtimes', 'node', + '--js-runtimes', 'deno', '--remote-components', 'ejs:github', '--no-playlist', ] @@ -490,7 +490,14 @@ YOUTUBE_COOKIE_TEST_URL = os.getenv( 'YOUTUBE_COOKIE_TEST_URL', 'https://www.youtube.com/watch?v=jNQXAC9IVRw' # "Me at the zoo" — первое видео на YouTube, публичное, без возрастных ограничений ) -YOUTUBE_COOKIE_INVALID_MARKERS = ('no longer valid', 'have likely been rotated') +# Маркеры, по которым сбой однозначно относится к cookies/авторизации. +# Всё остальное считаем поломкой извлечения (версия yt-dlp, JS-рантайм, сеть) и +# про cookies ничего не утверждаем — иначе мониторинг уводит диагностику не туда. +YOUTUBE_COOKIE_INVALID_MARKERS = ( + 'no longer valid', + 'have likely been rotated', + 'sign in to confirm', +) @app.route('/cookies/check', methods=['POST']) @@ -499,6 +506,7 @@ def cookies_check(): cookies_file = Path(os.getenv('YOUTUBE_COOKIES_FILE', '/app/youtube_cookies.txt')) if not (cookies_file.exists() and cookies_file.stat().st_size > 0): return jsonify({'cookies_present': False, 'cookies_valid': None, + 'status': 'no_cookies', 'detail': 'cookies file missing or empty'}), 200 # Без --quiet/--no-warnings: сигнал ротации кук — это подавляемый WARNING, а не ошибка с ненулевым returncode @@ -506,15 +514,23 @@ def cookies_check(): try: result = _run_ytdlp(cmd, timeout=INFO_TIMEOUT) except Exception as e: - return jsonify({'cookies_present': True, 'cookies_valid': False, + return jsonify({'cookies_present': True, 'cookies_valid': None, + 'status': 'extraction_failed', 'detail': f'yt-dlp invocation failed: {e}'}), 200 stderr = result.stderr - if any(marker in stderr for marker in YOUTUBE_COOKIE_INVALID_MARKERS) or result.returncode != 0: - return jsonify({'cookies_present': True, 'cookies_valid': False, - 'detail': stderr.strip()[-500:]}), 200 + detail = stderr.strip()[-500:] - return jsonify({'cookies_present': True, 'cookies_valid': True, 'detail': ''}), 200 + if any(marker in stderr.lower() for marker in YOUTUBE_COOKIE_INVALID_MARKERS): + return jsonify({'cookies_present': True, 'cookies_valid': False, + 'status': 'cookies_invalid', 'detail': detail}), 200 + + if result.returncode != 0: + return jsonify({'cookies_present': True, 'cookies_valid': None, + 'status': 'extraction_failed', 'detail': detail}), 200 + + return jsonify({'cookies_present': True, 'cookies_valid': True, + 'status': 'ok', 'detail': ''}), 200 @app.route('/download/stream', methods=['POST']) @@ -570,9 +586,10 @@ def download_stream(): error_msg = ( f"{error_str}\n\n" "💡 Совет: Cookies устарели или недействительны. " - "Обновите cookies через скрипт:\n" - " ./youtube-downloader/get_youtube_cookies.sh\n" - "Затем перезапустите сервис." + "Обновите их вручную по инструкции в cookies/README.md " + "(для YouTube — только через приватное окно, иначе сессия ротируется) " + "и доставьте через cookies/deliver_cookies.sh. " + "Перезапуск сервиса не требуется." ) else: error_msg = error_str diff --git a/youtube-downloader/get_youtube_cookies.sh b/youtube-downloader/get_youtube_cookies.sh deleted file mode 100755 index 1ec66ef..0000000 --- a/youtube-downloader/get_youtube_cookies.sh +++ /dev/null @@ -1,103 +0,0 @@ -#!/bin/bash -# Скрипт для получения cookies YouTube через yt-dlp из Firefox - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -COOKIES_FILE="$SCRIPT_DIR/youtube_cookies.txt" -BROWSER="firefox" - -# Если запущено с sudo, используем HOME реального пользователя -if [ -n "$SUDO_USER" ]; then - REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6) - export HOME="$REAL_HOME" - echo "⚠️ Обнаружен sudo, использую домашнюю директорию пользователя: $HOME" -fi - -echo "Получение cookies YouTube из Firefox..." -echo "Файл cookies будет сохранен в: $COOKIES_FILE" - -# Проверяем наличие yt-dlp -if ! command -v yt-dlp &> /dev/null; then - echo "" - echo "❌ yt-dlp не найден в системе" - echo "" - echo "Установите yt-dlp одним из способов:" - echo "" - echo "1. Через pacman (Arch Linux):" - echo " sudo pacman -S yt-dlp" - echo "" - echo "2. Через pip:" - echo " pip install yt-dlp" - echo " или" - echo " pip3 install yt-dlp" - echo "" - echo "3. Через pipx (рекомендуется):" - echo " pipx install yt-dlp" - echo "" - exit 1 -fi - -echo "Получаю cookies..." - -# Используем простой способ: извлекаем cookies из браузера и сохраняем в файл -# Используем конкретное короткое видео (не плейлист, не главную страницу) -# Таймаут 10 секунд -# --no-download - не скачивать видео -# --skip-download - пропустить скачивание -# --flat-playlist - не извлекать информацию о видео в плейлистах -# Перенаправляем весь вывод, чтобы не видеть процесс скачивания -timeout 10 yt-dlp \ - --cookies-from-browser "$BROWSER" \ - --cookies "$COOKIES_FILE" \ - --no-download \ - --skip-download \ - --flat-playlist \ - --quiet \ - "https://www.youtube.com/watch?v=jNQXAC9IVRw" > /dev/null 2>&1 || true - -EXIT_CODE=$? - -# Не важно, какой код возврата - главное проверить, создался ли файл cookies -# yt-dlp может вернуть ошибку, но cookies все равно сохранить - -if [ -f "$COOKIES_FILE" ]; then - # Проверяем, что файл содержит данные (не только заголовки) - COOKIE_LINES=$(grep -v '^#' "$COOKIES_FILE" | grep -v '^$' | wc -l) - - if [ "$COOKIE_LINES" -gt 0 ]; then - echo "" - echo "✅ Cookies успешно сохранены в $COOKIES_FILE" - echo " Найдено строк с cookies: $COOKIE_LINES" - echo "" - echo "Если cookies смонтированы с хоста (см. docker-compose.yml/docker-compose.prod.yml)," - echo "перезапуск не нужен — yt-dlp читает файл заново при каждом запросе." - else - echo "" - echo "❌ Ошибка: файл cookies создан, но не содержит данных" - rm -f "$COOKIES_FILE" - exit 1 - fi -else - echo "" - echo "❌ Ошибка: файл cookies не был создан" - echo "" - echo "Возможные причины:" - echo "1. Firefox не найден или недоступен" - echo "2. Проблемы с правами доступа к файлу cookies браузера" - echo "3. Cookies не найдены в браузере" - echo "" - echo "Проверка:" - if [ -n "$SUDO_USER" ]; then - REAL_HOME=$(getent passwd "$SUDO_USER" | cut -d: -f6) - FIREFOX_DIR="$REAL_HOME/.mozilla/firefox" - else - FIREFOX_DIR="$HOME/.mozilla/firefox" - fi - if [ -d "$FIREFOX_DIR" ]; then - echo " ✓ Директория Firefox найдена: $FIREFOX_DIR" - else - echo " ✗ Директория Firefox не найдена: $FIREFOX_DIR" - echo " Попробуйте запустить скрипт БЕЗ sudo: ./get_youtube_cookies.sh" - fi - exit 1 -fi - diff --git a/youtube-downloader/requirements.txt b/youtube-downloader/requirements.txt index c6b8723..a2bd6ee 100644 --- a/youtube-downloader/requirements.txt +++ b/youtube-downloader/requirements.txt @@ -1,5 +1,5 @@ Flask==3.0.0 flask-cors==4.0.0 -yt-dlp>=2025.12.01 +yt-dlp>=2026.7.4 gunicorn==21.2.0