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>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
8373a87826
commit
8a06244ef4
@@ -0,0 +1,200 @@
|
||||
#!/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()
|
||||
Reference in New Issue
Block a user