diff --git a/.env.example b/.env.example index 9d87bd5..379b87a 100644 --- a/.env.example +++ b/.env.example @@ -14,3 +14,4 @@ VK_DOWNLOADER_URL=http://localhost:5555 # INSTAGRAM_DOWNLOADER_URL=http://instagram-downloader:5000 # VK_DOWNLOADER_URL=http://vk-downloader:5000 YAPFILES_DOWNLOADER_URL=http://localhost:5558 +TIKTOK_DOWNLOADER_URL=http://localhost:5559 diff --git a/bot.py b/bot.py index 20b0d2f..5d82b85 100644 --- a/bot.py +++ b/bot.py @@ -28,6 +28,7 @@ YOUTUBE_DOWNLOADER_URL = os.getenv('YOUTUBE_DOWNLOADER_URL', 'http://localhost:5 INSTAGRAM_DOWNLOADER_URL = os.getenv('INSTAGRAM_DOWNLOADER_URL', 'http://localhost:5556') 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') # Базовая директория проекта (абсолютный путь), чтобы не зависеть от рабочей директории процесса BASE_DIR = Path(__file__).resolve().parent @@ -53,6 +54,7 @@ TEXTS = { "Поддерживаемые источники:\n" "• YouTube (youtube.com, youtu.be)\n" "• Instagram (instagram.com)\n" + "• TikTok (tiktok.com)\n" "• VK (vk.com)\n" "• Yapfiles (yapfiles.ru)\n\n" "👥 Работа в группах:\n" @@ -70,6 +72,7 @@ TEXTS = { "Этот бот позволяет скачивать видео из популярных источников:\n" "• YouTube — видео и shorts\n" "• Instagram — reels и посты с видео\n" + "• TikTok — видео\n" "• VK — видеозаписи\n" "• Yapfiles — видеофайлы\n\n" "🔧 Как использовать:\n" @@ -88,6 +91,7 @@ TEXTS = { "Поддерживаемые источники:\n" "• YouTube (youtube.com, youtu.be)\n" "• Instagram (instagram.com)\n" + "• TikTok (tiktok.com)\n" "• VK (vk.com)\n" "• Yapfiles (yapfiles.ru)\n\n" "Для других источников: Пардон, не умеем 😅" @@ -107,6 +111,7 @@ TEXTS = { "Supported sources:\n" "• YouTube (youtube.com, youtu.be)\n" "• Instagram (instagram.com)\n" + "• TikTok (tiktok.com)\n" "• VK (vk.com)\n" "• Yapfiles (yapfiles.ru)\n\n" "👥 Group usage:\n" @@ -124,6 +129,7 @@ TEXTS = { "This bot allows you to download videos from popular sources:\n" "• YouTube — videos and shorts\n" "• Instagram — reels and video posts\n" + "• TikTok — videos\n" "• VK — video recordings\n" "• Yapfiles — video files\n\n" "🔧 How to use:\n" @@ -142,6 +148,7 @@ TEXTS = { "Supported sources:\n" "• YouTube (youtube.com, youtu.be)\n" "• Instagram (instagram.com)\n" + "• TikTok (tiktok.com)\n" "• VK (vk.com)\n" "• Yapfiles (yapfiles.ru)\n\n" "Other sources: Sorry, not supported 😅" @@ -326,6 +333,8 @@ def detect_video_source(url: str) -> str: return 'vk' elif 'yapfiles.ru' in domain: return 'yapfiles' + elif 'tiktok.com' in domain: + return 'tiktok' else: return 'unknown' @@ -582,6 +591,62 @@ async def download_yapfiles_video(url: str, chat_id: int, max_retries: int = 3) raise last_error or Exception("Неизвестная ошибка при скачивании с Yapfiles через внешний сервис") +async def download_tiktok_video(url: str, chat_id: int, max_retries: int = 3) -> str: + """Скачивает видео с TikTok через внешний сервис""" + logger.info(f"TikTok: отправка запроса на внешний сервис {TIKTOK_DOWNLOADER_URL}") + + last_error = None + for attempt in range(max_retries): + try: + async with httpx.AsyncClient(timeout=600.0) as client: + response = await client.post( + f"{TIKTOK_DOWNLOADER_URL}/download/stream", + json={"url": url}, + headers={"Content-Type": "application/json"} + ) + + if response.status_code != 200: + error_text = response.text + try: + error_json = response.json() + error_text = error_json.get('error', error_text) + except: + pass + raise Exception(f"TikTok сервис вернул ошибку {response.status_code}: {error_text}") + + video_data = response.content + video_ext = 'mp4' + + content_type = response.headers.get('Content-Type', '') + if 'video/' in content_type: + video_ext = content_type.split('/')[-1].split(';')[0] + + filename = response.headers.get('Content-Disposition', '') + if filename and 'filename=' in filename: + video_filename = filename.split('filename=')[1].strip('"\'') + else: + video_filename = f'{chat_id}_tiktok_video.{video_ext}' + + video_path = DOWNLOADS_DIR / video_filename + with open(video_path, 'wb') as f: + f.write(video_data) + + logger.info(f"TikTok: видео скачано через внешний сервис: {video_path}") + return str(video_path) + + except httpx.TimeoutException: + last_error = Exception(f"Таймаут при запросе к TikTok сервису (попытка {attempt + 1}/{max_retries})") + logger.warning(f"TikTok: таймаут при запросе к сервису: {last_error}") + except Exception as e: + last_error = e + logger.warning(f"TikTok: попытка {attempt + 1}/{max_retries} не удалась: {e}") + + if attempt < max_retries - 1: + await asyncio.sleep((attempt + 1) * 2) + + raise last_error or Exception("Неизвестная ошибка при скачивании с TikTok через внешний сервис") + + async def download_video(url: str, chat_id: int, locale: str, max_retries: int = 3) -> str: """Главная функция скачивания - вызывает нужную функцию в зависимости от источника""" source = detect_video_source(url) @@ -595,6 +660,8 @@ async def download_video(url: str, chat_id: int, locale: str, max_retries: int = return await download_vk_video(url, chat_id, max_retries) elif source == 'yapfiles': return await download_yapfiles_video(url, chat_id, max_retries) + elif source == 'tiktok': + return await download_tiktok_video(url, chat_id, max_retries) else: raise Exception(get_text(locale, 'error_unknown_source')) diff --git a/docker-compose.yml b/docker-compose.yml index e49260f..0825957 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -12,6 +12,7 @@ services: - INSTAGRAM_DOWNLOADER_URL=${INSTAGRAM_DOWNLOADER_URL} - VK_DOWNLOADER_URL=${VK_DOWNLOADER_URL} - YAPFILES_DOWNLOADER_URL=${YAPFILES_DOWNLOADER_URL} + - TIKTOK_DOWNLOADER_URL=${TIKTOK_DOWNLOADER_URL} volumes: - ./video:/app/video - ./data:/app/data:Z diff --git a/tiktok-downloader/Dockerfile b/tiktok-downloader/Dockerfile new file mode 100644 index 0000000..8edf75d --- /dev/null +++ b/tiktok-downloader/Dockerfile @@ -0,0 +1,22 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Установка зависимостей системы +RUN apt-get update && apt-get install -y --no-install-recommends \ + ffmpeg \ + && rm -rf /var/lib/apt/lists/* + +# Установка зависимостей Python +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +# Копирование приложения +COPY app.py . + +# Создание директории для загрузок +RUN mkdir -p downloads + +# Запуск приложения +CMD ["python", "app.py"] + diff --git a/tiktok-downloader/README.md b/tiktok-downloader/README.md new file mode 100644 index 0000000..8dba668 --- /dev/null +++ b/tiktok-downloader/README.md @@ -0,0 +1,47 @@ +# TikTok Video Downloader + +Микросервис для скачивания видео с TikTok + +## Порт + +- Внутренний порт: 5000 +- Внешний порт: 5559 + +## API + +### Health Check + +``` +GET /health +``` + +Ответ: +```json +{"status": "ok", "service": "tiktok-downloader"} +``` + +### Скачать видео + +``` +POST /download/stream +Content-Type: application/json + +{ + "url": "https://www.tiktok.com/@username/video/1234567890" +} +``` + +Возвращает бинарные данные видео. + +## Запуск + +```bash +docker-compose up -d --build +``` + +## Поддерживаемые URL + +- `https://www.tiktok.com/@username/video/1234567890` +- `https://vm.tiktok.com/ZM...` (короткие ссылки) +- `https://m.tiktok.com/v/1234567890` + diff --git a/tiktok-downloader/app.py b/tiktok-downloader/app.py new file mode 100644 index 0000000..4c6e4ec --- /dev/null +++ b/tiktok-downloader/app.py @@ -0,0 +1,156 @@ +""" +TikTok Video Downloader Service +Отдельный микросервис для скачивания видео с TikTok +""" +import os +import logging +import re +import uuid +from pathlib import Path +from flask import Flask, request, jsonify +from flask_cors import CORS +import yt_dlp + +# Настройка логирования +logging.basicConfig( + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s', + level=logging.INFO +) +logger = logging.getLogger(__name__) + +app = Flask(__name__) +CORS(app) + +# Директория для временных файлов +DOWNLOADS_DIR = Path('downloads') +DOWNLOADS_DIR.mkdir(exist_ok=True) + +# User-Agent для TikTok +USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' + + +def download_tiktok_video(url: str, max_retries: int = 3) -> Path: + """Скачивает видео с TikTok""" + last_error = None + + for attempt in range(max_retries): + try: + # Получаем информацию о видео + ydl_opts_info = { + 'quiet': False, + 'no_warnings': False, + 'user_agent': USER_AGENT, + 'socket_timeout': 30, + 'http_headers': { + 'User-Agent': USER_AGENT, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + }, + } + + with yt_dlp.YoutubeDL(ydl_opts_info) as ydl: + info = ydl.extract_info(url, download=False) + video_title = info.get('title', 'tiktok_video') + logger.info(f"TikTok: получена информация о видео: {video_title}") + + # Безопасное имя файла + safe_title = re.sub(r'[<>:"/\\|?*]', '', video_title)[:80] + output_template = str(DOWNLOADS_DIR / f'{uuid.uuid4()}_{safe_title}.%(ext)s') + + # Скачиваем видео + ydl_opts_download = { + 'format': 'best[ext=mp4]/best', + 'outtmpl': output_template, + 'quiet': False, + 'no_warnings': False, + 'user_agent': USER_AGENT, + 'socket_timeout': 30, + 'http_headers': { + 'User-Agent': USER_AGENT, + 'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8', + 'Accept-Language': 'en-US,en;q=0.5', + }, + } + + logger.info(f"TikTok: начинаем скачивание (попытка {attempt + 1}/{max_retries})") + with yt_dlp.YoutubeDL(ydl_opts_download) 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"TikTok: попытка {attempt + 1}/{max_retries} не удалась: {e}") + if attempt < max_retries - 1: + import time + time.sleep((attempt + 1) * 2) + + raise last_error or Exception("Неизвестная ошибка при скачивании с TikTok") + + +@app.route('/health', methods=['GET']) +def health(): + """Health check endpoint""" + return jsonify({'status': 'ok', 'service': 'tiktok-downloader'}), 200 + + +@app.route('/download/stream', methods=['POST']) +def download_stream(): + """Скачивает видео с TikTok и возвращает бинарные данные""" + 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}") + + # Проверяем, что это TikTok URL + if 'tiktok.com' not in url: + return jsonify({'error': 'Only TikTok URLs are supported'}), 400 + + # Скачиваем видео + video_path = download_tiktok_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 'tiktok_video.mp4' + if not safe_filename.endswith(('.mp4', '.webm', '.mkv')): + safe_filename = 'tiktok_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"Запуск TikTok Downloader сервиса на {host}:{port}") + app.run(host=host, port=port, debug=False) + diff --git a/tiktok-downloader/docker-compose.yml b/tiktok-downloader/docker-compose.yml new file mode 100644 index 0000000..8fe8a7d --- /dev/null +++ b/tiktok-downloader/docker-compose.yml @@ -0,0 +1,16 @@ +services: + tiktok-downloader: + build: . + container_name: tiktok_downloader_service + restart: unless-stopped + ports: + - "5559:5000" + volumes: + - ./downloads:/app/downloads + networks: + - tiktok_network + +networks: + tiktok_network: + driver: bridge + diff --git a/tiktok-downloader/requirements.txt b/tiktok-downloader/requirements.txt new file mode 100644 index 0000000..8962917 --- /dev/null +++ b/tiktok-downloader/requirements.txt @@ -0,0 +1,5 @@ +Flask==3.0.0 +flask-cors==4.0.0 +yt-dlp>=2024.12.13 +requests==2.31.0 +