Пользователь tgexport, сессия /var/lib/tg-export (600), тексты в /mnt/backup/tg (группа astra), докачка раз в сутки без медиа, итог в backup-metrics. Вход делает владелец. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
902 lines
38 KiB
Python
902 lines
38 KiB
Python
#!/usr/bin/env python3
|
||
"""Полная выгрузка истории личного Telegram-аккаунта через Telethon.
|
||
|
||
Возможности:
|
||
* логин по номеру телефона (MTProto, свой api_id/api_hash);
|
||
* индекс всех диалогов (личные, боты, группы, каналы);
|
||
* выгрузка всех сообщений в JSONL (сырой `to_dict()` — ничего не теряется)
|
||
плюс опциональный человекочитаемый .txt;
|
||
* скачивание медиа с лимитом на размер;
|
||
* возобновление: повторный запуск догружает только новое;
|
||
* аккуратная обработка FloodWait и приватных/недоступных чатов.
|
||
|
||
Команды:
|
||
python export.py login — только авторизация (создаёт .session)
|
||
python export.py list — вывести список диалогов
|
||
python export.py run — выгрузить историю
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import asyncio
|
||
import base64
|
||
import datetime as dt
|
||
import json
|
||
import os
|
||
import re
|
||
import signal
|
||
import sys
|
||
import unicodedata
|
||
from pathlib import Path
|
||
|
||
from telethon import TelegramClient, errors, utils
|
||
from telethon.tl import types
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# конфиг
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
ROOT = Path(__file__).resolve().parent
|
||
|
||
|
||
def load_dotenv(path: Path) -> None:
|
||
"""Минимальный .env-лоадер, чтобы не тащить python-dotenv."""
|
||
if not path.exists():
|
||
return
|
||
for raw in path.read_text(encoding="utf-8").splitlines():
|
||
line = raw.strip()
|
||
if not line or line.startswith("#") or "=" not in line:
|
||
continue
|
||
key, _, val = line.partition("=")
|
||
key, val = key.strip(), val.strip().strip("'\"")
|
||
os.environ.setdefault(key, val)
|
||
|
||
|
||
def require_credentials() -> tuple[int, str]:
|
||
api_id = os.environ.get("TG_API_ID")
|
||
api_hash = os.environ.get("TG_API_HASH")
|
||
if not api_id or not api_hash:
|
||
sys.exit(
|
||
"Нет TG_API_ID / TG_API_HASH.\n"
|
||
"Получи их на https://my.telegram.org → API development tools,\n"
|
||
f"затем скопируй {ROOT / '.env.example'} в {ROOT / '.env'} и заполни."
|
||
)
|
||
try:
|
||
return int(api_id), api_hash
|
||
except ValueError:
|
||
sys.exit(f"TG_API_ID должен быть числом, получено: {api_id!r}")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# утилиты
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
class Encoder(json.JSONEncoder):
|
||
"""Сериализует всё, что встречается в Telethon-объектах."""
|
||
|
||
def default(self, o): # noqa: D102
|
||
if isinstance(o, (dt.datetime, dt.date, dt.time)):
|
||
return o.isoformat()
|
||
if isinstance(o, dt.timedelta):
|
||
return o.total_seconds()
|
||
if isinstance(o, (bytes, bytearray, memoryview)):
|
||
return base64.b64encode(bytes(o)).decode("ascii")
|
||
if isinstance(o, set):
|
||
return sorted(o, key=str)
|
||
return str(o)
|
||
|
||
|
||
_SLUG_BAD = re.compile(r"[^\w\-. ]+", re.UNICODE)
|
||
|
||
|
||
def slugify(name: str, limit: int = 48) -> str:
|
||
name = unicodedata.normalize("NFKC", name or "").strip()
|
||
name = _SLUG_BAD.sub("", name)
|
||
name = re.sub(r"\s+", "_", name).strip("._-")
|
||
return (name[:limit] or "unnamed")
|
||
|
||
|
||
def dialog_kind(entity) -> str:
|
||
if isinstance(entity, types.User):
|
||
return "bot" if getattr(entity, "bot", False) else "user"
|
||
if isinstance(entity, types.Chat) or isinstance(entity, types.ChatForbidden):
|
||
return "group"
|
||
if isinstance(entity, types.Channel):
|
||
return "supergroup" if getattr(entity, "megagroup", False) else "channel"
|
||
return "unknown"
|
||
|
||
|
||
def human_size(n: int | None) -> str:
|
||
if not n:
|
||
return "0B"
|
||
for unit in ("B", "K", "M", "G", "T"):
|
||
if n < 1024 or unit == "T":
|
||
return f"{n:.0f}{unit}" if unit == "B" else f"{n:.1f}{unit}"
|
||
n /= 1024
|
||
return f"{n:.1f}T"
|
||
|
||
|
||
def parse_date(value: str | None) -> dt.datetime | None:
|
||
if not value:
|
||
return None
|
||
try:
|
||
parsed = dt.datetime.fromisoformat(value)
|
||
except ValueError:
|
||
sys.exit(f"Не смог разобрать дату {value!r}. Формат: YYYY-MM-DD или YYYY-MM-DDTHH:MM")
|
||
if parsed.tzinfo is None:
|
||
parsed = parsed.replace(tzinfo=dt.timezone.utc)
|
||
return parsed
|
||
|
||
|
||
STOP = False
|
||
|
||
|
||
def install_signal_handlers() -> None:
|
||
def handler(signum, frame): # noqa: ARG001
|
||
global STOP
|
||
if STOP:
|
||
sys.exit(130)
|
||
STOP = True
|
||
print("\n[!] Останавливаюсь после текущего сообщения; прогресс сохранён. Ctrl-C ещё раз — выход.",
|
||
file=sys.stderr)
|
||
|
||
signal.signal(signal.SIGINT, handler)
|
||
signal.signal(signal.SIGTERM, handler)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# состояние (для возобновления)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
class State:
|
||
def __init__(self, path: Path):
|
||
self.path = path
|
||
self.data = {"last_id": 0, "count": 0, "media": 0, "updated_at": None}
|
||
if path.exists():
|
||
try:
|
||
self.data.update(json.loads(path.read_text(encoding="utf-8")))
|
||
except (ValueError, OSError):
|
||
pass
|
||
|
||
@property
|
||
def last_id(self) -> int:
|
||
return int(self.data.get("last_id") or 0)
|
||
|
||
def bump(self, msg_id: int, media: int = 0) -> None:
|
||
self.data["last_id"] = max(self.last_id, int(msg_id))
|
||
self.data["count"] = int(self.data.get("count") or 0) + 1
|
||
self.data["media"] = int(self.data.get("media") or 0) + media
|
||
|
||
def save(self) -> None:
|
||
self.data["updated_at"] = dt.datetime.now(dt.timezone.utc).isoformat()
|
||
tmp = self.path.with_suffix(".tmp")
|
||
tmp.write_text(json.dumps(self.data, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
tmp.replace(self.path)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# текстовое представление
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def render_line(msg, sender_name: str) -> str:
|
||
when = msg.date.astimezone().strftime("%Y-%m-%d %H:%M:%S") if msg.date else "?"
|
||
body = (msg.message or "").replace("\n", "\n ")
|
||
tags = []
|
||
if msg.media is not None and not isinstance(msg.media, types.MessageMediaWebPage):
|
||
tags.append(type(msg.media).__name__.replace("MessageMedia", "").lower())
|
||
if getattr(msg, "reply_to_msg_id", None):
|
||
tags.append(f"reply→{msg.reply_to_msg_id}")
|
||
if getattr(msg, "fwd_from", None):
|
||
tags.append("forwarded")
|
||
if getattr(msg, "action", None):
|
||
tags.append(type(msg.action).__name__.replace("MessageAction", "action:"))
|
||
suffix = f" [{', '.join(tags)}]" if tags else ""
|
||
return f"[{when}] #{msg.id} {sender_name}:{suffix}\n {body}\n" if body else \
|
||
f"[{when}] #{msg.id} {sender_name}:{suffix}\n"
|
||
|
||
|
||
async def sender_label(client, msg, cache: dict) -> str:
|
||
sid = msg.sender_id
|
||
if sid is None:
|
||
return "—"
|
||
if sid in cache:
|
||
return cache[sid]
|
||
label = str(sid)
|
||
try:
|
||
sender = await msg.get_sender()
|
||
if sender is not None:
|
||
if isinstance(sender, types.User):
|
||
parts = [sender.first_name or "", sender.last_name or ""]
|
||
label = " ".join(p for p in parts if p) or (sender.username or str(sid))
|
||
else:
|
||
label = getattr(sender, "title", None) or str(sid)
|
||
except (errors.RPCError, ValueError, TypeError):
|
||
pass
|
||
cache[sid] = label
|
||
return label
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# читаемое представление (messages.json)
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def media_info(msg, record: dict) -> dict | None:
|
||
media = getattr(msg, "media", None)
|
||
if media is None:
|
||
return None
|
||
|
||
if isinstance(media, types.MessageMediaWebPage):
|
||
url = getattr(getattr(media, "webpage", None), "url", None)
|
||
return {"type": "link", "url": url} if url else None
|
||
|
||
info: dict = {"type": type(media).__name__.replace("MessageMedia", "").lower()}
|
||
|
||
handle = getattr(msg, "file", None)
|
||
for key in ("name", "mime_type", "size", "duration"):
|
||
val = getattr(handle, key, None)
|
||
if val:
|
||
info[key] = round(val, 1) if key == "duration" else val
|
||
if info.get("size"):
|
||
info["size_human"] = human_size(info["size"])
|
||
|
||
for attr in getattr(getattr(media, "document", None), "attributes", None) or []:
|
||
if isinstance(attr, types.DocumentAttributeSticker):
|
||
info["type"] = "sticker"
|
||
info["emoji"] = attr.alt
|
||
elif isinstance(attr, types.DocumentAttributeAudio) and attr.voice:
|
||
info["type"] = "voice"
|
||
|
||
if isinstance(media, types.MessageMediaPoll):
|
||
poll = media.poll
|
||
info["question"] = getattr(poll.question, "text", poll.question)
|
||
info["answers"] = [getattr(a.text, "text", a.text) for a in poll.answers]
|
||
elif isinstance(media, (types.MessageMediaGeo, types.MessageMediaGeoLive)):
|
||
geo = media.geo
|
||
info["lat"], info["long"] = getattr(geo, "lat", None), getattr(geo, "long", None)
|
||
elif isinstance(media, types.MessageMediaContact):
|
||
info["contact"] = " ".join(
|
||
p for p in (media.first_name, media.last_name, media.phone_number) if p
|
||
)
|
||
|
||
for key in ("_media_file", "_media_skipped", "_media_error"):
|
||
if key in record:
|
||
info[key.lstrip("_").removeprefix("media_")] = record[key]
|
||
return info
|
||
|
||
|
||
def forward_info(msg) -> dict | None:
|
||
fwd = getattr(msg, "fwd_from", None)
|
||
if fwd is None:
|
||
return None
|
||
info: dict = {}
|
||
if fwd.from_name:
|
||
info["from_name"] = fwd.from_name
|
||
if fwd.from_id is not None:
|
||
try:
|
||
info["from_id"] = utils.get_peer_id(fwd.from_id)
|
||
except (TypeError, ValueError):
|
||
info["from_id"] = str(fwd.from_id)
|
||
if fwd.date:
|
||
info["original_date"] = fwd.date.astimezone().isoformat()
|
||
if fwd.post_author:
|
||
info["post_author"] = fwd.post_author
|
||
return info or None
|
||
|
||
|
||
def reactions_info(msg) -> list | None:
|
||
results = getattr(getattr(msg, "reactions", None), "results", None) or []
|
||
out = []
|
||
for res in results:
|
||
emoji = getattr(res.reaction, "emoticon", None) \
|
||
or getattr(res.reaction, "document_id", None)
|
||
out.append({"reaction": str(emoji), "count": res.count})
|
||
return out or None
|
||
|
||
|
||
def simplify_message(msg, sender: str, record: dict) -> dict:
|
||
"""Плоское, читаемое представление сообщения для messages.json."""
|
||
out: dict = {
|
||
"id": msg.id,
|
||
"date": msg.date.astimezone().isoformat() if msg.date else None,
|
||
"from": sender,
|
||
"from_id": msg.sender_id,
|
||
"outgoing": bool(getattr(msg, "out", False)),
|
||
"text": getattr(msg, "message", "") or "",
|
||
}
|
||
if getattr(msg, "reply_to_msg_id", None):
|
||
out["reply_to"] = msg.reply_to_msg_id
|
||
if getattr(msg, "edit_date", None):
|
||
out["edited"] = msg.edit_date.astimezone().isoformat()
|
||
if fwd := forward_info(msg):
|
||
out["forwarded"] = fwd
|
||
if media := media_info(msg, record):
|
||
out["media"] = media
|
||
if reactions := reactions_info(msg):
|
||
out["reactions"] = reactions
|
||
|
||
action = getattr(msg, "action", None)
|
||
if action is not None:
|
||
out["service_action"] = type(action).__name__.replace("MessageAction", "")
|
||
if duration := getattr(action, "duration", None):
|
||
out["service_action_duration_sec"] = duration
|
||
if getattr(msg, "views", None):
|
||
out["views"] = msg.views
|
||
if getattr(msg, "via_bot_id", None):
|
||
out["via_bot_id"] = msg.via_bot_id
|
||
if getattr(msg, "pinned", False):
|
||
out["pinned"] = True
|
||
if getattr(msg, "grouped_id", None):
|
||
out["album_id"] = str(msg.grouped_id)
|
||
return out
|
||
|
||
|
||
# медиа без файла: качать нечего, для них download_media вернёт None.
|
||
# MessageMediaContact сюда НЕ входит — Telethon сохраняет контакт как .vcard.
|
||
NO_FILE_MEDIA = frozenset({
|
||
"MessageMediaWebPage", "MessageMediaGeo", "MessageMediaGeoLive",
|
||
"MessageMediaPoll", "MessageMediaDice", "MessageMediaVenue",
|
||
"MessageMediaGame", "MessageMediaUnsupported", "MessageMediaStory",
|
||
"MessageMediaInvoice", "MessageMediaGiveaway",
|
||
"MessageMediaGiveawayResults", "MessageMediaEmpty",
|
||
})
|
||
|
||
|
||
def raw_media_tags(media: dict) -> set[str]:
|
||
"""Метки медиа из сырого to_dict(): тип, mime, расширение, вид документа.
|
||
|
||
Позволяет отсеивать по `--skip-media webp` / `sticker` / `video` ещё до
|
||
запроса к API — по данным, которые уже лежат в messages.jsonl.
|
||
"""
|
||
if not media:
|
||
return set()
|
||
kind = media.get("_", "")
|
||
tags = {kind.replace("MessageMedia", "").lower()}
|
||
if kind == "MessageMediaPhoto":
|
||
tags |= {"photo", "jpg", "jpeg", "image/jpeg"}
|
||
|
||
doc = media.get("document") or {}
|
||
mime = (doc.get("mime_type") or "").lower()
|
||
if mime:
|
||
tags.add(mime)
|
||
tags.add(mime.rsplit("/", 1)[-1])
|
||
|
||
for attr in doc.get("attributes") or []:
|
||
name = attr.get("_", "")
|
||
if name == "DocumentAttributeSticker":
|
||
tags.add("sticker")
|
||
elif name == "DocumentAttributeAudio":
|
||
tags.add("voice" if attr.get("voice") else "audio")
|
||
elif name == "DocumentAttributeVideo":
|
||
tags.add("round" if attr.get("round_message") else "video")
|
||
elif name == "DocumentAttributeAnimated":
|
||
tags.add("gif")
|
||
elif name == "DocumentAttributeFilename":
|
||
filename = attr.get("file_name") or ""
|
||
if "." in filename:
|
||
tags.add(filename.rsplit(".", 1)[-1].lower())
|
||
return tags
|
||
|
||
|
||
def has_downloadable_media(msg) -> bool:
|
||
media = getattr(msg, "media", None)
|
||
return media is not None and type(media).__name__ not in NO_FILE_MEDIA
|
||
|
||
|
||
def load_media_index(folder: Path) -> dict[str, dict]:
|
||
path = folder / "media_index.json"
|
||
if not path.exists():
|
||
return {}
|
||
try:
|
||
return json.loads(path.read_text(encoding="utf-8"))
|
||
except (ValueError, OSError):
|
||
return {}
|
||
|
||
|
||
def assemble_pretty(folder: Path, header: dict) -> int:
|
||
"""Собирает messages.json из накопленного _pretty.jsonl (устойчиво к докачке)."""
|
||
src = folder / "_pretty.jsonl"
|
||
if not src.exists():
|
||
return 0
|
||
by_id: dict[int, dict] = {}
|
||
with src.open(encoding="utf-8") as handle:
|
||
for line in handle:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
rec = json.loads(line)
|
||
except ValueError:
|
||
continue
|
||
by_id[rec["id"]] = rec
|
||
|
||
# подмешиваем результаты догрузки медиа, если она уже прошла
|
||
for msg_id, entry in load_media_index(folder).items():
|
||
rec = by_id.get(int(msg_id))
|
||
if rec is None or "media" not in rec:
|
||
continue
|
||
# не ссылаемся на файлы, которых уже нет на диске (удалили вручную и т.п.)
|
||
if "file" in entry and not (folder / entry["file"]).exists():
|
||
continue
|
||
rec["media"].update({k: v for k, v in entry.items() if v is not None})
|
||
|
||
messages = [by_id[key] for key in sorted(by_id)]
|
||
payload = {
|
||
"chat": header,
|
||
"messages_count": len(messages),
|
||
"first_message_date": messages[0]["date"] if messages else None,
|
||
"last_message_date": messages[-1]["date"] if messages else None,
|
||
"messages": messages,
|
||
}
|
||
(folder / "messages.json").write_text(
|
||
json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8"
|
||
)
|
||
return len(messages)
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# выгрузка одного диалога
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
async def export_dialog(client, dialog, out_root: Path, args) -> dict:
|
||
entity = dialog.entity
|
||
kind = dialog_kind(entity)
|
||
folder = out_root / "chats" / f"{dialog.id}_{slugify(dialog.name)}"
|
||
media_dir = folder / "media"
|
||
folder.mkdir(parents=True, exist_ok=True)
|
||
|
||
state = State(folder / "_state.json")
|
||
start_from = 0 if args.full else state.last_id
|
||
|
||
header = {
|
||
"id": dialog.id,
|
||
"name": dialog.name,
|
||
"kind": kind,
|
||
"username": getattr(entity, "username", None),
|
||
"phone": getattr(entity, "phone", None),
|
||
"is_archived": bool(getattr(dialog, "archived", False)),
|
||
"exported_at": dt.datetime.now(dt.timezone.utc).isoformat(),
|
||
}
|
||
(folder / "meta.json").write_text(
|
||
json.dumps({**header, "entity": entity.to_dict()},
|
||
ensure_ascii=False, indent=2, cls=Encoder),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
excluded_types = {t.lower().lstrip(".") for t in args.skip_media}
|
||
mode = "w" if args.full else "a"
|
||
jsonl = (folder / "messages.jsonl").open(mode, encoding="utf-8")
|
||
txt = (folder / "messages.txt").open(mode, encoding="utf-8") if args.txt else None
|
||
pretty = (folder / "_pretty.jsonl").open(mode, encoding="utf-8") if args.pretty else None
|
||
cache: dict = {}
|
||
new_msgs = 0
|
||
new_media = 0
|
||
media_bytes = 0
|
||
errors_count = 0
|
||
|
||
try:
|
||
async for msg in client.iter_messages(
|
||
entity,
|
||
reverse=True, # от старых к новым — так работает докачка
|
||
offset_id=start_from,
|
||
offset_date=args._since,
|
||
limit=args.limit,
|
||
wait_time=args.wait or None,
|
||
):
|
||
if STOP:
|
||
break
|
||
if args._until and msg.date and msg.date > args._until:
|
||
break
|
||
|
||
record = msg.to_dict()
|
||
|
||
if args.media and has_downloadable_media(msg):
|
||
size = getattr(getattr(msg, "file", None), "size", None) or 0
|
||
hit = raw_media_tags(record.get("media") or {}) & excluded_types
|
||
if hit:
|
||
record["_media_skipped"] = f"type_excluded:{','.join(sorted(hit))}"
|
||
elif args.max_media_mb and size > args.max_media_mb * 1024 * 1024:
|
||
record["_media_skipped"] = f"too_large:{human_size(size)}"
|
||
else:
|
||
media_dir.mkdir(parents=True, exist_ok=True)
|
||
try:
|
||
path = await client.download_media(msg, file=str(media_dir / f"{msg.id}"))
|
||
if path:
|
||
record["_media_file"] = str(Path(path).relative_to(folder))
|
||
new_media += 1
|
||
media_bytes += size or Path(path).stat().st_size
|
||
except errors.FloodWaitError as exc:
|
||
print(f" FloodWait {exc.seconds}s на медиа #{msg.id} — жду", file=sys.stderr)
|
||
await asyncio.sleep(exc.seconds + 1)
|
||
except (errors.RPCError, OSError, ValueError) as exc:
|
||
record["_media_error"] = f"{type(exc).__name__}: {exc}"
|
||
errors_count += 1
|
||
|
||
jsonl.write(json.dumps(record, ensure_ascii=False, cls=Encoder) + "\n")
|
||
|
||
label = await sender_label(client, msg, cache) if (txt or pretty) else ""
|
||
if txt is not None:
|
||
txt.write(render_line(msg, label))
|
||
if pretty is not None:
|
||
pretty.write(json.dumps(simplify_message(msg, label, record),
|
||
ensure_ascii=False, cls=Encoder) + "\n")
|
||
|
||
state.bump(msg.id, media=1 if "_media_file" in record else 0)
|
||
new_msgs += 1
|
||
if new_msgs % 500 == 0:
|
||
jsonl.flush()
|
||
for stream in (txt, pretty):
|
||
if stream is not None:
|
||
stream.flush()
|
||
state.save()
|
||
print(f" … {new_msgs} новых (всего {state.data['count']})", flush=True)
|
||
finally:
|
||
jsonl.close()
|
||
for stream in (txt, pretty):
|
||
if stream is not None:
|
||
stream.close()
|
||
state.save()
|
||
if args.pretty:
|
||
assemble_pretty(folder, header)
|
||
|
||
return {
|
||
"id": dialog.id,
|
||
"name": dialog.name,
|
||
"kind": kind,
|
||
"new_messages": new_msgs,
|
||
"total_messages": state.data["count"],
|
||
"new_media": new_media,
|
||
"media_bytes": media_bytes,
|
||
"media_errors": errors_count,
|
||
"folder": str(folder.relative_to(out_root)),
|
||
}
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# отбор диалогов
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
def dialog_matches(dialog, args) -> bool:
|
||
kind = dialog_kind(dialog.entity)
|
||
if args.kinds and kind not in args.kinds:
|
||
return False
|
||
if not args.archived and getattr(dialog, "archived", False):
|
||
return False
|
||
|
||
if args.skip:
|
||
for needle in args.skip:
|
||
if _needle_hits(dialog, needle):
|
||
return False
|
||
if args.only:
|
||
return any(_needle_hits(dialog, needle) for needle in args.only)
|
||
return True
|
||
|
||
|
||
def _needle_hits(dialog, needle: str) -> bool:
|
||
needle = needle.strip()
|
||
if not needle:
|
||
return False
|
||
if needle.lstrip("-").isdigit() and int(needle) == dialog.id:
|
||
return True
|
||
username = getattr(dialog.entity, "username", None) or ""
|
||
if needle.lstrip("@").lower() == username.lower() and username:
|
||
return True
|
||
return needle.lower() in (dialog.name or "").lower()
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# команды
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
async def cmd_login(client, args) -> None: # noqa: ARG001
|
||
me = await client.get_me()
|
||
print(f"OK: {me.first_name or ''} {me.last_name or ''} "
|
||
f"(@{me.username or '—'}, id={me.id}). Сессия: {args.session}")
|
||
|
||
|
||
async def cmd_list(client, args) -> None:
|
||
rows = []
|
||
async for dialog in client.iter_dialogs():
|
||
if not dialog_matches(dialog, args):
|
||
continue
|
||
rows.append((dialog.id, dialog_kind(dialog.entity), dialog.name or "",
|
||
getattr(dialog.entity, "username", None) or "",
|
||
getattr(dialog, "unread_count", 0)))
|
||
width = max((len(r[2]) for r in rows), default=10)
|
||
print(f"{'ID':>15} {'ТИП':<10} {'НАЗВАНИЕ':<{min(width, 50)}} USERNAME")
|
||
for did, kind, name, username, _ in rows:
|
||
print(f"{did:>15} {kind:<10} {name[:50]:<{min(width, 50)}} "
|
||
f"{'@' + username if username else ''}")
|
||
print(f"\nвсего: {len(rows)}")
|
||
|
||
|
||
async def cmd_media(client, args) -> None:
|
||
"""Догружает медиа к уже выгруженным сообщениям.
|
||
|
||
Идёт не по API-истории, а по локальному messages.jsonl, поэтому:
|
||
* не зависит от прогресса `run` и не мешает ему;
|
||
* инкрементальна — уже скачанное фиксируется в media_index.json;
|
||
* может гоняться повторно, пока `run` дописывает историю.
|
||
"""
|
||
out_root = Path(args.out).expanduser().resolve()
|
||
folders = sorted(p for p in (out_root / "chats").glob("*") if p.is_dir())
|
||
if not folders:
|
||
sys.exit(f"В {out_root / 'chats'} нет выгруженных чатов — сначала запусти `run`.")
|
||
|
||
limit_bytes = int(args.max_media_mb * 1024 * 1024) if args.max_media_mb else 0
|
||
grand_new = grand_bytes = grand_skip = grand_err = 0
|
||
|
||
for folder in folders:
|
||
try:
|
||
header = json.loads((folder / "meta.json").read_text(encoding="utf-8"))
|
||
except (ValueError, OSError):
|
||
print(f"[!] {folder.name}: нет читаемого meta.json — пропуск", file=sys.stderr)
|
||
continue
|
||
|
||
needles = args.only or []
|
||
if needles and not any(
|
||
n.lstrip("@").lower() in ((header.get("username") or "").lower(),
|
||
str(header.get("id")))
|
||
or n.lower() in (header.get("name") or "").lower()
|
||
for n in needles
|
||
):
|
||
continue
|
||
|
||
raw = folder / "messages.jsonl"
|
||
if not raw.exists():
|
||
continue
|
||
|
||
index = load_media_index(folder)
|
||
excluded = {t.lower().lstrip(".") for t in args.skip_media}
|
||
todo: list[int] = []
|
||
by_type_excluded = 0
|
||
with raw.open(encoding="utf-8") as handle:
|
||
for line in handle:
|
||
line = line.strip()
|
||
if not line:
|
||
continue
|
||
try:
|
||
rec = json.loads(line)
|
||
except ValueError:
|
||
continue # хвост может быть недописан, если `run` ещё идёт
|
||
media = rec.get("media")
|
||
if not media or media.get("_") in NO_FILE_MEDIA:
|
||
index.pop(str(rec["id"]), None) # чистим ложные nothing_to_download
|
||
continue
|
||
if excluded and (raw_media_tags(media) & excluded):
|
||
index.pop(str(rec["id"]), None) # забываем то, что решили не хранить
|
||
by_type_excluded += 1
|
||
continue
|
||
if rec.get("_media_file"):
|
||
continue
|
||
# готово только то, у чего реально есть файл: записи skipped/error
|
||
# переспрашиваются — иначе поднятый --max-media-mb ничего не даст
|
||
if "file" in index.get(str(rec["id"]), {}):
|
||
continue
|
||
todo.append(rec["id"])
|
||
|
||
print(f"\n{folder.name}: к догрузке {len(todo)} медиа "
|
||
f"(уже есть {len(index)}"
|
||
+ (f", исключено по типу {by_type_excluded}" if by_type_excluded else "")
|
||
+ ")", flush=True)
|
||
if not todo:
|
||
continue
|
||
|
||
entity = await client.get_entity(int(header["id"]))
|
||
media_dir = folder / "media"
|
||
media_dir.mkdir(parents=True, exist_ok=True)
|
||
sem = asyncio.Semaphore(max(1, args.jobs))
|
||
done = new = skipped = failed = 0
|
||
got_bytes = 0
|
||
|
||
async def fetch(msg) -> None:
|
||
nonlocal done, new, skipped, failed, got_bytes
|
||
async with sem:
|
||
if msg is None or STOP:
|
||
return
|
||
size = getattr(getattr(msg, "file", None), "size", None) or 0
|
||
if limit_bytes and size > limit_bytes:
|
||
index[str(msg.id)] = {"skipped": f"too_large:{human_size(size)}"}
|
||
skipped += 1
|
||
else:
|
||
try:
|
||
path = await client.download_media(
|
||
msg, file=str(media_dir / f"{msg.id}"))
|
||
if path:
|
||
index[str(msg.id)] = {
|
||
"file": str(Path(path).relative_to(folder))}
|
||
new += 1
|
||
got_bytes += size or Path(path).stat().st_size
|
||
else:
|
||
index[str(msg.id)] = {"error": "nothing_to_download"}
|
||
failed += 1
|
||
except errors.FloodWaitError as exc:
|
||
print(f" FloodWait {exc.seconds}s — жду", file=sys.stderr, flush=True)
|
||
await asyncio.sleep(exc.seconds + 1)
|
||
return
|
||
except (errors.RPCError, OSError, ValueError) as exc:
|
||
index[str(msg.id)] = {"error": f"{type(exc).__name__}: {exc}"}
|
||
failed += 1
|
||
done += 1
|
||
if done % 200 == 0:
|
||
(folder / "media_index.json").write_text(
|
||
json.dumps(index, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
print(f" … {done}/{len(todo)} +{new} файлов "
|
||
f"({human_size(got_bytes)}), крупных пропущено {skipped}, "
|
||
f"ошибок {failed}", flush=True)
|
||
|
||
for start in range(0, len(todo), 100):
|
||
if STOP:
|
||
break
|
||
chunk = todo[start:start + 100]
|
||
try:
|
||
msgs = await client.get_messages(entity, ids=chunk)
|
||
except errors.FloodWaitError as exc:
|
||
print(f" FloodWait {exc.seconds}s на выборке — жду", file=sys.stderr, flush=True)
|
||
await asyncio.sleep(exc.seconds + 1)
|
||
msgs = await client.get_messages(entity, ids=chunk)
|
||
await asyncio.gather(*(fetch(m) for m in msgs))
|
||
|
||
(folder / "media_index.json").write_text(
|
||
json.dumps(index, ensure_ascii=False, indent=1), encoding="utf-8")
|
||
if args.pretty:
|
||
assemble_pretty(folder, header)
|
||
print(f" итого: +{new} файлов ({human_size(got_bytes)}), "
|
||
f"крупных пропущено {skipped}, ошибок {failed}", flush=True)
|
||
grand_new += new
|
||
grand_bytes += got_bytes
|
||
grand_skip += skipped
|
||
grand_err += failed
|
||
|
||
print(f"\nМедиа{' (прервано)' if STOP else ''}: +{grand_new} файлов "
|
||
f"({human_size(grand_bytes)}), пропущено по размеру {grand_skip}, "
|
||
f"ошибок {grand_err}.")
|
||
|
||
|
||
async def cmd_run(client, args) -> None:
|
||
out_root = Path(args.out).expanduser().resolve()
|
||
(out_root / "chats").mkdir(parents=True, exist_ok=True)
|
||
|
||
dialogs = []
|
||
async for dialog in client.iter_dialogs():
|
||
if dialog_matches(dialog, args):
|
||
dialogs.append(dialog)
|
||
|
||
me = await client.get_me()
|
||
print(f"Аккаунт: @{me.username or me.id} · диалогов к выгрузке: {len(dialogs)} · вывод: {out_root}\n")
|
||
|
||
index, summary = [], []
|
||
for n, dialog in enumerate(dialogs, 1):
|
||
if STOP:
|
||
break
|
||
kind = dialog_kind(dialog.entity)
|
||
print(f"[{n}/{len(dialogs)}] {kind:<10} {dialog.name} (id={dialog.id})", flush=True)
|
||
try:
|
||
result = await export_dialog(client, dialog, out_root, args)
|
||
except errors.FloodWaitError as exc:
|
||
print(f" FloodWait {exc.seconds}s — жду и повторяю", file=sys.stderr)
|
||
await asyncio.sleep(exc.seconds + 1)
|
||
result = await export_dialog(client, dialog, out_root, args)
|
||
except (errors.ChannelPrivateError, errors.ChatAdminRequiredError,
|
||
errors.UserBannedInChannelError) as exc:
|
||
print(f" пропуск: {type(exc).__name__}", file=sys.stderr)
|
||
result = {"id": dialog.id, "name": dialog.name, "kind": kind,
|
||
"error": type(exc).__name__}
|
||
except errors.RPCError as exc:
|
||
print(f" ошибка: {type(exc).__name__}: {exc}", file=sys.stderr)
|
||
result = {"id": dialog.id, "name": dialog.name, "kind": kind,
|
||
"error": f"{type(exc).__name__}: {exc}"}
|
||
|
||
index.append(result)
|
||
summary.append(result)
|
||
if "error" not in result:
|
||
print(f" +{result['new_messages']} сообщений, +{result['new_media']} медиа "
|
||
f"({human_size(result['media_bytes'])}), всего {result['total_messages']}",
|
||
flush=True)
|
||
|
||
(out_root / "dialogs.json").write_text(
|
||
json.dumps(index, ensure_ascii=False, indent=2), encoding="utf-8")
|
||
|
||
total_msgs = sum(r.get("new_messages", 0) for r in summary)
|
||
total_media = sum(r.get("new_media", 0) for r in summary)
|
||
total_bytes = sum(r.get("media_bytes", 0) for r in summary)
|
||
failed = [r for r in summary if "error" in r]
|
||
print(f"\nГотово{' (прервано)' if STOP else ''}: "
|
||
f"{len(summary)} диалогов, +{total_msgs} сообщений, "
|
||
f"+{total_media} медиа ({human_size(total_bytes)}).")
|
||
if failed:
|
||
print(f"Недоступны ({len(failed)}): " +
|
||
", ".join(f"{r['name']} [{r['error']}]" for r in failed[:10]) +
|
||
(" …" if len(failed) > 10 else ""))
|
||
print(f"Каталог: {out_root}")
|
||
|
||
|
||
# --------------------------------------------------------------------------- #
|
||
# CLI
|
||
# --------------------------------------------------------------------------- #
|
||
|
||
KINDS = ("user", "bot", "group", "supergroup", "channel")
|
||
|
||
|
||
def build_parser() -> argparse.ArgumentParser:
|
||
p = argparse.ArgumentParser(
|
||
description="Выгрузка истории личного Telegram через Telethon",
|
||
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
|
||
)
|
||
p.add_argument("command", choices=("login", "list", "run", "media"))
|
||
p.add_argument("--session", default=str(ROOT / "tg_export.session"),
|
||
help="файл сессии Telethon")
|
||
p.add_argument("--out", default=str(ROOT / "out"), help="каталог для выгрузки")
|
||
|
||
sel = p.add_argument_group("отбор диалогов")
|
||
sel.add_argument("--kinds", nargs="*", choices=KINDS, default=list(KINDS),
|
||
help="какие типы чатов выгружать")
|
||
sel.add_argument("--only", nargs="*", default=[], metavar="NEEDLE",
|
||
help="только эти чаты: id, @username или часть названия")
|
||
sel.add_argument("--skip", nargs="*", default=[], metavar="NEEDLE",
|
||
help="исключить чаты (id / @username / часть названия)")
|
||
sel.add_argument("--archived", action="store_true", help="включить архив")
|
||
|
||
rng = p.add_argument_group("границы истории")
|
||
rng.add_argument("--since", metavar="YYYY-MM-DD", help="только сообщения не раньше даты")
|
||
rng.add_argument("--until", metavar="YYYY-MM-DD", help="только сообщения не позже даты")
|
||
rng.add_argument("--limit", type=int, default=None,
|
||
help="максимум сообщений на чат (для пробного прогона)")
|
||
rng.add_argument("--full", action="store_true",
|
||
help="игнорировать сохранённый прогресс и перезаписать всё с нуля")
|
||
|
||
med = p.add_argument_group("медиа и вывод")
|
||
med.add_argument("--media", action=argparse.BooleanOptionalAction, default=True,
|
||
help="скачивать медиафайлы")
|
||
med.add_argument("--max-media-mb", type=float, default=50,
|
||
help="не качать файлы крупнее (0 — без лимита)")
|
||
med.add_argument("--skip-media", nargs="*", default=[], metavar="TAG",
|
||
help="не качать медиа с такими метками: расширение (webp, mp4), "
|
||
"mime (image/webp) или вид (sticker, video, voice, round, gif)")
|
||
med.add_argument("--pretty", action=argparse.BooleanOptionalAction, default=True,
|
||
help="писать читаемый messages.json (плоские поля, отступы)")
|
||
med.add_argument("--txt", action=argparse.BooleanOptionalAction, default=True,
|
||
help="дополнительно писать читаемый messages.txt")
|
||
med.add_argument("--wait", type=float, default=0.0,
|
||
help="пауза между запросами истории, сек (мягче к лимитам)")
|
||
med.add_argument("--jobs", type=int, default=4,
|
||
help="параллельных загрузок в команде media")
|
||
return p
|
||
|
||
|
||
async def main_async(args) -> None:
|
||
api_id, api_hash = require_credentials()
|
||
client = TelegramClient(args.session, api_id, api_hash)
|
||
client.flood_sleep_threshold = 24 * 60 * 60 # сам ждёт длинные FloodWait
|
||
|
||
await client.start(
|
||
phone=lambda: input("Номер телефона (в формате +7...): ").strip(),
|
||
password=lambda: input("Пароль двухфакторной аутентификации: ").strip(),
|
||
)
|
||
try:
|
||
handler = {"login": cmd_login, "list": cmd_list,
|
||
"run": cmd_run, "media": cmd_media}[args.command]
|
||
await handler(client, args)
|
||
finally:
|
||
await client.disconnect()
|
||
|
||
|
||
def main() -> None:
|
||
load_dotenv(ROOT / ".env")
|
||
args = build_parser().parse_args()
|
||
args._since = parse_date(args.since)
|
||
args._until = parse_date(args.until)
|
||
if args.max_media_mb <= 0:
|
||
args.max_media_mb = 0
|
||
install_signal_handlers()
|
||
try:
|
||
asyncio.run(main_async(args))
|
||
except (errors.PhoneNumberInvalidError, errors.SessionPasswordNeededError) as exc:
|
||
sys.exit(f"Ошибка авторизации: {type(exc).__name__}: {exc}")
|
||
except KeyboardInterrupt:
|
||
sys.exit(130)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|