proxyprobe 0.1.0__tar.gz

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,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,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,127 @@
1
+ # proxyprobe
2
+
3
+ Check a list of proxies for **liveness, latency, exit IP and anonymity** — one file, one dependency.
4
+
5
+ ```console
6
+ $ proxyprobe proxies.txt --geo
7
+ PROXY OK MS EXIT IP ANONYMITY LOCATION ERROR
8
+ --------------------------------- --- ---- --------------- ----------- -------------- ---------------------
9
+ http://user:***@gw.example.com:80 yes 312 203.0.113.44 elite Tokyo Japan
10
+ socks5://198.51.100.7:1080 yes 1180 198.51.100.7 transparent Frankfurt Germany
11
+ http://10.0.0.9:3128 no - - - ConnectTimeout: timed out
12
+
13
+ 2/3 working | 312ms median | 1 elite, 1 transparent
14
+ ```
15
+
16
+ Most "proxy checker" scripts only tell you whether a connection opened. That is the
17
+ least interesting thing about a proxy. proxyprobe also answers the questions that
18
+ actually decide whether a proxy is usable:
19
+
20
+ - **Does it leak my real IP?** Graded `transparent` / `anonymous` / `elite` by comparing
21
+ your direct IP against what the proxy forwards, and by inspecting `Via`,
22
+ `X-Forwarded-For` and friends.
23
+ - **Where does it actually exit?** The advertised location and the real one often differ.
24
+ - **How slow is it really?** Measured on a full request, not a TCP handshake.
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install "httpx[socks]"
30
+ curl -O https://raw.githubusercontent.com/roamproxy/proxyprobe/main/proxyprobe.py
31
+ python proxyprobe.py proxies.txt
32
+ ```
33
+
34
+ It is a single file with no packaging required. Drop it in a repo, a container, or a
35
+ CI job and run it.
36
+
37
+ ## Usage
38
+
39
+ ```bash
40
+ proxyprobe proxies.txt # table output
41
+ proxyprobe proxies.txt --json # machine readable
42
+ proxyprobe proxies.txt --geo # add country/city
43
+ proxyprobe proxies.txt --working-only # drop the dead ones
44
+ cat proxies.txt | proxyprobe - # read stdin
45
+ proxyprobe proxies.txt -c 100 -t 5 # 100 in parallel, 5s timeout
46
+ ```
47
+
48
+ Proxy list format — one per line, `#` comments and blank lines ignored:
49
+
50
+ ```
51
+ http://user:pass@host:8080
52
+ socks5://host:1080
53
+ host:8080 # scheme defaults to http
54
+ ```
55
+
56
+ ### Options
57
+
58
+ | Flag | Description |
59
+ | --- | --- |
60
+ | `-c, --concurrency` | Parallel checks (default 20) |
61
+ | `-t, --timeout` | Per-request timeout in seconds (default 10) |
62
+ | `--json` | JSON instead of a table |
63
+ | `--geo` | Look up country/city (rate limited, so opt-in) |
64
+ | `--working-only` | Only output proxies that responded |
65
+ | `--echo-url` | IP echo endpoint (default `api.ipify.org`) |
66
+ | `--headers-url` | Header echo endpoint used for anonymity grading |
67
+ | `--geo-url` | Geo lookup template, must contain `{ip}` |
68
+ | `-q, --quiet` | Suppress the progress counter |
69
+
70
+ Exit code is `0` if at least one proxy worked, `1` if none did — so it drops straight
71
+ into CI or a shell pipeline:
72
+
73
+ ```bash
74
+ proxyprobe proxies.txt --working-only --json > alive.json || echo "all proxies down"
75
+ ```
76
+
77
+ ## Anonymity grades
78
+
79
+ | Grade | Meaning |
80
+ | --- | --- |
81
+ | `elite` | Request looks like an ordinary direct request |
82
+ | `anonymous` | Real IP hidden, but headers announce a proxy is in use |
83
+ | `transparent` | Your real IP is visible through the proxy |
84
+
85
+ Grading needs a baseline, so proxyprobe first fetches your direct IP once. If that
86
+ fails it degrades safely: a proxy is never labelled `transparent` without a baseline
87
+ to compare against.
88
+
89
+ ## Passwords are masked
90
+
91
+ Credentials are stripped from every output path, so results are safe to paste into an
92
+ issue or a CI log:
93
+
94
+ ```
95
+ http://user:hunter2@host:80 -> http://user:***@host:80
96
+ ```
97
+
98
+ Passwords containing `@` and `:` are handled correctly.
99
+
100
+ ## Privacy
101
+
102
+ By default proxyprobe talks to `api.ipify.org` (IP echo), `httpbin.org` (header echo)
103
+ and, only with `--geo`, `ip-api.com`. All three are overridable — point them at your
104
+ own endpoints if you would rather not route a proxy list through third parties:
105
+
106
+ ```bash
107
+ proxyprobe proxies.txt --echo-url https://your-host/ip --headers-url https://your-host/headers
108
+ ```
109
+
110
+ Nothing else is sent anywhere. There is no telemetry.
111
+
112
+ ## Development
113
+
114
+ ```bash
115
+ pip install "httpx[socks]" pytest
116
+ pytest -q
117
+ ```
118
+
119
+ ## License
120
+
121
+ MIT — see [LICENSE](LICENSE).
122
+
123
+ ---
124
+
125
+ Maintained by [Roam](https://roamproxy.com), a residential and datacenter proxy
126
+ provider. proxyprobe works with any proxy from any provider, and always will —
127
+ we built it because we needed it ourselves.
@@ -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,11 @@
1
+ LICENSE
2
+ README.md
3
+ proxyprobe.py
4
+ pyproject.toml
5
+ proxyprobe.egg-info/PKG-INFO
6
+ proxyprobe.egg-info/SOURCES.txt
7
+ proxyprobe.egg-info/dependency_links.txt
8
+ proxyprobe.egg-info/entry_points.txt
9
+ proxyprobe.egg-info/requires.txt
10
+ proxyprobe.egg-info/top_level.txt
11
+ tests/test_proxyprobe.py
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ proxyprobe = proxyprobe:main
@@ -0,0 +1 @@
1
+ httpx[socks]>=0.24
@@ -0,0 +1 @@
1
+ proxyprobe
@@ -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())
@@ -0,0 +1,36 @@
1
+ [build-system]
2
+ requires = ["setuptools>=61"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "proxyprobe"
7
+ version = "0.1.0"
8
+ description = "Check a list of proxies for liveness, latency, exit IP and anonymity."
9
+ readme = "README.md"
10
+ requires-python = ">=3.9"
11
+ license = { text = "MIT" }
12
+ authors = [{ name = "Roam", email = "dev@roamproxy.com" }]
13
+ keywords = ["proxy", "proxies", "proxy-checker", "socks5", "anonymity", "scraping"]
14
+ classifiers = [
15
+ "Development Status :: 4 - Beta",
16
+ "Environment :: Console",
17
+ "Intended Audience :: Developers",
18
+ "License :: OSI Approved :: MIT License",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Internet :: Proxy Servers",
21
+ "Topic :: Software Development :: Testing",
22
+ ]
23
+ dependencies = ["httpx[socks]>=0.24"]
24
+
25
+ [project.urls]
26
+ Homepage = "https://github.com/roamproxy/proxyprobe"
27
+ Issues = "https://github.com/roamproxy/proxyprobe/issues"
28
+
29
+ [project.scripts]
30
+ proxyprobe = "proxyprobe:main"
31
+
32
+ [tool.setuptools]
33
+ py-modules = ["proxyprobe"]
34
+
35
+ [tool.pytest.ini_options]
36
+ testpaths = ["tests"]
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,122 @@
1
+ import sys
2
+ from pathlib import Path
3
+
4
+ import pytest
5
+
6
+ sys.path.insert(0, str(Path(__file__).resolve().parent.parent))
7
+
8
+ from proxyprobe import ( # noqa: E402
9
+ Result,
10
+ grade_anonymity,
11
+ mask,
12
+ normalize,
13
+ parse_proxies,
14
+ render_table,
15
+ summarize,
16
+ )
17
+
18
+
19
+ class TestMask:
20
+ def test_hides_password(self):
21
+ assert mask("http://user:secret@1.2.3.4:8080") == "http://user:***@1.2.3.4:8080"
22
+
23
+ def test_leaves_credential_free_proxy_alone(self):
24
+ assert mask("socks5://1.2.3.4:1080") == "socks5://1.2.3.4:1080"
25
+
26
+ def test_handles_password_containing_at_and_colon(self):
27
+ assert mask("http://u:p@ss:word@host:80") == "http://u:***@host:80"
28
+
29
+
30
+ class TestNormalize:
31
+ def test_adds_default_scheme(self):
32
+ assert normalize("1.2.3.4:8080") == "http://1.2.3.4:8080"
33
+
34
+ def test_keeps_existing_scheme(self):
35
+ assert normalize("socks5://1.2.3.4:1080") == "socks5://1.2.3.4:1080"
36
+
37
+ @pytest.mark.parametrize("line", ["", " ", "# a comment", "\t"])
38
+ def test_skips_blanks_and_comments(self, line):
39
+ assert normalize(line) is None
40
+
41
+
42
+ class TestParseProxies:
43
+ def test_filters_and_dedupes_preserving_order(self):
44
+ got = parse_proxies([
45
+ "1.1.1.1:80",
46
+ "# comment",
47
+ "",
48
+ "socks5://2.2.2.2:1080",
49
+ "1.1.1.1:80", # duplicate
50
+ "http://1.1.1.1:80", # same thing once normalized
51
+ ])
52
+ assert got == ["http://1.1.1.1:80", "socks5://2.2.2.2:1080"]
53
+
54
+ def test_empty_input(self):
55
+ assert parse_proxies([]) == []
56
+
57
+
58
+ class TestGradeAnonymity:
59
+ def test_transparent_when_real_ip_leaks(self):
60
+ grade, leaked = grade_anonymity(
61
+ {"X-Forwarded-For": "9.9.9.9"}, direct_ip="9.9.9.9", exit_ip="1.1.1.1"
62
+ )
63
+ assert grade == "transparent"
64
+ assert "x-forwarded-for" in leaked
65
+
66
+ def test_anonymous_when_proxy_headers_present_but_ip_hidden(self):
67
+ grade, leaked = grade_anonymity(
68
+ {"Via": "1.1 squid"}, direct_ip="9.9.9.9", exit_ip="1.1.1.1"
69
+ )
70
+ assert grade == "anonymous"
71
+ assert leaked == ["via"]
72
+
73
+ def test_elite_when_nothing_leaks(self):
74
+ grade, leaked = grade_anonymity(
75
+ {"User-Agent": "curl/8"}, direct_ip="9.9.9.9", exit_ip="1.1.1.1"
76
+ )
77
+ assert grade == "elite"
78
+ assert leaked == []
79
+
80
+ def test_header_matching_is_case_insensitive(self):
81
+ grade, leaked = grade_anonymity({"VIA": "x"}, direct_ip=None, exit_ip=None)
82
+ assert grade == "anonymous"
83
+ assert leaked == ["via"]
84
+
85
+ def test_no_direct_ip_cannot_be_transparent(self):
86
+ # Without a baseline we must not claim the real IP leaked.
87
+ grade, _ = grade_anonymity({"X-Real-IP": "9.9.9.9"}, direct_ip=None, exit_ip="1.1.1.1")
88
+ assert grade == "anonymous"
89
+
90
+
91
+ class TestRenderTable:
92
+ def test_includes_headers_and_values(self):
93
+ out = render_table([Result(proxy="http://a:80", ok=True, latency_ms=12.0, exit_ip="1.1.1.1")], show_geo=False)
94
+ assert "PROXY" in out and "EXIT IP" in out
95
+ assert "http://a:80" in out and "1.1.1.1" in out
96
+ assert "LOCATION" not in out
97
+
98
+ def test_geo_column_toggles(self):
99
+ out = render_table([Result(proxy="p", ok=True, country="Japan", city="Tokyo")], show_geo=True)
100
+ assert "LOCATION" in out and "Tokyo Japan" in out
101
+
102
+ def test_failed_proxy_shows_error(self):
103
+ out = render_table([Result(proxy="p", ok=False, error="ConnectTimeout: boom")], show_geo=False)
104
+ assert "no" in out and "ConnectTimeout" in out
105
+
106
+
107
+ class TestSummarize:
108
+ def test_counts_working_and_grades(self):
109
+ s = summarize([
110
+ Result(proxy="a", ok=True, latency_ms=100.0, anonymity="elite"),
111
+ Result(proxy="b", ok=True, latency_ms=200.0, anonymity="elite"),
112
+ Result(proxy="c", ok=False),
113
+ ])
114
+ assert "2/3 working" in s
115
+ assert "2 elite" in s
116
+
117
+ def test_handles_no_results(self):
118
+ assert summarize([]) == "no proxies checked"
119
+
120
+ def test_handles_all_failed(self):
121
+ s = summarize([Result(proxy="a", ok=False)])
122
+ assert "0/1 working" in s