proxycheck-cli 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.
- proxycheck_cli-0.1.0.dist-info/METADATA +124 -0
- proxycheck_cli-0.1.0.dist-info/RECORD +15 -0
- proxycheck_cli-0.1.0.dist-info/WHEEL +5 -0
- proxycheck_cli-0.1.0.dist-info/entry_points.txt +3 -0
- proxycheck_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- proxycheck_cli-0.1.0.dist-info/top_level.txt +1 -0
- proxychecker/__init__.py +0 -0
- proxychecker/blacklist.py +49 -0
- proxychecker/checker.py +195 -0
- proxychecker/cli.py +153 -0
- proxychecker/enrich.py +113 -0
- proxychecker/models.py +62 -0
- proxychecker/parser.py +95 -0
- proxychecker/report.py +196 -0
- proxychecker/speed.py +33 -0
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: proxycheck-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: CLI tool to check HTTP/HTTPS/SOCKS5 proxies — liveness, latency, exit IP, speed, geo, rotation, blacklist
|
|
5
|
+
Author: proxy-checker contributors
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/anomalyco/Proxy-checker
|
|
8
|
+
Project-URL: Bug Tracker, https://github.com/anomalyco/Proxy-checker/issues
|
|
9
|
+
Project-URL: Source, https://github.com/anomalyco/Proxy-checker
|
|
10
|
+
Keywords: proxy,checker,socks5,http-proxy,cli
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Operating System :: OS Independent
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
18
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
19
|
+
Classifier: Topic :: System :: Networking
|
|
20
|
+
Requires-Python: >=3.11
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
License-File: LICENSE
|
|
23
|
+
Requires-Dist: httpx[socks]>=0.27
|
|
24
|
+
Requires-Dist: typer>=0.12
|
|
25
|
+
Requires-Dist: rich>=13
|
|
26
|
+
Requires-Dist: pydantic>=2
|
|
27
|
+
Dynamic: license-file
|
|
28
|
+
|
|
29
|
+
# proxy-checker
|
|
30
|
+
|
|
31
|
+
CLI tool to check HTTP/HTTPS/SOCKS5 proxies — liveness, latency, exit IP, speed, geo enrichment, rotation detection, and DNSBL blacklist checks.
|
|
32
|
+
|
|
33
|
+

|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
```bash
|
|
38
|
+
pip install proxycheck-cli
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
Requires Python 3.11+.
|
|
42
|
+
|
|
43
|
+
## Quick start
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
# Check proxies from file
|
|
47
|
+
proxycheck check proxies.txt
|
|
48
|
+
|
|
49
|
+
# Single proxy
|
|
50
|
+
proxycheck check http://user:pass@localhost:8888
|
|
51
|
+
|
|
52
|
+
# With geo, speed & blacklist
|
|
53
|
+
proxycheck check proxies.txt --geo --speed --blacklist
|
|
54
|
+
|
|
55
|
+
# Export results
|
|
56
|
+
proxycheck check proxies.txt -o results.json -f json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
## Options
|
|
60
|
+
|
|
61
|
+
| Flag | Default | Description |
|
|
62
|
+
|------|---------|-------------|
|
|
63
|
+
| `SOURCE` | (required) | Proxy file path or inline proxy string |
|
|
64
|
+
| `-c`, `--concurrency` | 50 | Concurrent checks |
|
|
65
|
+
| `-t`, `--timeout` | 10 | Request timeout (s) |
|
|
66
|
+
| `--retries` | 1 | Retries per proxy on network error |
|
|
67
|
+
| `--rotations` | — | Rotation mode — makes N requests to detect exit IP changes |
|
|
68
|
+
| `--speed` | off | Measure download speed (Mbps) |
|
|
69
|
+
| `--geo` / `--no-geo` | on | Enrich with country, city, ASN via ip-api.com |
|
|
70
|
+
| `--expect-country` | — | Expected country code (e.g. US, RU) |
|
|
71
|
+
| `--expect-type` | — | Expected proxy type: residential, datacenter, mobile |
|
|
72
|
+
| `--blacklist` | off | Check exit IPs against public DNSBL blacklists |
|
|
73
|
+
| `-o`, `--output` | — | Export path |
|
|
74
|
+
| `-f`, `--format` | table | Output: table, csv, json |
|
|
75
|
+
| `-q`, `--quiet` | off | Only show final summary |
|
|
76
|
+
|
|
77
|
+
## Metrics per proxy
|
|
78
|
+
|
|
79
|
+
- **Status** — alive / dead
|
|
80
|
+
- **Latency** — response time in ms
|
|
81
|
+
- **Exit IP** — detected exit IP address
|
|
82
|
+
- **Country / City / ASN** — via ip-api.com (when `--geo`)
|
|
83
|
+
- **Proxy type** — residential, datacenter, mobile (when `--geo`)
|
|
84
|
+
- **Speed** — download speed in Mbps (when `--speed`)
|
|
85
|
+
- **Blacklisted** — status per DNSBL provider (when `--blacklist`)
|
|
86
|
+
- **Matches** — ✓ if exit IP matches expected country/type, ✗ if mismatch
|
|
87
|
+
|
|
88
|
+
## Input formats
|
|
89
|
+
|
|
90
|
+
One proxy per line. Comments (`#`) and blank lines are ignored.
|
|
91
|
+
|
|
92
|
+
```
|
|
93
|
+
host:port
|
|
94
|
+
user:pass@host:port
|
|
95
|
+
http://host:port
|
|
96
|
+
http://user:pass@host:port
|
|
97
|
+
https://host:port
|
|
98
|
+
socks5://host:port
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
## Privacy
|
|
102
|
+
|
|
103
|
+
Passwords are masked (`user:***@host:port`) in all output — table, CSV, and JSON.
|
|
104
|
+
|
|
105
|
+
## DNSBL providers
|
|
106
|
+
|
|
107
|
+
- `zen.spamhaus.org`
|
|
108
|
+
- `bl.blocklist.de`
|
|
109
|
+
- `dnsbl.dronebl.org`
|
|
110
|
+
|
|
111
|
+
## Contributing
|
|
112
|
+
|
|
113
|
+
PRs welcome. Run tests before submitting:
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
pip install -e ".[dev]"
|
|
117
|
+
ruff check
|
|
118
|
+
ruff format --check
|
|
119
|
+
python -m pytest
|
|
120
|
+
```
|
|
121
|
+
|
|
122
|
+
## License
|
|
123
|
+
|
|
124
|
+
MIT
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
proxycheck_cli-0.1.0.dist-info/licenses/LICENSE,sha256=noDTdVcAh8fHe3SpxGaodS1Wuc4I1zM6dejbyhN41Oc,1070
|
|
2
|
+
proxychecker/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
3
|
+
proxychecker/blacklist.py,sha256=Vodf0pMg-4_Y9E4puHz55mMPIU7TY8Rne2Ywx_W1d5U,1136
|
|
4
|
+
proxychecker/checker.py,sha256=NSSk7uRbT94l8YAlOaz8QRXlj8cX9uaatz2Fs4pM8iY,6389
|
|
5
|
+
proxychecker/cli.py,sha256=yrUgnH04IiaFTdgigpQOERFZvFGGdJGDCwWaPp_YA4A,4895
|
|
6
|
+
proxychecker/enrich.py,sha256=9Tdo1mi4bHgTunL5qFDYt36qypErbLnrPbsQ4fnXBHA,3218
|
|
7
|
+
proxychecker/models.py,sha256=Ar-D7nMMzC7lJnxheMU1dW3NjQvO52w3DXKDSBhZZVI,1528
|
|
8
|
+
proxychecker/parser.py,sha256=fbRpmslmGmWPaUt7KdlUVygIiypVIG1EQaCOul4Vcto,2527
|
|
9
|
+
proxychecker/report.py,sha256=831baiH5id84Nd8ByTzp0rGl95prKG90F15kUwk2va8,6323
|
|
10
|
+
proxychecker/speed.py,sha256=MJGvfbWRXGmIt-yItslZB1MUSIaHWTNfxxcV6frppQQ,1068
|
|
11
|
+
proxycheck_cli-0.1.0.dist-info/METADATA,sha256=sSACk7qHe7aOugqvFRlVv9iX2Qy_xIfUxt5otcJtj7I,3753
|
|
12
|
+
proxycheck_cli-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
13
|
+
proxycheck_cli-0.1.0.dist-info/entry_points.txt,sha256=SV6f0De33CM3A1B8_3Lj3VNE-FE4fpJP1Sx5VVfTLL8,92
|
|
14
|
+
proxycheck_cli-0.1.0.dist-info/top_level.txt,sha256=jgmWwP7qej_AHt_QNZ1PhNUNKd6h-JlVbFDUMYzj37I,13
|
|
15
|
+
proxycheck_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 proxy-checker
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
proxychecker
|
proxychecker/__init__.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import socket
|
|
3
|
+
|
|
4
|
+
DNSBL_PROVIDERS = [
|
|
5
|
+
"zen.spamhaus.org",
|
|
6
|
+
"bl.blocklist.de",
|
|
7
|
+
"dnsbl.dronebl.org",
|
|
8
|
+
]
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _ip_to_reversed(ip: str) -> str:
|
|
12
|
+
return ".".join(reversed(ip.split(".")))
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _check_single(ip: str, provider: str) -> bool | None:
|
|
16
|
+
hostname = f"{_ip_to_reversed(ip)}.{provider}"
|
|
17
|
+
try:
|
|
18
|
+
socket.getaddrinfo(hostname, 0)
|
|
19
|
+
return True
|
|
20
|
+
except socket.gaierror:
|
|
21
|
+
return False
|
|
22
|
+
except OSError:
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
async def check_blacklist(
|
|
27
|
+
ip: str,
|
|
28
|
+
providers: list[str] | None = None,
|
|
29
|
+
) -> dict[str, bool | None]:
|
|
30
|
+
if providers is None:
|
|
31
|
+
providers = DNSBL_PROVIDERS
|
|
32
|
+
|
|
33
|
+
result: dict[str, bool | None] = {}
|
|
34
|
+
|
|
35
|
+
for provider in providers:
|
|
36
|
+
listed = await asyncio.to_thread(_check_single, ip, provider)
|
|
37
|
+
result[provider] = listed
|
|
38
|
+
|
|
39
|
+
return result
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
async def check_blacklist_many(
|
|
43
|
+
ips: list[str],
|
|
44
|
+
providers: list[str] | None = None,
|
|
45
|
+
) -> dict[str, dict[str, bool | None]]:
|
|
46
|
+
result: dict[str, dict[str, bool | None]] = {}
|
|
47
|
+
for ip in ips:
|
|
48
|
+
result[ip] = await check_blacklist(ip, providers=providers)
|
|
49
|
+
return result
|
proxychecker/checker.py
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
import time
|
|
4
|
+
from collections import Counter
|
|
5
|
+
from statistics import median
|
|
6
|
+
|
|
7
|
+
import httpx
|
|
8
|
+
|
|
9
|
+
from .blacklist import check_blacklist_many
|
|
10
|
+
from .enrich import enrich_ips
|
|
11
|
+
from .models import CheckResult, ParsedProxy, RotationResult
|
|
12
|
+
from .speed import measure_speed
|
|
13
|
+
|
|
14
|
+
IP_SERVICES = [
|
|
15
|
+
"https://api.ipify.org?format=json",
|
|
16
|
+
"https://ifconfig.me/all.json",
|
|
17
|
+
"https://icanhazip.com",
|
|
18
|
+
]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _extract_ip(text: str) -> str | None:
|
|
22
|
+
try:
|
|
23
|
+
data = json.loads(text)
|
|
24
|
+
if isinstance(data, dict):
|
|
25
|
+
for key in ("ip", "ip_addr", "remote_host"):
|
|
26
|
+
if key in data:
|
|
27
|
+
return data[key]
|
|
28
|
+
except (json.JSONDecodeError, TypeError):
|
|
29
|
+
pass
|
|
30
|
+
text = text.strip()
|
|
31
|
+
if text and " " not in text and "\n" not in text:
|
|
32
|
+
return text
|
|
33
|
+
return None
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
async def check_single(
|
|
37
|
+
proxy: ParsedProxy,
|
|
38
|
+
timeout: float = 10,
|
|
39
|
+
retries: int = 1,
|
|
40
|
+
do_speed: bool = False,
|
|
41
|
+
) -> CheckResult:
|
|
42
|
+
latencies: list[float] = []
|
|
43
|
+
exit_ip: str | None = None
|
|
44
|
+
last_error: str | None = None
|
|
45
|
+
proxy_url = proxy.url
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
async with httpx.AsyncClient(proxy=proxy_url, timeout=timeout) as client:
|
|
49
|
+
for service_url in IP_SERVICES:
|
|
50
|
+
attempt_latencies: list[float] = []
|
|
51
|
+
for _ in range(1 + retries):
|
|
52
|
+
try:
|
|
53
|
+
start = time.monotonic()
|
|
54
|
+
resp = await client.get(service_url)
|
|
55
|
+
elapsed = (time.monotonic() - start) * 1000
|
|
56
|
+
if resp.status_code == 200:
|
|
57
|
+
ip = _extract_ip(resp.text)
|
|
58
|
+
if ip:
|
|
59
|
+
exit_ip = ip
|
|
60
|
+
attempt_latencies.append(elapsed)
|
|
61
|
+
if len(attempt_latencies) >= 3:
|
|
62
|
+
break
|
|
63
|
+
else:
|
|
64
|
+
last_error = "proxy error: no IP in response"
|
|
65
|
+
else:
|
|
66
|
+
last_error = f"proxy error: HTTP {resp.status_code}"
|
|
67
|
+
except httpx.ProxyError as e:
|
|
68
|
+
last_error = f"proxy error: {e}"
|
|
69
|
+
except httpx.ConnectError:
|
|
70
|
+
last_error = "connection refused"
|
|
71
|
+
except httpx.ConnectTimeout:
|
|
72
|
+
last_error = "timeout"
|
|
73
|
+
except httpx.RemoteProtocolError:
|
|
74
|
+
last_error = "protocol error"
|
|
75
|
+
except Exception as e:
|
|
76
|
+
last_error = str(e)
|
|
77
|
+
|
|
78
|
+
if attempt_latencies:
|
|
79
|
+
latencies = attempt_latencies
|
|
80
|
+
break
|
|
81
|
+
except Exception as e:
|
|
82
|
+
last_error = str(e)
|
|
83
|
+
|
|
84
|
+
if not latencies:
|
|
85
|
+
return CheckResult(proxy=proxy, status="dead", error=last_error or "all endpoints failed")
|
|
86
|
+
|
|
87
|
+
result = CheckResult(
|
|
88
|
+
proxy=proxy,
|
|
89
|
+
status="alive",
|
|
90
|
+
exit_ip=exit_ip,
|
|
91
|
+
latency_ms=round(median(latencies), 1),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
if do_speed:
|
|
95
|
+
result.speed = await measure_speed(proxy_url)
|
|
96
|
+
|
|
97
|
+
return result
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def check_many(
|
|
101
|
+
proxies: list[ParsedProxy],
|
|
102
|
+
concurrency: int = 50,
|
|
103
|
+
timeout: float = 10,
|
|
104
|
+
retries: int = 1,
|
|
105
|
+
do_speed: bool = False,
|
|
106
|
+
do_geo: bool = True,
|
|
107
|
+
do_blacklist: bool = False,
|
|
108
|
+
expect_country: str | None = None,
|
|
109
|
+
expect_type: str | None = None,
|
|
110
|
+
progress_callback=None,
|
|
111
|
+
) -> list[CheckResult]:
|
|
112
|
+
sem = asyncio.Semaphore(concurrency)
|
|
113
|
+
collected: list[CheckResult] = []
|
|
114
|
+
|
|
115
|
+
async def _check_one(proxy: ParsedProxy) -> CheckResult:
|
|
116
|
+
async with sem:
|
|
117
|
+
result = await check_single(proxy, timeout=timeout, retries=retries, do_speed=do_speed)
|
|
118
|
+
collected.append(result)
|
|
119
|
+
if progress_callback:
|
|
120
|
+
progress_callback(result)
|
|
121
|
+
return result
|
|
122
|
+
|
|
123
|
+
tasks = [_check_one(p) for p in proxies]
|
|
124
|
+
await asyncio.gather(*tasks)
|
|
125
|
+
|
|
126
|
+
if do_geo:
|
|
127
|
+
ips = [r.exit_ip for r in collected if r.exit_ip and r.status == "alive"]
|
|
128
|
+
if ips:
|
|
129
|
+
geo_map = await enrich_ips(list(set(ips)))
|
|
130
|
+
for r in collected:
|
|
131
|
+
if r.exit_ip and r.exit_ip in geo_map:
|
|
132
|
+
r.geo = geo_map[r.exit_ip]
|
|
133
|
+
|
|
134
|
+
for r in collected:
|
|
135
|
+
if r.geo:
|
|
136
|
+
mismatches = []
|
|
137
|
+
if expect_country and r.geo.country_code != expect_country:
|
|
138
|
+
mismatches.append(f"country: expected {expect_country}, got {r.geo.country_code}")
|
|
139
|
+
if expect_type and r.geo.proxy_type != expect_type:
|
|
140
|
+
mismatches.append(f"type: expected {expect_type}, got {r.geo.proxy_type}")
|
|
141
|
+
if mismatches:
|
|
142
|
+
r.mismatch = True
|
|
143
|
+
r.mismatch_reason = "; ".join(mismatches)
|
|
144
|
+
|
|
145
|
+
if do_blacklist:
|
|
146
|
+
ips = list({r.exit_ip for r in collected if r.exit_ip and r.status == "alive"})
|
|
147
|
+
if ips:
|
|
148
|
+
bl_map = await check_blacklist_many(ips)
|
|
149
|
+
for r in collected:
|
|
150
|
+
if r.exit_ip and r.exit_ip in bl_map:
|
|
151
|
+
r.blacklist_results = bl_map[r.exit_ip]
|
|
152
|
+
r.blacklisted = any(v is True for v in bl_map[r.exit_ip].values())
|
|
153
|
+
|
|
154
|
+
return collected
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
async def check_rotation(
|
|
158
|
+
proxy: ParsedProxy,
|
|
159
|
+
rotations: int,
|
|
160
|
+
timeout: float = 10,
|
|
161
|
+
) -> RotationResult:
|
|
162
|
+
exit_ips: list[str] = []
|
|
163
|
+
proxy_url = proxy.url
|
|
164
|
+
|
|
165
|
+
async with httpx.AsyncClient(proxy=proxy_url, timeout=timeout) as client:
|
|
166
|
+
for _ in range(rotations):
|
|
167
|
+
try:
|
|
168
|
+
resp = await client.get(IP_SERVICES[0])
|
|
169
|
+
if resp.status_code == 200:
|
|
170
|
+
ip = _extract_ip(resp.text)
|
|
171
|
+
if ip:
|
|
172
|
+
exit_ips.append(ip)
|
|
173
|
+
except httpx.RequestError:
|
|
174
|
+
pass
|
|
175
|
+
|
|
176
|
+
total_ok = len(exit_ips)
|
|
177
|
+
unique_ips = len(set(exit_ips))
|
|
178
|
+
uniqueness_ratio = unique_ips / total_ok if total_ok else 0.0
|
|
179
|
+
counter = Counter(exit_ips)
|
|
180
|
+
repeats = sum(1 for v in counter.values() if v > 1)
|
|
181
|
+
|
|
182
|
+
if uniqueness_ratio > 0.9:
|
|
183
|
+
behavior = "rotating"
|
|
184
|
+
elif uniqueness_ratio < 0.2:
|
|
185
|
+
behavior = "sticky"
|
|
186
|
+
else:
|
|
187
|
+
behavior = "mixed"
|
|
188
|
+
|
|
189
|
+
return RotationResult(
|
|
190
|
+
total_ok=total_ok,
|
|
191
|
+
unique_ips=unique_ips,
|
|
192
|
+
uniqueness_ratio=round(uniqueness_ratio, 3),
|
|
193
|
+
repeats=repeats,
|
|
194
|
+
behavior=behavior,
|
|
195
|
+
)
|
proxychecker/cli.py
ADDED
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
|
|
3
|
+
import typer
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
from .checker import check_many, check_rotation
|
|
7
|
+
from .parser import parse_source
|
|
8
|
+
from .report import (
|
|
9
|
+
export_csv,
|
|
10
|
+
export_json,
|
|
11
|
+
make_progress,
|
|
12
|
+
print_report,
|
|
13
|
+
print_rotation_report,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
app = typer.Typer(
|
|
17
|
+
name="proxycheck",
|
|
18
|
+
help="Check HTTP/HTTPS/SOCKS5 proxies — liveness, latency, exit IP.",
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
console = Console()
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@app.command("check")
|
|
25
|
+
def check(
|
|
26
|
+
source: str = typer.Argument(..., help="Path to proxy file or inline proxy string"),
|
|
27
|
+
concurrency: int = typer.Option(50, "--concurrency", "-c", help="Number of concurrent checks"),
|
|
28
|
+
timeout: int = typer.Option(10, "--timeout", "-t", help="Request timeout in seconds"),
|
|
29
|
+
retries: int = typer.Option(
|
|
30
|
+
1, "--retries", help="Number of retries per proxy on network error"
|
|
31
|
+
),
|
|
32
|
+
rotations: int = typer.Option(
|
|
33
|
+
None,
|
|
34
|
+
"--rotations",
|
|
35
|
+
help="Rotation mode: make N requests per endpoint to detect IP rotation",
|
|
36
|
+
),
|
|
37
|
+
speed: bool = typer.Option(
|
|
38
|
+
False, "--speed", help="Measure download speed (Mbps) for alive proxies"
|
|
39
|
+
),
|
|
40
|
+
geo: bool = typer.Option(True, "--geo/--no-geo", help="Enrich with country, city, ASN"),
|
|
41
|
+
expect_country: str = typer.Option(
|
|
42
|
+
None, "--expect-country", help="Expected country ISO code (e.g. US, RU)"
|
|
43
|
+
),
|
|
44
|
+
expect_type: str = typer.Option(
|
|
45
|
+
None, "--expect-type", help="Expected proxy type: residential, datacenter, mobile"
|
|
46
|
+
),
|
|
47
|
+
blacklist: bool = typer.Option(
|
|
48
|
+
False, "--blacklist", help="Check exit IPs against public DNSBL blacklists"
|
|
49
|
+
),
|
|
50
|
+
output: str = typer.Option(None, "--output", "-o", help="Export path for results"),
|
|
51
|
+
fmt: str = typer.Option("table", "--format", "-f", help="Output format: table, csv, json"),
|
|
52
|
+
quiet: bool = typer.Option(False, "--quiet", "-q", help="Only show final summary"),
|
|
53
|
+
):
|
|
54
|
+
"""
|
|
55
|
+
Check proxies for liveness, measure latency, and detect exit IP.
|
|
56
|
+
|
|
57
|
+
SOURCE can be a path to a file with one proxy per line, or an inline proxy string.
|
|
58
|
+
|
|
59
|
+
\b
|
|
60
|
+
Examples:
|
|
61
|
+
proxycheck check proxies.txt
|
|
62
|
+
proxycheck check http://localhost:8888
|
|
63
|
+
proxycheck check proxies.txt -c 100 -t 5
|
|
64
|
+
proxycheck check proxies.txt --speed --geo
|
|
65
|
+
proxycheck check http://rotating.proxy:8080 --rotations 30
|
|
66
|
+
proxycheck check proxies.txt -o results.json -f json
|
|
67
|
+
proxycheck check proxies.txt --expect-country US --expect-type residential
|
|
68
|
+
proxycheck check proxies.txt --blacklist --geo
|
|
69
|
+
"""
|
|
70
|
+
proxies, errors = parse_source(source)
|
|
71
|
+
|
|
72
|
+
if not proxies:
|
|
73
|
+
console.print("[red]No valid proxies found.[/red]")
|
|
74
|
+
raise typer.Exit(1)
|
|
75
|
+
|
|
76
|
+
if errors:
|
|
77
|
+
console.print(f"[yellow]Parse errors ({len(errors)}):[/yellow]")
|
|
78
|
+
for e in errors:
|
|
79
|
+
console.print(f" Line {e.line_number}: {e.reason}")
|
|
80
|
+
console.print()
|
|
81
|
+
|
|
82
|
+
if rotations:
|
|
83
|
+
if len(proxies) > 1:
|
|
84
|
+
console.print("[yellow]Rotation mode: only the first proxy will be used.[/yellow]")
|
|
85
|
+
rotation_result = asyncio.run(
|
|
86
|
+
check_rotation(proxies[0], rotations=rotations, timeout=timeout)
|
|
87
|
+
)
|
|
88
|
+
print_rotation_report(proxies[0].masked(), rotation_result)
|
|
89
|
+
return
|
|
90
|
+
|
|
91
|
+
running_results: list = []
|
|
92
|
+
progress = None if quiet else make_progress(len(proxies))
|
|
93
|
+
|
|
94
|
+
def progress_callback(result):
|
|
95
|
+
nonlocal running_results
|
|
96
|
+
running_results.append(result)
|
|
97
|
+
if progress:
|
|
98
|
+
progress.update(progress.task_ids[0], advance=1)
|
|
99
|
+
|
|
100
|
+
async def run():
|
|
101
|
+
return await check_many(
|
|
102
|
+
proxies,
|
|
103
|
+
concurrency=concurrency,
|
|
104
|
+
timeout=timeout,
|
|
105
|
+
retries=retries,
|
|
106
|
+
do_speed=speed,
|
|
107
|
+
do_geo=geo,
|
|
108
|
+
do_blacklist=blacklist,
|
|
109
|
+
expect_country=expect_country,
|
|
110
|
+
expect_type=expect_type,
|
|
111
|
+
progress_callback=progress_callback,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
results: list = []
|
|
115
|
+
try:
|
|
116
|
+
if quiet:
|
|
117
|
+
results = asyncio.run(run())
|
|
118
|
+
else:
|
|
119
|
+
with progress:
|
|
120
|
+
results = asyncio.run(run())
|
|
121
|
+
except KeyboardInterrupt:
|
|
122
|
+
console.print("\n[yellow]Interrupted. Showing partial results...[/yellow]")
|
|
123
|
+
results = list(running_results)
|
|
124
|
+
if not results:
|
|
125
|
+
console.print("No results collected yet.")
|
|
126
|
+
raise typer.Exit(0)
|
|
127
|
+
|
|
128
|
+
if fmt == "csv":
|
|
129
|
+
out = export_csv(results)
|
|
130
|
+
elif fmt == "json":
|
|
131
|
+
out = export_json(results)
|
|
132
|
+
else:
|
|
133
|
+
out = None
|
|
134
|
+
|
|
135
|
+
if out:
|
|
136
|
+
if output:
|
|
137
|
+
with open(output, "w", encoding="utf-8") as f:
|
|
138
|
+
f.write(out)
|
|
139
|
+
console.print(f"[green]Exported to {output}[/green]")
|
|
140
|
+
else:
|
|
141
|
+
console.print(out)
|
|
142
|
+
else:
|
|
143
|
+
print_report(results)
|
|
144
|
+
|
|
145
|
+
if output and fmt == "table":
|
|
146
|
+
print_report(results)
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def main():
|
|
150
|
+
import typer.main
|
|
151
|
+
|
|
152
|
+
group = typer.main.get_group(app)
|
|
153
|
+
group.main()
|
proxychecker/enrich.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
import httpx
|
|
5
|
+
|
|
6
|
+
from .models import GeoData
|
|
7
|
+
|
|
8
|
+
BATCH_URL = "http://ip-api.com/batch"
|
|
9
|
+
FIELDS = "status,message,country,countryCode,city,as,org,mobile,proxy,hosting,query"
|
|
10
|
+
BATCH_LIMIT = 100
|
|
11
|
+
RATE_LIMIT = 15 # requests per minute
|
|
12
|
+
MIN_INTERVAL = 60.0 / RATE_LIMIT
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _detect_proxy_type(data: dict) -> str:
|
|
16
|
+
if data.get("hosting"):
|
|
17
|
+
return "datacenter"
|
|
18
|
+
if data.get("mobile"):
|
|
19
|
+
return "mobile"
|
|
20
|
+
if data.get("proxy"):
|
|
21
|
+
return "residential"
|
|
22
|
+
return "residential"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def _parse_record(record: dict) -> GeoData | None:
|
|
26
|
+
if record.get("status") != "success":
|
|
27
|
+
return None
|
|
28
|
+
return GeoData(
|
|
29
|
+
country=record.get("country", "unknown"),
|
|
30
|
+
country_code=record.get("countryCode", "unknown"),
|
|
31
|
+
city=record.get("city", "unknown"),
|
|
32
|
+
org=record.get("org", "unknown"),
|
|
33
|
+
asn=record.get("as", "unknown"),
|
|
34
|
+
proxy_type=_detect_proxy_type(record),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
async def enrich_ips(
|
|
39
|
+
ips: list[str],
|
|
40
|
+
client: httpx.AsyncClient | None = None,
|
|
41
|
+
semaphore: asyncio.Semaphore | None = None,
|
|
42
|
+
) -> dict[str, GeoData]:
|
|
43
|
+
if not ips:
|
|
44
|
+
return {}
|
|
45
|
+
|
|
46
|
+
result: dict[str, GeoData] = {}
|
|
47
|
+
own_client = client is None
|
|
48
|
+
own_sem = semaphore is None and not own_client
|
|
49
|
+
|
|
50
|
+
if own_client:
|
|
51
|
+
client = httpx.AsyncClient(timeout=10)
|
|
52
|
+
await client.__aenter__()
|
|
53
|
+
|
|
54
|
+
if own_sem:
|
|
55
|
+
semaphore = asyncio.Semaphore(1)
|
|
56
|
+
|
|
57
|
+
try:
|
|
58
|
+
for i in range(0, len(ips), BATCH_LIMIT):
|
|
59
|
+
batch = ips[i : i + BATCH_LIMIT]
|
|
60
|
+
data = await _enrich_batch(batch, client, semaphore)
|
|
61
|
+
result.update(data)
|
|
62
|
+
finally:
|
|
63
|
+
if own_client:
|
|
64
|
+
await client.__aexit__(None, None, None)
|
|
65
|
+
|
|
66
|
+
return result
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
async def _enrich_batch(
|
|
70
|
+
batch_ips: list[str],
|
|
71
|
+
client: httpx.AsyncClient,
|
|
72
|
+
semaphore: asyncio.Semaphore | None,
|
|
73
|
+
) -> dict[str, GeoData]:
|
|
74
|
+
if semaphore:
|
|
75
|
+
async with semaphore:
|
|
76
|
+
return await _do_batch_request(batch_ips, client)
|
|
77
|
+
return await _do_batch_request(batch_ips, client)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
async def _do_batch_request(
|
|
81
|
+
batch_ips: list[str],
|
|
82
|
+
client: httpx.AsyncClient,
|
|
83
|
+
) -> dict[str, GeoData]:
|
|
84
|
+
result: dict[str, GeoData] = {}
|
|
85
|
+
for attempt in range(3):
|
|
86
|
+
try:
|
|
87
|
+
resp = await client.post(
|
|
88
|
+
BATCH_URL,
|
|
89
|
+
params={"fields": FIELDS},
|
|
90
|
+
content=json.dumps(batch_ips),
|
|
91
|
+
headers={"Content-Type": "application/json"},
|
|
92
|
+
)
|
|
93
|
+
if resp.status_code == 429:
|
|
94
|
+
ttl = int(resp.headers.get("X-Ttl", 5))
|
|
95
|
+
await asyncio.sleep(ttl)
|
|
96
|
+
continue
|
|
97
|
+
if resp.status_code != 200:
|
|
98
|
+
return result
|
|
99
|
+
records = resp.json()
|
|
100
|
+
if not isinstance(records, list):
|
|
101
|
+
return result
|
|
102
|
+
for record in records:
|
|
103
|
+
geo = _parse_record(record)
|
|
104
|
+
ip = record.get("query") or ""
|
|
105
|
+
if geo and ip:
|
|
106
|
+
result[ip] = geo
|
|
107
|
+
return result
|
|
108
|
+
except (httpx.RequestError, json.JSONDecodeError):
|
|
109
|
+
if attempt < 2:
|
|
110
|
+
await asyncio.sleep(1)
|
|
111
|
+
continue
|
|
112
|
+
return result
|
|
113
|
+
return result
|
proxychecker/models.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
from pydantic import BaseModel
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class ProxyCredentials(BaseModel):
|
|
5
|
+
username: str
|
|
6
|
+
password: str
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class ParsedProxy(BaseModel):
|
|
10
|
+
host: str
|
|
11
|
+
port: int
|
|
12
|
+
scheme: str = "http"
|
|
13
|
+
credentials: ProxyCredentials | None = None
|
|
14
|
+
|
|
15
|
+
def masked(self) -> str:
|
|
16
|
+
if self.credentials:
|
|
17
|
+
return f"{self.credentials.username}:***@{self.host}:{self.port}"
|
|
18
|
+
return f"{self.host}:{self.port}"
|
|
19
|
+
|
|
20
|
+
@property
|
|
21
|
+
def url(self) -> str:
|
|
22
|
+
auth = ""
|
|
23
|
+
if self.credentials:
|
|
24
|
+
auth = f"{self.credentials.username}:{self.credentials.password}@"
|
|
25
|
+
return f"{self.scheme}://{auth}{self.host}:{self.port}"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class GeoData(BaseModel):
|
|
29
|
+
country: str = "unknown"
|
|
30
|
+
country_code: str = "unknown"
|
|
31
|
+
city: str = "unknown"
|
|
32
|
+
org: str = "unknown"
|
|
33
|
+
asn: str = "unknown"
|
|
34
|
+
proxy_type: str = "unknown"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SpeedResult(BaseModel):
|
|
38
|
+
bps: float | None = None
|
|
39
|
+
elapsed_s: float | None = None
|
|
40
|
+
error: str | None = None
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class RotationResult(BaseModel):
|
|
44
|
+
total_ok: int = 0
|
|
45
|
+
unique_ips: int = 0
|
|
46
|
+
uniqueness_ratio: float = 0.0
|
|
47
|
+
repeats: int = 0
|
|
48
|
+
behavior: str = "unknown"
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class CheckResult(BaseModel):
|
|
52
|
+
proxy: ParsedProxy
|
|
53
|
+
status: str
|
|
54
|
+
exit_ip: str | None = None
|
|
55
|
+
latency_ms: float | None = None
|
|
56
|
+
error: str | None = None
|
|
57
|
+
geo: GeoData | None = None
|
|
58
|
+
speed: SpeedResult | None = None
|
|
59
|
+
mismatch: bool = False
|
|
60
|
+
mismatch_reason: str | None = None
|
|
61
|
+
blacklisted: bool = False
|
|
62
|
+
blacklist_results: dict[str, bool | None] | None = None
|
proxychecker/parser.py
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import re
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
from urllib.parse import urlparse
|
|
5
|
+
|
|
6
|
+
from .models import ParsedProxy, ProxyCredentials
|
|
7
|
+
|
|
8
|
+
RE_COLON_FORMAT = re.compile(r"^(.+):(\d+):(.+):(.+)$")
|
|
9
|
+
RE_AT_FORMAT = re.compile(r"^(.+):(.+)@(.+):(\d+)$")
|
|
10
|
+
RE_HOST_PORT = re.compile(r"^(.+):(\d+)$")
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass
|
|
14
|
+
class ParseError:
|
|
15
|
+
line_number: int
|
|
16
|
+
raw: str
|
|
17
|
+
reason: str
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def parse_line(raw: str) -> ParsedProxy | None:
|
|
21
|
+
stripped = raw.strip()
|
|
22
|
+
if not stripped or stripped.startswith("#"):
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
if "://" in stripped:
|
|
26
|
+
parsed = urlparse(stripped)
|
|
27
|
+
host = parsed.hostname
|
|
28
|
+
port = parsed.port
|
|
29
|
+
if not host or not port:
|
|
30
|
+
raise ValueError(f"Invalid proxy URL: {stripped}")
|
|
31
|
+
scheme = parsed.scheme or "http"
|
|
32
|
+
credentials = None
|
|
33
|
+
if parsed.username:
|
|
34
|
+
credentials = ProxyCredentials(
|
|
35
|
+
username=parsed.username,
|
|
36
|
+
password=parsed.password or "",
|
|
37
|
+
)
|
|
38
|
+
return ParsedProxy(
|
|
39
|
+
host=host,
|
|
40
|
+
port=port,
|
|
41
|
+
scheme=scheme,
|
|
42
|
+
credentials=credentials,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
m = RE_AT_FORMAT.match(stripped)
|
|
46
|
+
if m:
|
|
47
|
+
return ParsedProxy(
|
|
48
|
+
host=m.group(3),
|
|
49
|
+
port=int(m.group(4)),
|
|
50
|
+
credentials=ProxyCredentials(
|
|
51
|
+
username=m.group(1),
|
|
52
|
+
password=m.group(2),
|
|
53
|
+
),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
m = RE_COLON_FORMAT.match(stripped)
|
|
57
|
+
if m:
|
|
58
|
+
return ParsedProxy(
|
|
59
|
+
host=m.group(1),
|
|
60
|
+
port=int(m.group(2)),
|
|
61
|
+
credentials=ProxyCredentials(
|
|
62
|
+
username=m.group(3),
|
|
63
|
+
password=m.group(4),
|
|
64
|
+
),
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
m = RE_HOST_PORT.match(stripped)
|
|
68
|
+
if m:
|
|
69
|
+
return ParsedProxy(
|
|
70
|
+
host=m.group(1),
|
|
71
|
+
port=int(m.group(2)),
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
raise ValueError(f"Unrecognized format: {stripped}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def parse_source(source: str) -> tuple[list[ParsedProxy], list[ParseError]]:
|
|
78
|
+
if os.path.isfile(source):
|
|
79
|
+
with open(source, encoding="utf-8") as f:
|
|
80
|
+
lines = f.readlines()
|
|
81
|
+
else:
|
|
82
|
+
lines = source.split("\n")
|
|
83
|
+
|
|
84
|
+
proxies: list[ParsedProxy] = []
|
|
85
|
+
errors: list[ParseError] = []
|
|
86
|
+
|
|
87
|
+
for i, line in enumerate(lines, 1):
|
|
88
|
+
try:
|
|
89
|
+
result = parse_line(line)
|
|
90
|
+
if result is not None:
|
|
91
|
+
proxies.append(result)
|
|
92
|
+
except ValueError as e:
|
|
93
|
+
errors.append(ParseError(line_number=i, raw=line.rstrip("\n\r"), reason=str(e)))
|
|
94
|
+
|
|
95
|
+
return proxies, errors
|
proxychecker/report.py
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import csv
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.progress import BarColumn, Progress, SpinnerColumn, TaskProgressColumn, TextColumn
|
|
7
|
+
from rich.table import Table
|
|
8
|
+
|
|
9
|
+
from .models import CheckResult, RotationResult, SpeedResult
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _alive_style(r: CheckResult) -> str:
|
|
13
|
+
if r.blacklisted:
|
|
14
|
+
return "yellow"
|
|
15
|
+
if r.mismatch:
|
|
16
|
+
return "yellow"
|
|
17
|
+
return "green" if r.status == "alive" else "red"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def build_table(results: list[CheckResult]) -> Table:
|
|
21
|
+
table = Table(title="Proxy Check Results")
|
|
22
|
+
table.add_column("#", style="dim")
|
|
23
|
+
table.add_column("Proxy")
|
|
24
|
+
table.add_column("Status")
|
|
25
|
+
table.add_column("Exit IP")
|
|
26
|
+
table.add_column("Latency")
|
|
27
|
+
table.add_column("Speed")
|
|
28
|
+
table.add_column("Country")
|
|
29
|
+
table.add_column("Type")
|
|
30
|
+
table.add_column("Flags")
|
|
31
|
+
|
|
32
|
+
for i, r in enumerate(results, 1):
|
|
33
|
+
latency = f"{r.latency_ms:.0f} ms" if r.latency_ms else "—"
|
|
34
|
+
speed = _format_speed(r.speed) if r.speed else "—"
|
|
35
|
+
country = r.geo.country if r.geo else "—"
|
|
36
|
+
ptype = r.geo.proxy_type if r.geo else "—"
|
|
37
|
+
flags = ""
|
|
38
|
+
if r.blacklisted:
|
|
39
|
+
flags = "blacklisted"
|
|
40
|
+
elif r.mismatch:
|
|
41
|
+
flags = "mismatch"
|
|
42
|
+
elif r.status == "alive":
|
|
43
|
+
flags = "ok"
|
|
44
|
+
table.add_row(
|
|
45
|
+
str(i),
|
|
46
|
+
r.proxy.masked(),
|
|
47
|
+
r.status,
|
|
48
|
+
r.exit_ip or "—",
|
|
49
|
+
latency,
|
|
50
|
+
speed,
|
|
51
|
+
country,
|
|
52
|
+
ptype,
|
|
53
|
+
flags,
|
|
54
|
+
style=_alive_style(r),
|
|
55
|
+
)
|
|
56
|
+
return table
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _format_speed(speed) -> str:
|
|
60
|
+
if speed.bps is None:
|
|
61
|
+
return "—"
|
|
62
|
+
bps = speed.bps
|
|
63
|
+
if bps >= 1_000_000:
|
|
64
|
+
return f"{bps / 1_000_000:.1f} Mbps"
|
|
65
|
+
if bps >= 1_000:
|
|
66
|
+
return f"{bps / 1_000:.0f} Kbps"
|
|
67
|
+
return f"{bps:.0f} bps"
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def print_rotation_report(
|
|
71
|
+
proxy_masked: str,
|
|
72
|
+
rotation: RotationResult,
|
|
73
|
+
):
|
|
74
|
+
console = Console()
|
|
75
|
+
console.print(f"\nRotation results for {proxy_masked}")
|
|
76
|
+
console.print(f" Successful: {rotation.total_ok}")
|
|
77
|
+
console.print(f" Unique IPs: {rotation.unique_ips}")
|
|
78
|
+
console.print(f" Uniqueness: {rotation.uniqueness_ratio:.1%}")
|
|
79
|
+
console.print(f" Repeats: {rotation.repeats}")
|
|
80
|
+
console.print(f" Behavior: {rotation.behavior}")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def print_report(results: list[CheckResult]):
|
|
84
|
+
console = Console()
|
|
85
|
+
table = build_table(results)
|
|
86
|
+
console.print(table)
|
|
87
|
+
|
|
88
|
+
alive = [r for r in results if r.status == "alive"]
|
|
89
|
+
if alive:
|
|
90
|
+
latencies = sorted(r.latency_ms for r in alive if r.latency_ms is not None)
|
|
91
|
+
median_lat = latencies[len(latencies) // 2] if latencies else 0
|
|
92
|
+
speeds = [r.speed.bps for r in alive if r.speed and r.speed.bps is not None]
|
|
93
|
+
median_speed = sorted(speeds)[len(speeds) // 2] if speeds else 0
|
|
94
|
+
mismatches = [r for r in results if r.mismatch]
|
|
95
|
+
datacenters = [r for r in alive if r.geo and r.geo.proxy_type == "datacenter"]
|
|
96
|
+
|
|
97
|
+
console.print(f"\nChecked {len(results)} proxies")
|
|
98
|
+
pct = len(alive) / len(results) * 100
|
|
99
|
+
console.print(f"\nAlive: {len(alive)} / {len(results)} ({pct:.1f}%)")
|
|
100
|
+
unique_ips = len({r.exit_ip for r in alive if r.exit_ip})
|
|
101
|
+
console.print(f"Unique exit IPs: {unique_ips}")
|
|
102
|
+
if latencies:
|
|
103
|
+
console.print(f"Median latency: {median_lat:.0f} ms")
|
|
104
|
+
if speeds:
|
|
105
|
+
console.print(f"Median speed: {_format_speed(SpeedResult(bps=median_speed))}")
|
|
106
|
+
if mismatches:
|
|
107
|
+
console.print(f"Geo mismatches: {len(mismatches)}")
|
|
108
|
+
if datacenters:
|
|
109
|
+
console.print(f"Datacenter IPs: {len(datacenters)}")
|
|
110
|
+
blacklisted_count = len([r for r in results if r.blacklisted])
|
|
111
|
+
if blacklisted_count:
|
|
112
|
+
console.print(f"Blacklisted: {blacklisted_count}")
|
|
113
|
+
else:
|
|
114
|
+
console.print(f"\nAlive: 0 / {len(results)} (0%)")
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def export_csv(results: list[CheckResult]) -> str:
|
|
118
|
+
output = io.StringIO()
|
|
119
|
+
writer = csv.writer(output)
|
|
120
|
+
writer.writerow(
|
|
121
|
+
[
|
|
122
|
+
"proxy",
|
|
123
|
+
"status",
|
|
124
|
+
"exit_ip",
|
|
125
|
+
"latency_ms",
|
|
126
|
+
"speed_bps",
|
|
127
|
+
"country",
|
|
128
|
+
"country_code",
|
|
129
|
+
"city",
|
|
130
|
+
"org",
|
|
131
|
+
"asn",
|
|
132
|
+
"proxy_type",
|
|
133
|
+
"mismatch",
|
|
134
|
+
"mismatch_reason",
|
|
135
|
+
"blacklisted",
|
|
136
|
+
"error",
|
|
137
|
+
]
|
|
138
|
+
)
|
|
139
|
+
for r in results:
|
|
140
|
+
writer.writerow(
|
|
141
|
+
[
|
|
142
|
+
r.proxy.masked(),
|
|
143
|
+
r.status,
|
|
144
|
+
r.exit_ip or "",
|
|
145
|
+
r.latency_ms or "",
|
|
146
|
+
r.speed.bps if r.speed and r.speed.bps else "",
|
|
147
|
+
r.geo.country if r.geo else "",
|
|
148
|
+
r.geo.country_code if r.geo else "",
|
|
149
|
+
r.geo.city if r.geo else "",
|
|
150
|
+
r.geo.org if r.geo else "",
|
|
151
|
+
r.geo.asn if r.geo else "",
|
|
152
|
+
r.geo.proxy_type if r.geo else "",
|
|
153
|
+
"yes" if r.mismatch else "",
|
|
154
|
+
r.mismatch_reason or "",
|
|
155
|
+
"yes" if r.blacklisted else "",
|
|
156
|
+
r.error or "",
|
|
157
|
+
]
|
|
158
|
+
)
|
|
159
|
+
return output.getvalue()
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def export_json(results: list[CheckResult]) -> str:
|
|
163
|
+
data = []
|
|
164
|
+
for r in results:
|
|
165
|
+
entry = {
|
|
166
|
+
"proxy": r.proxy.masked(),
|
|
167
|
+
"status": r.status,
|
|
168
|
+
"exit_ip": r.exit_ip,
|
|
169
|
+
"latency_ms": r.latency_ms,
|
|
170
|
+
"error": r.error,
|
|
171
|
+
}
|
|
172
|
+
if r.geo:
|
|
173
|
+
entry["geo"] = r.geo.model_dump()
|
|
174
|
+
if r.speed and r.speed.bps:
|
|
175
|
+
entry["speed_bps"] = r.speed.bps
|
|
176
|
+
if r.mismatch:
|
|
177
|
+
entry["mismatch"] = True
|
|
178
|
+
entry["mismatch_reason"] = r.mismatch_reason
|
|
179
|
+
if r.blacklisted:
|
|
180
|
+
entry["blacklisted"] = True
|
|
181
|
+
entry["blacklist_providers"] = [
|
|
182
|
+
k for k, v in (r.blacklist_results or {}).items() if v is True
|
|
183
|
+
]
|
|
184
|
+
data.append(entry)
|
|
185
|
+
return json.dumps(data, indent=2, ensure_ascii=False)
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def make_progress(total: int) -> Progress:
|
|
189
|
+
progress = Progress(
|
|
190
|
+
SpinnerColumn(),
|
|
191
|
+
TextColumn("[progress.description]{task.description}"),
|
|
192
|
+
BarColumn(),
|
|
193
|
+
TaskProgressColumn(),
|
|
194
|
+
)
|
|
195
|
+
progress.add_task("Checking proxies...", total=total)
|
|
196
|
+
return progress
|
proxychecker/speed.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import time
|
|
2
|
+
|
|
3
|
+
import httpx
|
|
4
|
+
|
|
5
|
+
from .models import SpeedResult
|
|
6
|
+
|
|
7
|
+
SPEED_TEST_URL = "http://speedtest.tele2.net/1MB.zip"
|
|
8
|
+
SPEED_TIMEOUT = 30
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
async def measure_speed(
|
|
12
|
+
proxy_url: str,
|
|
13
|
+
timeout: float = SPEED_TIMEOUT,
|
|
14
|
+
) -> SpeedResult:
|
|
15
|
+
try:
|
|
16
|
+
async with httpx.AsyncClient(proxy=proxy_url, timeout=timeout) as client:
|
|
17
|
+
start = time.monotonic()
|
|
18
|
+
resp = await client.get(SPEED_TEST_URL)
|
|
19
|
+
elapsed = time.monotonic() - start
|
|
20
|
+
if resp.status_code != 200:
|
|
21
|
+
return SpeedResult(error=f"HTTP {resp.status_code}")
|
|
22
|
+
data = resp.content
|
|
23
|
+
bits = len(data) * 8
|
|
24
|
+
bps = bits / elapsed
|
|
25
|
+
return SpeedResult(bps=round(bps, 0), elapsed_s=round(elapsed, 2))
|
|
26
|
+
except httpx.TimeoutException:
|
|
27
|
+
return SpeedResult(error="timeout")
|
|
28
|
+
except httpx.ProxyError as e:
|
|
29
|
+
return SpeedResult(error=f"proxy error: {e}")
|
|
30
|
+
except httpx.ConnectError:
|
|
31
|
+
return SpeedResult(error="connection refused")
|
|
32
|
+
except Exception as e:
|
|
33
|
+
return SpeedResult(error=str(e))
|