Compare commits
3 Commits
main
...
dev_storie
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7feac6f3a4 | ||
|
|
bc42c8775d | ||
|
|
66a37ab5cb |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -1,2 +1,4 @@
|
|||||||
.env
|
.env
|
||||||
cookies.txt
|
cookies.txt
|
||||||
|
.venv
|
||||||
|
session-smolkikadm
|
||||||
BIN
bot/__pycache__/app.cpython-314.pyc
Normal file
BIN
bot/__pycache__/app.cpython-314.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/config.cpython-314.pyc
Normal file
BIN
bot/__pycache__/config.cpython-314.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/handlers.cpython-314.pyc
Normal file
BIN
bot/__pycache__/handlers.cpython-314.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/logging_conf.cpython-314.pyc
Normal file
BIN
bot/__pycache__/logging_conf.cpython-314.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/telegram_send.cpython-314.pyc
Normal file
BIN
bot/__pycache__/telegram_send.cpython-314.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/transcode.cpython-314.pyc
Normal file
BIN
bot/__pycache__/transcode.cpython-314.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/utils.cpython-314.pyc
Normal file
BIN
bot/__pycache__/utils.cpython-314.pyc
Normal file
Binary file not shown.
BIN
bot/__pycache__/ytdlp_client.cpython-314.pyc
Normal file
BIN
bot/__pycache__/ytdlp_client.cpython-314.pyc
Normal file
Binary file not shown.
@@ -3,12 +3,13 @@ from pathlib import Path
|
|||||||
|
|
||||||
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "REPLACE_ME")
|
TELEGRAM_TOKEN = os.getenv("TELEGRAM_TOKEN", "REPLACE_ME")
|
||||||
BOT_BASE_URL = os.getenv("BOT_BASE_URL", "http://127.0.0.1:8081/bot")
|
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 = Path(os.getenv("DOWNLOAD_DIR", "downloads"))
|
||||||
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
DOWNLOAD_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
COOKIES_FILE = Path(os.getenv("COOKIES_FILE", "")) if os.getenv("COOKIES_FILE") else Path()
|
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))
|
CONNECT_TIMEOUT = int(os.getenv("CONNECT_TIMEOUT", 600))
|
||||||
READ_TIMEOUT = int(os.getenv("READ_TIMEOUT", 1800))
|
READ_TIMEOUT = int(os.getenv("READ_TIMEOUT", 1800))
|
||||||
|
|||||||
130
bot/handlers.py
130
bot/handlers.py
@@ -1,13 +1,14 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import httpx
|
import httpx
|
||||||
|
from typing import Optional
|
||||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||||
from telegram.ext import ContextTypes
|
from telegram.ext import ContextTypes
|
||||||
|
|
||||||
from .config import DOWNLOAD_DIR, COOKIES_FILE, BROWSER, CONNECT_TIMEOUT, READ_TIMEOUT
|
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 .utils import is_instagram_story_url, VIDEO_SUFFIXES, IMAGE_SUFFIXES, extract_instagram_username
|
||||||
from .ytdlp_client import build_opts, run
|
from .ytdlp_client import build_opts, run
|
||||||
from .telegram_send import send_video, send_audio, send_document, send_photo
|
from .telegram_send import send_video, send_audio, send_document, send_photo
|
||||||
from .transcode import transcode_to_mobile_mp4
|
from .transcode import transcode_to_mobile_mp4
|
||||||
|
from .instaloader_client import download_user_stories
|
||||||
|
|
||||||
# Профили качества для видео
|
# Профили качества для видео
|
||||||
QUALITY_MAP = {
|
QUALITY_MAP = {
|
||||||
@@ -96,37 +97,37 @@ class Handlers:
|
|||||||
# -------- Stories: одиночная --------
|
# -------- Stories: одиночная --------
|
||||||
async def download_story(self, url: str, message, chat_id: int):
|
async def download_story(self, url: str, message, chat_id: int):
|
||||||
try:
|
try:
|
||||||
opts = build_opts(for_video=True, outdir=DOWNLOAD_DIR, cookiefile=COOKIES_FILE, browser=BROWSER, message=message, out_for_playlist=True)
|
username = extract_instagram_username(url)
|
||||||
# Для видео принудительно mp4, изображения не пострадают, т.к. merge только для видео
|
if not username:
|
||||||
opts['format'] = 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]'
|
await message.edit_text("⚠️ Не удалось определить username из ссылки.")
|
||||||
filepath, info = await run(url, opts)
|
return
|
||||||
|
|
||||||
count = await self._send_playlist_mixed_with_images(info, message, chat_id)
|
await message.edit_text("⏳ Загрузка Stories через Instaloader...")
|
||||||
# fallback одиночного файла
|
|
||||||
if count == 0 and filepath and filepath.exists():
|
files = download_user_stories(username)
|
||||||
suffix = (filepath.suffix or '').lower()
|
if not files:
|
||||||
|
await message.edit_text("ℹ️ Stories не найдены или доступ ограничен.")
|
||||||
|
return
|
||||||
|
|
||||||
|
count = 0
|
||||||
|
for fp in files:
|
||||||
|
suffix = fp.suffix.lower()
|
||||||
if suffix in IMAGE_SUFFIXES:
|
if suffix in IMAGE_SUFFIXES:
|
||||||
await self._send_photo_safe(message, filepath, chat_id)
|
await self._send_photo_safe(message, fp, chat_id)
|
||||||
count = 1
|
|
||||||
elif suffix in VIDEO_SUFFIXES:
|
elif suffix in VIDEO_SUFFIXES:
|
||||||
out_mp4 = await transcode_to_mobile_mp4(filepath)
|
out_mp4 = await transcode_to_mobile_mp4(fp)
|
||||||
try:
|
try:
|
||||||
await self._send_video_safe(message, out_mp4, chat_id)
|
await self._send_video_safe(message, out_mp4, chat_id)
|
||||||
count = 1
|
|
||||||
finally:
|
finally:
|
||||||
out_mp4.unlink(missing_ok=True)
|
out_mp4.unlink(missing_ok=True)
|
||||||
else:
|
else:
|
||||||
await self._send_document_safe(message, filepath, chat_id)
|
await self._send_document_safe(message, fp, chat_id)
|
||||||
count = 1
|
count += 1
|
||||||
filepath.unlink(missing_ok=True)
|
|
||||||
|
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:
|
except Exception as e:
|
||||||
await message.edit_text(f"⚠️ Ошибка Stories: {e}")
|
await message.edit_text(f"⚠️ Ошибка Stories (Instaloader): {e}")
|
||||||
|
|
||||||
# -------- Stories: все активные у автора --------
|
# -------- Stories: все активные у автора --------
|
||||||
async def download_all_stories(self, url: str, message, chat_id: int):
|
async def download_all_stories(self, url: str, message, chat_id: int):
|
||||||
try:
|
try:
|
||||||
@@ -135,18 +136,32 @@ class Handlers:
|
|||||||
await message.edit_text("⚠️ Не удалось определить username из ссылки.")
|
await message.edit_text("⚠️ Не удалось определить username из ссылки.")
|
||||||
return
|
return
|
||||||
|
|
||||||
list_url = f"https://www.instagram.com/stories/{username}/"
|
await message.edit_text("⏳ Загрузка всех активных Stories автора через Instaloader...")
|
||||||
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)
|
files = download_user_stories(username)
|
||||||
if count:
|
if not files:
|
||||||
await message.edit_text(f"✅ Отправлено элементов: {count} (у @{username})")
|
|
||||||
else:
|
|
||||||
await message.edit_text(f"ℹ️ У @{username} нет активных сторис или доступ ограничен.")
|
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:
|
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):
|
async def download_video(self, url: str, message, quality_key: str, chat_id: int):
|
||||||
@@ -251,7 +266,7 @@ class Handlers:
|
|||||||
finally:
|
finally:
|
||||||
self.sending_active[chat_id] = False
|
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
|
self.sending_active[chat_id] = True
|
||||||
try:
|
try:
|
||||||
await send_audio(message, filepath, title, CONNECT_TIMEOUT, READ_TIMEOUT)
|
await send_audio(message, filepath, title, CONNECT_TIMEOUT, READ_TIMEOUT)
|
||||||
@@ -273,12 +288,15 @@ class Handlers:
|
|||||||
self.sending_active[chat_id] = False
|
self.sending_active[chat_id] = False
|
||||||
|
|
||||||
async def _download_file(self, url: str, into: Path) -> Path:
|
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)
|
into.parent.mkdir(parents=True, exist_ok=True)
|
||||||
timeout = httpx.Timeout(30.0)
|
timeout = httpx.Timeout(30.0)
|
||||||
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
async with httpx.AsyncClient(timeout=timeout, follow_redirects=True) as client:
|
||||||
r = await client.get(url)
|
r = await client.get(url)
|
||||||
r.raise_for_status()
|
r.raise_for_status()
|
||||||
ctype = r.headers.get('content-type', '')
|
ctype = r.headers.get('content-type', '')
|
||||||
|
print(f"[DBG] _download_file: content-type={ctype}") # DBG
|
||||||
|
|
||||||
ext = ''
|
ext = ''
|
||||||
if 'image/jpeg' in ctype:
|
if 'image/jpeg' in ctype:
|
||||||
ext = '.jpg'
|
ext = '.jpg'
|
||||||
@@ -290,12 +308,15 @@ class Handlers:
|
|||||||
ext = '.avif'
|
ext = '.avif'
|
||||||
elif 'image/heic' in ctype or 'image/heif' in ctype:
|
elif 'image/heic' in ctype or 'image/heif' in ctype:
|
||||||
ext = '.heic'
|
ext = '.heic'
|
||||||
|
|
||||||
if not into.suffix:
|
if not into.suffix:
|
||||||
into = into.with_suffix(ext or '.jpg')
|
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):
|
if isinstance(entry, dict):
|
||||||
rd = entry.get('requested_downloads') or []
|
rd = entry.get('requested_downloads') or []
|
||||||
for it in rd[::-1]:
|
for it in rd[::-1]:
|
||||||
@@ -304,6 +325,7 @@ class Handlers:
|
|||||||
p = Path(fp)
|
p = Path(fp)
|
||||||
if p.exists():
|
if p.exists():
|
||||||
return p
|
return p
|
||||||
|
|
||||||
fn = entry.get('_filename')
|
fn = entry.get('_filename')
|
||||||
if fn:
|
if fn:
|
||||||
p = Path(fn)
|
p = Path(fn)
|
||||||
@@ -311,14 +333,19 @@ class Handlers:
|
|||||||
return p
|
return p
|
||||||
return None
|
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):
|
if not isinstance(info, dict):
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
entries = info.get('entries') or []
|
entries = info.get('entries') or []
|
||||||
sent = 0
|
sent = 0
|
||||||
|
|
||||||
for idx, entry in enumerate(entries):
|
for idx, entry in enumerate(entries):
|
||||||
fp = self._pick_path(entry)
|
fp = self._pick_path(entry)
|
||||||
temp_file: Path | None = None
|
temp_file: Optional[Path] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if fp and fp.exists():
|
if fp and fp.exists():
|
||||||
suffix = (fp.suffix or '').lower()
|
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')]
|
urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')]
|
||||||
if urls:
|
if urls:
|
||||||
image_url = max(urls, key=len)
|
image_url = max(urls, key=len)
|
||||||
|
|
||||||
if not image_url and isinstance(entry, dict):
|
if not image_url and isinstance(entry, dict):
|
||||||
cand = entry.get('url') or entry.get('thumbnail')
|
cand = entry.get('url') or entry.get('thumbnail')
|
||||||
if isinstance(cand, str) and cand.startswith(('http://', 'https://')):
|
if isinstance(cand, str) and cand.startswith(('http://', 'https://')):
|
||||||
image_url = cand
|
image_url = cand
|
||||||
|
|
||||||
if image_url:
|
if image_url:
|
||||||
temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"img_{idx}")
|
temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"img_{idx}")
|
||||||
await self._send_photo_safe(message, temp_file, chat_id)
|
await self._send_photo_safe(message, temp_file, chat_id)
|
||||||
@@ -347,23 +376,35 @@ class Handlers:
|
|||||||
temp_file.unlink(missing_ok=True)
|
temp_file.unlink(missing_ok=True)
|
||||||
if fp:
|
if fp:
|
||||||
fp.unlink(missing_ok=True)
|
fp.unlink(missing_ok=True)
|
||||||
|
|
||||||
return sent
|
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):
|
if not isinstance(info, dict):
|
||||||
|
print("[DBG] _send_playlist_mixed_with_images: info is not dict")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
entries = info.get('entries') or []
|
entries = info.get('entries') or []
|
||||||
|
print(f"[DBG] playlist entries={len(entries)}")
|
||||||
|
|
||||||
sent = 0
|
sent = 0
|
||||||
for idx, entry in enumerate(entries):
|
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)
|
fp = self._pick_path(entry)
|
||||||
temp_file: Path | None = None
|
temp_file: Optional[Path] = None
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if fp and fp.exists():
|
if fp and fp.exists():
|
||||||
suffix = (fp.suffix or '').lower()
|
suffix = (fp.suffix or '').lower()
|
||||||
|
print(f"[DBG] entry[{idx}] local file={fp}, suffix={suffix}")
|
||||||
if suffix in IMAGE_SUFFIXES:
|
if suffix in IMAGE_SUFFIXES:
|
||||||
|
print(f"[DBG] entry[{idx}] send as photo")
|
||||||
await self._send_photo_safe(message, fp, chat_id)
|
await self._send_photo_safe(message, fp, chat_id)
|
||||||
sent += 1
|
sent += 1
|
||||||
elif suffix in VIDEO_SUFFIXES:
|
elif suffix in VIDEO_SUFFIXES:
|
||||||
|
print(f"[DBG] entry[{idx}] transcode/send video")
|
||||||
out_mp4 = await transcode_to_mobile_mp4(fp)
|
out_mp4 = await transcode_to_mobile_mp4(fp)
|
||||||
try:
|
try:
|
||||||
await self._send_video_safe(message, out_mp4, chat_id)
|
await self._send_video_safe(message, out_mp4, chat_id)
|
||||||
@@ -371,26 +412,37 @@ class Handlers:
|
|||||||
finally:
|
finally:
|
||||||
out_mp4.unlink(missing_ok=True)
|
out_mp4.unlink(missing_ok=True)
|
||||||
else:
|
else:
|
||||||
|
print(f"[DBG] entry[{idx}] send as document")
|
||||||
await self._send_document_safe(message, fp, chat_id)
|
await self._send_document_safe(message, fp, chat_id)
|
||||||
sent += 1
|
sent += 1
|
||||||
else:
|
else:
|
||||||
|
print(f"[DBG] entry[{idx}] no local file, try thumbnails/url")
|
||||||
image_url = None
|
image_url = None
|
||||||
thumbs = entry.get('thumbnails') if isinstance(entry, dict) else None
|
thumbs = entry.get('thumbnails') if isinstance(entry, dict) else None
|
||||||
|
|
||||||
if isinstance(thumbs, list) and thumbs:
|
if isinstance(thumbs, list) and thumbs:
|
||||||
urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')]
|
urls = [t.get('url') for t in thumbs if isinstance(t, dict) and t.get('url')]
|
||||||
if urls:
|
if urls:
|
||||||
image_url = max(urls, key=len)
|
image_url = max(urls, key=len)
|
||||||
|
print(f"[DBG] entry[{idx}] thumbnail url={image_url}")
|
||||||
|
|
||||||
if not image_url and isinstance(entry, dict):
|
if not image_url and isinstance(entry, dict):
|
||||||
cand = entry.get('url') or entry.get('thumbnail')
|
cand = entry.get('url') or entry.get('thumbnail')
|
||||||
if isinstance(cand, str) and cand.startswith(('http://', 'https://')):
|
if isinstance(cand, str) and cand.startswith(('http://', 'https://')):
|
||||||
image_url = cand
|
image_url = cand
|
||||||
|
print(f"[DBG] entry[{idx}] direct url={image_url}")
|
||||||
|
|
||||||
if image_url:
|
if image_url:
|
||||||
temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"story_img_{idx}")
|
temp_file = await self._download_file(image_url, DOWNLOAD_DIR / f"story_img_{idx}")
|
||||||
await self._send_photo_safe(message, temp_file, chat_id)
|
await self._send_photo_safe(message, temp_file, chat_id)
|
||||||
sent += 1
|
sent += 1
|
||||||
|
else:
|
||||||
|
print(f"[DBG] entry[{idx}] no image url found")
|
||||||
finally:
|
finally:
|
||||||
if temp_file:
|
if temp_file:
|
||||||
temp_file.unlink(missing_ok=True)
|
temp_file.unlink(missing_ok=True)
|
||||||
if fp:
|
if fp:
|
||||||
fp.unlink(missing_ok=True)
|
fp.unlink(missing_ok=True)
|
||||||
|
|
||||||
|
print(f"[DBG] _send_playlist_mixed_with_images: sent={sent}")
|
||||||
return sent
|
return sent
|
||||||
|
|||||||
74
bot/instaloader_client.py
Normal file
74
bot/instaloader_client.py
Normal 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"}
|
||||||
|
]
|
||||||
|
)
|
||||||
@@ -1,41 +1,59 @@
|
|||||||
|
import asyncio
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
from telegram import Message
|
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):
|
logger = logging.getLogger(__name__)
|
||||||
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):
|
async def send_video(message: Message, filepath: Path, title: Optional[str] = None, *args, **kwargs):
|
||||||
with filepath.open('rb') as f:
|
"""Универсальная функция - принимает любые аргументы"""
|
||||||
await message.chat.send_audio(
|
try:
|
||||||
audio=f,
|
with open(filepath, 'rb') as video_file:
|
||||||
title=title,
|
await message.reply_video(
|
||||||
connect_timeout=connect_timeout,
|
video=video_file,
|
||||||
read_timeout=read_timeout
|
caption=title or "Видео",
|
||||||
)
|
supports_streaming=True,
|
||||||
|
parse_mode=ParseMode.HTML,
|
||||||
async def send_document(message: Message, filepath: Path, connect_timeout: int, read_timeout: int):
|
**kwargs # ✅ принимает любые доп. аргументы
|
||||||
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:
|
except TelegramError as e:
|
||||||
await send_document(message, filepath, connect_timeout, read_timeout)
|
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("❌ Ошибка при отправке фото")
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ def is_instagram_story_url(url: str) -> bool:
|
|||||||
VIDEO_SUFFIXES = ('.mp4', '.mov', '.mkv')
|
VIDEO_SUFFIXES = ('.mp4', '.mov', '.mkv')
|
||||||
IMAGE_SUFFIXES = ('.jpg', '.jpeg', '.png', '.webp')
|
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/stories/USERNAME/...
|
||||||
# https://www.instagram.com/USERNAME/
|
# https://www.instagram.com/USERNAME/
|
||||||
|
|||||||
@@ -1,18 +1,18 @@
|
|||||||
import asyncio
|
import asyncio
|
||||||
from functools import partial
|
from functools import partial
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
from typing import Optional
|
||||||
import os
|
import os
|
||||||
import yt_dlp
|
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 = []
|
postprocessors = []
|
||||||
if for_video:
|
if for_video:
|
||||||
# На этапе постпроцессора делаем простую конвертацию в mp4 (дальше будет наша принудительная перекодировка)
|
|
||||||
postprocessors.append({'key': 'FFmpegVideoConvertor', 'preferedformat': 'mp4'})
|
postprocessors.append({'key': 'FFmpegVideoConvertor', 'preferedformat': 'mp4'})
|
||||||
|
|
||||||
# Для плейлистов (stories/галереи) — уникальные имена, чтобы не затирать
|
|
||||||
outtmpl = '%(uploader)s_%(id)s.%(ext)s' if out_for_playlist else '%(title)s.%(ext)s'
|
outtmpl = '%(uploader)s_%(id)s.%(ext)s' if out_for_playlist else '%(title)s.%(ext)s'
|
||||||
|
|
||||||
opts: dict = {
|
opts: dict = {
|
||||||
'outtmpl': str(outdir / outtmpl),
|
'outtmpl': str(outdir / outtmpl),
|
||||||
'quiet': True,
|
'quiet': True,
|
||||||
@@ -21,53 +21,43 @@ def build_opts(for_video: bool, outdir: Path, cookiefile: Path | None, browser:
|
|||||||
'retries': 5,
|
'retries': 5,
|
||||||
'fragment_retries': 5,
|
'fragment_retries': 5,
|
||||||
'concurrent_fragment_downloads': 5,
|
'concurrent_fragment_downloads': 5,
|
||||||
'noprogress': True,
|
'noprogress': True, # ✅ Отключаем прогресс-бар
|
||||||
'source_address': '0.0.0.0', # форс IPv4
|
'source_address': '0.0.0.0',
|
||||||
}
|
}
|
||||||
|
|
||||||
proxy = os.getenv('HTTPS_PROXY') or os.getenv('HTTP_PROXY') or ''
|
proxy = os.getenv('HTTPS_PROXY') or os.getenv('HTTP_PROXY') or ''
|
||||||
if proxy:
|
if proxy:
|
||||||
opts['proxy'] = proxy
|
opts['proxy'] = proxy
|
||||||
|
|
||||||
if cookiefile and cookiefile.exists():
|
if cookiefile and cookiefile.exists():
|
||||||
opts['cookiefile'] = str(cookiefile)
|
opts['cookiefile'] = str(cookiefile)
|
||||||
elif browser:
|
elif browser:
|
||||||
opts['cookiesfrombrowser'] = (browser,)
|
opts['cookiesfrombrowser'] = (browser,)
|
||||||
|
|
||||||
if message:
|
# ✅ УБРАЛИ progress_hooks - они ломают event loop
|
||||||
opts['progress_hooks'] = [partial(progress_hook_async_bridge, message=message)]
|
|
||||||
|
|
||||||
return opts
|
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()
|
loop = asyncio.get_running_loop()
|
||||||
info, filepath = None, None
|
info, filepath = None, None
|
||||||
|
|
||||||
def _do():
|
def _do():
|
||||||
nonlocal info, filepath
|
nonlocal info, filepath
|
||||||
with yt_dlp.YoutubeDL(opts) as ydl:
|
with yt_dlp.YoutubeDL(opts) as ydl:
|
||||||
info = ydl.extract_info(url, download=True)
|
info = ydl.extract_info(url, download=True)
|
||||||
out = None
|
|
||||||
if isinstance(info, dict):
|
out = None
|
||||||
out = info.get('_filename')
|
if isinstance(info, dict):
|
||||||
if not out:
|
out = info.get('_filename')
|
||||||
rd = info.get('requested_downloads') or []
|
if not out:
|
||||||
for it in rd[::-1]:
|
rd = info.get('requested_downloads') or []
|
||||||
out = it.get('filepath') or it.get('filename')
|
for it in rd[::-1]:
|
||||||
if out:
|
out = it.get('filepath') or it.get('filename')
|
||||||
break
|
if out:
|
||||||
filepath = Path(out) if out else None
|
break
|
||||||
|
filepath = Path(out) if out else None
|
||||||
|
|
||||||
await loop.run_in_executor(None, _do)
|
await loop.run_in_executor(None, _do)
|
||||||
return filepath, info
|
return filepath, info
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
python-telegram-bot==20.7
|
python-telegram-bot>=22.0
|
||||||
yt-dlp>=2025.1.1
|
yt-dlp>=2025.1.1
|
||||||
httpx>=0.25.2
|
httpx>=0.27
|
||||||
instaloader>=4.13.1
|
instaloader>=4.13.1
|
||||||
|
|||||||
Reference in New Issue
Block a user