videoDownloadTGbot/instagram-downloader/app.py
vrubelroman d597a5e1c5
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 2m36s
fix(downloaders): Deno runtime, свежий yt-dlp, честный health-check, смоук-тест
Возрастные YouTube-видео не скачивались: с cookies yt-dlp отбрасывает клиент
android и остаётся web, которому нужно решить n-challenge, а в образе стоял
Node 20 при требуемом минимуме 22 ("JS runtimes: node-20.19.2 (unsupported)").
Ставим Deno — рекомендованный yt-dlp рантайм и один статический бинарник.

Instagram падал с "empty media response" одинаково с cookies и без них —
дело было не в сессии, а в устаревшем экстракторе: слой pip был закеширован
на yt-dlp 2026.06.09. Поднимаем нижнюю границу до 2026.7.4.

/cookies/check помечал проблемой с cookies ЛЮБОЙ сбой, из-за чего на поломку
JS-рантайма прилетел алерт про протухшие cookies и увёл разбор не туда.
Теперь ответ содержит status: ok | cookies_invalid | extraction_failed |
no_cookies, и админ-бот шлёт разные сообщения. Разбор ответа в bot.py
сохраняет совместимость со старым форматом без поля status.

Добавлен smoke_test.py — гоняет реальные ссылки (включая обе регрессии выше)
через запущенные сервисы и печатает таблицу. Запускать после каждой правки.

Схема получения cookies переведена с крона на разовый ручной экспорт:
cookies-cron/ -> cookies/, удалены скрипты с анти-паттерном
`--cookies-from-browser BROWSER --cookies FILE`, который wiki yt-dlp прямо
запрещает и который сам ломал YouTube-сессию ротацией. Ключевые cookies живут
около года, поэтому обновление по расписанию не нужно — триггером служит алерт
health-check. deliver_cookies.sh только доставляет файлы по scp.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-26 00:31:16 +00:00

304 lines
14 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}")
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:
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)
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)
)
# Маркеры, по которым сбой однозначно относится к cookies/авторизации.
# Всё остальное считаем поломкой извлечения (версия yt-dlp, изменения на стороне
# Instagram) и про cookies ничего не утверждаем — иначе мониторинг уводит
# диагностику не туда: например, "empty media response" воспроизводится и без cookies.
INSTAGRAM_COOKIE_INVALID_MARKERS = (
'login required',
'locked behind the login page',
'rate-limit reached',
)
@app.route('/cookies/check', methods=['POST'])
def cookies_check():
"""Проверяет, рабочие ли сейчас Instagram 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)
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
return jsonify({'cookies_present': True, 'cookies_valid': None,
'status': 'extraction_failed', 'detail': detail}), 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)