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.
- proxy_scraper_cli-1.7.1.dist-info/METADATA +794 -0
- proxy_scraper_cli-1.7.1.dist-info/RECORD +57 -0
- proxy_scraper_cli-1.7.1.dist-info/WHEEL +5 -0
- proxy_scraper_cli-1.7.1.dist-info/entry_points.txt +4 -0
- proxy_scraper_cli-1.7.1.dist-info/licenses/LICENSE +21 -0
- proxy_scraper_cli-1.7.1.dist-info/top_level.txt +1 -0
- proxyscraper/__init__.py +12 -0
- proxyscraper/__main__.py +7 -0
- proxyscraper/agent.py +635 -0
- proxyscraper/api.py +139 -0
- proxyscraper/app.py +595 -0
- proxyscraper/asndb.py +181 -0
- proxyscraper/blocklist.py +92 -0
- proxyscraper/checker.py +514 -0
- proxyscraper/cli.py +269 -0
- proxyscraper/compat.py +83 -0
- proxyscraper/completion.py +196 -0
- proxyscraper/exporters.py +118 -0
- proxyscraper/fetchcache.py +103 -0
- proxyscraper/geo.py +150 -0
- proxyscraper/geodb.py +143 -0
- proxyscraper/handshake.py +153 -0
- proxyscraper/history.py +106 -0
- proxyscraper/judges.py +159 -0
- proxyscraper/mcp_entry.py +34 -0
- proxyscraper/mcp_server.py +220 -0
- proxyscraper/netio.py +167 -0
- proxyscraper/options.py +274 -0
- proxyscraper/output.py +181 -0
- proxyscraper/pages.py +212 -0
- proxyscraper/parsing.py +192 -0
- proxyscraper/paths.py +55 -0
- proxyscraper/pipeline.py +434 -0
- proxyscraper/preferences.py +24 -0
- proxyscraper/publish.py +236 -0
- proxyscraper/server/__init__.py +41 -0
- proxyscraper/server/core.py +518 -0
- proxyscraper/server/http.py +164 -0
- proxyscraper/server/pool.py +195 -0
- proxyscraper/server/socks.py +65 -0
- proxyscraper/server/status.py +108 -0
- proxyscraper/server/upstream.py +119 -0
- proxyscraper/site/apple-touch-icon.png +0 -0
- proxyscraper/site/googleaac1161b7853c5b5.html +1 -0
- proxyscraper/site/index.html +934 -0
- proxyscraper/site/logo.png +0 -0
- proxyscraper/site/og.png +0 -0
- proxyscraper/sources.json +395 -0
- proxyscraper/sources.py +491 -0
- proxyscraper/targets.py +76 -0
- proxyscraper/ui/__init__.py +54 -0
- proxyscraper/ui/dashboard.py +344 -0
- proxyscraper/ui/keys.py +82 -0
- proxyscraper/ui/report.py +209 -0
- proxyscraper/ui/serve.py +142 -0
- proxyscraper/ui/widgets.py +250 -0
- 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)
|