proxyprobe 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.
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: proxyprobe
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Check a list of proxies for liveness, latency, exit IP and anonymity.
|
|
5
|
+
Author-email: Roam <dev@roamproxy.com>
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/roamproxy/proxyprobe
|
|
8
|
+
Project-URL: Issues, https://github.com/roamproxy/proxyprobe/issues
|
|
9
|
+
Keywords: proxy,proxies,proxy-checker,socks5,anonymity,scraping
|
|
10
|
+
Classifier: Development Status :: 4 - Beta
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Internet :: Proxy Servers
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.9
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Requires-Dist: httpx[socks]>=0.24
|
|
21
|
+
Dynamic: license-file
|
|
22
|
+
|
|
23
|
+
# proxyprobe
|
|
24
|
+
|
|
25
|
+
Check a list of proxies for **liveness, latency, exit IP and anonymity** — one file, one dependency.
|
|
26
|
+
|
|
27
|
+
```console
|
|
28
|
+
$ proxyprobe proxies.txt --geo
|
|
29
|
+
PROXY OK MS EXIT IP ANONYMITY LOCATION ERROR
|
|
30
|
+
--------------------------------- --- ---- --------------- ----------- -------------- ---------------------
|
|
31
|
+
http://user:***@gw.example.com:80 yes 312 203.0.113.44 elite Tokyo Japan
|
|
32
|
+
socks5://198.51.100.7:1080 yes 1180 198.51.100.7 transparent Frankfurt Germany
|
|
33
|
+
http://10.0.0.9:3128 no - - - ConnectTimeout: timed out
|
|
34
|
+
|
|
35
|
+
2/3 working | 312ms median | 1 elite, 1 transparent
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Most "proxy checker" scripts only tell you whether a connection opened. That is the
|
|
39
|
+
least interesting thing about a proxy. proxyprobe also answers the questions that
|
|
40
|
+
actually decide whether a proxy is usable:
|
|
41
|
+
|
|
42
|
+
- **Does it leak my real IP?** Graded `transparent` / `anonymous` / `elite` by comparing
|
|
43
|
+
your direct IP against what the proxy forwards, and by inspecting `Via`,
|
|
44
|
+
`X-Forwarded-For` and friends.
|
|
45
|
+
- **Where does it actually exit?** The advertised location and the real one often differ.
|
|
46
|
+
- **How slow is it really?** Measured on a full request, not a TCP handshake.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
pip install "httpx[socks]"
|
|
52
|
+
curl -O https://raw.githubusercontent.com/roamproxy/proxyprobe/main/proxyprobe.py
|
|
53
|
+
python proxyprobe.py proxies.txt
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
It is a single file with no packaging required. Drop it in a repo, a container, or a
|
|
57
|
+
CI job and run it.
|
|
58
|
+
|
|
59
|
+
## Usage
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
proxyprobe proxies.txt # table output
|
|
63
|
+
proxyprobe proxies.txt --json # machine readable
|
|
64
|
+
proxyprobe proxies.txt --geo # add country/city
|
|
65
|
+
proxyprobe proxies.txt --working-only # drop the dead ones
|
|
66
|
+
cat proxies.txt | proxyprobe - # read stdin
|
|
67
|
+
proxyprobe proxies.txt -c 100 -t 5 # 100 in parallel, 5s timeout
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Proxy list format — one per line, `#` comments and blank lines ignored:
|
|
71
|
+
|
|
72
|
+
```
|
|
73
|
+
http://user:pass@host:8080
|
|
74
|
+
socks5://host:1080
|
|
75
|
+
host:8080 # scheme defaults to http
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
### Options
|
|
79
|
+
|
|
80
|
+
| Flag | Description |
|
|
81
|
+
| --- | --- |
|
|
82
|
+
| `-c, --concurrency` | Parallel checks (default 20) |
|
|
83
|
+
| `-t, --timeout` | Per-request timeout in seconds (default 10) |
|
|
84
|
+
| `--json` | JSON instead of a table |
|
|
85
|
+
| `--geo` | Look up country/city (rate limited, so opt-in) |
|
|
86
|
+
| `--working-only` | Only output proxies that responded |
|
|
87
|
+
| `--echo-url` | IP echo endpoint (default `api.ipify.org`) |
|
|
88
|
+
| `--headers-url` | Header echo endpoint used for anonymity grading |
|
|
89
|
+
| `--geo-url` | Geo lookup template, must contain `{ip}` |
|
|
90
|
+
| `-q, --quiet` | Suppress the progress counter |
|
|
91
|
+
|
|
92
|
+
Exit code is `0` if at least one proxy worked, `1` if none did — so it drops straight
|
|
93
|
+
into CI or a shell pipeline:
|
|
94
|
+
|
|
95
|
+
```bash
|
|
96
|
+
proxyprobe proxies.txt --working-only --json > alive.json || echo "all proxies down"
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
## Anonymity grades
|
|
100
|
+
|
|
101
|
+
| Grade | Meaning |
|
|
102
|
+
| --- | --- |
|
|
103
|
+
| `elite` | Request looks like an ordinary direct request |
|
|
104
|
+
| `anonymous` | Real IP hidden, but headers announce a proxy is in use |
|
|
105
|
+
| `transparent` | Your real IP is visible through the proxy |
|
|
106
|
+
|
|
107
|
+
Grading needs a baseline, so proxyprobe first fetches your direct IP once. If that
|
|
108
|
+
fails it degrades safely: a proxy is never labelled `transparent` without a baseline
|
|
109
|
+
to compare against.
|
|
110
|
+
|
|
111
|
+
## Passwords are masked
|
|
112
|
+
|
|
113
|
+
Credentials are stripped from every output path, so results are safe to paste into an
|
|
114
|
+
issue or a CI log:
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
http://user:hunter2@host:80 -> http://user:***@host:80
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Passwords containing `@` and `:` are handled correctly.
|
|
121
|
+
|
|
122
|
+
## Privacy
|
|
123
|
+
|
|
124
|
+
By default proxyprobe talks to `api.ipify.org` (IP echo), `httpbin.org` (header echo)
|
|
125
|
+
and, only with `--geo`, `ip-api.com`. All three are overridable — point them at your
|
|
126
|
+
own endpoints if you would rather not route a proxy list through third parties:
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
proxyprobe proxies.txt --echo-url https://your-host/ip --headers-url https://your-host/headers
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
Nothing else is sent anywhere. There is no telemetry.
|
|
133
|
+
|
|
134
|
+
## Development
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
pip install "httpx[socks]" pytest
|
|
138
|
+
pytest -q
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
## License
|
|
142
|
+
|
|
143
|
+
MIT — see [LICENSE](LICENSE).
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
Maintained by [Roam](https://roamproxy.com), a residential and datacenter proxy
|
|
148
|
+
provider. proxyprobe works with any proxy from any provider, and always will —
|
|
149
|
+
we built it because we needed it ourselves.
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
proxyprobe.py,sha256=Y_z1FA9o77b4RDJJdIM9hHLC6uUQkyBtzzgecgw84QI,11436
|
|
2
|
+
proxyprobe-0.1.0.dist-info/licenses/LICENSE,sha256=FWGui2vySRCq4U2DFugs1fHEfAVF7bkgdYCHhoPr5rY,1077
|
|
3
|
+
proxyprobe-0.1.0.dist-info/METADATA,sha256=pF4YDB7kOyqXuPv-a7uF11cvJFNNcr8YhmDRSAE0Rt4,5221
|
|
4
|
+
proxyprobe-0.1.0.dist-info/WHEEL,sha256=K260EYznzXsJYBQGqmI8VTxEdiZYNvDZwW9cBh9-_MA,91
|
|
5
|
+
proxyprobe-0.1.0.dist-info/entry_points.txt,sha256=9IniX9MAiglcG4G_J0ujGEOhtxdJ67KUMVe68leZ3ZQ,47
|
|
6
|
+
proxyprobe-0.1.0.dist-info/top_level.txt,sha256=UStJspnoCSLg2LsA09gMJOM6hwjIpwAOXPpIA81NvcY,11
|
|
7
|
+
proxyprobe-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Goldkeep Digital LLC
|
|
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
|
+
proxyprobe
|
proxyprobe.py
ADDED
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""proxyprobe - check a list of proxies for liveness, latency, exit IP and anonymity.
|
|
3
|
+
|
|
4
|
+
Single file, one dependency (httpx). Run it directly or install it:
|
|
5
|
+
|
|
6
|
+
python proxyprobe.py proxies.txt
|
|
7
|
+
proxyprobe proxies.txt --json
|
|
8
|
+
|
|
9
|
+
Proxy list format, one per line (blank lines and # comments are ignored):
|
|
10
|
+
|
|
11
|
+
http://user:pass@host:8080
|
|
12
|
+
socks5://host:1080
|
|
13
|
+
host:8080 # scheme defaults to http
|
|
14
|
+
|
|
15
|
+
MIT licensed. https://github.com/roamproxy/proxyprobe
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import asyncio
|
|
21
|
+
import json
|
|
22
|
+
import re
|
|
23
|
+
import sys
|
|
24
|
+
import time
|
|
25
|
+
from dataclasses import dataclass, asdict, field
|
|
26
|
+
from typing import Iterable, Sequence
|
|
27
|
+
|
|
28
|
+
try:
|
|
29
|
+
import httpx
|
|
30
|
+
except ImportError: # pragma: no cover - only hit when dependency is missing
|
|
31
|
+
sys.exit("proxyprobe needs httpx. Install it with: pip install 'httpx[socks]'")
|
|
32
|
+
|
|
33
|
+
__version__ = "0.1.0"
|
|
34
|
+
|
|
35
|
+
# Endpoint that echoes the caller's IP as plain text. Kept deliberately neutral and
|
|
36
|
+
# overridable (--echo-url) so nobody has to route their proxy list through a host
|
|
37
|
+
# they don't trust.
|
|
38
|
+
DEFAULT_ECHO_URL = "https://api.ipify.org"
|
|
39
|
+
# Echoes the request headers back as JSON, used to grade anonymity.
|
|
40
|
+
DEFAULT_HEADERS_URL = "https://httpbin.org/headers"
|
|
41
|
+
# Free, key-less geo lookup. Rate limited, so it is opt-in via --geo.
|
|
42
|
+
DEFAULT_GEO_URL = "http://ip-api.com/json/{ip}?fields=status,country,countryCode,city"
|
|
43
|
+
|
|
44
|
+
SCHEMES = ("http://", "https://", "socks5://", "socks5h://", "socks4://")
|
|
45
|
+
|
|
46
|
+
# Headers that leak the fact a proxy is in play, or the client behind it.
|
|
47
|
+
PROXY_HEADERS = (
|
|
48
|
+
"via",
|
|
49
|
+
"x-forwarded-for",
|
|
50
|
+
"x-real-ip",
|
|
51
|
+
"forwarded",
|
|
52
|
+
"proxy-connection",
|
|
53
|
+
"x-proxy-id",
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Result:
|
|
59
|
+
proxy: str # password-masked, safe to print or log
|
|
60
|
+
ok: bool = False
|
|
61
|
+
latency_ms: float | None = None
|
|
62
|
+
exit_ip: str | None = None
|
|
63
|
+
anonymity: str | None = None # elite | anonymous | transparent
|
|
64
|
+
country: str | None = None
|
|
65
|
+
city: str | None = None
|
|
66
|
+
error: str | None = None
|
|
67
|
+
leaked_headers: list[str] = field(default_factory=list)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def mask(proxy: str) -> str:
|
|
71
|
+
"""Hide the password so results can be pasted into an issue or a CI log.
|
|
72
|
+
|
|
73
|
+
The password is matched greedily up to the *last* '@': passwords routinely
|
|
74
|
+
contain '@' and ':', and a lazy match would leave the tail of the secret in
|
|
75
|
+
the output.
|
|
76
|
+
"""
|
|
77
|
+
return re.sub(r"://([^:/@]+):(.*)@", r"://\1:***@", proxy)
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def normalize(line: str) -> str | None:
|
|
81
|
+
"""Turn one input line into a proxy URL, or None if the line is not one."""
|
|
82
|
+
line = line.strip()
|
|
83
|
+
if not line or line.startswith("#"):
|
|
84
|
+
return None
|
|
85
|
+
if not line.startswith(SCHEMES):
|
|
86
|
+
line = "http://" + line
|
|
87
|
+
return line
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def parse_proxies(lines: Iterable[str]) -> list[str]:
|
|
91
|
+
"""Parse a proxy list, dropping blanks/comments and de-duplicating in order."""
|
|
92
|
+
seen: dict[str, None] = {}
|
|
93
|
+
for line in lines:
|
|
94
|
+
p = normalize(line)
|
|
95
|
+
if p is not None:
|
|
96
|
+
seen.setdefault(p, None)
|
|
97
|
+
return list(seen)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def grade_anonymity(headers: dict, direct_ip: str | None, exit_ip: str | None) -> tuple[str, list[str]]:
|
|
101
|
+
"""Classify how much the proxy gives away.
|
|
102
|
+
|
|
103
|
+
transparent - our real IP is visible through the proxy
|
|
104
|
+
anonymous - real IP hidden, but proxy headers announce a proxy is in use
|
|
105
|
+
elite - looks like an ordinary direct request
|
|
106
|
+
"""
|
|
107
|
+
lowered = {k.lower(): str(v) for k, v in headers.items()}
|
|
108
|
+
leaked = [h for h in PROXY_HEADERS if h in lowered]
|
|
109
|
+
|
|
110
|
+
if direct_ip and any(direct_ip in v for v in lowered.values()):
|
|
111
|
+
return "transparent", leaked
|
|
112
|
+
if leaked:
|
|
113
|
+
return "anonymous", leaked
|
|
114
|
+
return "elite", leaked
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def probe_one(
|
|
118
|
+
proxy: str,
|
|
119
|
+
*,
|
|
120
|
+
client_kwargs: dict,
|
|
121
|
+
echo_url: str,
|
|
122
|
+
headers_url: str,
|
|
123
|
+
geo_url: str | None,
|
|
124
|
+
direct_ip: str | None,
|
|
125
|
+
) -> Result:
|
|
126
|
+
res = Result(proxy=mask(proxy))
|
|
127
|
+
started = time.perf_counter()
|
|
128
|
+
try:
|
|
129
|
+
async with httpx.AsyncClient(proxy=proxy, **client_kwargs) as client:
|
|
130
|
+
r = await client.get(echo_url)
|
|
131
|
+
r.raise_for_status()
|
|
132
|
+
res.latency_ms = round((time.perf_counter() - started) * 1000, 1)
|
|
133
|
+
res.exit_ip = r.text.strip()
|
|
134
|
+
res.ok = True
|
|
135
|
+
|
|
136
|
+
# Anonymity needs a second call; a failure here must not sink a
|
|
137
|
+
# proxy that already proved it works.
|
|
138
|
+
try:
|
|
139
|
+
hr = await client.get(headers_url)
|
|
140
|
+
payload = hr.json().get("headers", {})
|
|
141
|
+
res.anonymity, res.leaked_headers = grade_anonymity(payload, direct_ip, res.exit_ip)
|
|
142
|
+
except Exception:
|
|
143
|
+
pass
|
|
144
|
+
|
|
145
|
+
if geo_url and res.exit_ip:
|
|
146
|
+
try:
|
|
147
|
+
gr = await client.get(geo_url.format(ip=res.exit_ip))
|
|
148
|
+
g = gr.json()
|
|
149
|
+
if g.get("status") == "success":
|
|
150
|
+
res.country = g.get("country")
|
|
151
|
+
res.city = g.get("city")
|
|
152
|
+
except Exception:
|
|
153
|
+
pass
|
|
154
|
+
except Exception as exc:
|
|
155
|
+
res.error = f"{type(exc).__name__}: {exc}"[:120] or "unknown error"
|
|
156
|
+
return res
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
async def probe_all(
|
|
160
|
+
proxies: Sequence[str],
|
|
161
|
+
*,
|
|
162
|
+
concurrency: int,
|
|
163
|
+
timeout: float,
|
|
164
|
+
echo_url: str,
|
|
165
|
+
headers_url: str,
|
|
166
|
+
geo_url: str | None,
|
|
167
|
+
direct_ip: str | None,
|
|
168
|
+
progress: bool = False,
|
|
169
|
+
) -> list[Result]:
|
|
170
|
+
sem = asyncio.Semaphore(max(1, concurrency))
|
|
171
|
+
client_kwargs = {"timeout": timeout, "follow_redirects": True}
|
|
172
|
+
done = 0
|
|
173
|
+
|
|
174
|
+
async def worker(p: str) -> Result:
|
|
175
|
+
nonlocal done
|
|
176
|
+
async with sem:
|
|
177
|
+
r = await probe_one(
|
|
178
|
+
p,
|
|
179
|
+
client_kwargs=client_kwargs,
|
|
180
|
+
echo_url=echo_url,
|
|
181
|
+
headers_url=headers_url,
|
|
182
|
+
geo_url=geo_url,
|
|
183
|
+
direct_ip=direct_ip,
|
|
184
|
+
)
|
|
185
|
+
done += 1
|
|
186
|
+
if progress:
|
|
187
|
+
print(f"\r checked {done}/{len(proxies)}", end="", file=sys.stderr, flush=True)
|
|
188
|
+
return r
|
|
189
|
+
|
|
190
|
+
results = await asyncio.gather(*(worker(p) for p in proxies))
|
|
191
|
+
if progress:
|
|
192
|
+
print("\r" + " " * 32 + "\r", end="", file=sys.stderr, flush=True)
|
|
193
|
+
return list(results)
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
async def get_direct_ip(echo_url: str, timeout: float) -> str | None:
|
|
197
|
+
"""Our IP without a proxy - the baseline that makes 'transparent' detectable."""
|
|
198
|
+
try:
|
|
199
|
+
async with httpx.AsyncClient(timeout=timeout) as client:
|
|
200
|
+
r = await client.get(echo_url)
|
|
201
|
+
return r.text.strip()
|
|
202
|
+
except Exception:
|
|
203
|
+
return None
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def render_table(results: Sequence[Result], show_geo: bool) -> str:
|
|
207
|
+
cols = ["PROXY", "OK", "MS", "EXIT IP", "ANONYMITY"]
|
|
208
|
+
if show_geo:
|
|
209
|
+
cols += ["LOCATION"]
|
|
210
|
+
cols += ["ERROR"]
|
|
211
|
+
|
|
212
|
+
rows = []
|
|
213
|
+
for r in results:
|
|
214
|
+
location = " ".join(x for x in (r.city, r.country) if x)
|
|
215
|
+
row = [
|
|
216
|
+
r.proxy,
|
|
217
|
+
"yes" if r.ok else "no",
|
|
218
|
+
f"{r.latency_ms:.0f}" if r.latency_ms is not None else "-",
|
|
219
|
+
r.exit_ip or "-",
|
|
220
|
+
r.anonymity or "-",
|
|
221
|
+
]
|
|
222
|
+
if show_geo:
|
|
223
|
+
row.append(location or "-")
|
|
224
|
+
row.append(r.error or "")
|
|
225
|
+
rows.append(row)
|
|
226
|
+
|
|
227
|
+
widths = [len(c) for c in cols]
|
|
228
|
+
for row in rows:
|
|
229
|
+
for i, cell in enumerate(row):
|
|
230
|
+
widths[i] = max(widths[i], len(cell))
|
|
231
|
+
# Keep the error column from pushing the table off screen.
|
|
232
|
+
widths[-1] = min(widths[-1], 46)
|
|
233
|
+
|
|
234
|
+
def line(cells: Sequence[str]) -> str:
|
|
235
|
+
out = []
|
|
236
|
+
for i, cell in enumerate(cells):
|
|
237
|
+
cell = cell if len(cell) <= widths[i] else cell[: widths[i] - 1] + "…"
|
|
238
|
+
out.append(cell.ljust(widths[i]))
|
|
239
|
+
return " ".join(out).rstrip()
|
|
240
|
+
|
|
241
|
+
parts = [line(cols), line(["-" * w for w in widths])]
|
|
242
|
+
parts += [line(r) for r in rows]
|
|
243
|
+
return "\n".join(parts)
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def summarize(results: Sequence[Result]) -> str:
|
|
247
|
+
ok = [r for r in results if r.ok]
|
|
248
|
+
if not results:
|
|
249
|
+
return "no proxies checked"
|
|
250
|
+
lat = sorted(r.latency_ms for r in ok if r.latency_ms is not None)
|
|
251
|
+
median = f"{lat[len(lat) // 2]:.0f}ms median" if lat else "no timings"
|
|
252
|
+
grades = {}
|
|
253
|
+
for r in ok:
|
|
254
|
+
if r.anonymity:
|
|
255
|
+
grades[r.anonymity] = grades.get(r.anonymity, 0) + 1
|
|
256
|
+
grade_str = ", ".join(f"{v} {k}" for k, v in sorted(grades.items())) or "anonymity not graded"
|
|
257
|
+
return f"{len(ok)}/{len(results)} working | {median} | {grade_str}"
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
261
|
+
p = argparse.ArgumentParser(
|
|
262
|
+
prog="proxyprobe",
|
|
263
|
+
description="Check proxies for liveness, latency, exit IP and anonymity.",
|
|
264
|
+
epilog="Proxy list: one per line, '#' comments allowed. Use '-' to read stdin.",
|
|
265
|
+
)
|
|
266
|
+
p.add_argument("file", help="file with one proxy per line, or '-' for stdin")
|
|
267
|
+
p.add_argument("-c", "--concurrency", type=int, default=20, help="parallel checks (default: 20)")
|
|
268
|
+
p.add_argument("-t", "--timeout", type=float, default=10.0, help="per-request timeout in seconds (default: 10)")
|
|
269
|
+
p.add_argument("--json", action="store_true", help="emit JSON instead of a table")
|
|
270
|
+
p.add_argument("--geo", action="store_true", help="also look up country/city (rate limited)")
|
|
271
|
+
p.add_argument("--working-only", action="store_true", help="only output proxies that responded")
|
|
272
|
+
p.add_argument("--echo-url", default=DEFAULT_ECHO_URL, help=f"IP echo endpoint (default: {DEFAULT_ECHO_URL})")
|
|
273
|
+
p.add_argument("--headers-url", default=DEFAULT_HEADERS_URL, help="header echo endpoint used for anonymity grading")
|
|
274
|
+
p.add_argument("--geo-url", default=DEFAULT_GEO_URL, help="geo lookup URL template, must contain {ip}")
|
|
275
|
+
p.add_argument("-q", "--quiet", action="store_true", help="suppress the progress counter")
|
|
276
|
+
p.add_argument("-V", "--version", action="version", version=f"proxyprobe {__version__}")
|
|
277
|
+
return p
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
281
|
+
args = build_parser().parse_args(argv)
|
|
282
|
+
|
|
283
|
+
if args.file == "-":
|
|
284
|
+
lines = sys.stdin.read().splitlines()
|
|
285
|
+
else:
|
|
286
|
+
try:
|
|
287
|
+
with open(args.file, encoding="utf-8") as fh:
|
|
288
|
+
lines = fh.read().splitlines()
|
|
289
|
+
except OSError as exc:
|
|
290
|
+
print(f"proxyprobe: cannot read {args.file}: {exc}", file=sys.stderr)
|
|
291
|
+
return 2
|
|
292
|
+
|
|
293
|
+
proxies = parse_proxies(lines)
|
|
294
|
+
if not proxies:
|
|
295
|
+
print("proxyprobe: no proxies found in input", file=sys.stderr)
|
|
296
|
+
return 2
|
|
297
|
+
|
|
298
|
+
show_progress = not args.quiet and not args.json and sys.stderr.isatty()
|
|
299
|
+
|
|
300
|
+
direct_ip = asyncio.run(get_direct_ip(args.echo_url, args.timeout))
|
|
301
|
+
results = asyncio.run(
|
|
302
|
+
probe_all(
|
|
303
|
+
proxies,
|
|
304
|
+
concurrency=args.concurrency,
|
|
305
|
+
timeout=args.timeout,
|
|
306
|
+
echo_url=args.echo_url,
|
|
307
|
+
headers_url=args.headers_url,
|
|
308
|
+
geo_url=args.geo_url if args.geo else None,
|
|
309
|
+
direct_ip=direct_ip,
|
|
310
|
+
progress=show_progress,
|
|
311
|
+
)
|
|
312
|
+
)
|
|
313
|
+
|
|
314
|
+
if args.working_only:
|
|
315
|
+
results = [r for r in results if r.ok]
|
|
316
|
+
|
|
317
|
+
if args.json:
|
|
318
|
+
print(json.dumps([asdict(r) for r in results], indent=2))
|
|
319
|
+
else:
|
|
320
|
+
if results:
|
|
321
|
+
print(render_table(results, show_geo=args.geo))
|
|
322
|
+
print()
|
|
323
|
+
print(summarize(results))
|
|
324
|
+
|
|
325
|
+
# Exit 1 when nothing worked, so this is usable in CI and shell pipelines.
|
|
326
|
+
return 0 if any(r.ok for r in results) else 1
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
if __name__ == "__main__":
|
|
330
|
+
raise SystemExit(main())
|