fix(cookies): не давать yt-dlp затирать мастер-файл кук, честнее алерты
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 52s

Реальный инцидент: cookies "протухли" за пару дней вместо ~года. Причина —
не срок годности, а сам yt-dlp: --cookies FILE читает И дописывает cookie
jar обратно в файл после каждого запуска (--help: "read cookies from and
dump cookie jar in"), а когда Instagram-экстрактор решает, что сессия
невалидна, он явно чистит sessionid из jar'а — и это тут же сохраняется на
диск через YoutubeDL.close(). Наш собственный health-check (каждые 30 мин)
и обычные скачивания медленно, но верно стирали себе рабочие cookies.

Фикс: yt-dlp больше никогда не видит мастер-файл, только одноразовую копию
в фиксированном /tmp-пути (безопасно — оба сервиса --workers=1, гонок нет).
Проверено: md5sum/mtime мастер-файлов не меняются ни после серии
/cookies/check, ни после реального /download/stream.

Заодно в bot.py: notify_admin_cookie_alert больше не заявляет "это НЕ
cookies" для extraction_failed — на практике это оказалось не всегда
верно (анонимный rate-limit тоже "не cookies" по факту, но валидная
сессия могла бы его обойти). В алерты добавлена проверяемая ссылка
(test_url из /cookies/check), чтобы сразу было видно, что это health-check
дёргает тестовый ролик, а не реальная ссылка пользователя. Новый статус
cookies_incomplete детектирует "файл есть, но sessionid нет" ещё до
сетевых проверок — ловит именно тот случай, что привёл к инциденту.

Отдельно: пользователю теперь показывается понятное сообщение, когда
Instagram сам блокирует контент как возрастной/чувствительный
("can't be seen by certain audiences") — вместо общего "Something went
wrong", раз повторная попытка всё равно не поможет. Админ по-прежнему
получает полный технический текст без изменений.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
vrubelroman 2026-07-31 15:18:13 +00:00
parent 8fa53f7cfc
commit c9972f3d68
3 changed files with 93 additions and 25 deletions

View file

@ -3,6 +3,7 @@ Instagram Video Downloader Service
Отдельный микросервис для скачивания видео с Instagram
"""
import os
import shutil
import logging
import time
from pathlib import Path
@ -26,6 +27,15 @@ CORS(app) # Разрешаем CORS для взаимодействия с ос
DOWNLOADS_DIR = Path('downloads')
DOWNLOADS_DIR.mkdir(exist_ok=True)
# yt-dlp трактует --cookies/cookiefile как read-write: после каждого запуска он
# дописывает cookie jar обратно в файл (--help: "read cookies from and dump cookie
# jar in"), а когда решает, что сессия невалидна — сам чистит sessionid из jar'а и
# тут же сохраняет это на диск. В прошлый раз это молча стёрло единственный экземпляр
# настоящих cookies. Поэтому yt-dlp всегда получает одноразовую копию в этот файл,
# а мастер-файл он больше никогда не видит. Фиксированное имя безопасно, т.к. сервис
# работает в один воркер (см. Dockerfile CMD --workers=1).
INSTAGRAM_COOKIES_SCRATCH = Path('/tmp/instagram_cookies_scratch.txt')
def check_instagram_cookies_expiry() -> tuple[bool, int]:
"""
@ -150,7 +160,8 @@ def download_instagram_video(url: str, max_retries: int = 3) -> Path:
'socket_timeout': 30,
}
if use_cookies:
ydl_opts['cookiefile'] = str(cookies_file_path.absolute())
shutil.copy2(cookies_file_path, INSTAGRAM_COOKIES_SCRATCH)
ydl_opts['cookiefile'] = str(INSTAGRAM_COOKIES_SCRATCH)
headers = {
'Referer': 'https://www.instagram.com/',
'X-Requested-With': 'XMLHttpRequest',
@ -208,7 +219,8 @@ def _probe_extract(url: str, cookies_file: Path | None) -> tuple[bool, str]:
"""Пробует достать метаданные (без скачивания). Возвращает (получилось, текст ошибки)."""
ydl_opts = {'quiet': True, 'no_warnings': True, 'socket_timeout': 20}
if cookies_file is not None:
ydl_opts['cookiefile'] = str(cookies_file.absolute())
shutil.copy2(cookies_file, INSTAGRAM_COOKIES_SCRATCH)
ydl_opts['cookiefile'] = str(INSTAGRAM_COOKIES_SCRATCH)
ydl_opts['http_headers'] = {'Referer': 'https://www.instagram.com/'}
try:
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
@ -218,6 +230,29 @@ def _probe_extract(url: str, cookies_file: Path | None) -> tuple[bool, str]:
return False, str(e)
def _has_instagram_sessionid(cookies_file: Path) -> bool:
"""Проверяет, есть ли в файле реальный sessionid для instagram.com.
Без sessionid yt-dlp считает сессию неавторизованной (_is_logged_in проверяет
именно эту куку) и всегда идёт анонимным путём тогда прод может часами слать
непонятный "rate-limit for accessing posts anonymously", хотя причина в том, что
cookies были экспортированы неправильно (например, `yt-dlp --cookies-from-browser`
без реального логина или дампом всех доменов браузера сразу это ровно тот
антипаттерн, который убрали из cookies/, см. cookies/README.md).
"""
try:
with open(cookies_file, 'r') as f:
for line in f:
if line.startswith('#') or not line.strip():
continue
parts = line.rstrip('\n').split('\t')
if len(parts) >= 7 and 'instagram' in parts[0].lower() and parts[5] == 'sessionid':
return True
except Exception:
pass
return False
@app.route('/cookies/check', methods=['POST'])
def cookies_check():
"""Проверяет, способен ли сервис прямо сейчас скачать публичное видео.
@ -233,13 +268,19 @@ def cookies_check():
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',
'status': 'no_cookies', 'test_url': INSTAGRAM_COOKIE_TEST_URL,
'detail': 'cookies file missing'}), 200
if not _has_instagram_sessionid(cookies_file):
return jsonify({'cookies_present': True, 'cookies_valid': False,
'status': 'cookies_incomplete', 'test_url': INSTAGRAM_COOKIE_TEST_URL,
'detail': 'в файле нет sessionid для instagram.com — это не рабочая '
'сессия, yt-dlp всегда идёт анонимным путём'}), 200
ok_with, err_with = _probe_extract(INSTAGRAM_COOKIE_TEST_URL, cookies_file)
if ok_with:
return jsonify({'cookies_present': True, 'cookies_valid': True,
'status': 'ok', 'detail': ''}), 200
'status': 'ok', 'test_url': INSTAGRAM_COOKIE_TEST_URL, 'detail': ''}), 200
ok_without, err_without = _probe_extract(INSTAGRAM_COOKIE_TEST_URL, None)
if ok_without:
@ -247,12 +288,16 @@ def cookies_check():
# фоллбэк — так же, как реальный download_instagram_video. Не поднимаем тревогу.
detail = f"cookies-путь сломан, но fallback без cookies работает: {err_with[-400:]}"
return jsonify({'cookies_present': True, 'cookies_valid': None,
'status': 'ok', 'detail': detail}), 200
'status': 'ok', 'test_url': INSTAGRAM_COOKIE_TEST_URL, 'detail': detail}), 200
# Не работает и анонимный путь, который cookies вообще не использует —
# значит дело не в cookies, а в более общей поломке (сайт/экстрактор/сеть).
# Не работает и анонимный путь. cookies при этом присутствуют и содержат sessionid,
# так что дело не в их отсутствии — но могло оказаться, что именно этот sessionid
# невалиден на сервере, и тогда yt-dlp всё равно скатывается в анонимный путь.
# Однозначно утверждать "это не cookies" при отказе анонимного пути нельзя — текст
# ошибки и решение показываем как есть, без готового вердикта.
return jsonify({'cookies_present': True, 'cookies_valid': None,
'status': 'extraction_failed', 'detail': err_without[-500:]}), 200
'status': 'extraction_failed', 'test_url': INSTAGRAM_COOKIE_TEST_URL,
'detail': err_without[-500:]}), 200
@app.route('/download/stream', methods=['POST'])