diff --git a/README.md b/README.md index 9186972..1d287c4 100644 --- a/README.md +++ b/README.md @@ -25,15 +25,26 @@ pip install -r requirements.txt python configure.py ``` -`configure.py` prompts for `api_id`, `api_hash`, phone, and group IDs. +`configure.py` prompts for `api_id`, `api_hash`, phone, and group IDs. The stored +group IDs remain available to commands that use configured defaults, such as +`compare.py`. -7. Crawl configured groups: +7. Crawl one or more groups by ID: ```bash -python crawl.py +python crawl.py [ ...] ``` -The first crawl 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 the Redis config and session, and **does not** prompt again. +For example: + +```bash +python crawl.py -1001234567890 -1009876543210 +``` + +Only the group IDs supplied to this command are crawled; the stored group list +is not used. The first crawl 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 the Redis config and session, and **does not** prompt again. ## Compare two crawls diff --git a/crawl.py b/crawl.py index 6dacf17..51750ac 100644 --- a/crawl.py +++ b/crawl.py @@ -1,3 +1,4 @@ +import argparse import getpass from telethon.errors import SessionPasswordNeededError @@ -10,11 +11,25 @@ from config import load_app_config from session_store import load_session, save_session -def main(): +def parse_args(argv=None): + parser = argparse.ArgumentParser( + description='Export members from one or more Telegram groups.', + ) + parser.add_argument( + 'group_ids', + nargs='+', + type=int, + metavar='group_id', + help='Telegram group ID to crawl (provide one or more)', + ) + return parser.parse_args(argv) + + +def main(argv=None): + args = parse_args(argv) app_config = load_app_config() api_hash = app_config['api_hash'] api_id = app_config['api_id'] - group_ids = app_config['group_ids'] phone = app_config['phone'] # Session loads from Redis: a saved string means no re-login on any device. @@ -32,7 +47,7 @@ def main(): run_time = new_run_time() saved = 0 - for group_id in group_ids: + for group_id in args.group_ids: try: entity = client.get_entity(group_id) if not isinstance(entity, (Chat, Channel)): diff --git a/tests/test_crawl.py b/tests/test_crawl.py new file mode 100644 index 0000000..438b9ad --- /dev/null +++ b/tests/test_crawl.py @@ -0,0 +1,98 @@ +import os +import unittest +from unittest.mock import MagicMock, call, patch + +os.environ.setdefault('REDIS_URL', 'redis://localhost') + +import crawl + + +class FakeChat: + def __init__(self, group_id): + self.id = group_id + self.title = f'Group {group_id}' + + +class FakeMember: + def __init__(self, member_id): + self.id = member_id + + +class CrawlArgumentsTest(unittest.TestCase): + def test_accepts_multiple_group_ids(self): + args = crawl.parse_args(['-100123', '-100456']) + + self.assertEqual(args.group_ids, [-100123, -100456]) + + def test_requires_at_least_one_group_id(self): + with ( + patch.object(crawl, 'load_app_config') as load_app_config, + patch.object(crawl, 'TelegramClient') as telegram_client, + self.assertRaises(SystemExit), + ): + crawl.main([]) + + load_app_config.assert_not_called() + telegram_client.assert_not_called() + + def test_rejects_non_integer_group_id(self): + with ( + patch.object(crawl, 'load_app_config') as load_app_config, + patch.object(crawl, 'TelegramClient') as telegram_client, + self.assertRaises(SystemExit), + ): + crawl.main(['not-a-group']) + + load_app_config.assert_not_called() + telegram_client.assert_not_called() + + +class CrawlMainTest(unittest.TestCase): + @patch.object(crawl, 'save_group_export') + @patch.object(crawl, 'save_session') + @patch.object(crawl, 'load_session', return_value='stored-session') + @patch.object(crawl, 'load_app_config') + @patch.object(crawl, 'TelegramClient') + def test_crawls_supplied_ids_instead_of_configured_ids( + self, + telegram_client, + load_app_config, + load_session, + save_session, + save_group_export, + ): + load_app_config.return_value = { + 'api_id': 123, + 'api_hash': 'hash', + 'phone': '+15551234567', + 'group_ids': [999], + } + client = MagicMock() + telegram_client.return_value = client + client.is_user_authorized.return_value = True + client.session.save.return_value = 'updated-session' + client.get_entity.side_effect = [FakeChat(101), FakeChat(202)] + client.get_participants.side_effect = [ + [FakeMember(2), FakeMember(1)], + [FakeMember(4), FakeMember(3)], + ] + + with ( + patch.object(crawl, 'Chat', FakeChat), + patch.object(crawl, 'Channel', FakeChat), + patch.object(crawl, 'StringSession', return_value='session'), + patch.object(crawl, 'new_run_time', return_value='20260724120000'), + ): + result = crawl.main(['101', '202']) + + self.assertEqual(result, 0) + self.assertEqual(client.get_entity.call_args_list, [call(101), call(202)]) + self.assertNotIn(call(999), client.get_entity.call_args_list) + save_session.assert_called_once_with('updated-session') + self.assertEqual(save_group_export.call_count, 2) + first_members = save_group_export.call_args_list[0].args[2] + self.assertEqual([member.id for member in first_members], [1, 2]) + + +if __name__ == '__main__': + unittest.main()