diff --git a/README.md b/README.md index 72f6e97..5f6ca86 100644 --- a/README.md +++ b/README.md @@ -19,14 +19,30 @@ pip install -r requirements.txt 3. Create a new Telegram app at [https://my.telegram.org](https://my.telegram.org) and get the `api_id` and `api_hash`. 4. Create a free Redis database (e.g. [Upstash](https://upstash.com)) and copy its `rediss://` connection URL. 5. Copy `.env.example` to `.env` and fill in `API_ID`, `API_HASH`, `PHONE`, `GROUP_IDS`, and `REDIS_URL`. -6. Run the script: +6. Crawl configured groups: ```bash -python main.py +python crawl.py ``` The first run asks for the Telegram login code once, then stores the session in Redis. Any later run — on any device pointed at the same Redis — reuses that session and **does not** prompt again. +## Compare two crawls + +Compare membership changes for one group between two saved crawls: + +```bash +python compare.py +``` + +If `time1` and `time2` are omitted, the command compares the latest two crawls for that group: + +```bash +python compare.py +``` + +The output lists members added in `time2` and removed since `time1`. + ## Configuration | Variable | Description | diff --git a/common.py b/common.py index 762a5c7..a2cc8ef 100644 --- a/common.py +++ b/common.py @@ -60,3 +60,50 @@ def list_exports(): continue records.sort(key=lambda r: (r.get('time', ''), r.get('group_id', 0))) return records + + +def list_group_exports(group_id): + """Return export records for one group, sorted by run time.""" + group_id = str(group_id) + return [ + record for record in list_exports() + if str(record.get('group_id')) == group_id + ] + + +def get_group_export(group_id, run_time): + """Return a group export at one run time, or None when missing.""" + for record in list_group_exports(group_id): + if record.get('time') == run_time: + return record + return None + + +def latest_two_group_exports(group_id): + """Return the latest two export records for a group.""" + exports = list_group_exports(group_id) + if len(exports) < 2: + return None, None + return exports[-2], exports[-1] + + +def diff_group_members(before_record, after_record): + """Compare two group export records by Telegram member id.""" + before_members = _member_index(before_record) + after_members = _member_index(after_record) + + before_ids = set(before_members) + after_ids = set(after_members) + + added = [after_members[member_id] for member_id in sorted(after_ids - before_ids)] + removed = [before_members[member_id] for member_id in sorted(before_ids - after_ids)] + return added, removed + + +def _member_index(record): + members = {} + for member in record.get('members', []): + member_id = member.get('id') + if member_id is not None: + members[int(member_id)] = member + return members diff --git a/compare.py b/compare.py new file mode 100644 index 0000000..00cae25 --- /dev/null +++ b/compare.py @@ -0,0 +1,86 @@ +import argparse +import sys + +from common import diff_group_members, get_group_export, latest_two_group_exports + + +def parse_args(): + parser = argparse.ArgumentParser( + description='Compare member changes between two crawls for one group.', + ) + parser.add_argument('group_id', type=int, help='Telegram group id to compare') + parser.add_argument( + 'times', + nargs='*', + help='Optional pair: time1 time2. When omitted, latest two crawls are used.', + ) + args = parser.parse_args() + if len(args.times) not in (0, 2): + parser.error('provide both time1 and time2, or omit both to compare latest two crawls') + return args + + +def main(): + args = parse_args() + + if args.times: + before_time, after_time = args.times + before_record = get_group_export(args.group_id, before_time) + after_record = get_group_export(args.group_id, after_time) + if before_record is None: + print(f'export not found for group {args.group_id} at {before_time}', file=sys.stderr) + return 1 + if after_record is None: + print(f'export not found for group {args.group_id} at {after_time}', file=sys.stderr) + return 1 + else: + before_record, after_record = latest_two_group_exports(args.group_id) + if before_record is None or after_record is None: + print(f'need at least 2 exports for group {args.group_id}', file=sys.stderr) + return 1 + + print_summary(args.group_id, before_record, after_record) + return 0 + + +def print_summary(group_id, before_record, after_record): + added, removed = diff_group_members(before_record, after_record) + title = after_record.get('title') or before_record.get('title') or '' + before_members = before_record.get('members', []) + after_members = after_record.get('members', []) + + print(f'group: {title} ({group_id})' if title else f'group: {group_id}') + print(f'before: {before_record.get("time")} ({len(before_members)} members)') + print(f'after: {after_record.get("time")} ({len(after_members)} members)') + print() + + print(f'added: {len(added)}') + for member in added: + print(f'+ {format_member(member)}') + + print() + print(f'removed: {len(removed)}') + for member in removed: + print(f'- {format_member(member)}') + + if not added and not removed: + print() + print('no membership changes.') + + +def format_member(member): + parts = [str(member.get('id'))] + username = member.get('username') + full_name = ' '.join( + part for part in [member.get('first_name'), member.get('last_name')] + if part + ) + if username: + parts.append(f'@{username}') + if full_name: + parts.append(full_name) + return ' | '.join(parts) + + +if __name__ == '__main__': + raise SystemExit(main()) diff --git a/main.py b/crawl.py similarity index 100% rename from main.py rename to crawl.py diff --git a/plans/260609-compare-group-export-diffs/plan.md b/plans/260609-compare-group-export-diffs/plan.md new file mode 100644 index 0000000..0c8a7ce --- /dev/null +++ b/plans/260609-compare-group-export-diffs/plan.md @@ -0,0 +1,33 @@ +# Compare Group Export Diffs + +## Context + +- `common.py` stores and lists Redis export records. +- `crawl.py` exports groups and should not be reused for comparison because it logs into Telegram. +- `README.md` documents current usage and Redis record shape. + +## Requirements + +- Add command that receives `group_id`, optional `time1`, optional `time2`. +- If both times missing, compare latest 2 exports for that group. +- If one time missing, fail with clear usage error. +- Print members added and removed between two crawls. +- Keep Redis schema unchanged. + +## Implementation + +1. Add focused helpers in `common.py` to filter exports by group/time and compare members by Telegram `id`. +2. Add `compare.py` CLI with argparse. +3. Update `README.md` with command examples. +4. Run Python compile check. + +## Success Criteria + +- `python compare.py ` compares latest two times. +- `python compare.py ` compares explicit times. +- Missing export records produce readable errors. +- Existing export flow remains unchanged. + +## Unresolved Questions + +None.