Пользователь tgexport, сессия /var/lib/tg-export (600), тексты в /mnt/backup/tg (группа astra), докачка раз в сутки без медиа, итог в backup-metrics. Вход делает владелец. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
201 lines
8.9 KiB
Python
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()
|