From 7feac6f3a4da0074897409b19fb487edf08fa699 Mon Sep 17 00:00:00 2001 From: smolkik-code Date: Sat, 27 Dec 2025 02:16:41 +0700 Subject: [PATCH] update v0.1.1.1 bed --- .gitignore | 3 +- bot/config.py | 3 +- bot/handlers.py | 77 +++++++++++++++++++++++---------------- bot/instaloader_client.py | 74 +++++++++++++++++++++++++++++++++++++ 4 files changed, 123 insertions(+), 34 deletions(-) create mode 100644 bot/instaloader_client.py diff --git a/.gitignore b/.gitignore index 83d7beb..e66d101 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ .env cookies.txt -.venv \ No newline at end of file +.venv +session-smolkikadm \ No newline at end of file diff --git a/bot/config.py b/bot/config.py index 6caca0f..59f965e 100644 --- a/bot/config.py +++ b/bot/config.py @@ -3,7 +3,8 @@ 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) diff --git a/bot/handlers.py b/bot/handlers.py index 52413e0..5b292d9 100644 --- a/bot/handlers.py +++ b/bot/handlers.py @@ -8,6 +8,7 @@ from .utils import is_instagram_story_url, VIDEO_SUFFIXES, IMAGE_SUFFIXES, extra 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,39 +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: - try: - await message.edit_text(f"⚠️ Ошибка Stories: {str(e)[:100]}") - except: - await message.reply_text(f"⚠️ Ошибка Stories: {str(e)[:100]}") + await message.edit_text(f"⚠️ Ошибка Stories (Instaloader): {e}") # -------- Stories: все активные у автора -------- async def download_all_stories(self, url: str, message, chat_id: int): try: @@ -137,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): diff --git a/bot/instaloader_client.py b/bot/instaloader_client.py new file mode 100644 index 0000000..9aecc4f --- /dev/null +++ b/bot/instaloader_client.py @@ -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"} + ] + )