videoDownloadTGbot/instagram-downloader/app.py
vrubelroman c9972f3d68
All checks were successful
CI/CD Pipeline / build-and-deploy (push) Successful in 52s
fix(cookies): не давать yt-dlp затирать мастер-файл кук, честнее алерты
Реальный инцидент: cookies "протухли" за пару дней вместо ~года. Причина —
не срок годности, а сам yt-dlp: --cookies FILE читает И дописывает cookie
jar обратно в файл после каждого запуска (--help: "read cookies from and
dump cookie jar in"), а когда Instagram-экстрактор решает, что сессия
невалидна, он явно чистит sessionid из jar'а — и это тут же сохраняется на
диск через YoutubeDL.close(). Наш собственный health-check (каждые 30 мин)
и обычные скачивания медленно, но верно стирали себе рабочие cookies.

Фикс: yt-dlp больше никогда не видит мастер-файл, только одноразовую копию
в фиксированном /tmp-пути (безопасно — оба сервиса --workers=1, гонок нет).
Проверено: md5sum/mtime мастер-файлов не меняются ни после серии
/cookies/check, ни после реального /download/stream.

Заодно в bot.py: notify_admin_cookie_alert больше не заявляет "это НЕ
cookies" для extraction_failed — на практике это оказалось не всегда
верно (анонимный rate-limit тоже "не cookies" по факту, но валидная
сессия могла бы его обойти). В алерты добавлена проверяемая ссылка
(test_url из /cookies/check), чтобы сразу было видно, что это health-check
дёргает тестовый ролик, а не реальная ссылка пользователя. Новый статус
cookies_incomplete детектирует "файл есть, но sessionid нет" ещё до
сетевых проверок — ловит именно тот случай, что привёл к инциденту.

Отдельно: пользователю теперь показывается понятное сообщение, когда
Instagram сам блокирует контент как возрастной/чувствительный
("can't be seen by certain audiences") — вместо общего "Something went
wrong", раз повторная попытка всё равно не поможет. Админ по-прежнему
получает полный технический текст без изменений.

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

356 lines
19 KiB
Python
Raw Permalink 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 shutil
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)
# yt-dlp трактует --cookies/cookiefile как read-write: после каждого запуска он
# дописывает cookie jar обратно в файл (--help: "read cookies from and dump cookie
# jar in"), а когда решает, что сессия невалидна — сам чистит sessionid из jar'а и
# тут же сохраняет это на диск. В прошлый раз это молча стёрло единственный экземпляр
# настоящих cookies. Поэтому yt-dlp всегда получает одноразовую копию в этот файл,
# а мастер-файл он больше никогда не видит. Фиксированное имя безопасно, т.к. сервис
# работает в один воркер (см. Dockerfile CMD --workers=1).
INSTAGRAM_COOKIES_SCRATCH = Path('/tmp/instagram_cookies_scratch.txt')
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:
shutil.copy2(cookies_file_path, INSTAGRAM_COOKIES_SCRATCH)
ydl_opts['cookiefile'] = str(INSTAGRAM_COOKIES_SCRATCH)
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:
shutil.copy2(cookies_file, INSTAGRAM_COOKIES_SCRATCH)
ydl_opts['cookiefile'] = str(INSTAGRAM_COOKIES_SCRATCH)
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)
def _has_instagram_sessionid(cookies_file: Path) -> bool:
"""Проверяет, есть ли в файле реальный sessionid для instagram.com.
Без sessionid yt-dlp считает сессию неавторизованной (_is_logged_in проверяет
именно эту куку) и всегда идёт анонимным путём — тогда прод может часами слать
непонятный "rate-limit for accessing posts anonymously", хотя причина в том, что
cookies были экспортированы неправильно (например, `yt-dlp --cookies-from-browser`
без реального логина или дампом всех доменов браузера сразу — это ровно тот
антипаттерн, который убрали из cookies/, см. cookies/README.md).
"""
try:
with open(cookies_file, 'r') as f:
for line in f:
if line.startswith('#') or not line.strip():
continue
parts = line.rstrip('\n').split('\t')
if len(parts) >= 7 and 'instagram' in parts[0].lower() and parts[5] == 'sessionid':
return True
except Exception:
pass
return False
@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', 'test_url': INSTAGRAM_COOKIE_TEST_URL,
'detail': 'cookies file missing'}), 200
if not _has_instagram_sessionid(cookies_file):
return jsonify({'cookies_present': True, 'cookies_valid': False,
'status': 'cookies_incomplete', 'test_url': INSTAGRAM_COOKIE_TEST_URL,
'detail': 'в файле нет sessionid для instagram.com — это не рабочая '
'сессия, yt-dlp всегда идёт анонимным путём'}), 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', 'test_url': INSTAGRAM_COOKIE_TEST_URL, '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', 'test_url': INSTAGRAM_COOKIE_TEST_URL, 'detail': detail}), 200
# Не работает и анонимный путь. cookies при этом присутствуют и содержат sessionid,
# так что дело не в их отсутствии — но могло оказаться, что именно этот sessionid
# невалиден на сервере, и тогда yt-dlp всё равно скатывается в анонимный путь.
# Однозначно утверждать "это не cookies" при отказе анонимного пути нельзя — текст
# ошибки и решение показываем как есть, без готового вердикта.
return jsonify({'cookies_present': True, 'cookies_valid': None,
'status': 'extraction_failed', 'test_url': INSTAGRAM_COOKIE_TEST_URL,
'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)