tgdata 0.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
tgdata/models.py ADDED
@@ -0,0 +1,69 @@
1
+ """
2
+ Data models for the Telegram Group Message Crawler.
3
+ """
4
+
5
+ from dataclasses import dataclass
6
+ from datetime import datetime
7
+ from typing import Optional, Any, Dict
8
+
9
+
10
+
11
+ @dataclass
12
+ class RateLimitInfo:
13
+ """Track rate limit information"""
14
+ requests_made: int = 0
15
+ window_start: float = 0
16
+ last_request: float = 0
17
+ flood_wait_until: float = 0
18
+
19
+
20
+ @dataclass
21
+ class GroupInfo:
22
+ """Information about a Telegram group/channel"""
23
+ id: int
24
+ title: str
25
+ username: Optional[str] = None
26
+ is_channel: bool = False
27
+ is_megagroup: bool = False
28
+ participants_count: Optional[int] = None
29
+
30
+
31
+ @dataclass
32
+ class MessageData:
33
+ """Structured message data"""
34
+ message_id: int
35
+ sender_id: int
36
+ sender_name: str
37
+ username: str
38
+ message: Optional[str]
39
+ date: datetime
40
+ reply_to_id: Optional[int] = None
41
+ forwarded_from: Optional[int] = None
42
+ photo_data: Optional[bytes] = None
43
+
44
+ def to_dict(self) -> Dict[str, Any]:
45
+ """Convert to dictionary for DataFrame creation"""
46
+ return {
47
+ 'MessageId': self.message_id,
48
+ 'SenderId': self.sender_id,
49
+ 'Name': self.sender_name,
50
+ 'Username': self.username,
51
+ 'Message': self.message,
52
+ 'Date': self.date,
53
+ 'ReplyToId': self.reply_to_id,
54
+ 'ForwardedFrom': self.forwarded_from,
55
+ 'PhotoData': self.photo_data
56
+ }
57
+
58
+
59
+ @dataclass
60
+ class ConnectionConfig:
61
+ """Configuration for Telegram connection"""
62
+ api_id: str
63
+ api_hash: str
64
+ session_file: str
65
+ phone: Optional[str] = None
66
+ username: Optional[str] = None
67
+ max_retries: int = 3
68
+ retry_delay: float = 1.0
69
+ exponential_backoff: bool = True
tgdata/progress.py ADDED
@@ -0,0 +1,105 @@
1
+ """
2
+ Progress tracking utilities for long-running operations.
3
+ """
4
+
5
+ from datetime import datetime, timedelta
6
+ from typing import Optional, Callable
7
+ import logging
8
+
9
+ logger = logging.getLogger(__name__)
10
+
11
+
12
+ class ProgressTracker:
13
+ """
14
+ Track progress of long-running operations.
15
+ This doesn't need persistence as it's for real-time progress only.
16
+ """
17
+
18
+ def __init__(self,
19
+ total_expected: Optional[int] = None,
20
+ callback: Optional[Callable[[int, Optional[int], float], None]] = None):
21
+ """
22
+ Initialize progress tracker.
23
+
24
+ Args:
25
+ total_expected: Expected total items (if known)
26
+ callback: Optional callback function(current, total, rate)
27
+ """
28
+ self.total_expected = total_expected
29
+ self.callback = callback
30
+ self.current = 0
31
+ self.start_time: Optional[datetime] = None
32
+
33
+ def start(self) -> None:
34
+ """Start tracking progress."""
35
+ self.start_time = datetime.now()
36
+ self.current = 0
37
+ logger.debug("Progress tracking started")
38
+
39
+ def update(self, count: int = 1) -> None:
40
+ """
41
+ Update progress.
42
+
43
+ Args:
44
+ count: Number of items processed
45
+ """
46
+ self.current += count
47
+
48
+ # Calculate rate
49
+ if self.start_time:
50
+ elapsed = (datetime.now() - self.start_time).total_seconds()
51
+ rate = self.current / elapsed if elapsed > 0 else 0
52
+ else:
53
+ rate = 0
54
+
55
+ # Call callback
56
+ if self.callback:
57
+ try:
58
+ self.callback(self.current, self.total_expected, rate)
59
+ except Exception as e:
60
+ logger.error(f"Progress callback error: {e}")
61
+
62
+ def get_rate(self) -> float:
63
+ """Get current processing rate (items per second)."""
64
+ if not self.start_time:
65
+ return 0.0
66
+
67
+ elapsed = (datetime.now() - self.start_time).total_seconds()
68
+ return self.current / elapsed if elapsed > 0 else 0.0
69
+
70
+ def get_eta(self) -> Optional[datetime]:
71
+ """Get estimated time of completion."""
72
+ if not self.total_expected or not self.start_time or self.current <= 0:
73
+ return None
74
+
75
+ rate = self.get_rate()
76
+ if rate <= 0:
77
+ return None
78
+
79
+ remaining = self.total_expected - self.current
80
+ eta_seconds = remaining / rate
81
+
82
+ return datetime.now() + timedelta(seconds=eta_seconds)
83
+
84
+ def get_elapsed(self) -> Optional[timedelta]:
85
+ """Get elapsed time since start."""
86
+ if not self.start_time:
87
+ return None
88
+ return datetime.now() - self.start_time
89
+
90
+ def get_progress_percentage(self) -> Optional[float]:
91
+ """Get progress as percentage (0-100)."""
92
+ if not self.total_expected or self.total_expected <= 0:
93
+ return None
94
+ return min(100.0, (self.current / self.total_expected) * 100)
95
+
96
+ def get_summary(self) -> dict:
97
+ """Get progress summary."""
98
+ return {
99
+ 'current': self.current,
100
+ 'total': self.total_expected,
101
+ 'rate': self.get_rate(),
102
+ 'eta': self.get_eta(),
103
+ 'elapsed': self.get_elapsed(),
104
+ 'percentage': self.get_progress_percentage()
105
+ }
@@ -0,0 +1 @@
1
+ # Smoke tests for Telegram Group Message Crawler
@@ -0,0 +1,149 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+ import pandas as pd
5
+ from tgdata import TgData
6
+
7
+ # python -m tgdata.smoke_tests.my_test
8
+
9
+
10
+
11
+ async def extract_to_csv_with_resume(tg: TgData, group_id: int, csv_file: str = "messages.csv", batch_size: int = 10):
12
+ """
13
+ Extract messages to CSV using the CSV itself as checkpoint.
14
+ Supports resuming from the last message ID in the CSV.
15
+
16
+ Uses get_messages(after_id=...) for incremental extraction.
17
+ """
18
+ # Read last message ID from existing CSV
19
+ last_message_id = 0
20
+ existing_count = 0
21
+
22
+ if os.path.exists(csv_file):
23
+ try:
24
+ # Read just the last row efficiently
25
+ # In production, you might use: pd.read_csv(csv_file, skiprows=lambda x: x < last_row)
26
+ df_existing = pd.read_csv(csv_file)
27
+ if not df_existing.empty:
28
+ # Get the maximum message ID (in case CSV is not ordered)
29
+ last_message_id = int(df_existing['MessageId'].max())
30
+ existing_count = len(df_existing)
31
+ print(f"📄 Found existing CSV with {existing_count} messages")
32
+ print(f"📍 Resuming from message ID: {last_message_id}")
33
+ except Exception as e:
34
+ print(f"⚠️ Could not read existing CSV: {e}")
35
+ print(" Starting fresh extraction")
36
+ else:
37
+ print("📄 No existing CSV found, starting fresh extraction")
38
+
39
+ # Use get_messages with after_id for incremental extraction
40
+ print(f"\n🔄 Extracting messages since ID {last_message_id}...")
41
+
42
+ # Get today's date at midnight in UTC
43
+ from datetime import datetime, time, timezone
44
+ today_utc = datetime.combine(datetime.now(timezone.utc).date(), time.min, tzinfo=timezone.utc)
45
+ print(f"📅 Filtering for messages from today ({today_utc.date()}) only")
46
+ print(f"⏰ Start time filter: {today_utc} (UTC)")
47
+ print(f"⏰ Current time: {datetime.now(timezone.utc)} (UTC)")
48
+
49
+ try:
50
+ # Use get_messages with after_id for proper batch extraction
51
+ new_messages = await tg.get_messages(
52
+ group_id=group_id,
53
+ after_id=last_message_id
54
+ )
55
+
56
+ # For demo purposes, limit to batch_size and filter for today
57
+ if not new_messages.empty:
58
+ print(f" Got {len(new_messages)} messages since ID {last_message_id}")
59
+
60
+ # Convert Date column to UTC if needed
61
+ new_messages['Date'] = pd.to_datetime(new_messages['Date'], utc=True)
62
+
63
+ # Filter for today's messages only (for demo)
64
+ today_messages = new_messages[new_messages['Date'] >= today_utc]
65
+ print(f" Found {len(today_messages)} messages from today")
66
+
67
+ # Limit to batch_size
68
+ new_messages = today_messages.head(batch_size)
69
+ print(f" Limited to {len(new_messages)} messages (batch_size={batch_size})")
70
+
71
+ if not new_messages.empty:
72
+ # Sort by MessageId to ensure order
73
+ new_messages = new_messages.sort_values('MessageId')
74
+
75
+ # Append to CSV
76
+ new_messages.to_csv(
77
+ csv_file,
78
+ mode='a',
79
+ header=not os.path.exists(csv_file),
80
+ index=False
81
+ )
82
+
83
+ print(f"✅ Added {len(new_messages)} new messages to CSV")
84
+
85
+ # Show sample of new messages
86
+ print("\n📋 Sample of new messages:")
87
+ # Show relevant columns
88
+ cols_to_show = ['MessageId', 'Name', 'Date']
89
+ print(new_messages[cols_to_show].head(5))
90
+ else:
91
+ print("ℹ️ No new messages found")
92
+
93
+ except Exception as e:
94
+ print(f"❌ Error extracting messages: {e}")
95
+ new_messages = pd.DataFrame()
96
+
97
+ # Summary
98
+ print(f"\n📊 Extraction Summary:")
99
+ print(f" - Existing messages: {existing_count}")
100
+ print(f" - New messages: {len(new_messages) if not new_messages.empty else 0}")
101
+ print(f" - Total messages: {existing_count + (len(new_messages) if not new_messages.empty else 0)}")
102
+
103
+ return new_messages
104
+
105
+
106
+
107
+ async def main():
108
+ """Run CSV checkpoint test"""
109
+
110
+
111
+ print("This test demonstrates:")
112
+ print("- Using CSV file itself as checkpoint (no checkpoint.json)")
113
+ print("- Reading last message ID from existing CSV")
114
+ print("- Resuming extraction from that point")
115
+ print("- Appending new messages to existing CSV")
116
+
117
+ # Define the test function inline or import it
118
+ # For now, let's create a simple test
119
+ tg = TgData("config.ini")
120
+ csv_file = "test_messages.csv"
121
+
122
+ try:
123
+ # Get test group ID from environment or use Bitcoinsensus channel
124
+ group_id = int(os.environ.get('TEST_GROUP_ID', '1670178185')) # Bitcoinsensus channel
125
+
126
+ print(f"Using group ID: {group_id}")
127
+ print("\n⚠️ Using gentle settings: batch_size=10, only today's messages")
128
+
129
+ # Run the extraction
130
+ messages = await extract_to_csv_with_resume(tg, group_id, csv_file, batch_size=10)
131
+
132
+ success = True
133
+ except Exception as e:
134
+ print(f"Error: {e}")
135
+ success = False
136
+ finally:
137
+ await tg.close()
138
+ # Cleanup
139
+ if os.path.exists(csv_file):
140
+ os.remove(csv_file)
141
+ print(f"Cleaned up {csv_file}")
142
+
143
+
144
+ if not success:
145
+ sys.exit(1)
146
+
147
+
148
+ if __name__ == "__main__":
149
+ asyncio.run(main())
@@ -0,0 +1,286 @@
1
+ """
2
+ Smoke tests for ConnectionEngine with real Telegram connections
3
+ """
4
+ # To run: python -m tgdata.smoke_tests.test_01_connection
5
+
6
+ import asyncio
7
+ import sys
8
+ import os
9
+ import time
10
+ sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
11
+
12
+ from tgdata.connection_engine import ConnectionEngine, ConnectionPool, RateLimitInfo
13
+
14
+
15
+ async def test_connection_engine_initialization():
16
+ """Test ConnectionEngine initialization with real config"""
17
+ print("TEST: ConnectionEngine initialization...")
18
+ try:
19
+ # Test with real config.ini
20
+ if not os.path.exists("config.ini"):
21
+ print("✗ config.ini not found. Please ensure config.ini exists with valid Telegram credentials")
22
+ return False
23
+
24
+ engine = ConnectionEngine("config.ini")
25
+ assert engine.config_path == "config.ini"
26
+ assert engine.pool_size == 1
27
+ assert engine.max_retries == 3
28
+ print("✓ Default initialization successful")
29
+
30
+ # Test with custom settings
31
+ engine2 = ConnectionEngine(
32
+ config_path="config.ini",
33
+ pool_size=2,
34
+ max_retries=2,
35
+ retry_delay=1.0,
36
+ exponential_backoff=True
37
+ )
38
+ assert engine2.pool_size == 2
39
+ assert engine2.max_retries == 2
40
+ print("✓ Custom initialization successful")
41
+
42
+ return True
43
+ except Exception as e:
44
+ print(f"✗ Initialization test failed: {e}")
45
+ return False
46
+
47
+
48
+ async def test_real_connection():
49
+ """Test real Telegram connection"""
50
+ print("\nTEST: Real Telegram connection...")
51
+ engine = None
52
+ try:
53
+ engine = ConnectionEngine("config.ini", pool_size=1)
54
+
55
+ # Test getting a real client
56
+ print("Attempting to get Telegram client...")
57
+ client = await engine.get_client()
58
+
59
+ # Verify it's connected
60
+ assert client is not None
61
+ assert await client.is_user_authorized()
62
+ print("✓ Successfully connected to Telegram")
63
+
64
+ # Get some basic info to verify connection
65
+ me = await client.get_me()
66
+ print(f"✓ Authenticated as: {me.first_name} (ID: {me.id})")
67
+
68
+ # Test connection validation
69
+ is_valid = await engine.validate_connection()
70
+ assert is_valid == True
71
+ print("✓ Connection validation successful")
72
+
73
+ return True
74
+ except Exception as e:
75
+ print(f"✗ Real connection test failed: {e}")
76
+ print("Make sure:")
77
+ print(" 1. config.ini has valid Telegram API credentials")
78
+ print(" 2. You have an authenticated session ('karaposu'.session)")
79
+ return False
80
+ finally:
81
+ if engine:
82
+ await engine.close()
83
+
84
+
85
+ async def test_config_loading():
86
+ """Test real configuration loading"""
87
+ print("\nTEST: Real configuration loading...")
88
+ try:
89
+ engine = ConnectionEngine("config.ini")
90
+ config = engine._load_config()
91
+
92
+ # Check that real config values are loaded
93
+ assert config.api_id is not None
94
+ assert config.api_hash is not None
95
+ assert len(config.api_id) > 0
96
+ assert len(config.api_hash) > 0
97
+ print(f"✓ Config loaded successfully")
98
+ print(f" - API ID: {config.api_id}")
99
+ print(f" - Session: {config.username if config.username else config.session_file}")
100
+ print(f" - Phone: {config.phone if config.phone else 'Not specified'}")
101
+
102
+ return True
103
+ except Exception as e:
104
+ print(f"✗ Config loading test failed: {e}")
105
+ return False
106
+
107
+
108
+ async def test_health_check():
109
+ """Test health check with real connection"""
110
+ print("\nTEST: Health check with real connection...")
111
+ engine = None
112
+ try:
113
+ engine = ConnectionEngine("config.ini")
114
+
115
+ # Get a connection first
116
+ await engine.get_client()
117
+
118
+ # Run health check
119
+ health = await engine.health_check()
120
+
121
+ assert isinstance(health, dict)
122
+ assert 'timestamp' in health
123
+ assert 'primary_connection' in health
124
+ assert 'pool_connections' in health
125
+ assert 'errors' in health
126
+
127
+ print("✓ Health check structure correct")
128
+ print(f" - Primary connection: {'healthy' if health['primary_connection'] else 'unhealthy'}")
129
+ print(f" - Pool connections: {len(health['pool_connections'])}")
130
+ print(f" - Errors: {len(health['errors'])}")
131
+
132
+ # Primary connection should be healthy
133
+ assert health['primary_connection'] == True
134
+ print("✓ Primary connection is healthy")
135
+
136
+ return True
137
+ except Exception as e:
138
+ print(f"✗ Health check test failed: {e}")
139
+ return False
140
+ finally:
141
+ if engine:
142
+ await engine.close()
143
+
144
+
145
+ async def test_connection_pool_real():
146
+ """Test connection pool with real connections"""
147
+ print("\nTEST: Connection pool with real connections...")
148
+ engine = None
149
+ try:
150
+ # Note: For authenticated sessions, pool_size=1 is usually sufficient
151
+ # as Telegram allows multiple concurrent requests on the same connection
152
+ engine = ConnectionEngine("config.ini", pool_size=1)
153
+
154
+ print("Testing connection reuse...")
155
+
156
+ # Get multiple client references
157
+ client1 = await engine.get_client()
158
+ client2 = await engine.get_client()
159
+
160
+ # Both should be valid
161
+ assert await client1.is_user_authorized()
162
+ print("✓ First client authorized")
163
+
164
+ assert await client2.is_user_authorized()
165
+ print("✓ Second client authorized")
166
+
167
+ # For pool_size=1, they should be the same client instance
168
+ if engine.pool_size == 1:
169
+ assert client1 is client2
170
+ print("✓ Single connection reused correctly (pool_size=1)")
171
+ else:
172
+ print(f"✓ Pool with {engine.pool_size} connections working")
173
+
174
+ # Test that we can use both references
175
+ me1 = await client1.get_me()
176
+ me2 = await client2.get_me()
177
+ assert me1.id == me2.id
178
+ print("✓ Both client references work correctly")
179
+
180
+ return True
181
+ except Exception as e:
182
+ print(f"✗ Connection pool test failed: {e}")
183
+ print("This might happen if the session requires re-authentication")
184
+ return False
185
+ finally:
186
+ if engine:
187
+ await engine.close()
188
+
189
+
190
+ async def test_connection_close():
191
+ """Test connection closing with real connection"""
192
+ print("\nTEST: Connection closing...")
193
+ engine = None
194
+ try:
195
+ engine = ConnectionEngine("config.ini")
196
+
197
+ # Get a real connection
198
+ client = await engine.get_client()
199
+ assert await client.is_user_authorized()
200
+
201
+ # Close connections
202
+ await engine.close()
203
+ print("✓ Close operation successful")
204
+
205
+ # Verify client is disconnected
206
+ # Note: We can't easily test if it's truly disconnected without errors
207
+ print("✓ Engine closed without errors")
208
+
209
+ return True
210
+ except Exception as e:
211
+ print(f"✗ Connection close test failed: {e}")
212
+ return False
213
+
214
+
215
+ async def test_rate_limiting():
216
+ """Test rate limiting behavior"""
217
+ print("\nTEST: Rate limiting behavior...")
218
+ engine = None
219
+ try:
220
+ engine = ConnectionEngine("config.ini")
221
+ client = await engine.get_client()
222
+
223
+ # Note: We can't easily trigger real rate limits without making many requests
224
+ # But we can test the mechanism
225
+ print("✓ Rate limiting mechanisms in place")
226
+ print(" - Exponential backoff enabled")
227
+ print(" - Retry logic configured")
228
+
229
+ return True
230
+ except Exception as e:
231
+ print(f"✗ Rate limiting test failed: {e}")
232
+ return False
233
+ finally:
234
+ if engine:
235
+ await engine.close()
236
+
237
+
238
+ async def main():
239
+ """Run all connection engine tests with real connections"""
240
+ print("Connection Engine Tests (Real Connections)")
241
+ print("=" * 50)
242
+ print("\nNOTE: These tests require:")
243
+ print(" 1. Valid config.ini with Telegram API credentials")
244
+ print(" 2. Authenticated session file ('karaposu'.session)")
245
+ print(" 3. Active internet connection")
246
+ print("=" * 50)
247
+
248
+ tests = [
249
+ test_connection_engine_initialization,
250
+ test_config_loading,
251
+ test_real_connection,
252
+ test_health_check,
253
+ test_connection_pool_real,
254
+ test_rate_limiting,
255
+ test_connection_close
256
+ ]
257
+
258
+ results = []
259
+ for test in tests:
260
+ try:
261
+ result = await test()
262
+ results.append(result)
263
+ except Exception as e:
264
+ print(f"✗ Test failed with error: {e}")
265
+ import traceback
266
+ traceback.print_exc()
267
+ results.append(False)
268
+
269
+ # Summary
270
+ print("\nSummary")
271
+ print("=" * 50)
272
+ passed = sum(results)
273
+ total = len(results)
274
+ print(f"Passed: {passed}/{total}")
275
+
276
+ if passed == total:
277
+ print("✓ All connection engine tests passed!")
278
+ return 0
279
+ else:
280
+ print("✗ Some tests failed")
281
+ return 1
282
+
283
+
284
+ if __name__ == "__main__":
285
+ exit_code = asyncio.run(main())
286
+ sys.exit(exit_code)