Compare commits

3 Commits

Author SHA1 Message Date
smolkik-code
7feac6f3a4 update v0.1.1.1 bed 2025-12-27 02:16:41 +07:00
smolkik-code
bc42c8775d update v0.1.1 2025-12-27 01:37:53 +07:00
smolkik-code
66a37ab5cb update v0.1 2025-12-27 01:20:49 +07:00
17 changed files with 256 additions and 117 deletions

BIN
.DS_Store vendored

Binary file not shown.

4
.gitignore vendored
View File

@@ -1,2 +1,4 @@
.env
cookies.txt
cookies.txt
.venv
session-smolkikadm

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.

View File

@@ -3,12 +3,13 @@ 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")
INSTA_SESSION_FILE = Path("session-smolkikadm")
INSTA_USERNAME = "smolkikadm"
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", "")
BROWSER = os.getenv("BROWSER", "chrome")
CONNECT_TIMEOUT = int(os.getenv("CONNECT_TIMEOUT", 600))
READ_TIMEOUT = int(os.getenv("READ_TIMEOUT", 1800))

View File

@@ -1,13 +1,14 @@
from pathlib import Path
import httpx
from typing import Optional
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
from .instaloader_client import download_user_stories
# Профили качества для видео
QUALITY_MAP = {
@@ -96,37 +97,37 @@ class Handlers:
# -------- 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)
username = extract_instagram_username(url)
if not username:
await message.edit_text("⚠️ Не удалось определить username из ссылки.")
return
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()
await message.edit_text("⏳ Загрузка Stories через Instaloader...")
files = download_user_stories(username)
if not files:
await message.edit_text(" Stories не найдены или доступ ограничен.")
return
count = 0
for fp in files:
suffix = fp.suffix.lower()
if suffix in IMAGE_SUFFIXES:
await self._send_photo_safe(message, filepath, chat_id)
count = 1
await self._send_photo_safe(message, fp, chat_id)
elif suffix in VIDEO_SUFFIXES:
out_mp4 = await transcode_to_mobile_mp4(filepath)
out_mp4 = await transcode_to_mobile_mp4(fp)
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)
await self._send_document_safe(message, fp, chat_id)
count += 1
await message.edit_text(f"✅ Stories отправлены! ({count} шт.)")
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}")
await message.edit_text(f"⚠️ Ошибка Stories (Instaloader): {e}")
# -------- Stories: все активные у автора --------
async def download_all_stories(self, url: str, message, chat_id: int):
try:
@@ -135,18 +136,32 @@ class Handlers:
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)
await message.edit_text("⏳ Загрузка всех активных Stories автора через Instaloader...")
count = await self._send_playlist_mixed_with_images(info, message, chat_id)
if count:
await message.edit_text(f"✅ Отправлено элементов: {count} (у @{username})")
else:
files = download_user_stories(username)
if not files:
await message.edit_text(f" У @{username} нет активных сторис или доступ ограничен.")
return
count = 0
for fp in files:
suffix = fp.suffix.lower()
if suffix in IMAGE_SUFFIXES:
await self._send_photo_safe(message, fp, chat_id)
elif suffix in VIDEO_SUFFIXES:
out_mp4 = await transcode_to_mobile_mp4(fp)
try:
await self._send_video_safe(message, out_mp4, chat_id)
finally:
out_mp4.unlink(missing_ok=True)
else:
await self._send_document_safe(message, fp, chat_id)
count += 1
await message.edit_text(f"✅ Отправлено элементов: {count} (у @{username})")
except Exception as e:
await message.edit_text(f"⚠️ Ошибка при загрузке всех Stories автора: {e}")
await message.edit_text(f"⚠️ Ошибка при загрузке всех Stories автора (Instaloader): {e}")
# -------- Видео (с выбором качества) --------
async def download_video(self, url: str, message, quality_key: str, chat_id: int):
@@ -251,7 +266,7 @@ class Handlers:
finally:
self.sending_active[chat_id] = False
async def _send_audio_safe(self, message, filepath: Path, title: str | None, chat_id: int):
async def _send_audio_safe(self, message, filepath: Path, title: Optional[str], chat_id: int):
self.sending_active[chat_id] = True
try:
await send_audio(message, filepath, title, CONNECT_TIMEOUT, READ_TIMEOUT)
@@ -273,12 +288,15 @@ class Handlers:
self.sending_active[chat_id] = False
async def _download_file(self, url: str, into: Path) -> Path:
print(f"[DBG] _download_file: url={url}, into={into}") # DBG
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', '')
print(f"[DBG] _download_file: content-type={ctype}") # DBG
ext = ''
if 'image/jpeg' in ctype:
ext = '.jpg'
@@ -290,12 +308,15 @@ class Handlers:
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:
into.write_bytes(r.content)
print(f"[DBG] _download_file: saved {into}") # DBG
return into
def _pick_path(self, entry) -> Optional[Path]:
if isinstance(entry, dict):
rd = entry.get('requested_downloads') or []
for it in rd[::-1]:
@@ -304,6 +325,7 @@ class Handlers:
p = Path(fp)
if p.exists():
return p
fn = entry.get('_filename')
if fn:
p = Path(fn)
@@ -311,14 +333,19 @@ class Handlers:
return p
return None
async def _send_images_from_playlist_with_fallback(self, info: dict | None, message, chat_id: int) -> int:
async def _send_images_from_playlist_with_fallback(
self, info: Optional[dict], 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
temp_file: Optional[Path] = None
try:
if fp and fp.exists():
suffix = (fp.suffix or '').lower()
@@ -334,10 +361,12 @@ class Handlers:
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)
@@ -347,23 +376,35 @@ class Handlers:
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:
async def _send_playlist_mixed_with_images(
self, info: Optional[dict], message, chat_id: int
) -> int:
if not isinstance(info, dict):
print("[DBG] _send_playlist_mixed_with_images: info is not dict")
return 0
entries = info.get('entries') or []
print(f"[DBG] playlist entries={len(entries)}")
sent = 0
for idx, entry in enumerate(entries):
print(f"[DBG] entry[{idx}] keys={list(entry.keys()) if isinstance(entry, dict) else type(entry)}")
fp = self._pick_path(entry)
temp_file: Path | None = None
temp_file: Optional[Path] = None
try:
if fp and fp.exists():
suffix = (fp.suffix or '').lower()
print(f"[DBG] entry[{idx}] local file={fp}, suffix={suffix}")
if suffix in IMAGE_SUFFIXES:
print(f"[DBG] entry[{idx}] send as photo")
await self._send_photo_safe(message, fp, chat_id)
sent += 1
elif suffix in VIDEO_SUFFIXES:
print(f"[DBG] entry[{idx}] transcode/send video")
out_mp4 = await transcode_to_mobile_mp4(fp)
try:
await self._send_video_safe(message, out_mp4, chat_id)
@@ -371,26 +412,37 @@ class Handlers:
finally:
out_mp4.unlink(missing_ok=True)
else:
print(f"[DBG] entry[{idx}] send as document")
await self._send_document_safe(message, fp, chat_id)
sent += 1
else:
print(f"[DBG] entry[{idx}] no local file, try thumbnails/url")
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)
print(f"[DBG] entry[{idx}] thumbnail url={image_url}")
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
print(f"[DBG] entry[{idx}] direct url={image_url}")
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
else:
print(f"[DBG] entry[{idx}] no image url found")
finally:
if temp_file:
temp_file.unlink(missing_ok=True)
if fp:
fp.unlink(missing_ok=True)
print(f"[DBG] _send_playlist_mixed_with_images: sent={sent}")
return sent

74
bot/instaloader_client.py Normal file
View File

@@ -0,0 +1,74 @@
from pathlib import Path
from typing import List, Optional
import instaloader
from instaloader import Profile
from .config import DOWNLOAD_DIR, INSTA_USERNAME, INSTA_SESSION_FILE
_instaloader: Optional[instaloader.Instaloader] = None
def get_instaloader() -> instaloader.Instaloader:
"""Создаёт Instaloader и грузит session-файл один раз."""
global _instaloader
if _instaloader is not None:
return _instaloader
L = instaloader.Instaloader(
dirname_pattern=str(DOWNLOAD_DIR / "{target}"),
filename_pattern="{date_utc}_story_{shortcode}",
download_video_thumbnails=False,
save_metadata=False,
download_geotags=False,
compress_json=False,
)
session_path = Path(INSTA_SESSION_FILE)
if not INSTA_USERNAME or not session_path.exists():
raise RuntimeError(
f"Instaloader session not configured: {INSTA_USERNAME=}, {session_path=}"
)
# загружаем сессию, созданную командой: instaloader -l INSTA_USERNAME
L.load_session_from_file(INSTA_USERNAME, filename=str(session_path))
_instaloader = L
return L
def download_user_stories(username: str) -> List[Path]:
"""Скачивает все актуальные stories пользователя и возвращает список файлов."""
L = get_instaloader()
try:
profile = Profile.from_username(L.context, username)
# Скачиваем stories для данного user id
L.download_stories(userids=[profile.userid])
except KeyError as e:
# Типичный кейс несовместимости Instaloader с текущим ответом Instagram:
# Instagram возвращает ответ без поля 'data' в GraphQL.
if str(e) == "'data'":
raise RuntimeError(
"Instagram вернул ответ без поля 'data' для GraphQL-запроса stories. "
"Это, как правило, несовместимость текущей версии Instaloader с API Instagram. "
"Обновите instaloader до последней версии или проверьте открытые issue проекта."
) from e
raise
except Exception as e:
raise RuntimeError(f"Ошибка при работе с @{username}: {e}") from e
user_dir = DOWNLOAD_DIR / username
if not user_dir.exists():
return []
# Возвращаем только медиа-файлы сторис
return sorted(
[
p
for p in user_dir.iterdir()
if p.is_file() and p.suffix.lower() in {".jpg", ".jpeg", ".png", ".mp4"}
]
)

View File

@@ -1,41 +1,59 @@
import asyncio
from pathlib import Path
from typing import Optional
from telegram import Message
from telegram.constants import ParseMode
from telegram.error import TelegramError
import logging
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
)
logger = logging.getLogger(__name__)
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
async def send_video(message: Message, filepath: Path, title: Optional[str] = None, *args, **kwargs):
"""Универсальная функция - принимает любые аргументы"""
try:
with open(filepath, 'rb') as video_file:
await message.reply_video(
video=video_file,
caption=title or "Видео",
supports_streaming=True,
parse_mode=ParseMode.HTML,
**kwargs # ✅ принимает любые доп. аргументы
)
else:
await send_document(message, filepath, connect_timeout, read_timeout)
except TelegramError as e:
logger.error(f"Ошибка отправки видео: {e}")
await message.reply_text("❌ Ошибка при отправке видео")
async def send_audio(message: Message, filepath: Path, title: Optional[str] = None, *args, **kwargs):
try:
with open(filepath, 'rb') as audio_file:
await message.reply_audio(
audio=audio_file,
title=title or "Аудио",
parse_mode=ParseMode.HTML,
**kwargs
)
except TelegramError as e:
logger.error(f"Ошибка отправки аудио: {e}")
await message.reply_text("❌ Ошибка при отправке аудио")
async def send_document(message: Message, filepath: Path, *args, **kwargs):
try:
with open(filepath, 'rb') as doc_file:
await message.reply_document(
document=doc_file,
**kwargs
)
except TelegramError as e:
logger.error(f"Ошибка отправки документа: {e}")
await message.reply_text("❌ Ошибка при отправке документа")
async def send_photo(message: Message, filepath: Path, *args, **kwargs):
try:
with open(filepath, 'rb') as photo_file:
await message.reply_photo(
photo=photo_file,
**kwargs
)
except TelegramError as e:
logger.error(f"Ошибка отправки фото: {e}")
await message.reply_text("❌ Ошибка при отправке фото")

View File

@@ -4,7 +4,9 @@ def is_instagram_story_url(url: str) -> bool:
VIDEO_SUFFIXES = ('.mp4', '.mov', '.mkv')
IMAGE_SUFFIXES = ('.jpg', '.jpeg', '.png', '.webp')
def extract_instagram_username(url: str) -> str | None:
from typing import Optional
def extract_instagram_username(url: Optional[str]) -> Optional[str]:
# Поддержка:
# https://www.instagram.com/stories/USERNAME/...
# https://www.instagram.com/USERNAME/

View File

@@ -1,18 +1,18 @@
import asyncio
from functools import partial
from pathlib import Path
from typing import Optional
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:
def build_opts(for_video: bool, outdir: Path, cookiefile: Optional[Path], browser: Optional[str], 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,
@@ -21,53 +21,43 @@ def build_opts(for_video: bool, outdir: Path, cookiefile: Path | None, browser:
'retries': 5,
'fragment_retries': 5,
'concurrent_fragment_downloads': 5,
'noprogress': True,
'source_address': '0.0.0.0', # форс IPv4
'noprogress': True, # ✅ Отключаем прогресс-бар
'source_address': '0.0.0.0',
}
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)]
# ✅ УБРАЛИ progress_hooks - они ломают event loop
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]:
async def run(url: str, opts: dict) -> tuple[Optional[Path], Optional[dict]]:
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
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

View File

@@ -1,4 +1,4 @@
python-telegram-bot==20.7
python-telegram-bot>=22.0
yt-dlp>=2025.1.1
httpx>=0.25.2
httpx>=0.27
instaloader>=4.13.1