""" Instagram Video Downloader Service Отдельный микросервис для скачивания видео с Instagram """ import os import logging import time from pathlib import Path from flask import Flask, request, jsonify from flask_cors import CORS import yt_dlp import uuid import re # Настройка логирования logging.basicConfig( format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', level=logging.INFO ) logger = logging.getLogger(__name__) app = Flask(__name__) CORS(app) # Разрешаем CORS для взаимодействия с основным ботом # Директория для временных файлов DOWNLOADS_DIR = Path('downloads') DOWNLOADS_DIR.mkdir(exist_ok=True) def check_instagram_cookies_expiry() -> tuple[bool, int]: """ Проверяет срок действия Instagram cookies Returns: (is_valid, days_until_expiry) """ cookies_file = os.getenv('INSTAGRAM_COOKIES_FILE', 'instagram_cookies.txt') cookies_file_path = Path(cookies_file) if not cookies_file_path.exists(): return False, 0 try: current_time = time.time() valid_expiries = [] important_cookies_found = {'sessionid': False, 'csrftoken': False, 'ds_user_id': False} # Важные cookies для Instagram (проверяем их в первую очередь) important_cookies = ['sessionid', 'csrftoken', 'ds_user_id'] with open(cookies_file_path, 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue parts = line.split('\t') if len(parts) >= 7: domain = parts[0] if 'instagram' in domain.lower(): try: expiry = int(parts[4]) # Unix timestamp cookie_name = parts[5] if len(parts) > 5 else '' # Отмечаем найденные важные cookies if cookie_name in important_cookies: important_cookies_found[cookie_name] = True # Игнорируем невалидные expiry (0, отрицательные, или слишком старые) # Session cookies (expiry = 0) также игнорируем для проверки срока if expiry > 0 and expiry > 946684800: # Фильтр: после 2000-01-01 (избегаем epoch 0) # Для важных cookies проверяем строже if cookie_name in important_cookies: if expiry > current_time: valid_expiries.append(expiry) else: valid_expiries.append(expiry) except (ValueError, IndexError): continue # Если найдены важные cookies, но их expiry истекли - все равно считаем валидными # (Instagram может принимать истекшие cookies, если они еще работают на сервере) if any(important_cookies_found.values()): if not valid_expiries: # Важные cookies найдены, но expiry истекли - считаем валидными на 30 дней logger.info("Важные Instagram cookies найдены, но expiry истекли. Пробуем использовать их.") return True, 30 if not valid_expiries: logger.warning("Не найдено валидных Instagram cookies с нормальным сроком действия") # Если нет валидных expiry, но есть cookies - считаем их действительными # (возможно, это session cookies) return True, 30 # Возвращаем разумное значение по умолчанию # Берем минимальный валидный expiry min_expiry = min(valid_expiries) days_until_expiry = (min_expiry - current_time) / 86400 is_valid = min_expiry > current_time return is_valid, int(days_until_expiry) except Exception as e: logger.error(f"Ошибка при проверке срока действия cookies: {e}") # В случае ошибки считаем cookies действительными (не блокируем работу) return True, 30 def download_instagram_video(url: str, max_retries: int = 3) -> Path: """Скачивает видео с Instagram - используем cookies с правильными заголовками""" cookies_file = os.getenv('INSTAGRAM_COOKIES_FILE', 'instagram_cookies.txt') cookies_file_path = Path(cookies_file) # Проверяем срок действия cookies перед использованием if cookies_file_path.exists(): is_valid, days_left = check_instagram_cookies_expiry() if not is_valid: logger.warning("Instagram cookies истекли по сроку, но пробуем использовать их (возможно, они еще работают на сервере)") elif days_left < 7: logger.warning(f"Instagram cookies истекают через {days_left} дней. Рекомендуется обновить.") # Парсим cookies для получения csrf token (формат Netscape) csrf_token = None sessionid = None if cookies_file_path.exists(): try: with open(cookies_file_path, 'r') as f: for line in f: line = line.strip() if not line or line.startswith('#'): continue parts = line.split('\t') if len(parts) >= 7: domain = parts[0] # Ищем только cookies от instagram.com if 'instagram' in domain.lower(): cookie_name = parts[5] # Имя cookie cookie_value = parts[6] # Значение cookie if cookie_name == 'csrftoken': csrf_token = cookie_value elif cookie_name == 'sessionid': sessionid = cookie_value # Если нашли оба - можно выходить if csrf_token and sessionid: break 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): 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: {mode}, попытка {attempt + 1}/{max_retries} не удалась: {e}") if attempt < max_retries - 1: time.sleep((attempt + 1) * 2) raise last_error or Exception("Неизвестная ошибка при скачивании с Instagram. Возможно, нужно обновить cookies.") @app.route('/health', methods=['GET']) def health(): """Health check endpoint""" return jsonify({'status': 'ok', 'service': 'instagram-downloader'}), 200 INSTAGRAM_COOKIE_TEST_URL = os.getenv( 'INSTAGRAM_COOKIE_TEST_URL', 'https://www.instagram.com/reel/Cvuh19eNWv_/' # стабильный публичный ролик (NASA) ) 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(): """Проверяет, способен ли сервис прямо сейчас скачать публичное видео. Намеренно повторяет эффективный путь 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 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 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': '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']) def download_stream(): """Скачивает видео с Instagram и возвращает бинарные данные""" try: data = request.get_json() if not data or 'url' not in data: return jsonify({'error': 'URL is required'}), 400 url = data['url'] logger.info(f"Получен запрос на скачивание (stream): {url}") # Проверяем, что это Instagram URL if 'instagram.com' not in url: return jsonify({'error': 'Only Instagram URLs are supported'}), 400 # Скачиваем видео video_path = download_instagram_video(url) logger.info(f"Видео скачано: {video_path}") # Читаем файл и отправляем with open(video_path, 'rb') as f: video_data = f.read() # Безопасное имя файла без кириллицы для заголовка safe_filename = video_path.name.encode('ascii', 'ignore').decode('ascii') or 'instagram_video.mp4' if not safe_filename.endswith(('.mp4', '.webm', '.mkv')): safe_filename = 'instagram_video.mp4' # Определяем content-type content_type = 'video/mp4' if video_path.suffix == '.webm': content_type = 'video/webm' elif video_path.suffix == '.mkv': content_type = 'video/x-matroska' # Удаляем временный файл video_path.unlink() return video_data, 200, { 'Content-Type': content_type, 'Content-Disposition': f'attachment; filename="{safe_filename}"' } except Exception as e: logger.error(f"Ошибка при скачивании: {e}") return jsonify({'error': str(e)}), 500 if __name__ == '__main__': port = int(os.getenv('PORT', 5000)) # Внутренний порт контейнера host = os.getenv('HOST', '0.0.0.0') logger.info(f"Запуск Instagram Downloader сервиса на {host}:{port}") app.run(host=host, port=port, debug=False)