proxy-scraper-cli 1.7.1__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.
Files changed (57) hide show
  1. proxy_scraper_cli-1.7.1.dist-info/METADATA +794 -0
  2. proxy_scraper_cli-1.7.1.dist-info/RECORD +57 -0
  3. proxy_scraper_cli-1.7.1.dist-info/WHEEL +5 -0
  4. proxy_scraper_cli-1.7.1.dist-info/entry_points.txt +4 -0
  5. proxy_scraper_cli-1.7.1.dist-info/licenses/LICENSE +21 -0
  6. proxy_scraper_cli-1.7.1.dist-info/top_level.txt +1 -0
  7. proxyscraper/__init__.py +12 -0
  8. proxyscraper/__main__.py +7 -0
  9. proxyscraper/agent.py +635 -0
  10. proxyscraper/api.py +139 -0
  11. proxyscraper/app.py +595 -0
  12. proxyscraper/asndb.py +181 -0
  13. proxyscraper/blocklist.py +92 -0
  14. proxyscraper/checker.py +514 -0
  15. proxyscraper/cli.py +269 -0
  16. proxyscraper/compat.py +83 -0
  17. proxyscraper/completion.py +196 -0
  18. proxyscraper/exporters.py +118 -0
  19. proxyscraper/fetchcache.py +103 -0
  20. proxyscraper/geo.py +150 -0
  21. proxyscraper/geodb.py +143 -0
  22. proxyscraper/handshake.py +153 -0
  23. proxyscraper/history.py +106 -0
  24. proxyscraper/judges.py +159 -0
  25. proxyscraper/mcp_entry.py +34 -0
  26. proxyscraper/mcp_server.py +220 -0
  27. proxyscraper/netio.py +167 -0
  28. proxyscraper/options.py +274 -0
  29. proxyscraper/output.py +181 -0
  30. proxyscraper/pages.py +212 -0
  31. proxyscraper/parsing.py +192 -0
  32. proxyscraper/paths.py +55 -0
  33. proxyscraper/pipeline.py +434 -0
  34. proxyscraper/preferences.py +24 -0
  35. proxyscraper/publish.py +236 -0
  36. proxyscraper/server/__init__.py +41 -0
  37. proxyscraper/server/core.py +518 -0
  38. proxyscraper/server/http.py +164 -0
  39. proxyscraper/server/pool.py +195 -0
  40. proxyscraper/server/socks.py +65 -0
  41. proxyscraper/server/status.py +108 -0
  42. proxyscraper/server/upstream.py +119 -0
  43. proxyscraper/site/apple-touch-icon.png +0 -0
  44. proxyscraper/site/googleaac1161b7853c5b5.html +1 -0
  45. proxyscraper/site/index.html +934 -0
  46. proxyscraper/site/logo.png +0 -0
  47. proxyscraper/site/og.png +0 -0
  48. proxyscraper/sources.json +395 -0
  49. proxyscraper/sources.py +491 -0
  50. proxyscraper/targets.py +76 -0
  51. proxyscraper/ui/__init__.py +54 -0
  52. proxyscraper/ui/dashboard.py +344 -0
  53. proxyscraper/ui/keys.py +82 -0
  54. proxyscraper/ui/report.py +209 -0
  55. proxyscraper/ui/serve.py +142 -0
  56. proxyscraper/ui/widgets.py +250 -0
  57. proxyscraper/ui/wizard.py +595 -0
proxyscraper/judges.py ADDED
@@ -0,0 +1,159 @@
1
+ """Check targets ("judges"): services that return nothing but the sender's IP as text.
2
+
3
+ Every proxy has to fetch its exit IP from a check target. If the target goes down or throttles,
4
+ every proxy looks dead – and the source statistics would learn the wrong thing from it. So:
5
+
6
+ - test every target directly before the run and take the best reachable one
7
+ - check regularly during the run (JudgeWatch) and switch if it goes down
8
+
9
+ Targets behind Cloudflare are off limits: many "proxies" in the lists are Cloudflare addresses that
10
+ simply answer a request to a Cloudflare site themselves and would pass as working that way.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import ipaddress
17
+ import socket
18
+ import time
19
+ from dataclasses import dataclass
20
+ from typing import Awaitable, Callable, List, Optional
21
+
22
+ from .netio import read_response
23
+
24
+
25
+ @dataclass(frozen=True)
26
+ class Judge:
27
+ host: str
28
+ path: str = "/"
29
+ port: int = 80
30
+
31
+ @property
32
+ def authority(self) -> str:
33
+ """host[:port] for Host headers and URLs."""
34
+ return self.host if self.port == 80 else f"{self.host}:{self.port}"
35
+
36
+
37
+ # order = suitability. Measured with the same 300 most recently working proxies (September 2026):
38
+ # amazonaws 255, ifconfig.me 230, ipinfo.io 223, wtfismyip 213, ident.me 199 hits.
39
+ # The direct latency says little about it – some services simply reject proxies more often.
40
+ JUDGES = (
41
+ Judge("checkip.amazonaws.com"), # AWS
42
+ Judge("ifconfig.me", "/ip"), # Google Cloud
43
+ Judge("ipinfo.io", "/ip"), # Google Cloud
44
+ Judge("wtfismyip.com", "/text"),
45
+ Judge("ident.me"), # Hetzner
46
+ )
47
+ DEFAULT_JUDGE = JUDGES[0]
48
+
49
+ # https://www.cloudflare.com/ips-v4/
50
+ CLOUDFLARE_V4 = tuple(ipaddress.IPv4Network(n) for n in (
51
+ "173.245.48.0/20", "103.21.244.0/22", "103.22.200.0/22", "103.31.4.0/22", "141.101.64.0/18",
52
+ "108.162.192.0/18", "190.93.240.0/20", "188.114.96.0/20", "197.234.240.0/22", "198.41.128.0/17",
53
+ "162.158.0.0/15", "104.16.0.0/13", "104.24.0.0/14", "172.64.0.0/13", "131.0.72.0/22",
54
+ ))
55
+
56
+
57
+ def behind_cloudflare(ip: str) -> bool:
58
+ addr = ipaddress.IPv4Address(ip)
59
+ return any(addr in net for net in CLOUDFLARE_V4)
60
+
61
+
62
+ @dataclass(frozen=True)
63
+ class JudgeProbe:
64
+ judge: Judge
65
+ ip: str
66
+ latency: int # ms
67
+ seen_ip: str = "" # what the target saw as our IP (fallback in case get_own_ips fails)
68
+
69
+
70
+ async def probe_judge(judge: Judge, timeout: float = 5.0) -> Optional[JudgeProbe]:
71
+ """Query the target directly (without a proxy). None = unreachable, wrong answer or Cloudflare."""
72
+ loop = asyncio.get_running_loop()
73
+ try:
74
+ infos = await asyncio.wait_for(loop.getaddrinfo(judge.host, judge.port, family=socket.AF_INET), timeout)
75
+ except Exception:
76
+ return None
77
+ addresses = list(dict.fromkeys(info[4][0] for info in infos))
78
+ if any(behind_cloudflare(ip) for ip in addresses):
79
+ return None # being behind Cloudflare even partially is enough to exclude it
80
+ for ip in addresses: # first working address – exactly that one is used by the checker later
81
+ start = time.perf_counter()
82
+ seen = await _ask(ip, judge, timeout)
83
+ if seen:
84
+ return JudgeProbe(judge, ip, round((time.perf_counter() - start) * 1000), seen)
85
+ return None
86
+
87
+
88
+ async def _ask(ip: str, judge: Judge, timeout: float) -> str:
89
+ """Ask the target via exactly this address -> the IP it sees ('' on error)."""
90
+ request = (f"GET {judge.path} HTTP/1.1\r\nHost: {judge.authority}\r\nUser-Agent: Mozilla/5.0\r\n"
91
+ f"Connection: close\r\n\r\n").encode()
92
+ try:
93
+ reader, writer = await asyncio.wait_for(asyncio.open_connection(ip, judge.port), timeout)
94
+ try:
95
+ writer.write(request)
96
+ await writer.drain()
97
+ status, _, body = await asyncio.wait_for(read_response(reader), timeout)
98
+ finally:
99
+ writer.close()
100
+ seen = body.strip().decode("ascii", "ignore")
101
+ # directly this may also return IPv6 (e.g. iCloud Private Relay) – through proxies we address
102
+ # the target by its IPv4 address and then get the IPv4 exit IP
103
+ ipaddress.ip_address(seen)
104
+ except Exception: # timeout, connection error, no IP in the body – all mean "unusable right now"
105
+ return ""
106
+ return seen if status == 200 else ""
107
+
108
+
109
+ async def rank_judges(judges=JUDGES, timeout: float = 5.0, probe=probe_judge) -> List[JudgeProbe]:
110
+ """Test every target in parallel; the reachable ones in order of suitability (see JUDGES)."""
111
+ results = await asyncio.gather(*(probe(j, timeout) for j in judges))
112
+ return [r for r in results if r]
113
+
114
+
115
+ class JudgeWatch:
116
+ """Keeps an eye on the check target during the run and switches if it goes down.
117
+
118
+ on_ok is called after every successful probe, on_switch(old, new) on a switch –
119
+ that way the caller can treat the checks since the last good probe as suspicious.
120
+ """
121
+
122
+ def __init__(self, ranked: List[JudgeProbe], switch: Callable[[JudgeProbe], None],
123
+ interval: float = 15.0, failures_before_switch: int = 2,
124
+ probe: Callable[..., Awaitable[Optional[JudgeProbe]]] = probe_judge):
125
+ self.current = ranked[0]
126
+ self.reserve = list(ranked[1:])
127
+ self.switch = switch
128
+ self.interval = interval
129
+ self.failures_before_switch = failures_before_switch
130
+ self.probe = probe
131
+ self.failures = 0
132
+ self.switches: List[str] = []
133
+ self.on_ok: Callable[[], None] = lambda: None
134
+ self.on_switch: Callable[[JudgeProbe, JudgeProbe], None] = lambda old, new: None
135
+
136
+ async def run(self) -> None:
137
+ while True:
138
+ await asyncio.sleep(self.interval)
139
+ await self.check_once()
140
+
141
+ async def check_once(self) -> None:
142
+ if await self.probe(self.current.judge):
143
+ self.failures = 0
144
+ self.on_ok()
145
+ return
146
+ self.failures += 1
147
+ if self.failures < self.failures_before_switch:
148
+ return
149
+ for candidate in list(self.reserve):
150
+ fresh = await self.probe(candidate.judge)
151
+ if fresh:
152
+ old = self.current
153
+ self.reserve.remove(candidate)
154
+ self.reserve.append(old) # maybe it comes back later
155
+ self.current, self.failures = fresh, 0
156
+ self.switch(fresh)
157
+ self.switches.append(f"{old.judge.host} → {fresh.judge.host}")
158
+ self.on_switch(old, fresh)
159
+ return
@@ -0,0 +1,34 @@
1
+ """`proxy-scraper-mcp`: starts the MCP server over stdio – or explains what's missing.
2
+
3
+ A separate module on purpose: it has to run without the MCP SDK, to say how to install it.
4
+ stdout belongs to the MCP protocol: every message here goes to stderr.
5
+ """
6
+
7
+ import sys
8
+
9
+ INSTALL_HINT = ('The MCP server needs the MCP SDK: pip install "proxy-scraper-cli[mcp]" '
10
+ '(or: pipx inject proxy-scraper-cli mcp)')
11
+
12
+
13
+ def _load_server():
14
+ from . import mcp_server
15
+ return mcp_server
16
+
17
+
18
+ def main() -> int:
19
+ if sys.version_info < (3, 10):
20
+ print(f"The MCP server needs Python 3.10 or newer (this is {sys.version_info[0]}.{sys.version_info[1]}). "
21
+ "The rest of proxy-scraper works on 3.9.", file=sys.stderr)
22
+ return 1
23
+ try:
24
+ server = _load_server()
25
+ except ImportError as e:
26
+ if e.name and e.name.split(".")[0] in ("mcp", "pydantic"):
27
+ print(INSTALL_HINT, file=sys.stderr)
28
+ return 1
29
+ raise
30
+ return server.serve()
31
+
32
+
33
+ if __name__ == "__main__":
34
+ sys.exit(main())
@@ -0,0 +1,220 @@
1
+ """MCP server: working free proxies for AI agents, and pages fetched through them.
2
+
3
+ Three tools – the logic lives in agent.py, this module describes it for agents (names, parameter docs,
4
+ structured results, annotations) and turns AgentError into tool errors the agent can act on.
5
+ Start it with `proxy-scraper-mcp` (stdio); needs `pip install "proxy-scraper-cli[mcp]"` and Python 3.10+.
6
+ """
7
+
8
+ import contextlib
9
+ import logging
10
+ import sys
11
+ import time
12
+ from typing import Annotated, List, Literal, Optional
13
+
14
+ from mcp.server.mcpserver import Context, MCPServer
15
+ from mcp.server.mcpserver.exceptions import ToolError
16
+ from mcp.types import ToolAnnotations
17
+ from pydantic import BaseModel, Field
18
+
19
+ from . import __version__, agent
20
+
21
+ REPO = "https://github.com/maximilianfeix/proxy-scraper"
22
+
23
+ INSTRUCTIONS = """\
24
+ Free HTTP, SOCKS4 and SOCKS5 proxies that passed real checks: a protocol handshake, the same exit IP on two \
25
+ unrelated sites (drops honeypots), a byte-for-byte content check (drops proxies that inject scripts) and, for \
26
+ HTTPS, a verified TLS handshake.
27
+
28
+ - get_proxies: instant, from a list re-checked every hour. Start here.
29
+ - check_proxies: checks from this machine's network, so the results work from here right now. Takes 30-90 s.
30
+ - fetch_url: loads a page through a verified proxy and switches proxies by itself if one fails.
31
+
32
+ Free proxies are run by strangers: never send passwords, cookies, API keys or personal data through them, \
33
+ and expect some of them to stop working at any time."""
34
+
35
+ Protocol = Literal["any", "http", "socks4", "socks5"]
36
+ Countries = Annotated[List[str], Field(description="Two-letter country codes of the exit IP, e.g. ['DE', 'NL']. "
37
+ "Empty = any country.")]
38
+
39
+
40
+ class Proxy(BaseModel):
41
+ url: str = Field(description="Ready to use, e.g. socks5://1.2.3.4:1080 – curl, requests, httpx and "
42
+ "browsers take it as it is")
43
+ protocol: str = Field(description="http, socks4 or socks5")
44
+ address: str = Field(description="ip:port")
45
+ country: Optional[str] = Field(description="Two-letter code of the exit IP's country")
46
+ latency_ms: Optional[int] = Field(description="Response time in the last check")
47
+ https: Optional[bool] = Field(description="Tunnels HTTPS with a verified TLS handshake")
48
+ anonymity: Optional[str] = Field(description="elite (target sees no proxy), anonymous or transparent")
49
+ provider: Optional[str] = Field(description="Network the exit IP belongs to")
50
+ datacenter: Optional[bool] = Field(description="Exit IP in a datacenter – some sites block those")
51
+ blocklisted: Optional[bool] = Field(description="Exit IP on the SpamCop blocklist – expect captchas")
52
+ up_for_hours: Optional[int] = Field(description="How long it has worked without a gap (live list only)")
53
+
54
+
55
+ class ProxyList(BaseModel):
56
+ proxies: List[Proxy] = Field(description="Fastest first")
57
+ matched: int = Field(description="How many proxies in the list pass the filters (limit cuts the rest)")
58
+ list_updated: Optional[str] = Field(description="When the list was last checked (ISO 8601, UTC)")
59
+ list_age_minutes: Optional[int] = Field(description="Minutes since that check")
60
+ note: Optional[str] = Field(description="What to do if fewer proxies matched than asked for")
61
+
62
+
63
+ class FreshProxies(BaseModel):
64
+ proxies: List[Proxy] = Field(description="Working from this machine right now, fastest first")
65
+ mode: str = Field(description="live = the hourly list checked again, full = all 700+ sources")
66
+ took_seconds: float
67
+ note: Optional[str] = Field(description="What to do if none were found")
68
+
69
+
70
+ class Page(BaseModel):
71
+ url: str
72
+ final_url: Optional[str] = Field(description="After redirects")
73
+ status: int = Field(description="HTTP status of the target, e.g. 200, 403, 404")
74
+ content_type: Optional[str]
75
+ via: Optional[str] = Field(description="The proxy that delivered the page")
76
+ proxy_country: Optional[str] = Field(description="Country the target saw the request from")
77
+ elapsed_ms: int
78
+ bytes: int = Field(description="Size of the response body")
79
+ text: str = Field(description="Readable text (HTML is turned into plain text unless raw_html is set)")
80
+ truncated: bool = Field(description="True if the text was cut at max_chars")
81
+ note: Optional[str]
82
+
83
+
84
+ def _fail(e: agent.AgentError):
85
+ raise ToolError(str(e)) from None
86
+
87
+
88
+ def build_server(live: Optional[agent.LiveSource] = None, fetcher: Optional[agent.PageFetcher] = None) -> MCPServer:
89
+ live = live or agent.LiveSource()
90
+ fetcher = fetcher or agent.PageFetcher(live)
91
+
92
+ @contextlib.asynccontextmanager
93
+ async def lifespan(_server):
94
+ try:
95
+ yield {}
96
+ finally:
97
+ await fetcher.close() # the internal rotating server
98
+
99
+ server = MCPServer("proxy-scraper", title="proxy-scraper – free proxies that actually work",
100
+ version=__version__, instructions=INSTRUCTIONS, website_url=REPO, lifespan=lifespan)
101
+
102
+ @server.tool(title="Get working proxies",
103
+ annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=True, open_world_hint=True))
104
+ async def get_proxies(
105
+ protocol: Annotated[Protocol, Field(description="Proxy protocol. socks5 works for any TCP traffic, http is "
106
+ "what most HTTP clients expect.")] = "any",
107
+ countries: Countries = [], # noqa: B006 – pydantic copies defaults, and a list reads best in the schema
108
+ https_only: Annotated[bool, Field(description="Only proxies that tunnel HTTPS with verified TLS. "
109
+ "Needed for https:// sites.")] = False,
110
+ elite_only: Annotated[bool, Field(description="Only proxies the target can't recognize as proxies")] = False,
111
+ exclude_datacenter: Annotated[bool, Field(description="Skip exits in datacenters (blocked by many "
112
+ "sites)")] = False,
113
+ exclude_blocklisted: Annotated[bool, Field(description="Skip exits on the SpamCop blocklist (fewer "
114
+ "captchas)")] = False,
115
+ stable_only: Annotated[bool, Field(description="Only proxies that have worked for 24 h or more")] = False,
116
+ max_latency_ms: Annotated[int, Field(ge=0, description="Maximum response time, 0 = any")] = 0,
117
+ limit: Annotated[int, Field(ge=1, le=100, description="How many to return (fastest first)")] = 10,
118
+ ) -> ProxyList:
119
+ """Get working free proxies right now, fastest first.
120
+
121
+ Instant: comes from a public list that is re-checked every hour from GitHub's servers, so a proxy may have
122
+ died since, and some behave differently from this machine's network. Use check_proxies when they must
123
+ work from here right now. Every result has a ready-to-use url like socks5://1.2.3.4:1080."""
124
+ try:
125
+ data = await live.get()
126
+ rows = agent.select(data.rows, protocol, countries, https_only, elite_only, exclude_datacenter,
127
+ exclude_blocklisted, stable_only, max_latency_ms, data.run_hours)
128
+ except agent.AgentError as e:
129
+ _fail(e)
130
+ return ProxyList(proxies=[Proxy(**agent.describe(r, data.run_hours)) for r in rows[:limit]],
131
+ matched=len(rows), list_updated=data.updated or None, list_age_minutes=data.age_minutes(),
132
+ note=agent.shortage_note(data.rows, limit, len(rows), countries))
133
+
134
+ @server.tool(title="Check proxies from this machine",
135
+ annotations=ToolAnnotations(read_only_hint=False, destructive_hint=False, idempotent_hint=False,
136
+ open_world_hint=True))
137
+ async def check_proxies(
138
+ ctx: Context,
139
+ want: Annotated[int, Field(ge=1, le=100, description="Stop once this many work")] = 10,
140
+ protocol: Annotated[Protocol, Field(description="Proxy protocol to look for")] = "any",
141
+ countries: Countries = [], # noqa: B006
142
+ https_only: Annotated[bool, Field(description="Only proxies that tunnel HTTPS with verified TLS")] = False,
143
+ elite_only: Annotated[bool, Field(description="Only proxies the target can't recognize as proxies")] = False,
144
+ exclude_datacenter: Annotated[bool, Field(description="Skip exits in datacenters")] = False,
145
+ exclude_blocklisted: Annotated[bool, Field(description="Skip exits on the SpamCop blocklist")] = False,
146
+ max_latency_ms: Annotated[int, Field(ge=0, description="Maximum response time, 0 = any")] = 0,
147
+ mode: Annotated[Literal["live", "full"], Field(description="live: check the hourly list again from here "
148
+ "(30-90 s). full: collect from all 700+ "
149
+ "sources (a few minutes)")] = "live",
150
+ ) -> FreshProxies:
151
+ """Find proxies that work from this machine's network right now.
152
+
153
+ Runs the same checks as the public list – handshake, honeypot test, byte-for-byte content check, verified
154
+ TLS – but from here, so the results were verified seconds ago from the network your code runs on. Slower
155
+ than get_proxies: 30-90 s in live mode, and progress is reported while it runs. It also updates the local
156
+ statistics proxy-scraper learns from."""
157
+ started = time.monotonic()
158
+
159
+ async def progress(elapsed: float, message: str):
160
+ await ctx.report_progress(elapsed, None, message)
161
+
162
+ try:
163
+ found = await agent.check_fresh(want, protocol, countries, https_only, elite_only, exclude_datacenter,
164
+ exclude_blocklisted, max_latency_ms, mode, progress)
165
+ except agent.AgentError as e:
166
+ _fail(e)
167
+ note = None if found else ("Nothing passed from this machine. A firewall may block proxy ports – "
168
+ "try get_proxies, or mode='full'.")
169
+ return FreshProxies(proxies=[Proxy(**p) for p in found], mode=mode,
170
+ took_seconds=round(time.monotonic() - started, 1), note=note)
171
+
172
+ @server.tool(title="Fetch a page through a proxy",
173
+ annotations=ToolAnnotations(read_only_hint=True, idempotent_hint=False, open_world_hint=True))
174
+ async def fetch_url(
175
+ ctx: Context,
176
+ url: Annotated[str, Field(description="Full http:// or https:// URL of a public site")],
177
+ country: Annotated[str, Field(description="Two-letter code: load the page as seen from this country. "
178
+ "Empty = any country.")] = "",
179
+ protocol: Annotated[Protocol, Field(description="Only use proxies of this protocol")] = "any",
180
+ max_chars: Annotated[int, Field(ge=100, le=200_000, description="Cut the text after this many "
181
+ "characters")] = 20_000,
182
+ raw_html: Annotated[bool, Field(description="Return the HTML as it is instead of readable "
183
+ "text")] = False,
184
+ ) -> Page:
185
+ """Load a web page through a verified free proxy and return it as readable text.
186
+
187
+ If a proxy fails, the next one is tried by itself (up to 8), redirects are followed. HTTPS only goes
188
+ through proxies that passed a verified-TLS test, so the page can't be changed on the way. Local and
189
+ private addresses are refused, also when a redirect points there. Useful to see a site from another country or
190
+ when a site blocks this machine. GET only; don't use it for logins or anything with personal data."""
191
+ async def progress(elapsed: float, message: str):
192
+ await ctx.report_progress(elapsed, None, message)
193
+
194
+ try:
195
+ page = await fetcher.fetch(url, protocol=protocol, country=country, max_chars=max_chars,
196
+ raw_html=raw_html, progress=progress)
197
+ except agent.AgentError as e:
198
+ _fail(e)
199
+ return Page(**page)
200
+
201
+ return server
202
+
203
+
204
+ def serve() -> int:
205
+ # stdout carries the protocol: the terminal UI and every log line go to stderr
206
+ from rich.console import Console
207
+
208
+ from . import paths
209
+ from .ui import widgets
210
+ # clients start servers in the user's project folder – results/ doesn't belong there
211
+ paths.RESULTS_DIR = paths.DATA_DIR / "results"
212
+ widgets.console = Console(stderr=True, highlight=False)
213
+ logging.basicConfig(stream=sys.stderr, level=logging.WARNING)
214
+ build_server().run("stdio")
215
+ return 0
216
+
217
+
218
+ if __name__ == "__main__": # python -m proxyscraper.mcp_server: same checks as the proxy-scraper-mcp command
219
+ from .mcp_entry import main
220
+ sys.exit(main())
proxyscraper/netio.py ADDED
@@ -0,0 +1,167 @@
1
+ """Minimal HTTP client on plain asyncio + ssl – for sources, APIs and check targets."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import ssl
7
+ from functools import lru_cache
8
+ from typing import Dict, Optional, Set, Tuple
9
+ from urllib.parse import urljoin, urlsplit
10
+
11
+ USER_AGENT = (
12
+ "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 "
13
+ "(KHTML, like Gecko) Chrome/128.0 Safari/537.36"
14
+ )
15
+ MAX_BODY = 64 * 1024 * 1024
16
+
17
+ # hosts whose certificate couldn't be verified (typically: a firewall with TLS inspection like Sophos)
18
+ INSECURE_HOSTS: Set[str] = set()
19
+
20
+
21
+ @lru_cache(maxsize=None)
22
+ def ssl_context() -> ssl.SSLContext:
23
+ # build once instead of per connection – otherwise loading the CA file blocks the event loop every time
24
+ try:
25
+ import certifi # python.org Python on macOS often has no system certificates
26
+
27
+ return ssl.create_default_context(cafile=certifi.where())
28
+ except ImportError:
29
+ return ssl.create_default_context()
30
+
31
+
32
+ @lru_cache(maxsize=None)
33
+ def _insecure_ssl_context() -> ssl.SSLContext:
34
+ ctx = ssl.create_default_context()
35
+ ctx.check_hostname = False
36
+ ctx.verify_mode = ssl.CERT_NONE
37
+ return ctx
38
+
39
+
40
+ CONDITIONAL_HEADERS = {"If-None-Match", "If-Modified-Since"}
41
+
42
+
43
+ async def http_request(
44
+ url: str,
45
+ timeout: float = 15.0,
46
+ headers: Optional[Dict[str, str]] = None,
47
+ method: str = "GET",
48
+ body: Optional[bytes] = None,
49
+ max_redirects: int = 3,
50
+ insecure_fallback: bool = True,
51
+ ) -> Tuple[int, Dict[bytes, bytes], bytes]:
52
+ """One HTTP/1.1 request -> (status, headers, body). Follows redirects for GET.
53
+
54
+ If certificate verification fails, it is only retried unverified for GETs without a body and without
55
+ custom headers – so for public proxy lists, never with API tokens or sent data. The only headers
56
+ allowed are If-None-Match / If-Modified-Since (ETag cache), which carry no secret.
57
+ The lists are public, and every proxy from them gets checked on its own anyway.
58
+ """
59
+ extra = "".join(f"{k}: {v}\r\n" for k, v in (headers or {}).items())
60
+ if body is not None:
61
+ extra += f"Content-Length: {len(body)}\r\n"
62
+ for _ in range(max_redirects + 1):
63
+ u = urlsplit(url)
64
+ https = u.scheme == "https"
65
+ port = u.port or (443 if https else 80)
66
+ path = (u.path or "/") + (f"?{u.query}" if u.query else "")
67
+ # unverified only for GETs without a body and without custom headers (except the conditional ETag headers,
68
+ # they carry no secret). Never tokens or sent data over an unverified connection.
69
+ insecure_ok = insecure_fallback and method == "GET" and body is None and \
70
+ set(headers or ()) <= CONDITIONAL_HEADERS
71
+ reader, writer = await _connect(u.hostname, port, https, allow_insecure=insecure_ok, timeout=timeout)
72
+ try:
73
+ writer.write(
74
+ f"{method} {path} HTTP/1.1\r\nHost: {u.hostname}\r\nUser-Agent: {USER_AGENT}\r\n"
75
+ f"Accept: */*\r\nAccept-Encoding: identity\r\n{extra}Connection: close\r\n\r\n".encode()
76
+ + (body or b"")
77
+ )
78
+ await writer.drain()
79
+ status, resp_headers, resp_body = await asyncio.wait_for(read_response(reader), timeout)
80
+ finally:
81
+ writer.close()
82
+
83
+ if method == "GET" and status in (301, 302, 303, 307, 308) and b"location" in resp_headers:
84
+ url = urljoin(url, resp_headers[b"location"].decode())
85
+ continue
86
+ return status, resp_headers, resp_body
87
+ raise ConnectionError("too many redirects")
88
+
89
+
90
+ async def http_get(
91
+ url: str, timeout: float = 15.0, max_redirects: int = 3, headers: Optional[Dict[str, str]] = None
92
+ ) -> bytes:
93
+ """GET that raises an exception for anything but 200."""
94
+ status, _, body = await http_request(url, timeout, headers, max_redirects=max_redirects)
95
+ if status != 200:
96
+ raise ConnectionError(f"HTTP {status}")
97
+ return body
98
+
99
+
100
+ async def _connect(host: str, port: int, https: bool, allow_insecure: bool, timeout: float):
101
+ if not https:
102
+ return await asyncio.wait_for(asyncio.open_connection(host, port), timeout)
103
+ ctx = _insecure_ssl_context() if allow_insecure and host in INSECURE_HOSTS else ssl_context()
104
+ try:
105
+ return await asyncio.wait_for(asyncio.open_connection(host, port, ssl=ctx, server_hostname=host), timeout)
106
+ except ssl.SSLCertVerificationError:
107
+ if not allow_insecure:
108
+ raise
109
+ INSECURE_HOSTS.add(host)
110
+ return await asyncio.wait_for(
111
+ asyncio.open_connection(host, port, ssl=_insecure_ssl_context(), server_hostname=host), timeout
112
+ )
113
+
114
+
115
+ async def read_response(reader) -> Tuple[int, Dict[bytes, bytes], bytes]:
116
+ head = await reader.readuntil(b"\r\n\r\n")
117
+ lines = head[:-4].split(b"\r\n")
118
+ parts = lines[0].split()
119
+ status = int(parts[1]) if len(parts) > 1 and parts[1].isdigit() else 0
120
+ headers: Dict[bytes, bytes] = {}
121
+ for line in lines[1:]:
122
+ k, _, v = line.partition(b":")
123
+ headers[k.strip().lower()] = v.strip()
124
+
125
+ # respect Content-Length: servers with keep-alive (e.g. checkip.amazonaws.com) don't close
126
+ # the connection otherwise, and reading to the end would run into the timeout.
127
+ if headers.get(b"transfer-encoding", b"").lower() == b"chunked":
128
+ body = dechunk(await read_all(reader))
129
+ elif headers.get(b"content-length", b"").isdigit():
130
+ length = int(headers[b"content-length"])
131
+ if length > MAX_BODY:
132
+ raise ConnectionError("response too large")
133
+ body = await reader.readexactly(length)
134
+ else:
135
+ body = await read_all(reader)
136
+ return status, headers, body
137
+
138
+
139
+ async def read_all(reader, limit: int = MAX_BODY) -> bytes:
140
+ # some servers (e.g. Cloudflare) end with RST instead of FIN – keep the data already read
141
+ buf = bytearray()
142
+ while len(buf) < limit:
143
+ try:
144
+ chunk = await reader.read(65536)
145
+ except ConnectionResetError:
146
+ break
147
+ if not chunk:
148
+ break
149
+ buf += chunk
150
+ return bytes(buf)
151
+
152
+
153
+ def dechunk(data: bytes) -> bytes:
154
+ out, i = bytearray(), 0
155
+ while i < len(data):
156
+ j = data.find(b"\r\n", i)
157
+ if j < 0:
158
+ break
159
+ try:
160
+ size = int(data[i:j].split(b";")[0], 16)
161
+ except ValueError:
162
+ break
163
+ if size == 0:
164
+ break
165
+ out += data[j + 2 : j + 2 + size]
166
+ i = j + 2 + size + 2
167
+ return bytes(out)