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/agent.py
ADDED
|
@@ -0,0 +1,635 @@
|
|
|
1
|
+
"""What the MCP server does, without the MCP SDK: the live list, filters, fresh checks and fetching pages.
|
|
2
|
+
|
|
3
|
+
Kept free of the SDK on purpose – the SDK needs Python 3.10+, this module runs (and is tested) wherever
|
|
4
|
+
proxy-scraper runs. mcp_server.py only turns these functions into MCP tools.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import base64
|
|
11
|
+
import codecs
|
|
12
|
+
import contextlib
|
|
13
|
+
import http.client
|
|
14
|
+
import ipaddress
|
|
15
|
+
import json
|
|
16
|
+
import re
|
|
17
|
+
import secrets
|
|
18
|
+
import socket
|
|
19
|
+
import ssl
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from collections import Counter
|
|
23
|
+
from dataclasses import dataclass
|
|
24
|
+
from datetime import datetime, timezone
|
|
25
|
+
from html.parser import HTMLParser
|
|
26
|
+
from typing import Awaitable, Callable, Iterable, Iterator, List, Optional, Sequence, Union
|
|
27
|
+
from urllib.parse import quote, urljoin, urlsplit, urlunsplit
|
|
28
|
+
|
|
29
|
+
import certifi
|
|
30
|
+
|
|
31
|
+
from .api import find_proxies_async
|
|
32
|
+
from .checker import CheckResult
|
|
33
|
+
from .netio import http_get
|
|
34
|
+
from .pages import SITE_URL
|
|
35
|
+
from .parsing import PROXY_TYPES, make_key
|
|
36
|
+
from .publish import RAW_BASE
|
|
37
|
+
from .server import ProxyPool, RotatingServer
|
|
38
|
+
|
|
39
|
+
LIVE_BASES = (SITE_URL.rstrip("/"), RAW_BASE) # GitHub Pages first, the raw branch as the mirror
|
|
40
|
+
CACHE_SECONDS = 300.0 # the list changes once an hour – no need to load 1 MB for every question
|
|
41
|
+
RETRY_AFTER = 60.0 # after a failed refresh: serve the old list this long before asking GitHub again
|
|
42
|
+
NEXT_RUN_GRACE = 300.0 # the hourly run takes a few minutes – look for the new list a bit after the hour
|
|
43
|
+
MAX_PAGE_BYTES = 2_000_000
|
|
44
|
+
MAX_REDIRECTS = 5
|
|
45
|
+
FETCH_ROUNDS = 3 # a proxy that breaks off or breaks TLS is retired, and the page is tried through another
|
|
46
|
+
FETCH_ATTEMPTS = 8 # proxies per page before giving up – more than --serve's 3, an agent can't just press reload
|
|
47
|
+
USER_AGENT = "Mozilla/5.0 (compatible; proxy-scraper; +https://github.com/maximilianfeix/proxy-scraper)"
|
|
48
|
+
PROTOCOLS = ("any", *PROXY_TYPES)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class AgentError(Exception):
|
|
52
|
+
"""Something the agent can act on – the message says what to do instead."""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
class _ProxyFault(Exception):
|
|
56
|
+
"""The proxy that carried this request may have misbehaved (dropped the page, broke TLS) – try another.
|
|
57
|
+
Only proven when another proxy then gets the page; if all fail the same way, it's the site."""
|
|
58
|
+
|
|
59
|
+
def __init__(self, message: str, certificate: bool = False):
|
|
60
|
+
super().__init__(message)
|
|
61
|
+
self.certificate = certificate
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class _NoProxy(AgentError):
|
|
65
|
+
"""The local server tried its proxies and none got through."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# --------------------------------------------------------------------------- the live list
|
|
69
|
+
|
|
70
|
+
@dataclass
|
|
71
|
+
class LiveList:
|
|
72
|
+
rows: List[dict]
|
|
73
|
+
stats: dict
|
|
74
|
+
loaded_at: float
|
|
75
|
+
|
|
76
|
+
@property
|
|
77
|
+
def updated(self) -> str:
|
|
78
|
+
return str(self.stats.get("updated", ""))
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def run_hours(self) -> int:
|
|
82
|
+
hours = self.stats.get("run_hours")
|
|
83
|
+
return hours if isinstance(hours, int) and hours > 0 else 6 # lists from before run_hours ran every 6 h
|
|
84
|
+
|
|
85
|
+
def age_minutes(self, now: Optional[datetime] = None) -> Optional[int]:
|
|
86
|
+
try:
|
|
87
|
+
updated = datetime.fromisoformat(self.updated)
|
|
88
|
+
except ValueError:
|
|
89
|
+
return None
|
|
90
|
+
return max(0, round(((now or datetime.now(timezone.utc)) - updated).total_seconds() / 60))
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
Fetch = Callable[..., Awaitable[bytes]]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class LiveSource:
|
|
97
|
+
"""The hourly list from GitHub Pages, cached for a few minutes. A failed refresh keeps the old list."""
|
|
98
|
+
|
|
99
|
+
def __init__(self, fetch: Fetch = http_get, clock: Callable[[], float] = time.monotonic,
|
|
100
|
+
ttl: float = CACHE_SECONDS, bases: Sequence[str] = LIVE_BASES,
|
|
101
|
+
wall: Callable[[], float] = time.time):
|
|
102
|
+
self.fetch, self.clock, self.ttl, self.bases, self.wall = fetch, clock, ttl, bases, wall
|
|
103
|
+
self._cache: Optional[LiveList] = None
|
|
104
|
+
self._lock: Optional[asyncio.Lock] = None
|
|
105
|
+
self._retry_at = 0.0
|
|
106
|
+
self._expires = 0.0
|
|
107
|
+
|
|
108
|
+
def _fresh_enough(self) -> bool:
|
|
109
|
+
return bool(self._cache) and (self.clock() < self._expires or self.clock() < self._retry_at)
|
|
110
|
+
|
|
111
|
+
def _keep_for(self, live: LiveList) -> float:
|
|
112
|
+
"""Seconds to keep this list: until the next run should have published a new one, at least ttl."""
|
|
113
|
+
try:
|
|
114
|
+
updated = datetime.fromisoformat(live.updated).timestamp()
|
|
115
|
+
except ValueError:
|
|
116
|
+
return self.ttl
|
|
117
|
+
until_next = updated + live.run_hours * 3600 + NEXT_RUN_GRACE - self.wall()
|
|
118
|
+
return max(self.ttl, min(until_next, live.run_hours * 3600))
|
|
119
|
+
|
|
120
|
+
async def get(self) -> LiveList:
|
|
121
|
+
if self._fresh_enough():
|
|
122
|
+
return self._cache
|
|
123
|
+
if self._lock is None: # created here: before Python 3.10 a lock is bound to the event loop
|
|
124
|
+
self._lock = asyncio.Lock()
|
|
125
|
+
async with self._lock: # several tools at once shouldn't load the list several times
|
|
126
|
+
if self._fresh_enough():
|
|
127
|
+
return self._cache
|
|
128
|
+
error: Optional[Exception] = None
|
|
129
|
+
for base in self.bases:
|
|
130
|
+
try:
|
|
131
|
+
rows, stats = await asyncio.gather(self._json(f"{base}/proxies.json"),
|
|
132
|
+
self._json(f"{base}/stats.json"))
|
|
133
|
+
except Exception as e: # try the next mirror
|
|
134
|
+
error = e
|
|
135
|
+
continue
|
|
136
|
+
if isinstance(rows, list) and isinstance(stats, dict):
|
|
137
|
+
self._cache = LiveList([r for r in rows if isinstance(r, dict)], stats, self.clock())
|
|
138
|
+
self._expires = self.clock() + self._keep_for(self._cache)
|
|
139
|
+
return self._cache
|
|
140
|
+
if self._cache: # GitHub is unreachable: the old list, and don't wait for it again on every call
|
|
141
|
+
self._retry_at = self.clock() + RETRY_AFTER
|
|
142
|
+
return self._cache
|
|
143
|
+
raise AgentError(f"The live proxy list couldn't be loaded ({error}). check_proxies can still find "
|
|
144
|
+
"working proxies by checking them from this machine.")
|
|
145
|
+
|
|
146
|
+
async def _json(self, url: str):
|
|
147
|
+
return json.loads(await self.fetch(url, timeout=20))
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# --------------------------------------------------------------------------- filtering
|
|
151
|
+
|
|
152
|
+
def _check_protocol(protocol: str) -> str:
|
|
153
|
+
protocol = (protocol or "any").lower()
|
|
154
|
+
if protocol not in PROTOCOLS:
|
|
155
|
+
raise AgentError(f"protocol must be one of {', '.join(PROTOCOLS)}, not {protocol!r}")
|
|
156
|
+
return protocol
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def normalize_countries(countries: Iterable[str]) -> List[str]:
|
|
160
|
+
out = []
|
|
161
|
+
for c in countries or ():
|
|
162
|
+
code = str(c).strip().upper()
|
|
163
|
+
if len(code) != 2 or not code.isalpha():
|
|
164
|
+
raise AgentError(f"Countries are two-letter codes like DE, US or JP, not {c!r}.")
|
|
165
|
+
out.append(code)
|
|
166
|
+
return list(dict.fromkeys(out))
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def select(rows: Iterable[dict], protocol: str = "any", countries: Iterable[str] = (), https_only: bool = False,
|
|
170
|
+
elite_only: bool = False, exclude_datacenter: bool = False, exclude_blocklisted: bool = False,
|
|
171
|
+
stable_only: bool = False, max_latency_ms: int = 0, run_hours: int = 1) -> List[dict]:
|
|
172
|
+
"""The rows that pass every filter, fastest first – the same filters as on the website."""
|
|
173
|
+
return sorted(matching(rows, protocol, countries, https_only, elite_only, exclude_datacenter,
|
|
174
|
+
exclude_blocklisted, stable_only, max_latency_ms, run_hours),
|
|
175
|
+
key=lambda r: r.get("latency") or 0)
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def matching(rows: Iterable[dict], protocol: str = "any", countries: Iterable[str] = (), https_only: bool = False,
|
|
179
|
+
elite_only: bool = False, exclude_datacenter: bool = False, exclude_blocklisted: bool = False,
|
|
180
|
+
stable_only: bool = False, max_latency_ms: int = 0, run_hours: int = 1) -> Iterator[dict]:
|
|
181
|
+
"""The rows that pass every filter, in list order – checks the filters before the first row is asked for."""
|
|
182
|
+
protocol = _check_protocol(protocol)
|
|
183
|
+
wanted = set(normalize_countries(countries))
|
|
184
|
+
stable_runs = -(-24 // max(run_hours, 1)) # runs in a row that make a day
|
|
185
|
+
return (r for r in rows
|
|
186
|
+
if (protocol == "any" or r.get("ptype") == protocol)
|
|
187
|
+
and (not wanted or r.get("country") in wanted)
|
|
188
|
+
and (not https_only or r.get("https") is True)
|
|
189
|
+
and (not elite_only or r.get("anonymity") == "elite")
|
|
190
|
+
and (not exclude_datacenter or not r.get("hosting"))
|
|
191
|
+
and (not exclude_blocklisted or not r.get("blocklisted"))
|
|
192
|
+
and (not stable_only or (r.get("streak") or 0) >= stable_runs)
|
|
193
|
+
and (not max_latency_ms or (r.get("latency") or 0) <= max_latency_ms))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def describe(item: Union[dict, CheckResult], run_hours: Optional[int] = None) -> dict:
|
|
197
|
+
"""One proxy the way an agent gets it: plain names, a ready-to-use URL."""
|
|
198
|
+
if isinstance(item, CheckResult):
|
|
199
|
+
item = {"ptype": item.ptype, "proxy": item.proxy, "country": item.country, "latency": item.latency,
|
|
200
|
+
"https": item.https, "anonymity": item.anonymity, "org": item.org, "hosting": item.hosting,
|
|
201
|
+
"blocklisted": item.blocklisted}
|
|
202
|
+
streak = item.get("streak")
|
|
203
|
+
return {
|
|
204
|
+
"url": f"{item['ptype']}://{item['proxy']}",
|
|
205
|
+
"protocol": item["ptype"],
|
|
206
|
+
"address": item["proxy"],
|
|
207
|
+
"country": item.get("country") or None,
|
|
208
|
+
"latency_ms": item.get("latency"),
|
|
209
|
+
"https": item.get("https"),
|
|
210
|
+
"anonymity": item.get("anonymity") or None,
|
|
211
|
+
"provider": item.get("org") or None,
|
|
212
|
+
"datacenter": item.get("hosting"),
|
|
213
|
+
"blocklisted": item.get("blocklisted"),
|
|
214
|
+
"up_for_hours": streak * run_hours if streak and run_hours else None,
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
def shortage_note(rows: Sequence[dict], wanted: int, matched: int, countries: Iterable[str] = ()) -> Optional[str]:
|
|
219
|
+
"""When fewer proxies match than asked for: say so, and where there are more."""
|
|
220
|
+
if matched >= wanted:
|
|
221
|
+
return None
|
|
222
|
+
note = f"Only {matched} proxy matches" if matched == 1 else f"Only {matched} proxies match"
|
|
223
|
+
note += " the filters right now – loosen them for more, or use check_proxies to test from this machine."
|
|
224
|
+
if list(countries):
|
|
225
|
+
top = Counter(r.get("country") for r in rows if r.get("country")).most_common(8)
|
|
226
|
+
note += " Countries with the most proxies: " + ", ".join(f"{c} ({n})" for c, n in top) + "."
|
|
227
|
+
return note
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
# --------------------------------------------------------------------------- fresh checks
|
|
231
|
+
|
|
232
|
+
Progress = Callable[[float, str], Awaitable[None]]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
async def _with_progress(awaitable, progress: Optional[Progress], tick: float, message: str):
|
|
236
|
+
"""Wait for `awaitable`, telling `progress` every `tick` seconds that we're still at it – MCP clients reset
|
|
237
|
+
their timeouts on progress, so long checks and slow pages don't get cancelled."""
|
|
238
|
+
task = asyncio.ensure_future(awaitable)
|
|
239
|
+
started = time.monotonic()
|
|
240
|
+
try:
|
|
241
|
+
while not task.done():
|
|
242
|
+
await asyncio.wait({task}, timeout=tick)
|
|
243
|
+
if progress and not task.done():
|
|
244
|
+
elapsed = time.monotonic() - started
|
|
245
|
+
await progress(elapsed, f"{message} … {elapsed:.0f} s")
|
|
246
|
+
return task.result()
|
|
247
|
+
finally:
|
|
248
|
+
if not task.done():
|
|
249
|
+
task.cancel()
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
async def check_fresh(want: int = 10, protocol: str = "any", countries: Iterable[str] = (), https_only: bool = False,
|
|
253
|
+
elite_only: bool = False, exclude_datacenter: bool = False, exclude_blocklisted: bool = False,
|
|
254
|
+
max_latency_ms: int = 0, mode: str = "live", progress: Optional[Progress] = None,
|
|
255
|
+
tick: float = 5.0) -> List[dict]:
|
|
256
|
+
"""Check proxies from this machine: the live list again ("live", ~30–90 s) or all 700+ sources ("full").
|
|
257
|
+
While it runs, `progress` hears from us every `tick` seconds – that keeps clients from timing out."""
|
|
258
|
+
protocol = _check_protocol(protocol)
|
|
259
|
+
if mode not in ("live", "full"):
|
|
260
|
+
raise AgentError(f"mode must be 'live' or 'full', not {mode!r}")
|
|
261
|
+
found = await _with_progress(find_proxies_async(
|
|
262
|
+
types=list(PROXY_TYPES) if protocol == "any" else [protocol], want=want, https=https_only,
|
|
263
|
+
countries=normalize_countries(countries), anonymity="elite" if elite_only else "",
|
|
264
|
+
max_latency=max_latency_ms, no_datacenter=exclude_datacenter, no_blocklisted=exclude_blocklisted,
|
|
265
|
+
concurrency=500, verbose=False, _recheck="live" if mode == "live" else None),
|
|
266
|
+
progress, tick, "checking proxies from this machine")
|
|
267
|
+
return [describe(r) for r in found]
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
# --------------------------------------------------------------------------- fetching pages
|
|
271
|
+
|
|
272
|
+
_NUMERIC_HOST = re.compile(r"[0-9a-fx.]+") # 127.1, 2130706433, 0x7f000001: IPv4 in disguise
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def check_scheme(url: str) -> str:
|
|
276
|
+
"""A well-formed http(s) URL with an ASCII host (bücher.de -> xn--bcher-kva.de) and a real port."""
|
|
277
|
+
url = url.strip()
|
|
278
|
+
parts = urlsplit(url)
|
|
279
|
+
if parts.scheme not in ("http", "https") or not parts.hostname or any(c.isspace() for c in url):
|
|
280
|
+
raise AgentError(f"Give a full http:// or https:// URL, e.g. https://example.com – not {url!r}.")
|
|
281
|
+
if parts.username is not None:
|
|
282
|
+
raise AgentError("URLs with a login (user:password@) aren't sent through public proxies – strangers run "
|
|
283
|
+
"them.")
|
|
284
|
+
try:
|
|
285
|
+
port = parts.port
|
|
286
|
+
host = parts.hostname.encode("idna").decode("ascii") if not parts.hostname.isascii() else parts.hostname
|
|
287
|
+
except (ValueError, UnicodeError):
|
|
288
|
+
raise AgentError(f"{url!r} has an invalid host or port.") from None
|
|
289
|
+
netloc = f"[{host}]" if ":" in host else host
|
|
290
|
+
if port is not None:
|
|
291
|
+
netloc += f":{port}"
|
|
292
|
+
# the request line is ASCII: /wiki/München -> /wiki/M%C3%BCnchen, anything already encoded stays as it is
|
|
293
|
+
path = quote(parts.path, safe="/%:@!$&'()*+,;=-._~")
|
|
294
|
+
query = quote(parts.query, safe="=&%+/?:@!$'()*,;-._~")
|
|
295
|
+
return urlunsplit((parts.scheme, netloc, path, query, ""))
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _is_public(ip) -> bool:
|
|
299
|
+
if ip.version == 6 and ip.ipv4_mapped:
|
|
300
|
+
ip = ip.ipv4_mapped
|
|
301
|
+
return ip.is_global
|
|
302
|
+
|
|
303
|
+
|
|
304
|
+
def check_target(url: str) -> str:
|
|
305
|
+
"""Only http(s) to public hosts – runs for the first URL and for every redirect. Local names and private
|
|
306
|
+
ranges would mean the proxy operator's own network (or, through a misconfigured proxy, yours)."""
|
|
307
|
+
url = check_scheme(url)
|
|
308
|
+
host = urlsplit(url).hostname.rstrip(".").lower()
|
|
309
|
+
if host == "localhost" or host.endswith((".localhost", ".local", ".internal", ".home.arpa")):
|
|
310
|
+
raise AgentError(f"{host} is a local name – fetch it directly, not through a public proxy.")
|
|
311
|
+
try:
|
|
312
|
+
ip = ipaddress.ip_address(host)
|
|
313
|
+
except ValueError:
|
|
314
|
+
if not _NUMERIC_HOST.fullmatch(host):
|
|
315
|
+
return _check_resolved(url, host)
|
|
316
|
+
try:
|
|
317
|
+
ip = ipaddress.IPv4Address(socket.inet_aton(host))
|
|
318
|
+
except (OSError, ValueError):
|
|
319
|
+
return _check_resolved(url, host)
|
|
320
|
+
if not _is_public(ip):
|
|
321
|
+
raise AgentError(f"{host} is a private or reserved address – free proxies are on the public internet.")
|
|
322
|
+
return url
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
_SKIP = {"script", "style", "noscript", "svg", "template", "iframe", "canvas"}
|
|
326
|
+
_BLOCK = {"p", "div", "br", "li", "tr", "title", "h1", "h2", "h3", "h4", "h5", "h6", "section", "article", "ul",
|
|
327
|
+
"ol", "table", "header", "footer", "nav", "main", "aside", "blockquote", "pre", "dt", "dd", "figcaption",
|
|
328
|
+
"hr", "form", "details", "summary"}
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
class _TextParser(HTMLParser):
|
|
332
|
+
def __init__(self):
|
|
333
|
+
super().__init__(convert_charrefs=True)
|
|
334
|
+
self.parts: List[str] = []
|
|
335
|
+
self.skipping = 0
|
|
336
|
+
|
|
337
|
+
def handle_starttag(self, tag, attrs):
|
|
338
|
+
if tag in _SKIP:
|
|
339
|
+
self.skipping += 1
|
|
340
|
+
elif tag in _BLOCK:
|
|
341
|
+
self.parts.append("\n")
|
|
342
|
+
|
|
343
|
+
def handle_startendtag(self, tag, attrs):
|
|
344
|
+
if tag in _BLOCK:
|
|
345
|
+
self.parts.append("\n")
|
|
346
|
+
|
|
347
|
+
def handle_endtag(self, tag):
|
|
348
|
+
if tag in _SKIP:
|
|
349
|
+
self.skipping = max(0, self.skipping - 1)
|
|
350
|
+
elif tag in _BLOCK:
|
|
351
|
+
self.parts.append("\n")
|
|
352
|
+
|
|
353
|
+
def handle_data(self, data):
|
|
354
|
+
if not self.skipping:
|
|
355
|
+
self.parts.append(data)
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def html_to_text(html: str) -> str:
|
|
359
|
+
"""Readable text from HTML: no scripts or styles, one line per block, whitespace collapsed."""
|
|
360
|
+
parser = _TextParser()
|
|
361
|
+
parser.feed(html)
|
|
362
|
+
parser.close()
|
|
363
|
+
lines = (" ".join(line.split()) for line in "".join(parser.parts).split("\n"))
|
|
364
|
+
return "\n".join(line for line in lines if line)
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def _check_resolved(url: str, host: str) -> str:
|
|
368
|
+
"""A name like 127.0.0.1.nip.io or an intranet host is as private as its address. Looked up here because
|
|
369
|
+
some upstreams (SOCKS4) resolve names on this machine. Unknown names pass – no proxy will reach them."""
|
|
370
|
+
try:
|
|
371
|
+
infos = socket.getaddrinfo(host, None, type=socket.SOCK_STREAM)
|
|
372
|
+
except (OSError, UnicodeError):
|
|
373
|
+
return url
|
|
374
|
+
for info in infos:
|
|
375
|
+
try:
|
|
376
|
+
ip = ipaddress.ip_address(info[4][0].split("%", 1)[0])
|
|
377
|
+
except ValueError:
|
|
378
|
+
continue
|
|
379
|
+
if not _is_public(ip):
|
|
380
|
+
raise AgentError(f"{host} resolves to {ip}, a private or reserved address – free proxies are on the "
|
|
381
|
+
"public internet.")
|
|
382
|
+
return url
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def next_hop(url: str, location: str, check: Callable[[str], str]) -> str:
|
|
386
|
+
"""Where a redirect leads – checked like the first URL, and never from https down to plain http."""
|
|
387
|
+
# http.client hands header values over as latin-1 – UTF-8 locations come back readable this way
|
|
388
|
+
with contextlib.suppress(UnicodeEncodeError, UnicodeDecodeError):
|
|
389
|
+
location = location.encode("latin-1").decode("utf-8")
|
|
390
|
+
target = urljoin(url, location)
|
|
391
|
+
if urlsplit(url).scheme == "https" and urlsplit(target).scheme == "http":
|
|
392
|
+
raise AgentError(f"{url} redirects to plain http ({target}). Not followed: that part would skip the "
|
|
393
|
+
"verified TLS. Fetch the http URL directly if you want it anyway.")
|
|
394
|
+
return check(target)
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def decode_body(body: bytes, charset: Optional[str]) -> str:
|
|
398
|
+
"""Text in the charset the server named – or UTF-8 when it named one Python doesn't know (utf8mb4 …)."""
|
|
399
|
+
try:
|
|
400
|
+
if not codecs.lookup(charset or "utf-8")._is_text_encoding: # zlib, base64 … aren't charsets
|
|
401
|
+
charset = "utf-8"
|
|
402
|
+
except LookupError:
|
|
403
|
+
charset = "utf-8"
|
|
404
|
+
return body.decode(charset or "utf-8", "replace")
|
|
405
|
+
|
|
406
|
+
|
|
407
|
+
def _as_result(r: dict) -> Optional[CheckResult]:
|
|
408
|
+
try:
|
|
409
|
+
return CheckResult(make_key(r["ptype"], r["proxy"]), r["ptype"], r["proxy"], int(r.get("latency") or 0),
|
|
410
|
+
str(r.get("exit_ip") or ""), https=r.get("https"), anonymity=r.get("anonymity") or "",
|
|
411
|
+
country=r.get("country") or "", asn=int(r.get("asn") or 0), org=r.get("org") or "",
|
|
412
|
+
hosting=r.get("hosting"), blocklisted=r.get("blocklisted"))
|
|
413
|
+
except (KeyError, TypeError, ValueError):
|
|
414
|
+
return None
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
_TEXT_TYPES = ("text/", "json", "xml", "javascript", "x-www-form-urlencoded")
|
|
418
|
+
_TUNNEL_502 = re.compile(r"Tunnel connection failed: 502\b") # http.client's words for our server's 502
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def _read_all(response) -> bytes:
|
|
422
|
+
"""The body, or _ProxyFault if the connection ended early. read(n) doesn't complain when a Content-Length
|
|
423
|
+
body comes up short – it just returns less – so the remaining length is checked by hand."""
|
|
424
|
+
try:
|
|
425
|
+
body = response.read(MAX_PAGE_BYTES + 1)
|
|
426
|
+
except http.client.IncompleteRead:
|
|
427
|
+
raise _ProxyFault("the proxy dropped the page halfway") from None
|
|
428
|
+
remaining = getattr(response, "length", None)
|
|
429
|
+
if remaining and len(body) <= MAX_PAGE_BYTES:
|
|
430
|
+
raise _ProxyFault("the proxy dropped the page halfway")
|
|
431
|
+
return body
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _to_text(body: bytes, headers, content_type: str, raw_html: bool) -> str:
|
|
435
|
+
text = decode_body(body, headers.get_content_charset() if headers else None)
|
|
436
|
+
return text if raw_html or "html" not in content_type.lower() else html_to_text(text)
|
|
437
|
+
|
|
438
|
+
|
|
439
|
+
class PageFetcher:
|
|
440
|
+
"""Loads pages through the rotating server of this package, running on 127.0.0.1 with a random password:
|
|
441
|
+
the same failover as `--serve`, and HTTPS only through proxies that passed the verified-TLS test.
|
|
442
|
+
|
|
443
|
+
Deliberately http.client and not urllib: urllib honours no_proxy and the system's proxy exceptions (on macOS
|
|
444
|
+
*.local and 169.254/16), which would send such requests out directly from this machine."""
|
|
445
|
+
|
|
446
|
+
def __init__(self, source: LiveSource, allow_private: bool = False, timeout: float = 20.0,
|
|
447
|
+
strategy: str = "weighted", check: Optional[Callable[[str], str]] = None):
|
|
448
|
+
self.source, self.timeout, self.strategy = source, timeout, strategy
|
|
449
|
+
self.check = check or (check_scheme if allow_private else check_target) # for the URL and every redirect
|
|
450
|
+
self.allows_private = allow_private
|
|
451
|
+
self.server: Optional[RotatingServer] = None
|
|
452
|
+
self._password = secrets.token_urlsafe(24)
|
|
453
|
+
self._list_id: object = None
|
|
454
|
+
self._lock: Optional[asyncio.Lock] = None
|
|
455
|
+
self.downloads_running = 0 # threads inside _get right now (worker threads: counted under a lock)
|
|
456
|
+
self._count_lock = threading.Lock()
|
|
457
|
+
self._tls = ssl.create_default_context(cafile=certifi.where())
|
|
458
|
+
per_attempt = min(timeout, 10.0)
|
|
459
|
+
# the client waits for the server to work through its attempts (connect + first answer each)
|
|
460
|
+
self._client_timeout = per_attempt * 2 * FETCH_ATTEMPTS + 10
|
|
461
|
+
|
|
462
|
+
async def _ready(self) -> LiveList:
|
|
463
|
+
live = await self.source.get()
|
|
464
|
+
if self._lock is None:
|
|
465
|
+
self._lock = asyncio.Lock()
|
|
466
|
+
async with self._lock: # parallel fetches: one starts the server, the others wait for it
|
|
467
|
+
# the list is downloaded again every few minutes – only a new run's list may change the pool,
|
|
468
|
+
# otherwise every proxy we retired would be back five minutes later
|
|
469
|
+
list_id = live.updated or live.loaded_at
|
|
470
|
+
if self.server is None or list_id != self._list_id:
|
|
471
|
+
results = [r for r in (_as_result(row) for row in live.rows) if r]
|
|
472
|
+
if self.server is None:
|
|
473
|
+
server = RotatingServer(ProxyPool(results, strategy=self.strategy, strict_tls=True),
|
|
474
|
+
host="127.0.0.1", port=0, timeout=min(self.timeout, 10.0),
|
|
475
|
+
password=self._password, max_attempts=FETCH_ATTEMPTS,
|
|
476
|
+
public_targets_only=not self.allows_private)
|
|
477
|
+
await server.start()
|
|
478
|
+
self.server = server # only once it listens
|
|
479
|
+
else:
|
|
480
|
+
self.server.pool.merge(results, drop_missing=True) # a new run's list is the whole truth
|
|
481
|
+
self._list_id = list_id
|
|
482
|
+
return live
|
|
483
|
+
|
|
484
|
+
async def fetch(self, url: str, protocol: str = "any", country: str = "", max_chars: int = 20000,
|
|
485
|
+
raw_html: bool = False, progress: Optional[Progress] = None, tick: float = 5.0) -> dict:
|
|
486
|
+
url = await asyncio.to_thread(self.check, url) # may look the name up
|
|
487
|
+
protocol = _check_protocol(protocol)
|
|
488
|
+
countries = normalize_countries([country] if country else [])
|
|
489
|
+
live = await self._ready()
|
|
490
|
+
tls = urlsplit(url).scheme == "https"
|
|
491
|
+
if not any(True for _ in matching(live.rows, protocol, countries, https_only=tls, run_hours=live.run_hours)):
|
|
492
|
+
what = " ".join(x for x in (countries[0] if countries else "", "" if protocol == "any" else protocol,
|
|
493
|
+
"HTTPS-capable" if tls else "") if x)
|
|
494
|
+
raise AgentError(f"No {what} proxy in the live list right now."
|
|
495
|
+
+ (shortage_note(live.rows, 1, 0, countries) or ""))
|
|
496
|
+
session = secrets.token_hex(6)
|
|
497
|
+
wishes = [f"country-{countries[0].lower()}"] if countries else []
|
|
498
|
+
if protocol != "any":
|
|
499
|
+
wishes.append(f"type-{protocol}")
|
|
500
|
+
pool, port = self.server.pool, self.server.port
|
|
501
|
+
started = time.monotonic()
|
|
502
|
+
result, faults, suspects = None, [], []
|
|
503
|
+
for round_ in range(FETCH_ROUNDS):
|
|
504
|
+
# the same wishes a user would put in the proxy login; session names are letters and digits only
|
|
505
|
+
name = f"{session}r{round_}"
|
|
506
|
+
user = "-".join([*wishes, f"session-{name}"])
|
|
507
|
+
try:
|
|
508
|
+
result = await self._download(url, user, port, progress, tick, session=name)
|
|
509
|
+
break
|
|
510
|
+
except _ProxyFault as e:
|
|
511
|
+
faults.append(e)
|
|
512
|
+
bad = pool.session_entry(name)
|
|
513
|
+
if bad and not bad.disabled:
|
|
514
|
+
pool.retire(bad) # out while we retry, so the next round gets a different proxy
|
|
515
|
+
suspects.append(bad)
|
|
516
|
+
except _NoProxy:
|
|
517
|
+
if not faults:
|
|
518
|
+
raise
|
|
519
|
+
break # nobody left after the faults – below it's decided whose fault it was
|
|
520
|
+
if result is None:
|
|
521
|
+
for entry in suspects:
|
|
522
|
+
pool.revive(entry) # every proxy failed the same way: that's the site, not them
|
|
523
|
+
if faults and all(f.certificate for f in faults):
|
|
524
|
+
raise AgentError(f"The TLS certificate of {url} failed verification through {len(faults)} different "
|
|
525
|
+
f"prox{'y' if len(faults) == 1 else 'ies'} ({faults[-1]}). The site's certificate is "
|
|
526
|
+
"probably invalid or expired – nothing a proxy can fix.")
|
|
527
|
+
raise AgentError(f"Couldn't load {url}: {faults[-1]}, and no other proxy got it through. Free proxies "
|
|
528
|
+
"come and go – try again in a moment.")
|
|
529
|
+
# got it: the proxies that failed on the way are proven bad and stay out
|
|
530
|
+
status, final_url, headers, body = result
|
|
531
|
+
held = pool.session_entry(name)
|
|
532
|
+
content_type = headers.get("Content-Type", "") if headers else ""
|
|
533
|
+
is_text = not content_type or any(t in content_type.lower() for t in _TEXT_TYPES)
|
|
534
|
+
# up to 2 MB of HTML: parse it off the event loop, which also runs the proxy server
|
|
535
|
+
text = await asyncio.to_thread(_to_text, body, headers, content_type, raw_html) if is_text else ""
|
|
536
|
+
truncated = len(text) > max_chars or len(body) > MAX_PAGE_BYTES
|
|
537
|
+
return {
|
|
538
|
+
"url": url,
|
|
539
|
+
"final_url": final_url,
|
|
540
|
+
"status": status,
|
|
541
|
+
"content_type": content_type or None,
|
|
542
|
+
"via": held.result.url if held else None,
|
|
543
|
+
"proxy_country": (held.result.country or None) if held else None,
|
|
544
|
+
"elapsed_ms": round((time.monotonic() - started) * 1000),
|
|
545
|
+
"bytes": min(len(body), MAX_PAGE_BYTES),
|
|
546
|
+
"text": text[:max_chars] if is_text else "",
|
|
547
|
+
"truncated": truncated,
|
|
548
|
+
"note": None if is_text else f"Binary content ({content_type}) isn't returned as text.",
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
async def _download(self, url: str, user: str, port: int, progress: Optional[Progress], tick: float,
|
|
552
|
+
session: str = ""):
|
|
553
|
+
"""_get in a thread. If the caller is cancelled, the socket is shut down so the thread ends too – a thread
|
|
554
|
+
can't be cancelled, and it would otherwise wait for minutes on a proxy that doesn't answer."""
|
|
555
|
+
slot: dict = {}
|
|
556
|
+
try:
|
|
557
|
+
return await _with_progress(asyncio.to_thread(self._get, url, user, port, slot), progress, tick,
|
|
558
|
+
"loading the page through a proxy")
|
|
559
|
+
except BaseException: # cancelled, or the progress report failed because the client left
|
|
560
|
+
if session and self.server is not None:
|
|
561
|
+
self.server.abort_session(session) # the server hangs up: wakes the thread on every platform
|
|
562
|
+
conn = slot.get("conn")
|
|
563
|
+
sock = conn.sock if conn is not None else None
|
|
564
|
+
if sock is not None:
|
|
565
|
+
with contextlib.suppress(OSError):
|
|
566
|
+
sock.shutdown(socket.SHUT_RDWR) # wakes the reading thread on Unix
|
|
567
|
+
with contextlib.suppress(OSError):
|
|
568
|
+
sock.close() # and on Windows, where shutdown alone leaves the read waiting
|
|
569
|
+
raise
|
|
570
|
+
|
|
571
|
+
def _get(self, url: str, user: str, port: int, slot: dict):
|
|
572
|
+
"""GET through the local rotating server, following redirects by hand so every hop gets checked."""
|
|
573
|
+
with self._count_lock:
|
|
574
|
+
self.downloads_running += 1
|
|
575
|
+
try:
|
|
576
|
+
auth = "Basic " + base64.b64encode(f"{user}:{self._password}".encode()).decode()
|
|
577
|
+
for _hop in range(MAX_REDIRECTS + 1):
|
|
578
|
+
status, headers, body, location = self._get_once(url, auth, port, slot)
|
|
579
|
+
if location is None:
|
|
580
|
+
return status, url, headers, body
|
|
581
|
+
url = next_hop(url, location, self.check) # a public page may point somewhere private
|
|
582
|
+
raise AgentError(f"{url} redirected more than {MAX_REDIRECTS} times – stopped there.")
|
|
583
|
+
finally:
|
|
584
|
+
with self._count_lock:
|
|
585
|
+
self.downloads_running -= 1
|
|
586
|
+
|
|
587
|
+
def _get_once(self, url: str, auth: str, port: int, slot: dict):
|
|
588
|
+
parts = urlsplit(url)
|
|
589
|
+
https = parts.scheme == "https"
|
|
590
|
+
headers = {"User-Agent": USER_AGENT, "Accept": "*/*", "Accept-Encoding": "identity"}
|
|
591
|
+
if https: # CONNECT through the server, then TLS end to end – verified against certifi's roots
|
|
592
|
+
conn = http.client.HTTPSConnection("127.0.0.1", port, timeout=self._client_timeout, context=self._tls)
|
|
593
|
+
conn.set_tunnel(parts.hostname, parts.port or 443, headers={"Proxy-Authorization": auth})
|
|
594
|
+
target = urlunsplit(("", "", parts.path or "/", parts.query, ""))
|
|
595
|
+
else: # a classic proxy request with the absolute URL
|
|
596
|
+
conn = http.client.HTTPConnection("127.0.0.1", port, timeout=self._client_timeout)
|
|
597
|
+
headers["Proxy-Authorization"] = auth
|
|
598
|
+
target = urlunsplit((parts.scheme, parts.netloc, parts.path or "/", parts.query, ""))
|
|
599
|
+
slot["conn"] = conn
|
|
600
|
+
try:
|
|
601
|
+
conn.request("GET", target, headers=headers)
|
|
602
|
+
response = conn.getresponse()
|
|
603
|
+
if conn.sock is not None: # the answer is there: the rest shouldn't take the failover's patience
|
|
604
|
+
conn.sock.settimeout(min(self.timeout, 30.0))
|
|
605
|
+
marker = response.getheader("X-Proxy-Scraper")
|
|
606
|
+
if marker == "no-proxy-answered":
|
|
607
|
+
raise _NoProxy(self._failed(url, f"none of {FETCH_ATTEMPTS} proxies got through"))
|
|
608
|
+
if marker == "bad-request":
|
|
609
|
+
raise AgentError(f"The request for {url} couldn't be passed on – an unusual URL?")
|
|
610
|
+
location = response.getheader("Location")
|
|
611
|
+
if response.status in (301, 302, 303, 307, 308) and location:
|
|
612
|
+
return response.status, response.headers, b"", location # the body isn't needed – not read at all
|
|
613
|
+
return response.status, response.headers, _read_all(response), None
|
|
614
|
+
except ssl.SSLCertVerificationError as e:
|
|
615
|
+
raise _ProxyFault(f"certificate check failed: {e.verify_message}", certificate=True) from None
|
|
616
|
+
except (ssl.SSLError, ConnectionResetError, http.client.RemoteDisconnected, http.client.IncompleteRead) as e:
|
|
617
|
+
raise _ProxyFault(f"the proxy dropped the connection ({e.__class__.__name__})") from None
|
|
618
|
+
except (OSError, http.client.HTTPException) as e:
|
|
619
|
+
reason = str(e)
|
|
620
|
+
if _TUNNEL_502.search(reason): # the server's answer to CONNECT when no proxy got through
|
|
621
|
+
raise _NoProxy(self._failed(url, f"none of {FETCH_ATTEMPTS} proxies got through")) from None
|
|
622
|
+
raise AgentError(self._failed(url, reason)) from None
|
|
623
|
+
finally:
|
|
624
|
+
conn.close()
|
|
625
|
+
|
|
626
|
+
@staticmethod
|
|
627
|
+
def _failed(url: str, reason: str) -> str:
|
|
628
|
+
return (f"Couldn't load {url}: {reason}. Some sites block known public proxies (Wikipedia, for example); "
|
|
629
|
+
"otherwise free proxies simply come and go – try again, other proxies get picked, or use "
|
|
630
|
+
"check_proxies for fresh ones.")
|
|
631
|
+
|
|
632
|
+
async def close(self) -> None:
|
|
633
|
+
if self.server is not None:
|
|
634
|
+
await self.server.close()
|
|
635
|
+
self.server = None
|