add finders

This commit is contained in:
smolkik-code
2025-12-26 23:26:13 +07:00
parent f49db9adf6
commit d77e00f029
32 changed files with 681 additions and 330 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

24
bot/app.py Normal file
View File

@@ -0,0 +1,24 @@
from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, CallbackQueryHandler, filters
from bot.logging_conf import setup_logging
from bot.config import TELEGRAM_TOKEN, BOT_BASE_URL, CONNECT_TIMEOUT, READ_TIMEOUT
from bot.handlers import Handlers
def main():
setup_logging()
h = Handlers()
app = (
ApplicationBuilder()
.token(TELEGRAM_TOKEN)
.base_url(BOT_BASE_URL)
.connect_timeout(CONNECT_TIMEOUT)
.read_timeout(READ_TIMEOUT)
.build()
)
app.add_handler(CommandHandler("start", h.start))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, h.on_message))
app.add_handler(CallbackQueryHandler(h.on_callback))
app.run_polling()
if __name__ == "__main__":
main()

14
bot/config.py Normal file
View File

@@ -0,0 +1,14 @@
import os
from pathlib import Path
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "REPLACE_ME")
BOT_BASE_URL = os.getenv("BOT_BASE_URL", "http://127.0.0.1:8081/bot")
DOWNLOAD_DIR = Path(os.getenv("DOWNLOAD_DIR", "downloads"))
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
COOKIES_FILE = Path(os.getenv("COOKIES_FILE", "")) if os.getenv("COOKIES_FILE") else Path()
BROWSER = os.getenv("BROWSER", "")
CONNECT_TIMEOUT = int(os.getenv("CONNECT_TIMEOUT", 600))
READ_TIMEOUT = int(os.getenv("READ_TIMEOUT", 1800))

396
bot/handlers.py Normal file
View File

@@ -0,0 +1,396 @@
from pathlib import Path
import httpx
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
from telegram.ext import ContextTypes
from .config import DOWNLOAD_DIR, COOKIES_FILE, BROWSER, CONNECT_TIMEOUT, READ_TIMEOUT
from .utils import is_instagram_story_url, VIDEO_SUFFIXES, IMAGE_SUFFIXES, extract_instagram_username
from .ytdlp_client import build_opts, run
from .telegram_send import send_video, send_audio, send_document, send_photo
from .transcode import transcode_to_mobile_mp4
# Профили качества для видео
QUALITY_MAP = {
'max': 'bestvideo*+bestaudio/best',
'1080': 'bestvideo[height<=1080]*+bestaudio/best[height<=1080]',
'720': 'bestvideo[height<=720]*+bestaudio/best[height<=720]',
'480': 'bestvideo[height<=480]*+bestaudio/best[height<=480]',
}
class Handlers:
def __init__(self):
self.user_data = {}
self.sending_active: dict[int, bool] = {}
async def start(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
text = "🎬 Отправьте ссылку (YouTube/TikTok/Instagram/VK и др.) или Stories Instagram.\n" \
"Доступно: видео (выбор качества), аудио (MP3), изображения, совместимый MP4, Stories (одна/все у автора)."
if COOKIES_FILE and COOKIES_FILE.exists():
text += "\n🔐 Instagram: используется cookies.txt"
elif BROWSER:
text += f"\n🔐 Instagram: cookies из браузера ({BROWSER})"
else:
text += "\n⚠️ Для Instagram Stories обычно нужны cookies."
await update.message.reply_text(text)
async def on_message(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
url = (update.message.text or "").strip()
if not url.startswith(("http://", "https://")):
await update.message.reply_text("⚠️ Отправьте корректную ссылку")
return
self.user_data[update.message.from_user.id] = {"url": url}
kb = [
[InlineKeyboardButton("🌟 Stories (по ссылке)", callback_data="story")],
[InlineKeyboardButton("📚 Все Stories автора", callback_data="story_all")],
[InlineKeyboardButton("🎥 Видео (выбор качества)", callback_data="video")],
[InlineKeyboardButton("🎧 Только аудио (MP3)", callback_data="audio")],
[InlineKeyboardButton("📸 Картинка/Галерея", callback_data="image")],
[InlineKeyboardButton("📁 Оригинал", callback_data="original")],
]
await update.message.reply_text("Выберите действие:", reply_markup=InlineKeyboardMarkup(kb))
async def on_callback(self, update: Update, context: ContextTypes.DEFAULT_TYPE):
query = update.callback_query
await query.answer()
uid = query.from_user.id
url = self.user_data.get(uid, {}).get("url", "")
chat_id = query.message.chat_id
if query.data == "story":
if not is_instagram_story_url(url):
await query.edit_message_text("⚠️ Нужен URL вида: https://www.instagram.com/stories/USERNAME/...")
return
msg = await query.edit_message_text("⏳ Загрузка Stories...")
await self.download_story(url, msg, chat_id)
elif query.data == "story_all":
msg = await query.edit_message_text("⏳ Ищу все активные Stories автора...")
await self.download_all_stories(url, msg, chat_id)
elif query.data == "video":
kb = [
[InlineKeyboardButton("🔥 Максимальное", callback_data="q_max")],
[InlineKeyboardButton("🖥 1080p", callback_data="q_1080")],
[InlineKeyboardButton("📺 720p", callback_data="q_720")],
[InlineKeyboardButton("📱 480p", callback_data="q_480")],
]
await query.edit_message_text("Выберите качество видео:", reply_markup=InlineKeyboardMarkup(kb))
elif query.data.startswith("q_"):
qual = query.data.split("_", 1)[1]
msg = await query.edit_message_text(f"⏳ Загрузка видео ({qual})...")
await self.download_video(url, msg, qual, chat_id)
elif query.data == "audio":
msg = await query.edit_message_text("⏳ Загрузка аудио...")
await self.download_audio(url, msg, chat_id)
elif query.data == "image":
msg = await query.edit_message_text("⏳ Загрузка изображений...")
await self.download_images(url, msg, chat_id)
elif query.data == "original":
msg = await query.edit_message_text("⏳ Загрузка оригинала...")
await self.download_original(url, msg, chat_id)
# -------- Stories: одиночная --------
async def download_story(self, url: str, message, chat_id: int):
try:
opts = build_opts(for_video=True, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=True)
# Для видео принудительно mp4, изображения не пострадают, т.к. merge только для видео
opts['format'] = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]'
filepath, info = await run(url, opts)
count = await self._send_playlist_mixed_with_images(info, message, chat_id)
# fallback одиночного файла
if count == 0 and filepath and filepath.exists():
suffix = (filepath.suffix or '').lower()
if suffix in IMAGE_SUFFIXES:
await self._send_photo_safe(message, filepath, chat_id)
count = 1
elif suffix in VIDEO_SUFFIXES:
out_mp4 = await transcode_to_mobile_mp4(filepath)
try:
await self._send_video_safe(message, out_mp4, chat_id)
count = 1
finally:
out_mp4.unlink(missing_ok=True)
else:
await self._send_document_safe(message, filepath, chat_id)
count = 1
filepath.unlink(missing_ok=True)
if count > 0:
await message.edit_text(f"✅ Stories отправлены! ({count} шт.)")
else:
await message.edit_text(" Stories не найдены или доступ ограничен.")
except Exception as e:
await message.edit_text(f"⚠️ Ошибка Stories: {e}")
# -------- Stories: все активные у автора --------
async def download_all_stories(self, url: str, message, chat_id: int):
try:
username = extract_instagram_username(url)
if not username:
await message.edit_text("⚠️ Не удалось определить username из ссылки.")
return
list_url = f"https://www.instagram.com/stories/{username}/"
opts = build_opts(for_video=True, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=True)
opts['format'] = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]'
_filepath, info = await run(list_url, opts)
count = await self._send_playlist_mixed_with_images(info, message, chat_id)
if count:
await message.edit_text(f"✅ Отправлено элементов: {count} (у @{username})")
else:
await message.edit_text(f" У @{username} нет активных сторис или доступ ограничен.")
except Exception as e:
await message.edit_text(f"⚠️ Ошибка при загрузке всех Stories автора: {e}")
# -------- Видео (с выбором качества) --------
async def download_video(self, url: str, message, quality_key: str, chat_id: int):
try:
fmt = QUALITY_MAP.get(quality_key, QUALITY_MAP['max'])
opts = build_opts(for_video=True, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=False)
opts['format'] = fmt
filepath, info = await run(url, opts)
sent_any = False
if filepath and filepath.exists():
out_mp4 = await transcode_to_mobile_mp4(filepath)
try:
await self._send_video_safe(message, out_mp4, chat_id)
sent_any = True
finally:
out_mp4.unlink(missing_ok=True)
filepath.unlink(missing_ok=True)
sent_any = (await self._send_playlist_mixed_with_images(info, message, chat_id)) or sent_any
if sent_any:
await message.edit_text("✅ Видео отправлено!")
else:
await message.edit_text("❌ Файл не найден после загрузки.")
except Exception as e:
await message.edit_text(f"⚠️ Ошибка: {e}")
# -------- Аудио --------
async def download_audio(self, url: str, message, chat_id: int):
try:
opts = build_opts(for_video=False, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=False)
opts.update({
'format': 'bestaudio/best',
'postprocessors': [{'key': 'FFmpegExtractAudio', 'preferredcodec': 'mp3', 'preferredquality': '192'}]
})
filepath, info = await run(url, opts)
if filepath and filepath.exists():
title = (info.get('title') if isinstance(info, dict) else None)
await self._send_audio_safe(message, filepath, title, chat_id)
await message.edit_text("✅ Аудио отправлено!")
filepath.unlink(missing_ok=True)
return
await message.edit_text("❌ Аудиофайл не найден.")
except Exception as e:
await message.edit_text(f"⚠️ Ошибка: {e}")
# -------- Изображения --------
async def download_images(self, url: str, message, chat_id: int):
try:
opts = build_opts(for_video=False, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=True)
opts['format'] = 'best'
filepath, info = await run(url, opts)
sent = 0
if filepath and filepath.exists():
suffix = (filepath.suffix or '').lower()
if suffix in IMAGE_SUFFIXES:
await self._send_photo_safe(message, filepath, chat_id)
sent += 1
else:
await self._send_document_safe(message, filepath, chat_id)
filepath.unlink(missing_ok=True)
sent += await self._send_images_from_playlist_with_fallback(info, message, chat_id)
if sent:
await message.edit_text(f"✅ Отправлено изображений: {sent}")
else:
await message.edit_text(" Изображения не найдены.")
except Exception as e:
await message.edit_text(f"⚠️ Ошибка при загрузке изображений: {e}")
# -------- Оригинал --------
async def download_original(self, url: str, message, chat_id: int):
try:
opts = build_opts(for_video=False, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=False)
opts['format'] = 'best'
filepath, info = await run(url, opts)
if filepath and filepath.exists():
await self._send_document_safe(message, filepath, chat_id)
await message.edit_text("✅ Файл отправлен!")
filepath.unlink(missing_ok=True)
return
sent_any = (await self._send_playlist_mixed_with_images(info, message, chat_id)) > 0
if sent_any:
await message.edit_text("✅ Файлы отправлены!")
else:
await message.edit_text("❌ Файл не найден.")
except Exception as e:
await message.edit_text(f"⚠️ Ошибка: {e}")
# -------- Вспомогательные --------
async def _send_video_safe(self, message, filepath: Path, chat_id: int):
self.sending_active[chat_id] = True
try:
await send_video(message, filepath, CONNECT_TIMEOUT, READ_TIMEOUT)
finally:
self.sending_active[chat_id] = False
async def _send_audio_safe(self, message, filepath: Path, title: str | None, chat_id: int):
self.sending_active[chat_id] = True
try:
await send_audio(message, filepath, title, CONNECT_TIMEOUT, READ_TIMEOUT)
finally:
self.sending_active[chat_id] = False
async def _send_document_safe(self, message, filepath: Path, chat_id: int):
self.sending_active[chat_id] = True
try:
await send_document(message, filepath, CONNECT_TIMEOUT, READ_TIMEOUT)
finally:
self.sending_active[chat_id] = False
async def _send_photo_safe(self, message, filepath: Path, chat_id: int):
self.sending_active[chat_id] = True
try:
await send_photo(message, filepath, CONNECT_TIMEOUT, READ_TIMEOUT)
finally:
self.sending_active[chat_id] = False
async def _download_file(self, url: str, into: Path) -> Path:
into.parent.mkdir(parents=True, exist_ok=True)
timeout = httpx.Timeout(30.0)
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
r = await client.get(url)
r.raise_for_status()
ctype = r.headers.get('content-type', '')
ext = ''
if 'image/jpeg' in ctype:
ext = '.jpg'
elif 'image/png' in ctype:
ext = '.png'
elif 'image/webp' in ctype:
ext = '.webp'
elif 'image/avif' in ctype:
ext = '.avif'
elif 'image/heic' in ctype or 'image/heif' in ctype:
ext = '.heic'
if not into.suffix:
into = into.with_suffix(ext or '.jpg')
into.write_bytes(r.content)
return into
def _pick_path(self, entry) -> Path | None:
if isinstance(entry, dict):
rd = entry.get('requested_downloads') or []
for it in rd[::-1]:
fp = it.get('filepath') or it.get('filename')
if fp:
p = Path(fp)
if p.exists():
return p
fn = entry.get('_filename')
if fn:
p = Path(fn)
if p.exists():
return p
return None
async def _send_images_from_playlist_with_fallback(self, info: dict | None, message, chat_id: int) -> int:
if not isinstance(info, dict):
return 0
entries = info.get('entries') or []
sent = 0
for idx, entry in enumerate(entries):
fp = self._pick_path(entry)
temp_file: Path | None = None
try:
if fp and fp.exists():
suffix = (fp.suffix or '').lower()
if suffix in IMAGE_SUFFIXES:
await self._send_photo_safe(message, fp, chat_id)
sent += 1
else:
await self._send_document_safe(message, fp, chat_id)
else:
image_url = None
thumbs = entry.get('thumbnails') if isinstance(entry, dict) else None
if isinstance(thumbs, list) and thumbs:
urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')]
if urls:
image_url = max(urls, key=len)
if not image_url and isinstance(entry, dict):
cand = entry.get('url') or entry.get('thumbnail')
if isinstance(cand, str) and cand.startswith(('http://', 'https://')):
image_url = cand
if image_url:
temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"img_{idx}")
await self._send_photo_safe(message, temp_file, chat_id)
sent += 1
finally:
if temp_file:
temp_file.unlink(missing_ok=True)
if fp:
fp.unlink(missing_ok=True)
return sent
async def _send_playlist_mixed_with_images(self, info: dict | None, message, chat_id: int) -> int:
if not isinstance(info, dict):
return 0
entries = info.get('entries') or []
sent = 0
for idx, entry in enumerate(entries):
fp = self._pick_path(entry)
temp_file: Path | None = None
try:
if fp and fp.exists():
suffix = (fp.suffix or '').lower()
if suffix in IMAGE_SUFFIXES:
await self._send_photo_safe(message, fp, chat_id)
sent += 1
elif suffix in VIDEO_SUFFIXES:
out_mp4 = await transcode_to_mobile_mp4(fp)
try:
await self._send_video_safe(message, out_mp4, chat_id)
sent += 1
finally:
out_mp4.unlink(missing_ok=True)
else:
await self._send_document_safe(message, fp, chat_id)
sent += 1
else:
image_url = None
thumbs = entry.get('thumbnails') if isinstance(entry, dict) else None
if isinstance(thumbs, list) and thumbs:
urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')]
if urls:
image_url = max(urls, key=len)
if not image_url and isinstance(entry, dict):
cand = entry.get('url') or entry.get('thumbnail')
if isinstance(cand, str) and cand.startswith(('http://', 'https://')):
image_url = cand
if image_url:
temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"story_img_{idx}")
await self._send_photo_safe(message, temp_file, chat_id)
sent += 1
finally:
if temp_file:
temp_file.unlink(missing_ok=True)
if fp:
fp.unlink(missing_ok=True)
return sent

1
bot/init.py Normal file
View File

@@ -0,0 +1 @@
# empty marker for package

7
bot/logging_conf.py Normal file
View File

@@ -0,0 +1,7 @@
import logging
def setup_logging():
logging.basicConfig(
level=logging.INFO,
format="%(asctime)s [%(levelname)s] %(message)s"
)

41
bot/telegram_send.py Normal file
View File

@@ -0,0 +1,41 @@
from pathlib import Path
from telegram import Message
async def send_video(message: Message, filepath: Path, connect_timeout: int, read_timeout: int):
with filepath.open('rb') as f:
await message.chat.send_video(
video=f,
supports_streaming=True,
connect_timeout=connect_timeout,
read_timeout=read_timeout
)
async def send_audio(message: Message, filepath: Path, title: str | None, connect_timeout: int, read_timeout: int):
with filepath.open('rb') as f:
await message.chat.send_audio(
audio=f,
title=title,
connect_timeout=connect_timeout,
read_timeout=read_timeout
)
async def send_document(message: Message, filepath: Path, connect_timeout: int, read_timeout: int):
with filepath.open('rb') as f:
await message.chat.send_document(
document=f,
filename=filepath.name,
connect_timeout=connect_timeout,
read_timeout=read_timeout
)
async def send_photo(message: Message, filepath: Path, connect_timeout: int, read_timeout: int):
# Фото до ~20MB лучше отправлять как фото, большие — как документ
if filepath.stat().st_size <= 20 * 1024 * 1024:
with filepath.open('rb') as f:
await message.chat.send_photo(
photo=f,
connect_timeout=connect_timeout,
read_timeout=read_timeout
)
else:
await send_document(message, filepath, connect_timeout, read_timeout)

46
bot/transcode.py Normal file
View File

@@ -0,0 +1,46 @@
import asyncio
from pathlib import Path
import shutil
import subprocess
FFMPEG_BIN = shutil.which("ffmpeg") or "ffmpeg"
async def transcode_to_mobile_mp4(src: Path) -> Path:
"""
Перекодировать видео в MP4, совместимый с мобильными клиентами Telegram:
- H.264 (libx264), профиль main
- pix_fmt yuv420p
- movflags +faststart
- AAC 128k
Возвращает путь к новому файлу (.tg.mp4).
"""
if not src.exists():
raise FileNotFoundError(f"Source file not found: {src}")
dst = src.with_suffix(".tg.mp4")
cmd = [
FFMPEG_BIN,
"-y",
"-i", str(src),
"-c:v", "libx264",
"-preset", "veryfast",
"-crf", "23",
"-profile:v", "main",
"-pix_fmt", "yuv420p",
"-movflags", "+faststart",
"-c:a", "aac",
"-b:a", "128k",
str(dst),
]
def _run():
proc = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode != 0:
raise RuntimeError(f"ffmpeg failed: {proc.stderr.decode('utf-8', errors='ignore')}")
loop = asyncio.get_running_loop()
await loop.run_in_executor(None, _run)
if not dst.exists():
raise RuntimeError("Transcode failed: output file not created")
return dst

32
bot/utils.py Normal file
View File

@@ -0,0 +1,32 @@
def is_instagram_story_url(url: str) -> bool:
return "instagram.com/stories" in url
VIDEO_SUFFIXES = ('.mp4', '.mov', '.mkv')
IMAGE_SUFFIXES = ('.jpg', '.jpeg', '.png', '.webp')
def extract_instagram_username(url: str) -> str | None:
# Поддержка:
# https://www.instagram.com/stories/USERNAME/...
# https://www.instagram.com/USERNAME/
try:
base = url.split("?")[0].strip("/")
parts = base.split("/")
if "instagram.com" not in url:
return None
if "stories" in parts:
i = parts.index("stories")
if i + 1 < len(parts):
return parts[i + 1]
# профиль
host_idx = 0
for i, p in enumerate(parts):
if "instagram.com" in p:
host_idx = i
break
if host_idx + 1 < len(parts):
candidate = parts[host_idx + 1]
if candidate and candidate not in ("stories", "reel", "p", "tv"):
return candidate
except Exception:
return None
return None

73
bot/ytdlp_client.py Normal file
View File

@@ -0,0 +1,73 @@
import asyncio
from functools import partial
from pathlib import Path
import os
import yt_dlp
def build_opts(for_video: bool, outdir: Path, cookiefile: Path | None, browser: str | None, message=None, out_for_playlist: bool = True) -> dict:
postprocessors = []
if for_video:
# На этапе постпроцессора делаем простую конвертацию в mp4 (дальше будет наша принудительная перекодировка)
postprocessors.append({'key': 'FFmpegVideoConvertor', 'preferedformat': 'mp4'})
# Для плейлистов (stories/галереи) — уникальные имена, чтобы не затирать
outtmpl = '%(uploader)s_%(id)s.%(ext)s' if out_for_playlist else '%(title)s.%(ext)s'
opts: dict = {
'outtmpl': str(outdir / outtmpl),
'quiet': True,
'merge_output_format': 'mp4',
'postprocessors': postprocessors,
'retries': 5,
'fragment_retries': 5,
'concurrent_fragment_downloads': 5,
'noprogress': True,
'source_address': '0.0.0.0', # форс IPv4
}
proxy = os.getenv('HTTPS_PROXY') or os.getenv('HTTP_PROXY') or ''
if proxy:
opts['proxy'] = proxy
if cookiefile and cookiefile.exists():
opts['cookiefile'] = str(cookiefile)
elif browser:
opts['cookiesfrombrowser'] = (browser,)
if message:
opts['progress_hooks'] = [partial(progress_hook_async_bridge, message=message)]
return opts
async def progress_hook_async_bridge(d, message):
try:
if d.get('status') == 'downloading':
percent = d.get('_percent_str', '').strip()
speed = d.get('_speed_str', '').strip()
eta = d.get('_eta_str', '').strip()
text = f"⬇️ Загрузка: {percent or '?'}\n🚀 Скорость: {speed or '?'}\n⏱ Осталось: {eta or '?'}"
await message.edit_text(text)
except Exception:
pass
async def run(url: str, opts: dict) -> tuple[Path | None, dict | None]:
loop = asyncio.get_running_loop()
info, filepath = None, None
def _do():
nonlocal info, filepath
with yt_dlp.YoutubeDL(opts) as ydl:
info = ydl.extract_info(url, download=True)
out = None
if isinstance(info, dict):
out = info.get('_filename')
if not out:
rd = info.get('requested_downloads') or []
for it in rd[::-1]:
out = it.get('filepath') or it.get('filename')
if out:
break
filepath = Path(out) if out else None
await loop.run_in_executor(None, _do)
return filepath, info