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.
@@ -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
+ ![CLI screenshot](https://via.placeholder.com/800x400?text=Screenshot)
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (83.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ proxycheck = proxychecker.cli:main
3
+ proxycheck-cli = proxychecker.cli:main
@@ -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
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
@@ -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))