diff --git a/stacks/rpi-tg-export/README.md b/stacks/rpi-tg-export/README.md index 6584da6..71b19ce 100644 --- a/stacks/rpi-tg-export/README.md +++ b/stacks/rpi-tg-export/README.md @@ -63,3 +63,7 @@ sudo systemctl enable --now tg-export.timer сообщения после последней ежечасной докачки уйдут в следующий рекап, без дыр), до 60 тыс. знаков (больше — последние), Codex пишет темы дня и «Полезное» со ссылками → очередь outbox. Сейчас: CY Fast Track Talks (@cyfasttrack). `tg digest run --dry --hours 24` — посмотреть. +- **Участники группы с аватарками** — `app/tg_members.py` (`/opt/tg-export/tg_members.py @чат`, от tgexport, ⚠️ не параллельно + с `tg-export.service` — одна сессия): `/mnt/backup/tg/members/_<название>/members.{json,csv}` + `avatars/.jpg` + (маленькие). Персональные данные участников — в поиск/индекс не идут. 2026-10-18: CY Fast Track Talks — 2774 из 2774, + аватарок 1619. Разовая выгрузка, не по таймеру. diff --git a/stacks/rpi-tg-export/app/tg_members.py b/stacks/rpi-tg-export/app/tg_members.py new file mode 100644 index 0000000..c6ee9fe --- /dev/null +++ b/stacks/rpi-tg-export/app/tg_members.py @@ -0,0 +1,76 @@ +#!/usr/bin/env python3 +"""Участники группы с аватарками (владелец 2026-10-18): /mnt/backup/tg/members/_<название>/ +members.json и members.csv (id, username, имя, бот/премиум, статус, есть ли фото), avatars/.jpg (маленькие). + + sudo -u tgexport sh -c '. /etc/tg-export/env; /opt/tg-export/venv/bin/python /opt/tg-export/tg_members.py @cyfasttrack' + +⚠️ Не запускать одновременно с tg-export.service (одна сессия). Если админы скрыли список участников — Telegram +отдаст только админов. Это персональные данные участников: в поиск/индекс не идут. +""" +import asyncio +import csv +import json +import os +import sys +from pathlib import Path + +from telethon import TelegramClient, errors + +import export as ex + +OUT = Path(os.environ.get("TG_OUT", "/mnt/backup/tg")) / "members" + + +def status_of(u): + s = getattr(u, "status", None) + if s is None: + return "" + name = type(s).__name__.replace("UserStatus", "") + when = getattr(s, "was_online", None) + return f"{name} {when.isoformat()}" if when else name + + +async def main(chat): + client = TelegramClient("/var/lib/tg-export/tg.session", int(os.environ["TG_API_ID"]), os.environ["TG_API_HASH"]) + client.flood_sleep_threshold = 3600 + await client.start() + try: + ent = await client.get_entity(chat) + folder = OUT / f"-100{ent.id}_{ex.slugify(getattr(ent, 'title', chat))}" + av = folder / "avatars" + av.mkdir(parents=True, exist_ok=True) + people = await client.get_participants(ent, aggressive=True) + rows = [] + for i, u in enumerate(people, 1): + photo = "" + if getattr(u, "photo", None): + dest = av / f"{u.id}.jpg" + if not dest.exists(): + try: + await client.download_profile_photo(u, file=str(dest), download_big=False) + except errors.FloodWaitError as e: + await asyncio.sleep(e.seconds + 1) + except Exception as e: # фото недоступно — пропускаем + print(f" {u.id}: фото не скачалось ({type(e).__name__})", flush=True) + photo = dest.name if dest.exists() else "" + p = getattr(u, "participant", None) + rows.append({"id": u.id, "username": u.username or "", "first_name": u.first_name or "", + "last_name": u.last_name or "", "bot": bool(u.bot), "premium": bool(getattr(u, "premium", False)), + "deleted": bool(u.deleted), "status": status_of(u), + "role": type(p).__name__.replace("ChannelParticipant", "") if p else "", + "joined": p.date.isoformat() if getattr(p, "date", None) else "", "avatar": photo}) + if i % 100 == 0: + print(f" {i}/{len(people)}", flush=True) + (folder / "members.json").write_text(json.dumps({"chat": getattr(ent, "title", chat), "id": f"-100{ent.id}", + "count": len(rows), "members": rows}, ensure_ascii=False, indent=1)) + with (folder / "members.csv").open("w", newline="", encoding="utf-8") as f: + w = csv.DictWriter(f, fieldnames=list(rows[0].keys()) if rows else ["id"]) + w.writeheader() + w.writerows(rows) + total = getattr(await client.get_participants(ent, limit=0), "total", None) + print(f"{folder.name}: участников получено {len(rows)} (в группе всего {total}), аватарок {sum(1 for r in rows if r['avatar'])}") + finally: + await client.disconnect() + + +asyncio.run(main(sys.argv[1]))