videoDownloadTGbot/smoke_test.py
vrubelroman d597a5e1c5
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 2m36s
fix(downloaders): Deno runtime, свежий yt-dlp, честный health-check, смоук-тест
Возрастные 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>
2026-07-26 00:31:16 +00:00

128 lines
5.3 KiB
Python
Executable file
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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())