Files
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

201 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Выгрузка канала вместе с комментариями из привязанной группы обсуждений.
Обычный `export.py run` берёт только посты канала: комментарии физически
лежат в отдельном linked chat и через iter_dialogs не приходят. Здесь для
каждого поста дополнительно вытягивается его ветка (iter_messages(reply_to=...)),
плюс, при --with-linked, вся история группы обсуждений целиком — там бывают
сообщения, не привязанные ни к одному посту.
uv run channel_comments.py @cpython_notes
uv run channel_comments.py @cpython_notes --with-linked --session copy.session
Формат вывода совпадает с export.py: сырой to_dict() в *.jsonl, плоское
представление в *_pretty.jsonl, читаемая расшифровка в *.txt.
"""
from __future__ import annotations
import argparse
import asyncio
import datetime as dt
import json
import sys
from pathlib import Path
from telethon import TelegramClient, errors, functions
import export as ex
ROOT = Path(__file__).resolve().parent
async def dump_stream(client, entity, it, jsonl, pretty, txt, cache, label, tag=None):
"""Прогоняет итератор сообщений в три файла. Возвращает счётчик."""
n = 0
async for msg in it:
if ex.STOP:
break
record = msg.to_dict()
if tag:
record.update(tag)
sender = await ex.sender_label(client, msg, cache)
jsonl.write(json.dumps(record, ensure_ascii=False, cls=ex.Encoder) + "\n")
if pretty:
flat = ex.simplify_message(msg, sender, record)
if tag:
flat.update(tag)
pretty.write(json.dumps(flat, ensure_ascii=False, cls=ex.Encoder) + "\n")
if txt:
txt.write(ex.render_line(msg, sender))
n += 1
if n % 500 == 0:
print(f" … {label}: {n}", flush=True)
return n
async def main_async(args) -> None:
api_id, api_hash = ex.require_credentials()
client = TelegramClient(args.session, api_id, api_hash)
client.flood_sleep_threshold = 24 * 60 * 60
await client.start()
try:
chan = await client.get_entity(args.channel)
full = await client(functions.channels.GetFullChannelRequest(channel=chan))
linked_id = getattr(full.full_chat, "linked_chat_id", None)
linked = None
if linked_id:
linked = next((c for c in full.chats if c.id == linked_id), None)
dialog_id = int(f"-100{chan.id}")
folder = Path(args.out) / "chats" / f"{dialog_id}_{ex.slugify(getattr(chan, 'title', args.channel))}"
folder.mkdir(parents=True, exist_ok=True)
header = {
"id": dialog_id,
"name": getattr(chan, "title", None),
"kind": ex.dialog_kind(chan),
"username": getattr(chan, "username", None),
"participants": getattr(full.full_chat, "participants_count", None),
"linked_chat_id": linked_id,
"linked_chat_title": getattr(linked, "title", None) if linked else None,
"exported_at": dt.datetime.now(dt.timezone.utc).isoformat(),
}
(folder / "meta.json").write_text(
json.dumps({**header, "entity": chan.to_dict()},
ensure_ascii=False, indent=2, cls=ex.Encoder),
encoding="utf-8")
print(f"Канал: {header['name']} (@{header['username']}) · "
f"подписчиков {header['participants']}")
print(f"Группа обсуждений: {header['linked_chat_title']} (id={linked_id})\n"
if linked_id else "Группа обсуждений не привязана — комментариев нет\n")
cache: dict = {}
mode = "w" if args.full else "a"
# ---- посты канала ----
pj = (folder / "messages.jsonl").open(mode, encoding="utf-8")
pp = (folder / "_pretty.jsonl").open(mode, encoding="utf-8")
pt = (folder / "messages.txt").open(mode, encoding="utf-8")
posts = []
async for m in client.iter_messages(chan, reverse=True):
posts.append(m)
for m in posts:
record = m.to_dict()
sender = await ex.sender_label(client, m, cache)
pj.write(json.dumps(record, ensure_ascii=False, cls=ex.Encoder) + "\n")
pp.write(json.dumps(ex.simplify_message(m, sender, record),
ensure_ascii=False, cls=ex.Encoder) + "\n")
pt.write(ex.render_line(m, sender))
for f in (pj, pp, pt):
f.close()
print(f"постов: {len(posts)}")
if not linked_id:
return
# ---- комментарии: по ветке на каждый пост ----
cj = (folder / "comments.jsonl").open(mode, encoding="utf-8")
cp = (folder / "comments_pretty.jsonl").open(mode, encoding="utf-8")
thread = (folder / "threads.txt").open(mode, encoding="utf-8")
total_c = 0
with_c = 0
for i, post in enumerate(posts, 1):
if ex.STOP:
break
try:
it = client.iter_messages(chan, reply_to=post.id, reverse=True)
n = 0
head_written = False
async for cm in it:
if not head_written:
head = (post.message or "").split("\n")[0][:90]
thread.write(f"\n{'='*78}\nПОСТ #{post.id} "
f"[{post.date:%Y-%m-%d %H:%M}] {head}\n{'-'*78}\n")
head_written = True
record = cm.to_dict()
record["_post_id"] = post.id
sender = await ex.sender_label(client, cm, cache)
cj.write(json.dumps(record, ensure_ascii=False, cls=ex.Encoder) + "\n")
flat = ex.simplify_message(cm, sender, record)
flat["_post_id"] = post.id
cp.write(json.dumps(flat, ensure_ascii=False, cls=ex.Encoder) + "\n")
thread.write(" " + ex.render_line(cm, sender).replace("\n", "\n ").rstrip() + "\n")
n += 1
except (errors.MsgIdInvalidError, errors.ChannelPrivateError) as exc:
# у поста отключены комментарии либо ветка недоступна
print(f" пост #{post.id}: {type(exc).__name__}", file=sys.stderr)
continue
except errors.FloodWaitError as exc:
print(f" FloodWait {exc.seconds}s на посте #{post.id} — жду",
file=sys.stderr)
await asyncio.sleep(exc.seconds + 1)
continue
total_c += n
if n:
with_c += 1
print(f" [{i}/{len(posts)}] пост #{post.id}: {n} комментариев", flush=True)
for f in (cj, cp, thread):
f.close()
print(f"\nкомментариев: {total_c} в {with_c} ветках "
f"(из {len(posts)} постов)")
# ---- вся группа обсуждений целиком ----
if args.with_linked:
lj = (folder / "linked_chat.jsonl").open(mode, encoding="utf-8")
lp = (folder / "linked_chat_pretty.jsonl").open(mode, encoding="utf-8")
lt = (folder / "linked_chat.txt").open(mode, encoding="utf-8")
n = await dump_stream(
client, linked, client.iter_messages(linked, reverse=True),
lj, lp, lt, cache, "linked")
for f in (lj, lp, lt):
f.close()
print(f"вся группа обсуждений: {n} сообщений")
print(f"\nКаталог: {folder}")
finally:
await client.disconnect()
def main() -> None:
ex.load_dotenv(ROOT / ".env")
p = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("channel", help="@username, id или часть названия канала")
p.add_argument("--session", default=str(ROOT / "tg_export.session"))
p.add_argument("--out", default=str(ROOT / "out"))
p.add_argument("--with-linked", action="store_true",
help="дополнительно выгрузить всю группу обсуждений целиком")
p.add_argument("--full", action="store_true", help="перезаписать файлы с нуля")
args = p.parse_args()
ex.install_signal_handlers()
try:
asyncio.run(main_async(args))
except KeyboardInterrupt:
sys.exit(130)
if __name__ == "__main__":
main()