dexscreen 0.0.1__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.
@@ -0,0 +1,27 @@
1
+ from .http import HttpClientCffi
2
+ from .models import (
3
+ BaseToken,
4
+ Liquidity,
5
+ OrderInfo,
6
+ PairTransactionCounts,
7
+ PriceChangePeriods,
8
+ TokenInfo,
9
+ TokenLink,
10
+ TokenPair,
11
+ TransactionCount,
12
+ VolumeChangePeriods,
13
+ )
14
+
15
+ __all__ = [
16
+ "BaseToken",
17
+ "HttpClientCffi",
18
+ "Liquidity",
19
+ "OrderInfo",
20
+ "PairTransactionCounts",
21
+ "PriceChangePeriods",
22
+ "TokenInfo",
23
+ "TokenLink",
24
+ "TokenPair",
25
+ "TransactionCount",
26
+ "VolumeChangePeriods",
27
+ ]
dexscreen/core/http.py ADDED
@@ -0,0 +1,460 @@
1
+ """
2
+ Enhanced with realworld browser impersonation and custom configuration support
3
+ """
4
+
5
+ import asyncio
6
+ import contextlib
7
+ from datetime import datetime, timedelta
8
+ from enum import Enum
9
+ from threading import Lock
10
+ from typing import Any, Literal, Optional, Union
11
+
12
+ import orjson
13
+ from curl_cffi.requests import AsyncSession, Session
14
+
15
+ from ..utils.browser_selector import get_random_browser
16
+ from ..utils.ratelimit import RateLimiter
17
+
18
+ # Type alias for HTTP methods
19
+ HttpMethod = Literal["GET", "POST", "PUT", "DELETE", "OPTIONS", "HEAD", "TRACE"]
20
+
21
+
22
+ class SessionState(Enum):
23
+ """Session state"""
24
+
25
+ ACTIVE = "active" # Active, accepting new requests
26
+ DRAINING = "draining" # Draining, not accepting new requests
27
+ STANDBY = "standby" # Standby, ready to take over
28
+ CLOSED = "closed" # Closed
29
+
30
+
31
+ class HttpClientCffi:
32
+ """HTTP client with curl_cffi for bypassing anti-bot measures
33
+
34
+ Features:
35
+ - Session reuse for better performance (avoid TLS handshake)
36
+ - Zero-downtime configuration updates
37
+ - Graceful session switching
38
+ - Automatic connection warm-up
39
+ """
40
+
41
+ def __init__(
42
+ self,
43
+ calls: int,
44
+ period: int,
45
+ base_url: str = "https://api.dexscreener.com/",
46
+ client_kwargs: Optional[dict[str, Any]] = None,
47
+ warmup_url: str = "/latest/dex/tokens/solana?limit=1",
48
+ ):
49
+ """
50
+ Initialize HTTP client with rate limiting and browser impersonation.
51
+
52
+ Args:
53
+ calls: Maximum number of calls allowed
54
+ period: Time period in seconds
55
+ base_url: Base URL for API requests
56
+ client_kwargs: Optional kwargs to pass to curl_cffi Session/AsyncSession.
57
+ Common options include:
58
+ - impersonate: Browser to impersonate (default: "realworld")
59
+ - proxies: Proxy configuration
60
+ - timeout: Request timeout
61
+ - headers: Additional headers
62
+ - verify: SSL verification
63
+ warmup_url: URL path for warming up new sessions
64
+ """
65
+ self._limiter = RateLimiter(calls, period)
66
+ self.base_url = base_url
67
+ self.warmup_url = warmup_url
68
+
69
+ # Setup client kwargs with defaults
70
+ self.client_kwargs = client_kwargs or {}
71
+ # Use our custom realworld browser selection if not specified
72
+ if "impersonate" not in self.client_kwargs:
73
+ self.client_kwargs["impersonate"] = get_random_browser()
74
+
75
+ # Thread lock for safe updates
76
+ self._lock = Lock()
77
+
78
+ # Session management
79
+ # Primary session
80
+ self._primary_session: Optional[AsyncSession] = None
81
+ self._primary_state = SessionState.CLOSED
82
+ self._primary_requests = 0 # Active request count
83
+
84
+ # Secondary session for hot switching
85
+ self._secondary_session: Optional[AsyncSession] = None
86
+ self._secondary_state = SessionState.CLOSED
87
+ self._secondary_requests = 0
88
+
89
+ # Sync sessions
90
+ self._sync_primary: Optional[Session] = None
91
+ self._sync_secondary: Optional[Session] = None
92
+
93
+ # Async lock for session switching
94
+ self._switch_lock = asyncio.Lock()
95
+
96
+ # Statistics
97
+ self._stats = {
98
+ "switches": 0,
99
+ "failed_requests": 0,
100
+ "successful_requests": 0,
101
+ "last_switch": None,
102
+ }
103
+
104
+ def _create_absolute_url(self, relative: str) -> str:
105
+ base = self.base_url.rstrip("/")
106
+ relative = relative.lstrip("/")
107
+ return f"{base}/{relative}"
108
+
109
+ async def _ensure_active_session(self) -> AsyncSession:
110
+ """Ensure there's an active session"""
111
+ async with self._switch_lock:
112
+ # If primary session is not active, create it
113
+ if self._primary_state != SessionState.ACTIVE and self._primary_session is None:
114
+ self._primary_session = AsyncSession(**self.client_kwargs)
115
+ # Warm up connection
116
+ warmup_success = False
117
+ try:
118
+ warmup_url = self._create_absolute_url(self.warmup_url)
119
+ response = await self._primary_session.get(warmup_url)
120
+ if response.status_code == 200:
121
+ warmup_success = True
122
+ except Exception:
123
+ pass # Warmup failure doesn't affect usage
124
+
125
+ # Only activate if warmup succeeded
126
+ if warmup_success:
127
+ self._primary_state = SessionState.ACTIVE
128
+ else:
129
+ # Keep trying with the session even if warmup failed
130
+ # This maintains backward compatibility
131
+ self._primary_state = SessionState.ACTIVE
132
+
133
+ if self._primary_session is None:
134
+ raise RuntimeError("Failed to create primary session")
135
+ return self._primary_session
136
+
137
+ def _ensure_sync_session(self) -> Session:
138
+ """Ensure there's a sync session"""
139
+ with self._lock:
140
+ if self._sync_primary is None:
141
+ self._sync_primary = Session(**self.client_kwargs)
142
+ # Warm up
143
+ try:
144
+ warmup_url = self._create_absolute_url(self.warmup_url)
145
+ response = self._sync_primary.get(warmup_url)
146
+ # Check if warmup was successful
147
+ if response.status_code != 200:
148
+ pass # Log warning in production
149
+ except Exception:
150
+ pass
151
+
152
+ return self._sync_primary
153
+
154
+ def request(self, method: HttpMethod, url: str, **kwargs) -> Union[list, dict, None]:
155
+ """
156
+ Synchronous request with rate limiting and browser impersonation.
157
+
158
+ Args:
159
+ method: HTTP method (GET, POST, etc.)
160
+ url: Relative URL path
161
+ **kwargs: Additional request kwargs
162
+
163
+ Returns:
164
+ Parsed JSON response
165
+ """
166
+ url = self._create_absolute_url(url)
167
+
168
+ with self._limiter:
169
+ try:
170
+ # Use persistent session
171
+ session = self._ensure_sync_session()
172
+ response = session.request(method, url, **kwargs) # type: ignore
173
+ response.raise_for_status()
174
+
175
+ # Check if response is JSON
176
+ content_type = response.headers.get("content-type", "")
177
+ if "application/json" in content_type:
178
+ # Use orjson for better performance
179
+ return orjson.loads(response.content)
180
+ else:
181
+ # Non-JSON response (e.g., HTML error page)
182
+ return None
183
+ except Exception:
184
+ return None
185
+
186
+ async def request_async(self, method: HttpMethod, url: str, **kwargs) -> Union[list, dict, None]:
187
+ """
188
+ Asynchronous request with rate limiting and browser impersonation.
189
+
190
+ Args:
191
+ method: HTTP method (GET, POST, etc.)
192
+ url: Relative URL path
193
+ **kwargs: Additional request kwargs
194
+
195
+ Returns:
196
+ Parsed JSON response
197
+ """
198
+ url = self._create_absolute_url(url)
199
+
200
+ async with self._limiter:
201
+ # Get active session
202
+ session = await self._ensure_active_session()
203
+
204
+ # Track active requests
205
+ with self._lock:
206
+ self._primary_requests += 1
207
+
208
+ try:
209
+ response = await session.request(method, url, **kwargs) # type: ignore
210
+ response.raise_for_status()
211
+
212
+ # Statistics
213
+ with self._lock:
214
+ self._stats["successful_requests"] += 1
215
+
216
+ # Parse response
217
+ content_type = response.headers.get("content-type", "")
218
+ if "application/json" in content_type:
219
+ # Use orjson for better performance
220
+ return orjson.loads(response.content)
221
+ else:
222
+ return None
223
+
224
+ except Exception:
225
+ with self._lock:
226
+ self._stats["failed_requests"] += 1
227
+
228
+ # Try failover to secondary session if available
229
+ if self._secondary_state == SessionState.ACTIVE:
230
+ return await self._failover_request(method, url, **kwargs)
231
+
232
+ return None
233
+
234
+ finally:
235
+ # Decrease request count
236
+ with self._lock:
237
+ self._primary_requests -= 1
238
+
239
+ async def _failover_request(self, method: HttpMethod, url: str, **kwargs) -> Union[list, dict, None]:
240
+ """Failover to secondary session"""
241
+ if self._secondary_session and self._secondary_state == SessionState.ACTIVE:
242
+ try:
243
+ with self._lock:
244
+ self._secondary_requests += 1
245
+
246
+ response = await self._secondary_session.request(method, url, **kwargs) # type: ignore
247
+ response.raise_for_status()
248
+
249
+ content_type = response.headers.get("content-type", "")
250
+ if "application/json" in content_type:
251
+ # Use orjson for better performance
252
+ return orjson.loads(response.content)
253
+ else:
254
+ return None
255
+
256
+ finally:
257
+ with self._lock:
258
+ self._secondary_requests -= 1
259
+
260
+ return None
261
+
262
+ async def _perform_switch(self):
263
+ """Perform hot switch between sessions"""
264
+ # 1. Promote secondary to active
265
+ self._secondary_state = SessionState.ACTIVE
266
+
267
+ # 2. Mark primary as draining
268
+ if self._primary_state == SessionState.ACTIVE:
269
+ self._primary_state = SessionState.DRAINING
270
+
271
+ # 3. Swap references
272
+ old_primary = self._primary_session
273
+ old_primary_requests = self._primary_requests
274
+
275
+ self._primary_session = self._secondary_session
276
+ self._primary_requests = self._secondary_requests
277
+ self._primary_state = SessionState.ACTIVE
278
+
279
+ self._secondary_session = old_primary
280
+ self._secondary_requests = old_primary_requests
281
+ self._secondary_state = SessionState.DRAINING
282
+
283
+ # 4. Async cleanup of old session
284
+ if old_primary:
285
+ asyncio.create_task(self._graceful_close_session(old_primary, lambda: self._secondary_requests))
286
+
287
+ async def _graceful_close_session(self, session: AsyncSession, get_request_count):
288
+ """Gracefully close session after requests complete"""
289
+ # Wait for ongoing requests to complete (max 30 seconds)
290
+ start_time = datetime.now()
291
+ timeout = timedelta(seconds=30)
292
+
293
+ while get_request_count() > 0:
294
+ if datetime.now() - start_time > timeout:
295
+ break
296
+
297
+ await asyncio.sleep(0.1)
298
+
299
+ # Close session
300
+ with contextlib.suppress(Exception):
301
+ await session.close()
302
+
303
+ def set_impersonate(self, browser: str):
304
+ """
305
+ Change browser impersonation.
306
+
307
+ NOTE: This method only updates the configuration for future requests.
308
+ It does NOT trigger a hot-switch of existing sessions.
309
+ Use update_config() for hot-switching with zero downtime.
310
+
311
+ Args:
312
+ browser: Browser to impersonate. Options include:
313
+ - "chrome136", "chrome134", etc.: Specific Chrome versions
314
+ - "safari180", "safari184", etc.: Specific Safari versions
315
+ - "firefox133", "firefox135", etc.: Specific Firefox versions
316
+ Note: "realworld" is replaced by our custom browser selector
317
+ """
318
+ # Update client kwargs for future sessions
319
+ with self._lock:
320
+ self.client_kwargs["impersonate"] = browser
321
+
322
+ async def update_config(self, new_kwargs: dict[str, Any], replace: bool = False):
323
+ """
324
+ Hot update configuration with zero downtime.
325
+ Creates new session with new config and gracefully switches.
326
+
327
+ Args:
328
+ new_kwargs: New configuration options
329
+ replace: If True, replace entire config. If False (default), merge with existing.
330
+ """
331
+ # Don't lock here - we want requests to continue
332
+ # Prepare new config
333
+ if replace:
334
+ # Complete replacement
335
+ config = new_kwargs.copy()
336
+ else:
337
+ # Merge with existing
338
+ config = self.client_kwargs.copy()
339
+ config.update(new_kwargs)
340
+
341
+ # Handle special case: if proxy is None, remove it
342
+ if "proxy" in new_kwargs and new_kwargs["proxy"] is None:
343
+ config.pop("proxy", None)
344
+ config.pop("proxies", None)
345
+
346
+ if "impersonate" not in config:
347
+ config["impersonate"] = get_random_browser()
348
+
349
+ # Create new session (secondary) without blocking
350
+ new_session = AsyncSession(**config)
351
+
352
+ # Warm up new connection in background
353
+ warmup_success = False
354
+ try:
355
+ warmup_url = self._create_absolute_url(self.warmup_url)
356
+ response = await new_session.get(warmup_url)
357
+ # Only consider warmup successful if we get 200 OK
358
+ if response.status_code == 200:
359
+ warmup_success = True
360
+ except Exception:
361
+ pass
362
+
363
+ # Only proceed with switch if warmup was successful
364
+ if warmup_success:
365
+ # Now acquire lock for the actual switch
366
+ async with self._switch_lock:
367
+ # Store the new session
368
+ self._secondary_session = new_session
369
+ self._secondary_state = SessionState.STANDBY
370
+
371
+ # Perform switch
372
+ await self._perform_switch()
373
+
374
+ # Update config
375
+ self.client_kwargs = config
376
+
377
+ # Statistics
378
+ with self._lock:
379
+ self._stats["switches"] += 1
380
+ self._stats["last_switch"] = datetime.now()
381
+ else:
382
+ # Clean up failed session
383
+ with contextlib.suppress(Exception):
384
+ await new_session.close()
385
+
386
+ def update_client_kwargs(self, new_kwargs: dict[str, Any], merge: bool = True):
387
+ """
388
+ Update client configuration at runtime.
389
+
390
+ Args:
391
+ new_kwargs: New configuration options to apply
392
+ merge: If True, merge with existing kwargs. If False, replace entirely.
393
+
394
+ Example:
395
+ # Update proxy
396
+ client.update_client_kwargs({"proxies": {"https": "http://new-proxy:8080"}})
397
+
398
+ # Change impersonation
399
+ client.update_client_kwargs({"impersonate": "safari184"})
400
+
401
+ # Add custom headers
402
+ client.update_client_kwargs({"headers": {"X-Custom": "value"}})
403
+
404
+ # Replace all kwargs
405
+ client.update_client_kwargs({
406
+ "impersonate": "firefox135",
407
+ "timeout": 30,
408
+ "verify": False
409
+ }, merge=False)
410
+ """
411
+ with self._lock:
412
+ if merge:
413
+ self.client_kwargs.update(new_kwargs)
414
+ else:
415
+ self.client_kwargs = new_kwargs.copy()
416
+
417
+ # Ensure we have browser impersonation
418
+ if "impersonate" not in self.client_kwargs:
419
+ self.client_kwargs["impersonate"] = get_random_browser()
420
+
421
+ def get_current_config(self) -> dict[str, Any]:
422
+ """
423
+ Get a copy of current client configuration.
424
+
425
+ Returns:
426
+ Current client_kwargs dictionary
427
+ """
428
+ with self._lock:
429
+ return self.client_kwargs.copy()
430
+
431
+ def get_stats(self) -> dict[str, Any]:
432
+ """
433
+ Get statistics.
434
+
435
+ Returns:
436
+ Statistics dictionary with switches, requests, etc.
437
+ """
438
+ with self._lock:
439
+ return self._stats.copy()
440
+
441
+ async def close(self):
442
+ """
443
+ Close all sessions gracefully.
444
+ """
445
+ tasks = []
446
+
447
+ if self._primary_session:
448
+ tasks.append(self._primary_session.close())
449
+
450
+ if self._secondary_session:
451
+ tasks.append(self._secondary_session.close())
452
+
453
+ if self._sync_primary:
454
+ self._sync_primary.close()
455
+
456
+ if self._sync_secondary:
457
+ self._sync_secondary.close()
458
+
459
+ if tasks:
460
+ await asyncio.gather(*tasks, return_exceptions=True)
@@ -0,0 +1,106 @@
1
+ import datetime as dt
2
+ from typing import Optional
3
+
4
+ from pydantic import BaseModel, ConfigDict, Field
5
+
6
+ # Base configuration for all models
7
+ base_config = ConfigDict(
8
+ # Standard Pydantic config
9
+ populate_by_name=True,
10
+ )
11
+
12
+
13
+ class BaseToken(BaseModel):
14
+ model_config = base_config
15
+
16
+ address: str
17
+ name: str
18
+ symbol: str
19
+
20
+
21
+ class TransactionCount(BaseModel):
22
+ model_config = base_config
23
+
24
+ buys: int
25
+ sells: int
26
+
27
+
28
+ class PairTransactionCounts(BaseModel):
29
+ model_config = base_config
30
+
31
+ m5: TransactionCount
32
+ h1: TransactionCount
33
+ h6: TransactionCount
34
+ h24: TransactionCount
35
+
36
+
37
+ class _TimePeriodsFloat(BaseModel):
38
+ model_config = base_config
39
+
40
+ m5: Optional[float] = 0.0
41
+ h1: Optional[float] = 0.0
42
+ h6: Optional[float] = 0.0
43
+ h24: Optional[float] = 0.0
44
+
45
+
46
+ class VolumeChangePeriods(_TimePeriodsFloat): ...
47
+
48
+
49
+ class PriceChangePeriods(_TimePeriodsFloat): ...
50
+
51
+
52
+ class Liquidity(BaseModel):
53
+ model_config = base_config
54
+
55
+ usd: Optional[float] = None
56
+ base: float
57
+ quote: float
58
+
59
+
60
+ class TokenPair(BaseModel):
61
+ model_config = base_config
62
+
63
+ chain_id: str = Field(..., alias="chainId")
64
+ dex_id: str = Field(..., alias="dexId")
65
+ url: str = Field(...)
66
+ pair_address: str = Field(..., alias="pairAddress")
67
+ base_token: BaseToken = Field(..., alias="baseToken")
68
+ quote_token: BaseToken = Field(..., alias="quoteToken")
69
+ price_native: float = Field(..., alias="priceNative")
70
+ price_usd: Optional[float] = Field(None, alias="priceUsd")
71
+ transactions: PairTransactionCounts = Field(..., alias="txns")
72
+ volume: VolumeChangePeriods
73
+ price_change: PriceChangePeriods = Field(..., alias="priceChange")
74
+ liquidity: Optional[Liquidity] = None
75
+ fdv: Optional[float] = 0.0
76
+ pair_created_at: Optional[dt.datetime] = Field(None, alias="pairCreatedAt")
77
+
78
+
79
+ class TokenLink(BaseModel):
80
+ model_config = base_config
81
+
82
+ type: Optional[str] = None
83
+ label: Optional[str] = None
84
+ url: Optional[str] = None
85
+
86
+
87
+ class TokenInfo(BaseModel):
88
+ model_config = base_config
89
+
90
+ url: str
91
+ chain_id: str = Field(..., alias="chainId")
92
+ token_address: str = Field(..., alias="tokenAddress")
93
+ amount: float = 0.0 # Not sure if this is the best logic
94
+ total_amount: float = Field(0.0, alias="totalAmount")
95
+ icon: Optional[str] = None
96
+ header: Optional[str] = None
97
+ description: Optional[str] = None
98
+ links: list[TokenLink] = []
99
+
100
+
101
+ class OrderInfo(BaseModel):
102
+ model_config = base_config
103
+
104
+ type: str
105
+ status: str
106
+ payment_timestamp: int = Field(..., alias="paymentTimestamp")
@@ -0,0 +1,3 @@
1
+ from .polling import PollingStream, StreamingClient
2
+
3
+ __all__ = ["PollingStream", "StreamingClient"]