videoDownloadTGbot/instagram-downloader/app.py
vrubelroman 8fa53f7cfc
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 1m0s
fix(instagram): смягчить /cookies/check, честное сообщение для гейтованного контента
/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>
2026-07-26 10:47:41 +00:00

311 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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