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/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