asockslib 0.1.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.
- asockslib/__init__.py +117 -0
- asockslib/_console.py +18 -0
- asockslib/_port_utils.py +132 -0
- asockslib/benchmark.py +365 -0
- asockslib/cli/__init__.py +72 -0
- asockslib/cli/_helpers.py +162 -0
- asockslib/cli/_output.py +52 -0
- asockslib/cli/api_commands.py +377 -0
- asockslib/cli/commands.py +485 -0
- asockslib/client.py +634 -0
- asockslib/exceptions.py +89 -0
- asockslib/geo.json +649269 -0
- asockslib/geo_picker/__init__.py +30 -0
- asockslib/geo_picker/_completer.py +90 -0
- asockslib/geo_picker/_messages.py +89 -0
- asockslib/geo_picker/_types.py +89 -0
- asockslib/geo_picker/picker.py +667 -0
- asockslib/models/__init__.py +72 -0
- asockslib/models/directory.py +65 -0
- asockslib/models/enums.py +65 -0
- asockslib/models/port.py +187 -0
- asockslib/models/requests.py +89 -0
- asockslib/models/responses.py +20 -0
- asockslib/proxy_pool.py +659 -0
- asockslib/quick.py +174 -0
- asockslib/smart_proxy.py +251 -0
- asockslib-0.1.0.dist-info/METADATA +228 -0
- asockslib-0.1.0.dist-info/RECORD +30 -0
- asockslib-0.1.0.dist-info/WHEEL +4 -0
- asockslib-0.1.0.dist-info/entry_points.txt +3 -0
asockslib/__init__.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""ASocksLib — Python library and CLI for the ASocks Proxy API."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from asockslib.benchmark import (
|
|
6
|
+
CountryPingResult,
|
|
7
|
+
FindBestResult,
|
|
8
|
+
ProxyBenchmarkResult,
|
|
9
|
+
benchmark_proxies,
|
|
10
|
+
find_best_proxies,
|
|
11
|
+
ping_proxy,
|
|
12
|
+
select_best_proxies,
|
|
13
|
+
)
|
|
14
|
+
from asockslib.client import ASocksClient
|
|
15
|
+
from asockslib.exceptions import (
|
|
16
|
+
APIConnectionError,
|
|
17
|
+
ASocksError,
|
|
18
|
+
AuthenticationError,
|
|
19
|
+
InsufficientBalanceError,
|
|
20
|
+
NoAvailableProxyError,
|
|
21
|
+
PortNotFoundError,
|
|
22
|
+
ProxyHealthError,
|
|
23
|
+
RateLimitError,
|
|
24
|
+
)
|
|
25
|
+
from asockslib.geo_picker import (
|
|
26
|
+
GeoPicker,
|
|
27
|
+
PickedASN,
|
|
28
|
+
PickedCity,
|
|
29
|
+
PickedConnectionType,
|
|
30
|
+
PickedCountry,
|
|
31
|
+
PickedProxyType,
|
|
32
|
+
PickedServerPortType,
|
|
33
|
+
PickedState,
|
|
34
|
+
)
|
|
35
|
+
from asockslib.models import (
|
|
36
|
+
ASNInfo,
|
|
37
|
+
ASNListResponse,
|
|
38
|
+
AuthType,
|
|
39
|
+
BalanceResponse,
|
|
40
|
+
CityInfo,
|
|
41
|
+
ConnectionType,
|
|
42
|
+
ConnectionTypeId,
|
|
43
|
+
CountryInfo,
|
|
44
|
+
CreatePortRequest,
|
|
45
|
+
CreateTemplateRequest,
|
|
46
|
+
PortFilterParams,
|
|
47
|
+
PortInfo,
|
|
48
|
+
PortListResponse,
|
|
49
|
+
PortStatus,
|
|
50
|
+
ProxyType,
|
|
51
|
+
ProxyTypeId,
|
|
52
|
+
ServerPortType,
|
|
53
|
+
StateInfo,
|
|
54
|
+
UpdatePortRequest,
|
|
55
|
+
UpdateTemplateRequest,
|
|
56
|
+
WhitelistAddRequest,
|
|
57
|
+
)
|
|
58
|
+
from asockslib.proxy_pool import PoolStrategy, ProxyPool, ProxyPoolStats, ProxyStore
|
|
59
|
+
from asockslib.quick import get_proxies, get_proxies_sync
|
|
60
|
+
from asockslib.smart_proxy import SmartProxy
|
|
61
|
+
|
|
62
|
+
__all__ = [
|
|
63
|
+
"APIConnectionError",
|
|
64
|
+
"ASNInfo",
|
|
65
|
+
"ASNListResponse",
|
|
66
|
+
"ASocksClient",
|
|
67
|
+
"ASocksError",
|
|
68
|
+
"AuthType",
|
|
69
|
+
"AuthenticationError",
|
|
70
|
+
"BalanceResponse",
|
|
71
|
+
"CityInfo",
|
|
72
|
+
"ConnectionType",
|
|
73
|
+
"ConnectionTypeId",
|
|
74
|
+
"CountryInfo",
|
|
75
|
+
"CountryPingResult",
|
|
76
|
+
"CreatePortRequest",
|
|
77
|
+
"CreateTemplateRequest",
|
|
78
|
+
"FindBestResult",
|
|
79
|
+
"GeoPicker",
|
|
80
|
+
"InsufficientBalanceError",
|
|
81
|
+
"NoAvailableProxyError",
|
|
82
|
+
"PickedASN",
|
|
83
|
+
"PickedCity",
|
|
84
|
+
"PickedConnectionType",
|
|
85
|
+
"PickedCountry",
|
|
86
|
+
"PickedProxyType",
|
|
87
|
+
"PickedServerPortType",
|
|
88
|
+
"PickedState",
|
|
89
|
+
"PoolStrategy",
|
|
90
|
+
"PortFilterParams",
|
|
91
|
+
"PortInfo",
|
|
92
|
+
"PortListResponse",
|
|
93
|
+
"PortNotFoundError",
|
|
94
|
+
"PortStatus",
|
|
95
|
+
"ProxyBenchmarkResult",
|
|
96
|
+
"ProxyHealthError",
|
|
97
|
+
"ProxyPool",
|
|
98
|
+
"ProxyPoolStats",
|
|
99
|
+
"ProxyStore",
|
|
100
|
+
"ProxyType",
|
|
101
|
+
"ProxyTypeId",
|
|
102
|
+
"RateLimitError",
|
|
103
|
+
"ServerPortType",
|
|
104
|
+
"SmartProxy",
|
|
105
|
+
"StateInfo",
|
|
106
|
+
"UpdatePortRequest",
|
|
107
|
+
"UpdateTemplateRequest",
|
|
108
|
+
"WhitelistAddRequest",
|
|
109
|
+
"benchmark_proxies",
|
|
110
|
+
"find_best_proxies",
|
|
111
|
+
"get_proxies",
|
|
112
|
+
"get_proxies_sync",
|
|
113
|
+
"ping_proxy",
|
|
114
|
+
"select_best_proxies",
|
|
115
|
+
]
|
|
116
|
+
|
|
117
|
+
__version__ = "0.1.0"
|
asockslib/_console.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Centralized Rich Console singleton.
|
|
2
|
+
|
|
3
|
+
Every module that needs to print to the terminal should import
|
|
4
|
+
``console`` from here instead of creating its own instance.
|
|
5
|
+
|
|
6
|
+
Usage::
|
|
7
|
+
|
|
8
|
+
from asockslib._console import console
|
|
9
|
+
|
|
10
|
+
console.print("[green]OK[/green]")
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from rich.console import Console
|
|
16
|
+
|
|
17
|
+
console: Console = Console()
|
|
18
|
+
"""Module-level singleton :class:`~rich.console.Console` instance."""
|
asockslib/_port_utils.py
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""Shared port management mixin for SmartProxy and ProxyPool.
|
|
2
|
+
|
|
3
|
+
Extracts the duplicated ``_fetch_matching_ports`` and ``_create_ports``
|
|
4
|
+
methods into a reusable mixin, eliminating code duplication.
|
|
5
|
+
|
|
6
|
+
Both :class:`SmartProxy` and :class:`ProxyPool` store identical sets of
|
|
7
|
+
proxy-creation parameters and perform the same operations — this mixin
|
|
8
|
+
encapsulates that shared behaviour.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
# This mixin's entire purpose is to read the host class's private
|
|
12
|
+
# (`_`-prefixed) configuration attributes — that's package-internal sharing
|
|
13
|
+
# by design, not an encapsulation leak, so reportPrivateUsage is silenced
|
|
14
|
+
# for this file only.
|
|
15
|
+
# pyright: reportPrivateUsage=false
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import logging
|
|
20
|
+
from typing import TYPE_CHECKING, Protocol, runtime_checkable
|
|
21
|
+
|
|
22
|
+
from asockslib.models import (
|
|
23
|
+
CreatePortRequest,
|
|
24
|
+
PortFilterParams,
|
|
25
|
+
PortInfo,
|
|
26
|
+
PortStatus,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if TYPE_CHECKING:
|
|
30
|
+
from asockslib.client import ASocksClient
|
|
31
|
+
|
|
32
|
+
logger = logging.getLogger("asockslib")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
@runtime_checkable
|
|
36
|
+
class _HasClient(Protocol):
|
|
37
|
+
"""Protocol for classes that hold an ASocksClient and proxy params."""
|
|
38
|
+
|
|
39
|
+
_client: ASocksClient
|
|
40
|
+
_country_code: str
|
|
41
|
+
_city: str
|
|
42
|
+
_state: str
|
|
43
|
+
_type_id: int
|
|
44
|
+
_proxy_type_id: int
|
|
45
|
+
_server_port_type_id: int
|
|
46
|
+
_ttl: int
|
|
47
|
+
_traffic_limit: int
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _geo_matches(*, city: str, state: str, port: PortInfo) -> bool:
|
|
51
|
+
"""Return ``True`` if *port* is consistent with the requested geo.
|
|
52
|
+
|
|
53
|
+
Conservative: a port is rejected only when the requested city/state is
|
|
54
|
+
set *and* the port reports a different (non-empty) value. Ports whose
|
|
55
|
+
metadata is missing are kept — the server-side filter is trusted for
|
|
56
|
+
those. This guarantees geo-targeting without discarding otherwise-valid
|
|
57
|
+
ports that simply lack city/state metadata.
|
|
58
|
+
"""
|
|
59
|
+
|
|
60
|
+
def _mismatch(requested: str, actual: str) -> bool:
|
|
61
|
+
if not (requested and actual):
|
|
62
|
+
return False
|
|
63
|
+
return requested.strip().lower() != actual.strip().lower()
|
|
64
|
+
|
|
65
|
+
return not (_mismatch(city, port.city) or _mismatch(state, port.state))
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class PortManagerMixin:
|
|
69
|
+
"""Mixin providing shared port fetch/create operations.
|
|
70
|
+
|
|
71
|
+
Requires the consuming class to define:
|
|
72
|
+
- ``_client``: :class:`ASocksClient` instance
|
|
73
|
+
- ``_country_code``, ``_city``, ``_state``: geo filters
|
|
74
|
+
- ``_type_id``, ``_proxy_type_id``, ``_server_port_type_id``: type IDs
|
|
75
|
+
- ``_ttl``, ``_traffic_limit``: port parameters
|
|
76
|
+
"""
|
|
77
|
+
|
|
78
|
+
async def _fetch_matching_ports( # type: ignore[misc]
|
|
79
|
+
self: _HasClient,
|
|
80
|
+
limit: int | None = None,
|
|
81
|
+
) -> list[PortInfo]:
|
|
82
|
+
"""Load existing active ports matching the configured criteria.
|
|
83
|
+
|
|
84
|
+
Pages through ``GET /v2/proxy/ports`` (200 per page) until *limit*
|
|
85
|
+
matching ports are collected or the API runs out of pages, then
|
|
86
|
+
applies a client-side geo guard. Passing the full country/state/city
|
|
87
|
+
filter server-side keeps geo-targeting consistent; paginating fixes
|
|
88
|
+
the previous behaviour of only ever seeing the first page.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
limit: Stop once this many matching ports are collected
|
|
92
|
+
(``None`` = collect every matching port).
|
|
93
|
+
"""
|
|
94
|
+
per_page = 200
|
|
95
|
+
collected: list[PortInfo] = []
|
|
96
|
+
page = 1
|
|
97
|
+
while True:
|
|
98
|
+
filters = PortFilterParams(
|
|
99
|
+
status=PortStatus.ACTIVE,
|
|
100
|
+
countryName=self._country_code or None,
|
|
101
|
+
stateName=self._state or None,
|
|
102
|
+
cityName=self._city or None,
|
|
103
|
+
page=page,
|
|
104
|
+
per_page=per_page,
|
|
105
|
+
)
|
|
106
|
+
response = await self._client.list_ports(filters)
|
|
107
|
+
page_items = list(response.items)
|
|
108
|
+
collected.extend(
|
|
109
|
+
p for p in page_items if _geo_matches(city=self._city, state=self._state, port=p)
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
if limit is not None and len(collected) >= limit:
|
|
113
|
+
return collected[:limit]
|
|
114
|
+
# Last page reached when the API returns fewer than a full page.
|
|
115
|
+
if len(page_items) < per_page:
|
|
116
|
+
return collected
|
|
117
|
+
page += 1
|
|
118
|
+
|
|
119
|
+
async def _create_ports(self: _HasClient, quantity: int) -> list[PortInfo]: # type: ignore[misc]
|
|
120
|
+
"""Create new ports with the configured parameters."""
|
|
121
|
+
request = CreatePortRequest(
|
|
122
|
+
country_code=self._country_code,
|
|
123
|
+
city=self._city,
|
|
124
|
+
state=self._state,
|
|
125
|
+
count=quantity,
|
|
126
|
+
type_id=self._type_id,
|
|
127
|
+
proxy_type_id=self._proxy_type_id,
|
|
128
|
+
server_port_type_id=self._server_port_type_id,
|
|
129
|
+
ttl=self._ttl,
|
|
130
|
+
traffic_limit=self._traffic_limit,
|
|
131
|
+
)
|
|
132
|
+
return await self._client.create_ports(request)
|
asockslib/benchmark.py
ADDED
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
"""Proxy health-checking, latency measurement and benchmarking utilities.
|
|
2
|
+
|
|
3
|
+
All operations are fully async and parallelised with ``asyncio.gather``.
|
|
4
|
+
|
|
5
|
+
Public API:
|
|
6
|
+
- :func:`ping_proxy` — measure latency of a single proxy.
|
|
7
|
+
- :func:`benchmark_proxies` — benchmark a batch in parallel.
|
|
8
|
+
- :func:`select_best_proxies` — keep the fastest *N* proxies.
|
|
9
|
+
- :func:`find_best_proxies` — create, test, keep best, delete the rest.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import logging
|
|
16
|
+
import time
|
|
17
|
+
from collections.abc import Callable
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
import httpx
|
|
21
|
+
from beartype import beartype
|
|
22
|
+
|
|
23
|
+
# Runtime import (not TYPE_CHECKING): @beartype resolves the ASocksClient
|
|
24
|
+
# annotation at call time, so the name must exist in the module namespace.
|
|
25
|
+
from asockslib.client import ASocksClient # noqa: TC001
|
|
26
|
+
from asockslib.models import CreatePortRequest
|
|
27
|
+
|
|
28
|
+
logger = logging.getLogger("asockslib")
|
|
29
|
+
|
|
30
|
+
ProgressCallback = Callable[[str, int, int], None]
|
|
31
|
+
"""``(stage, current, total) -> None`` callback for :func:`find_best_proxies`."""
|
|
32
|
+
|
|
33
|
+
_PING_URLS = [
|
|
34
|
+
"https://api.ipify.org?format=json",
|
|
35
|
+
"https://httpbin.org/ip",
|
|
36
|
+
"https://ifconfig.me/ip",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
_PING_TIMEOUT = 5.0
|
|
40
|
+
_BENCHMARK_CONCURRENT = 200
|
|
41
|
+
_DELETE_CONCURRENT = 50
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# ── Data classes ──────────────────────────────────────────────────────────── #
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@dataclass
|
|
48
|
+
class ProxyBenchmarkResult:
|
|
49
|
+
"""Result of benchmarking a single proxy.
|
|
50
|
+
|
|
51
|
+
Attributes:
|
|
52
|
+
proxy_url: Full proxy URL.
|
|
53
|
+
port_id: ASocks port ID.
|
|
54
|
+
latency_ms: Round-trip latency in milliseconds (``None`` on failure).
|
|
55
|
+
external_ip: External IP seen through the proxy.
|
|
56
|
+
is_alive: Whether the proxy responded successfully.
|
|
57
|
+
country: Country name from port metadata.
|
|
58
|
+
city: City name from port metadata.
|
|
59
|
+
error: Error message on failure.
|
|
60
|
+
"""
|
|
61
|
+
|
|
62
|
+
proxy_url: str
|
|
63
|
+
port_id: int = 0
|
|
64
|
+
latency_ms: float | None = None
|
|
65
|
+
external_ip: str = ""
|
|
66
|
+
is_alive: bool = False
|
|
67
|
+
country: str = ""
|
|
68
|
+
city: str = ""
|
|
69
|
+
error: str = ""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass
|
|
73
|
+
class CountryPingResult:
|
|
74
|
+
"""Aggregated ping statistics for a country/region."""
|
|
75
|
+
|
|
76
|
+
country_name: str = ""
|
|
77
|
+
country_code: str = ""
|
|
78
|
+
avg_latency_ms: float = 0.0
|
|
79
|
+
min_latency_ms: float = 0.0
|
|
80
|
+
max_latency_ms: float = 0.0
|
|
81
|
+
alive_count: int = 0
|
|
82
|
+
total_count: int = 0
|
|
83
|
+
availability: str = ""
|
|
84
|
+
samples: list[ProxyBenchmarkResult] = field(default_factory=list[ProxyBenchmarkResult])
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@dataclass
|
|
88
|
+
class FindBestResult:
|
|
89
|
+
"""Result of the :func:`find_best_proxies` pipeline.
|
|
90
|
+
|
|
91
|
+
Attributes:
|
|
92
|
+
best: Top proxies that passed the benchmark.
|
|
93
|
+
total_created: Number of ports created.
|
|
94
|
+
total_tested: Number of proxies tested.
|
|
95
|
+
total_alive: Number of alive proxies.
|
|
96
|
+
total_deleted: Number of discarded ports deleted.
|
|
97
|
+
delete_errors: Port IDs that failed to delete.
|
|
98
|
+
avg_latency_ms: Average latency of the best proxies.
|
|
99
|
+
min_latency_ms: Best (lowest) latency.
|
|
100
|
+
max_latency_ms: Worst latency among the best.
|
|
101
|
+
"""
|
|
102
|
+
|
|
103
|
+
best: list[ProxyBenchmarkResult] = field(default_factory=list[ProxyBenchmarkResult])
|
|
104
|
+
total_created: int = 0
|
|
105
|
+
total_tested: int = 0
|
|
106
|
+
total_alive: int = 0
|
|
107
|
+
total_deleted: int = 0
|
|
108
|
+
delete_errors: list[int] = field(default_factory=list[int])
|
|
109
|
+
avg_latency_ms: float = 0.0
|
|
110
|
+
min_latency_ms: float = 0.0
|
|
111
|
+
max_latency_ms: float = 0.0
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
# ── Single-proxy ping ────────────────────────────────────────────────────── #
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
@beartype
|
|
118
|
+
async def ping_proxy(
|
|
119
|
+
proxy_url: str,
|
|
120
|
+
*,
|
|
121
|
+
timeout: float = _PING_TIMEOUT,
|
|
122
|
+
ping_urls: list[str] | None = None,
|
|
123
|
+
) -> ProxyBenchmarkResult:
|
|
124
|
+
"""Measure latency of a single proxy.
|
|
125
|
+
|
|
126
|
+
Sends requests to multiple lightweight endpoints in parallel
|
|
127
|
+
and keeps the fastest successful result.
|
|
128
|
+
|
|
129
|
+
Args:
|
|
130
|
+
proxy_url: Full proxy URL.
|
|
131
|
+
timeout: Request timeout in seconds.
|
|
132
|
+
ping_urls: Custom list of check URLs.
|
|
133
|
+
"""
|
|
134
|
+
urls = ping_urls or _PING_URLS
|
|
135
|
+
result = ProxyBenchmarkResult(proxy_url=proxy_url)
|
|
136
|
+
|
|
137
|
+
async def _try_url(url: str) -> tuple[float, str] | None:
|
|
138
|
+
start = time.monotonic()
|
|
139
|
+
try:
|
|
140
|
+
async with httpx.AsyncClient(proxy=proxy_url, timeout=timeout) as client:
|
|
141
|
+
resp = await client.get(url)
|
|
142
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
143
|
+
if resp.is_success:
|
|
144
|
+
ip = ""
|
|
145
|
+
try:
|
|
146
|
+
data = resp.json()
|
|
147
|
+
ip = data.get("ip", data.get("origin", ""))
|
|
148
|
+
except Exception: # noqa: BLE001
|
|
149
|
+
ip = resp.text.strip()
|
|
150
|
+
return elapsed, ip
|
|
151
|
+
except Exception: # noqa: BLE001
|
|
152
|
+
pass
|
|
153
|
+
return None
|
|
154
|
+
|
|
155
|
+
ping_results = await asyncio.gather(*[_try_url(u) for u in urls])
|
|
156
|
+
|
|
157
|
+
best: tuple[float, str] | None = None
|
|
158
|
+
for r in ping_results:
|
|
159
|
+
if r is not None and (best is None or r[0] < best[0]):
|
|
160
|
+
best = r
|
|
161
|
+
|
|
162
|
+
if best is not None:
|
|
163
|
+
result.latency_ms = round(best[0], 1)
|
|
164
|
+
result.is_alive = True
|
|
165
|
+
result.external_ip = best[1]
|
|
166
|
+
else:
|
|
167
|
+
result.error = "All ping endpoints failed"
|
|
168
|
+
|
|
169
|
+
return result
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
# ── Batch benchmark ───────────────────────────────────────────────────────── #
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
@beartype
|
|
176
|
+
async def benchmark_proxies(
|
|
177
|
+
proxy_urls: list[str],
|
|
178
|
+
*,
|
|
179
|
+
timeout: float = _PING_TIMEOUT,
|
|
180
|
+
concurrency: int = _BENCHMARK_CONCURRENT,
|
|
181
|
+
) -> list[ProxyBenchmarkResult]:
|
|
182
|
+
"""Benchmark a batch of proxies in parallel.
|
|
183
|
+
|
|
184
|
+
Returns:
|
|
185
|
+
Results sorted alive-first by ascending latency.
|
|
186
|
+
"""
|
|
187
|
+
semaphore = asyncio.Semaphore(concurrency)
|
|
188
|
+
|
|
189
|
+
async def _bounded_ping(url: str) -> ProxyBenchmarkResult:
|
|
190
|
+
async with semaphore:
|
|
191
|
+
return await ping_proxy(url, timeout=timeout)
|
|
192
|
+
|
|
193
|
+
results = await asyncio.gather(*[_bounded_ping(url) for url in proxy_urls])
|
|
194
|
+
|
|
195
|
+
alive = sorted(
|
|
196
|
+
[r for r in results if r.is_alive],
|
|
197
|
+
key=lambda r: r.latency_ms or float("inf"),
|
|
198
|
+
)
|
|
199
|
+
dead = [r for r in results if not r.is_alive]
|
|
200
|
+
return alive + dead
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
@beartype
|
|
204
|
+
async def select_best_proxies(
|
|
205
|
+
proxy_urls: list[str],
|
|
206
|
+
*,
|
|
207
|
+
keep: int = 10,
|
|
208
|
+
timeout: float = _PING_TIMEOUT,
|
|
209
|
+
concurrency: int = _BENCHMARK_CONCURRENT,
|
|
210
|
+
) -> tuple[list[ProxyBenchmarkResult], list[ProxyBenchmarkResult]]:
|
|
211
|
+
"""Select the fastest proxies by latency.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
``(best, discarded)`` tuple.
|
|
215
|
+
"""
|
|
216
|
+
results = await benchmark_proxies(proxy_urls, timeout=timeout, concurrency=concurrency)
|
|
217
|
+
best = results[:keep]
|
|
218
|
+
discarded = results[keep:]
|
|
219
|
+
logger.info(
|
|
220
|
+
"Selected %d best proxies out of %d (discarded %d)",
|
|
221
|
+
len(best),
|
|
222
|
+
len(results),
|
|
223
|
+
len(discarded),
|
|
224
|
+
)
|
|
225
|
+
return best, discarded
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
# ── Full pipeline ─────────────────────────────────────────────────────────── #
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
@beartype
|
|
232
|
+
async def find_best_proxies(
|
|
233
|
+
client: ASocksClient,
|
|
234
|
+
*,
|
|
235
|
+
country_code: str = "US",
|
|
236
|
+
city: str = "",
|
|
237
|
+
state: str = "",
|
|
238
|
+
total: int = 100,
|
|
239
|
+
keep: int = 10,
|
|
240
|
+
batch_size: int = 1000,
|
|
241
|
+
timeout: float = 5.0,
|
|
242
|
+
concurrency: int = 200,
|
|
243
|
+
type_id: int = 1,
|
|
244
|
+
proxy_type_id: int = 1,
|
|
245
|
+
server_port_type_id: int = 0,
|
|
246
|
+
ttl: int = 1,
|
|
247
|
+
traffic_limit: int = 1,
|
|
248
|
+
delete_failures: bool = True,
|
|
249
|
+
progress_callback: ProgressCallback | None = None,
|
|
250
|
+
) -> FindBestResult:
|
|
251
|
+
"""Create *total* proxies, benchmark them all, keep the fastest *keep*, delete the rest.
|
|
252
|
+
|
|
253
|
+
The entire pipeline is parallelised: creation in batches, benchmark
|
|
254
|
+
with up to *concurrency* simultaneous pings, deletion in parallel.
|
|
255
|
+
|
|
256
|
+
Args:
|
|
257
|
+
client: :class:`ASocksClient` instance.
|
|
258
|
+
country_code: ISO country code.
|
|
259
|
+
total: How many proxies to create.
|
|
260
|
+
keep: How many fastest proxies to keep.
|
|
261
|
+
timeout: Benchmark timeout per proxy.
|
|
262
|
+
concurrency: Maximum concurrent benchmark connections.
|
|
263
|
+
delete_failures: Delete discarded ports (default ``True``).
|
|
264
|
+
progress_callback: ``(stage, current, total) -> None``.
|
|
265
|
+
"""
|
|
266
|
+
result = FindBestResult()
|
|
267
|
+
all_ports: list[tuple[int, str]] = []
|
|
268
|
+
|
|
269
|
+
def _progress(stage: str, current: int, total_n: int) -> None:
|
|
270
|
+
if progress_callback is not None:
|
|
271
|
+
progress_callback(stage, current, total_n)
|
|
272
|
+
|
|
273
|
+
# Step 1: Create proxies in parallel batches
|
|
274
|
+
batches: list[int] = []
|
|
275
|
+
remaining = total
|
|
276
|
+
while remaining > 0:
|
|
277
|
+
chunk = min(remaining, batch_size, 1000)
|
|
278
|
+
batches.append(chunk)
|
|
279
|
+
remaining -= chunk
|
|
280
|
+
|
|
281
|
+
async def _create_batch(chunk: int, batch_num: int) -> list[tuple[int, str]]:
|
|
282
|
+
logger.info("Creating batch %d: %d proxies", batch_num, chunk)
|
|
283
|
+
req = CreatePortRequest(
|
|
284
|
+
country_code=country_code,
|
|
285
|
+
city=city,
|
|
286
|
+
state=state,
|
|
287
|
+
count=chunk,
|
|
288
|
+
type_id=type_id,
|
|
289
|
+
proxy_type_id=proxy_type_id,
|
|
290
|
+
server_port_type_id=server_port_type_id,
|
|
291
|
+
ttl=ttl,
|
|
292
|
+
traffic_limit=traffic_limit,
|
|
293
|
+
)
|
|
294
|
+
try:
|
|
295
|
+
ports = await client.create_ports(req)
|
|
296
|
+
return [(p.id, p.proxy_url) for p in ports]
|
|
297
|
+
except Exception: # noqa: BLE001
|
|
298
|
+
logger.exception("Failed to create batch %d", batch_num)
|
|
299
|
+
return []
|
|
300
|
+
|
|
301
|
+
_progress("creating", 0, total)
|
|
302
|
+
batch_results = await asyncio.gather(
|
|
303
|
+
*[_create_batch(chunk, i + 1) for i, chunk in enumerate(batches)]
|
|
304
|
+
)
|
|
305
|
+
for batch_ports in batch_results:
|
|
306
|
+
all_ports.extend(batch_ports)
|
|
307
|
+
result.total_created = len(all_ports)
|
|
308
|
+
_progress("creating", result.total_created, total)
|
|
309
|
+
|
|
310
|
+
if not all_ports:
|
|
311
|
+
return result
|
|
312
|
+
|
|
313
|
+
# Step 2: Benchmark all proxies
|
|
314
|
+
_progress("benchmarking", 0, len(all_ports))
|
|
315
|
+
url_to_id: dict[str, int] = {url: pid for pid, url in all_ports}
|
|
316
|
+
urls = [url for _, url in all_ports]
|
|
317
|
+
|
|
318
|
+
bench_results = await benchmark_proxies(urls, timeout=timeout, concurrency=concurrency)
|
|
319
|
+
for r in bench_results:
|
|
320
|
+
r.port_id = url_to_id.get(r.proxy_url, 0)
|
|
321
|
+
|
|
322
|
+
result.total_tested = len(bench_results)
|
|
323
|
+
result.total_alive = sum(1 for r in bench_results if r.is_alive)
|
|
324
|
+
_progress("benchmarking", len(bench_results), len(all_ports))
|
|
325
|
+
|
|
326
|
+
# Step 3: Select best
|
|
327
|
+
result.best = bench_results[:keep]
|
|
328
|
+
best_latencies = [r.latency_ms for r in result.best if r.latency_ms is not None]
|
|
329
|
+
if best_latencies:
|
|
330
|
+
result.avg_latency_ms = round(sum(best_latencies) / len(best_latencies), 1)
|
|
331
|
+
result.min_latency_ms = round(min(best_latencies), 1)
|
|
332
|
+
result.max_latency_ms = round(max(best_latencies), 1)
|
|
333
|
+
|
|
334
|
+
best_ids = {r.port_id for r in result.best if r.port_id}
|
|
335
|
+
|
|
336
|
+
# Step 4: Delete discarded ports
|
|
337
|
+
if delete_failures:
|
|
338
|
+
to_delete = [pid for pid, _ in all_ports if pid not in best_ids]
|
|
339
|
+
_progress("deleting", 0, len(to_delete))
|
|
340
|
+
delete_sem = asyncio.Semaphore(_DELETE_CONCURRENT)
|
|
341
|
+
|
|
342
|
+
async def _delete_one(pid: int) -> bool:
|
|
343
|
+
async with delete_sem:
|
|
344
|
+
try:
|
|
345
|
+
await client.delete_port(pid)
|
|
346
|
+
return True
|
|
347
|
+
except Exception: # noqa: BLE001
|
|
348
|
+
return False
|
|
349
|
+
|
|
350
|
+
delete_results = await asyncio.gather(*[_delete_one(pid) for pid in to_delete])
|
|
351
|
+
for pid, ok in zip(to_delete, delete_results, strict=False):
|
|
352
|
+
if ok:
|
|
353
|
+
result.total_deleted += 1
|
|
354
|
+
else:
|
|
355
|
+
result.delete_errors.append(pid)
|
|
356
|
+
_progress("deleting", len(to_delete), len(to_delete))
|
|
357
|
+
|
|
358
|
+
logger.info(
|
|
359
|
+
"find_best_proxies complete: %d best (avg %.0fms), %d deleted, %d errors",
|
|
360
|
+
len(result.best),
|
|
361
|
+
result.avg_latency_ms,
|
|
362
|
+
result.total_deleted,
|
|
363
|
+
len(result.delete_errors),
|
|
364
|
+
)
|
|
365
|
+
return result
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""CLI for ASocks Proxy API.
|
|
2
|
+
|
|
3
|
+
Two-level interface:
|
|
4
|
+
|
|
5
|
+
- **Main commands** (user-facing):
|
|
6
|
+
``wizard``, ``get``, ``balance``, ``list``, ``delete``, ``info``
|
|
7
|
+
- **Raw API** (``asocks api ...``):
|
|
8
|
+
low-level ASocks API wrappers for advanced users.
|
|
9
|
+
|
|
10
|
+
Requires ``ASOCKS_API_KEY`` environment variable.
|
|
11
|
+
|
|
12
|
+
Examples::
|
|
13
|
+
|
|
14
|
+
asocks wizard
|
|
15
|
+
asocks get US
|
|
16
|
+
asocks get DE -n 20
|
|
17
|
+
asocks balance
|
|
18
|
+
asocks list
|
|
19
|
+
asocks delete 12345
|
|
20
|
+
asocks api countries
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import typer
|
|
26
|
+
|
|
27
|
+
from asockslib.cli._helpers import (
|
|
28
|
+
ExportFormat,
|
|
29
|
+
apply_proxy_template,
|
|
30
|
+
format_output,
|
|
31
|
+
get_api_key,
|
|
32
|
+
run_async,
|
|
33
|
+
)
|
|
34
|
+
from asockslib.cli.api_commands import register_api_commands
|
|
35
|
+
from asockslib.cli.commands import register_commands
|
|
36
|
+
|
|
37
|
+
# ── App setup ─────────────────────────────────────────────────────────────── #
|
|
38
|
+
|
|
39
|
+
app = typer.Typer(
|
|
40
|
+
name="asocks",
|
|
41
|
+
help="ASocks Proxy — powerful CLI for proxy management.",
|
|
42
|
+
no_args_is_help=True,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
api_app = typer.Typer(
|
|
46
|
+
name="api",
|
|
47
|
+
help="Raw ASocks API — low-level methods for advanced users.",
|
|
48
|
+
no_args_is_help=True,
|
|
49
|
+
)
|
|
50
|
+
app.add_typer(api_app, name="api")
|
|
51
|
+
|
|
52
|
+
# Register commands from submodules
|
|
53
|
+
register_commands(app)
|
|
54
|
+
register_api_commands(api_app)
|
|
55
|
+
|
|
56
|
+
# Backward-compatible aliases used in tests
|
|
57
|
+
_get_api_key = get_api_key
|
|
58
|
+
_run = run_async
|
|
59
|
+
_format_output = format_output
|
|
60
|
+
_apply_proxy_template = apply_proxy_template
|
|
61
|
+
_copy_to_clipboard = None # Removed; use cli._helpers.copy_to_clipboard
|
|
62
|
+
|
|
63
|
+
__all__ = [
|
|
64
|
+
"ExportFormat",
|
|
65
|
+
"api_app",
|
|
66
|
+
"app",
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
# ── Entry point ───────────────────────────────────────────────────────────── #
|
|
70
|
+
|
|
71
|
+
if __name__ == "__main__":
|
|
72
|
+
app()
|