fix(instagram): смягчить /cookies/check, честное сообщение для гейтованного контента
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 1m0s

/cookies/check раньше тестировал только путь с cookies и при неудаче писал
cookies_invalid — даже когда реальное скачивание прекрасно работает через
app-level fallback без cookies. Теперь проверка повторяет этот же
эффективный путь: cookies path сломан известным багом yt-dlp (media/info
всегда 404 вместо редиректа на логин), но раз fallback его закрывает —
это не авария и не повод слать ложный алерт "куки протухли", который
обновление cookies всё равно не лечит. Если не работает и анонимный путь
(который cookies не использует) — это extraction_failed, а не cookies_invalid.

Отдельно: когда 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-26 10:47:41 +00:00
parent b5f0670754
commit 8fa53f7cfc
2 changed files with 92 additions and 75 deletions

12
bot.py
View file

@ -45,6 +45,11 @@ VK_DOWNLOADER_URL = os.getenv('VK_DOWNLOADER_URL', 'http://localhost:5555')
YAPFILES_DOWNLOADER_URL = os.getenv('YAPFILES_DOWNLOADER_URL', 'http://localhost:5558') YAPFILES_DOWNLOADER_URL = os.getenv('YAPFILES_DOWNLOADER_URL', 'http://localhost:5558')
TIKTOK_DOWNLOADER_URL = os.getenv('TIKTOK_DOWNLOADER_URL', 'http://localhost:5559') TIKTOK_DOWNLOADER_URL = os.getenv('TIKTOK_DOWNLOADER_URL', 'http://localhost:5559')
# Instagram сам отказывает в показе такого контента анонимным/несовместимым аккаунтам —
# повторная попытка не поможет, поэтому пользователю нужен отдельный, понятный текст
# вместо общего "Something went wrong"
INSTAGRAM_SENSITIVE_CONTENT_MARKERS = ("can't be seen by certain audiences", "isn't available to everyone")
# Базовая директория проекта (абсолютный путь), чтобы не зависеть от рабочей директории процесса # Базовая директория проекта (абсолютный путь), чтобы не зависеть от рабочей директории процесса
BASE_DIR = Path(__file__).resolve().parent BASE_DIR = Path(__file__).resolve().parent
@ -111,6 +116,7 @@ TEXTS = {
'sending': "📤 Отправляю видео...", 'sending': "📤 Отправляю видео...",
'caption': "Видео скачано с @{bot_username}", 'caption': "Видео скачано с @{bot_username}",
'error': "❌ Something went wrong while processing your video. Please try again in a bit.", 'error': "❌ Something went wrong while processing your video. Please try again in a bit.",
'error_sensitive_content': "⚠️ This content is restricted by Instagram (age/sensitive content) and can't be downloaded by the bot. Try opening the link directly in the Instagram app.",
'error_unknown_source': "Пардон, не умеем работать с этим источником", 'error_unknown_source': "Пардон, не умеем работать с этим источником",
'error_vk_not_video': "❌ Это ссылка VK, но не на видео. Пришлите ссылку вида vk.com/video... или vk.com/clip...", 'error_vk_not_video': "❌ Это ссылка VK, но не на видео. Пришлите ссылку вида vk.com/video... или vk.com/clip...",
'error_file_too_large': "❌ Видео слишком большое ({size_mb:.1f} МБ, max = 50)", 'error_file_too_large': "❌ Видео слишком большое ({size_mb:.1f} МБ, max = 50)",
@ -169,6 +175,7 @@ TEXTS = {
'sending': "📤 Sending video...", 'sending': "📤 Sending video...",
'caption': "Video downloaded via @{bot_username}", 'caption': "Video downloaded via @{bot_username}",
'error': "❌ Something went wrong while processing your video. Please try again in a bit.", 'error': "❌ Something went wrong while processing your video. Please try again in a bit.",
'error_sensitive_content': "⚠️ This content is restricted by Instagram (age/sensitive content) and can't be downloaded by the bot. Try opening the link directly in the Instagram app.",
'error_unknown_source': "Sorry, this source is not supported", 'error_unknown_source': "Sorry, this source is not supported",
'error_vk_not_video': "❌ This is a VK link, but not a video. Send a vk.com/video... or vk.com/clip... link.", 'error_vk_not_video': "❌ This is a VK link, but not a video. Send a vk.com/video... or vk.com/clip... link.",
'error_file_too_large': "❌ Video is too large ({size_mb:.1f} MB, max = 50)", 'error_file_too_large': "❌ Video is too large ({size_mb:.1f} MB, max = 50)",
@ -841,7 +848,10 @@ async def process_queue_item(item: QueueItem):
await notify_admin_error(item.url, str(e), item.original_message.from_user) await notify_admin_error(item.url, str(e), item.original_message.from_user)
error_msg = get_text(item.locale, 'error') if any(marker in str(e).lower() for marker in INSTAGRAM_SENSITIVE_CONTENT_MARKERS):
error_msg = get_text(item.locale, 'error_sensitive_content')
else:
error_msg = get_text(item.locale, 'error')
try: try:
await item.status_message.edit_text(error_msg) await item.status_message.edit_text(error_msg)
except: except:

View file

@ -141,56 +141,54 @@ def download_instagram_video(url: str, max_retries: int = 3) -> Path:
except Exception as e: except Exception as e:
logger.warning(f"Не удалось прочитать cookies: {e}") logger.warning(f"Не удалось прочитать cookies: {e}")
def _build_opts(use_cookies: bool) -> dict:
ydl_opts = {
'format': 'best',
'outtmpl': str(DOWNLOADS_DIR / f'{uuid.uuid4()}_%(title)s.%(ext)s'),
'quiet': False,
'no_warnings': False,
'socket_timeout': 30,
}
if use_cookies:
ydl_opts['cookiefile'] = str(cookies_file_path.absolute())
headers = {
'Referer': 'https://www.instagram.com/',
'X-Requested-With': 'XMLHttpRequest',
}
if csrf_token:
headers['X-CSRFToken'] = csrf_token
ydl_opts['http_headers'] = headers
return ydl_opts
# Протухшая сессия заставляет yt-dlp ходить в API, который отдаёт 404, тогда как
# анонимный путь через веб-страницу для публичных постов работает. Поэтому после
# неудачи с cookies пробуем без них — как это давно делает YouTube-загрузчик.
cookie_modes = [True, False] if cookies_file_path.exists() else [False]
last_error = None last_error = None
for attempt in range(max_retries): for attempt in range(max_retries):
try: for use_cookies in cookie_modes:
# Базовые настройки mode = "с cookies" if use_cookies else "без cookies"
ydl_opts = { try:
'format': 'best', logger.info(f"Instagram: скачивание {mode} (попытка {attempt + 1}/{max_retries})")
'outtmpl': str(DOWNLOADS_DIR / f'{uuid.uuid4()}_%(title)s.%(ext)s'),
'quiet': False, with yt_dlp.YoutubeDL(_build_opts(use_cookies)) as ydl:
'no_warnings': False, ydl.download([url])
'socket_timeout': 30,
} # Находим скачанный файл
downloaded_files = list(DOWNLOADS_DIR.glob('*'))
# Если есть файл с cookies, используем его if downloaded_files:
if cookies_file_path.exists(): downloaded_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
# Используем абсолютный путь к cookies return downloaded_files[0]
ydl_opts['cookiefile'] = str(cookies_file_path.absolute())
logger.info(f"Instagram: используем cookies из {cookies_file_path}")
# Добавляем заголовки с csrf token если есть
headers = {
'Referer': 'https://www.instagram.com/',
'X-Requested-With': 'XMLHttpRequest',
}
if csrf_token:
headers['X-CSRFToken'] = csrf_token
logger.info(f"Instagram: добавлен csrf token в заголовки")
if sessionid:
logger.info(f"Instagram: sessionid найден (длина: {len(sessionid)})")
ydl_opts['http_headers'] = headers
logger.info(f"Instagram: начинаем скачивание (попытка {attempt + 1}/{max_retries})")
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
ydl.download([url])
# Находим скачанный файл
downloaded_files = list(DOWNLOADS_DIR.glob('*'))
if downloaded_files:
downloaded_files.sort(key=lambda x: x.stat().st_mtime, reverse=True)
return downloaded_files[0]
else:
raise Exception("Файл не был найден после скачивания") raise Exception("Файл не был найден после скачивания")
except Exception as e: except Exception as e:
last_error = e last_error = e
logger.warning(f"Instagram: попытка {attempt + 1}/{max_retries} не удалась: {e}") logger.warning(f"Instagram: {mode}, попытка {attempt + 1}/{max_retries} не удалась: {e}")
if attempt < max_retries - 1:
time.sleep((attempt + 1) * 2) if attempt < max_retries - 1:
time.sleep((attempt + 1) * 2)
raise last_error or Exception("Неизвестная ошибка при скачивании с Instagram. Возможно, нужно обновить cookies.") raise last_error or Exception("Неизвестная ошибка при скачивании с Instagram. Возможно, нужно обновить cookies.")
@ -206,46 +204,55 @@ INSTAGRAM_COOKIE_TEST_URL = os.getenv(
) )
# Маркеры, по которым сбой однозначно относится к cookies/авторизации. def _probe_extract(url: str, cookies_file: Path | None) -> tuple[bool, str]:
# Всё остальное считаем поломкой извлечения (версия yt-dlp, изменения на стороне """Пробует достать метаданные (без скачивания). Возвращает (получилось, текст ошибки)."""
# Instagram) и про cookies ничего не утверждаем — иначе мониторинг уводит ydl_opts = {'quiet': True, 'no_warnings': True, 'socket_timeout': 20}
# диагностику не туда: например, "empty media response" воспроизводится и без cookies. if cookies_file is not None:
INSTAGRAM_COOKIE_INVALID_MARKERS = ( ydl_opts['cookiefile'] = str(cookies_file.absolute())
'login required', ydl_opts['http_headers'] = {'Referer': 'https://www.instagram.com/'}
'locked behind the login page', try:
'rate-limit reached', with yt_dlp.YoutubeDL(ydl_opts) as ydl:
) ydl.extract_info(url, download=False)
return True, ''
except Exception as e:
return False, str(e)
@app.route('/cookies/check', methods=['POST']) @app.route('/cookies/check', methods=['POST'])
def cookies_check(): def cookies_check():
"""Проверяет, рабочие ли сейчас Instagram cookies (для проактивного мониторинга).""" """Проверяет, способен ли сервис прямо сейчас скачать публичное видео.
Намеренно повторяет эффективный путь download_instagram_video: сначала с cookies,
при неудаче без них. Известный баг yt-dlp (эндпоинт media/{id}/info/ отдаёт 404
вместо редиректа на логин даже с валидной sessionid, из-за чего внутренний фоллбэк
yt-dlp не срабатывает) означает, что путь с cookies может не проходить всегда
но раз наш собственный фоллбэк без cookies его закрывает, для пользователя это не
авария и не повод слать алерт "куки протухли" (обновление cookies это не лечит).
Незачем алармировать админа тем, что уже само себя чинит.
"""
cookies_file = Path(os.getenv('INSTAGRAM_COOKIES_FILE', 'instagram_cookies.txt')) cookies_file = Path(os.getenv('INSTAGRAM_COOKIES_FILE', 'instagram_cookies.txt'))
if not cookies_file.exists(): if not cookies_file.exists():
return jsonify({'cookies_present': False, 'cookies_valid': None, return jsonify({'cookies_present': False, 'cookies_valid': None,
'status': 'no_cookies', 'status': 'no_cookies',
'detail': 'cookies file missing'}), 200 'detail': 'cookies file missing'}), 200
ydl_opts = { ok_with, err_with = _probe_extract(INSTAGRAM_COOKIE_TEST_URL, cookies_file)
'quiet': True, if ok_with:
'no_warnings': True,
'socket_timeout': 20,
'cookiefile': str(cookies_file.absolute()),
'http_headers': {'Referer': 'https://www.instagram.com/'},
}
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, return jsonify({'cookies_present': True, 'cookies_valid': True,
'status': 'ok', 'detail': ''}), 200 'status': 'ok', 'detail': ''}), 200
except Exception as e:
err = str(e) ok_without, err_without = _probe_extract(INSTAGRAM_COOKIE_TEST_URL, None)
detail = err[-500:] if ok_without:
if any(marker in err.lower() for marker in INSTAGRAM_COOKIE_INVALID_MARKERS): # Путь с cookies сломан, но эффективное скачивание всё равно работает через
return jsonify({'cookies_present': True, 'cookies_valid': False, # фоллбэк — так же, как реальный download_instagram_video. Не поднимаем тревогу.
'status': 'cookies_invalid', 'detail': detail}), 200 detail = f"cookies-путь сломан, но fallback без cookies работает: {err_with[-400:]}"
return jsonify({'cookies_present': True, 'cookies_valid': None, return jsonify({'cookies_present': True, 'cookies_valid': None,
'status': 'extraction_failed', 'detail': detail}), 200 'status': 'ok', 'detail': detail}), 200
# Не работает и анонимный путь, который cookies вообще не использует —
# значит дело не в cookies, а в более общей поломке (сайт/экстрактор/сеть).
return jsonify({'cookies_present': True, 'cookies_valid': None,
'status': 'extraction_failed', 'detail': err_without[-500:]}), 200
@app.route('/download/stream', methods=['POST']) @app.route('/download/stream', methods=['POST'])