diff --git a/bot.py b/bot.py index 9fb6e00..1c3f7af 100644 --- a/bot.py +++ b/bot.py @@ -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') 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 @@ -111,6 +116,7 @@ TEXTS = { 'sending': "📤 Отправляю видео...", 'caption': "Видео скачано с @{bot_username}", '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_vk_not_video': "❌ Это ссылка VK, но не на видео. Пришлите ссылку вида vk.com/video... или vk.com/clip...", 'error_file_too_large': "❌ Видео слишком большое ({size_mb:.1f} МБ, max = 50)", @@ -169,6 +175,7 @@ TEXTS = { 'sending': "📤 Sending video...", 'caption': "Video downloaded via @{bot_username}", '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_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)", @@ -841,7 +848,10 @@ async def process_queue_item(item: QueueItem): 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: await item.status_message.edit_text(error_msg) except: diff --git a/instagram-downloader/app.py b/instagram-downloader/app.py index b82c2ca..f048bd2 100644 --- a/instagram-downloader/app.py +++ b/instagram-downloader/app.py @@ -141,56 +141,54 @@ def download_instagram_video(url: str, max_retries: int = 3) -> Path: except Exception as 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 for attempt in range(max_retries): - try: - # Базовые настройки - ydl_opts = { - 'format': 'best', - 'outtmpl': str(DOWNLOADS_DIR / f'{uuid.uuid4()}_%(title)s.%(ext)s'), - 'quiet': False, - 'no_warnings': False, - 'socket_timeout': 30, - } - - # Если есть файл с cookies, используем его - if cookies_file_path.exists(): - # Используем абсолютный путь к cookies - 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: + for use_cookies in cookie_modes: + mode = "с cookies" if use_cookies else "без cookies" + try: + logger.info(f"Instagram: скачивание {mode} (попытка {attempt + 1}/{max_retries})") + + with yt_dlp.YoutubeDL(_build_opts(use_cookies)) 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] raise Exception("Файл не был найден после скачивания") - - except Exception as e: - last_error = e - logger.warning(f"Instagram: попытка {attempt + 1}/{max_retries} не удалась: {e}") - if attempt < max_retries - 1: - time.sleep((attempt + 1) * 2) - + + except Exception as e: + last_error = e + logger.warning(f"Instagram: {mode}, попытка {attempt + 1}/{max_retries} не удалась: {e}") + + if attempt < max_retries - 1: + time.sleep((attempt + 1) * 2) + raise last_error or Exception("Неизвестная ошибка при скачивании с Instagram. Возможно, нужно обновить cookies.") @@ -206,46 +204,55 @@ INSTAGRAM_COOKIE_TEST_URL = os.getenv( ) -# Маркеры, по которым сбой однозначно относится к cookies/авторизации. -# Всё остальное считаем поломкой извлечения (версия yt-dlp, изменения на стороне -# Instagram) и про cookies ничего не утверждаем — иначе мониторинг уводит -# диагностику не туда: например, "empty media response" воспроизводится и без cookies. -INSTAGRAM_COOKIE_INVALID_MARKERS = ( - 'login required', - 'locked behind the login page', - 'rate-limit reached', -) +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()) + ydl_opts['http_headers'] = {'Referer': 'https://www.instagram.com/'} + try: + 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']) 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')) if not cookies_file.exists(): return jsonify({'cookies_present': False, 'cookies_valid': None, 'status': 'no_cookies', 'detail': 'cookies file missing'}), 200 - ydl_opts = { - 'quiet': True, - '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) + 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 - except Exception as e: - err = str(e) - detail = err[-500:] - if any(marker in err.lower() for marker in INSTAGRAM_COOKIE_INVALID_MARKERS): - return jsonify({'cookies_present': True, 'cookies_valid': False, - 'status': 'cookies_invalid', 'detail': detail}), 200 + + ok_without, err_without = _probe_extract(INSTAGRAM_COOKIE_TEST_URL, None) + if ok_without: + # Путь с cookies сломан, но эффективное скачивание всё равно работает через + # фоллбэк — так же, как реальный download_instagram_video. Не поднимаем тревогу. + detail = f"cookies-путь сломан, но fallback без cookies работает: {err_with[-400:]}" 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'])