update v0.1
This commit is contained in:
+2
-1
@@ -1,2 +1,3 @@
|
||||
.env
|
||||
cookies.txt
|
||||
cookies.txt
|
||||
.venv
|
||||
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.
+1
-1
@@ -8,7 +8,7 @@ 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))
|
||||
|
||||
+98
-67
@@ -1,3 +1,4 @@
|
||||
from typing import Optional
|
||||
from pathlib import Path
|
||||
import httpx
|
||||
from telegram import Update, InlineKeyboardButton, InlineKeyboardMarkup
|
||||
@@ -125,8 +126,10 @@ class Handlers:
|
||||
else:
|
||||
await message.edit_text("ℹ️ Stories не найдены или доступ ограничен.")
|
||||
except Exception as e:
|
||||
await message.edit_text(f"⚠️ Ошибка Stories: {e}")
|
||||
|
||||
try:
|
||||
await message.edit_text(f"⚠️ Ошибка Stories: {str(e)[:100]}")
|
||||
except:
|
||||
await message.reply_text(f"⚠️ Ошибка Stories: {str(e)[:100]}")
|
||||
# -------- Stories: все активные у автора --------
|
||||
async def download_all_stories(self, url: str, message, chat_id: int):
|
||||
try:
|
||||
@@ -251,7 +254,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,29 +276,35 @@ class Handlers:
|
||||
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)
|
||||
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'
|
||||
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)
|
||||
print(f"[DBG] _download_file: saved {into}") # DBG
|
||||
return into
|
||||
|
||||
def _pick_path(self, entry) -> Path | None:
|
||||
def _pick_path(self, entry) -> Optional[Path]:
|
||||
if isinstance(entry, dict):
|
||||
rd = entry.get('requested_downloads') or []
|
||||
for it in rd[::-1]:
|
||||
@@ -318,7 +327,7 @@ class Handlers:
|
||||
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()
|
||||
@@ -350,47 +359,69 @@ class Handlers:
|
||||
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)
|
||||
if not isinstance(info, dict):
|
||||
print("[DBG] _send_playlist_mixed_with_images: info is not dict") # DBG
|
||||
return 0
|
||||
|
||||
entries = info.get('entries') or []
|
||||
print(f"[DBG] playlist entries={len(entries)}") # DBG
|
||||
|
||||
sent = 0
|
||||
for idx, entry in enumerate(entries):
|
||||
print(f"[DBG] entry[{idx}] keys={list(entry.keys()) if isinstance(entry, dict) else type(entry)}") # DBG
|
||||
fp = self._pick_path(entry)
|
||||
temp_file: Path | None = None
|
||||
|
||||
try:
|
||||
if fp and fp.exists():
|
||||
suffix = (fp.suffix or '').lower()
|
||||
print(f"[DBG] entry[{idx}] local file={fp}, suffix={suffix}") # DBG
|
||||
|
||||
if suffix in IMAGE_SUFFIXES:
|
||||
print(f"[DBG] entry[{idx}] send as photo") # DBG
|
||||
await self._send_photo_safe(message, fp, chat_id)
|
||||
sent += 1
|
||||
elif suffix in VIDEO_SUFFIXES:
|
||||
print(f"[DBG] entry[{idx}] transcode/send video") # DBG
|
||||
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:
|
||||
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
|
||||
print(f"[DBG] entry[{idx}] send as document") # DBG
|
||||
await self._send_document_safe(message, fp, chat_id)
|
||||
sent += 1
|
||||
else:
|
||||
print(f"[DBG] entry[{idx}] no local file, try thumbnails/url") # DBG
|
||||
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}") # DBG
|
||||
|
||||
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}") # DBG
|
||||
|
||||
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") # DBG
|
||||
|
||||
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}") # DBG
|
||||
return sent
|
||||
|
||||
+54
-36
@@ -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("❌ Ошибка при отправке фото")
|
||||
|
||||
+3
-1
@@ -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/
|
||||
|
||||
+26
-36
@@ -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
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user