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

43
bot.py
View file

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