#!/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()