Files
infra/stacks/rpi-tg-export/app/export.py
T
gilfanovrandClaude Opus 5.5 8a06244ef4 rpi: выгрузка Telegram второго аккаунта (tg_export с Mac) — stacks/rpi-tg-export
Пользователь tgexport, сессия /var/lib/tg-export (600), тексты в /mnt/backup/tg (группа astra),
докачка раз в сутки без медиа, итог в backup-metrics. Вход делает владелец.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 11:26:35 +03:00

902 lines
38 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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()