fix(downloaders): Deno runtime, свежий yt-dlp, честный health-check, смоук-тест
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 2m36s

Возрастные 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 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-26 00:31:16 +00:00
parent 5092d882e5
commit d597a5e1c5
19 changed files with 373 additions and 733 deletions

128
smoke_test.py Executable file
View file

@ -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())