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
@@ -0,0 +1,103 @@
1
+ """Don't reload unchanged lists every time.
2
+
3
+ For every list the cache remembers the ETag or Last-Modified and the parsed proxy keys.
4
+ On the next run the download asks with If-None-Match / If-Modified-Since; if 304 comes back,
5
+ the keys come from the cache. They are stored unfiltered (all types), because --types can
6
+ change between two runs.
7
+
8
+ data/fetch-cache/index.json url -> {etag, modified, type, file, used}
9
+ data/fetch-cache/<sha1>.txt.gz the keys, one per line
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import gzip
15
+ import hashlib
16
+ import json
17
+ import time
18
+ import zlib
19
+ from pathlib import Path
20
+ from typing import Dict, Optional
21
+
22
+ from .paths import DATA_DIR, atomic_write
23
+
24
+ CACHE_DIR = DATA_DIR / "fetch-cache"
25
+ FORGET_AFTER = 14 * 86400 # lists that haven't been loaded for this long are dropped
26
+
27
+
28
+ def _valid_entry(entry) -> bool:
29
+ return (isinstance(entry, dict) and isinstance(entry.get("file"), str) and entry["file"].endswith(".txt.gz")
30
+ and "/" not in entry["file"] and "\\" not in entry["file"]
31
+ and isinstance(entry.get("etag", ""), str) and isinstance(entry.get("modified", ""), str)
32
+ and isinstance(entry.get("used", 0), (int, float)))
33
+
34
+
35
+ class FetchCache:
36
+ def __init__(self, directory: Path = CACHE_DIR, enabled: bool = True):
37
+ self.dir = directory
38
+ self.enabled = enabled
39
+ self.entries: Dict[str, dict] = {}
40
+ self.hits = 0
41
+ if enabled:
42
+ try:
43
+ raw = json.loads((directory / "index.json").read_text(encoding="utf-8"))
44
+ except (OSError, ValueError):
45
+ raw = {}
46
+ # only entries of the expected shape – a broken index.json simply means "empty cache"
47
+ if isinstance(raw, dict):
48
+ self.entries = {url: e for url, e in raw.items() if _valid_entry(e)}
49
+
50
+ def conditional_headers(self, url: str, ptype: str) -> Dict[str, str]:
51
+ """Headers for a conditional request – empty if there's nothing (usable) in the cache.
52
+
53
+ ptype is the type the list is parsed with: if it changes (e.g. http -> auto),
54
+ the stored keys no longer fit and the list is loaded again."""
55
+ entry = self.entries.get(url) if self.enabled else None
56
+ if not entry or entry.get("type") != ptype or not (self.dir / entry["file"]).is_file():
57
+ return {}
58
+ headers = {}
59
+ if entry.get("etag"):
60
+ headers["If-None-Match"] = entry["etag"]
61
+ if entry.get("modified"):
62
+ headers["If-Modified-Since"] = entry["modified"]
63
+ return headers
64
+
65
+ def load(self, url: str) -> Optional[str]:
66
+ """Keys from the cache after a 304 response (None if the file is broken)."""
67
+ entry = self.entries.get(url)
68
+ if not entry:
69
+ return None
70
+ try:
71
+ keys = gzip.decompress((self.dir / entry["file"]).read_bytes()).decode("utf-8")
72
+ except (OSError, EOFError, UnicodeDecodeError, zlib.error): # broken file = cache miss
73
+ return None
74
+ entry["used"] = time.time()
75
+ self.hits += 1
76
+ return keys
77
+
78
+ def store(self, url: str, headers: Dict[bytes, bytes], keys: str, ptype: str) -> None:
79
+ """After a normal download: remember it if the server sends an ETag or Last-Modified."""
80
+ if not self.enabled:
81
+ return
82
+ etag = headers.get(b"etag", b"").decode("latin-1").strip()
83
+ modified = headers.get(b"last-modified", b"").decode("latin-1").strip()
84
+ if not etag and not modified:
85
+ old = self.entries.pop(url, None)
86
+ if old: # remove the file too, otherwise it would stay forever
87
+ (self.dir / old["file"]).unlink(missing_ok=True)
88
+ return
89
+ name = hashlib.sha1(url.encode()).hexdigest() + ".txt.gz"
90
+ self.dir.mkdir(parents=True, exist_ok=True)
91
+ tmp = self.dir / (name + ".tmp")
92
+ tmp.write_bytes(gzip.compress(keys.encode("utf-8"), compresslevel=5))
93
+ tmp.replace(self.dir / name)
94
+ self.entries[url] = {"etag": etag, "modified": modified, "type": ptype, "file": name, "used": time.time()}
95
+
96
+ def save(self, now: Optional[float] = None) -> None:
97
+ index = self.dir / "index.json"
98
+ if not self.enabled or (not self.entries and not index.exists()):
99
+ return # an empty index is written too – otherwise a removed entry would come back
100
+ now = time.time() if now is None else now
101
+ for url in [u for u, e in self.entries.items() if now - e.get("used", 0) > FORGET_AFTER]:
102
+ (self.dir / self.entries.pop(url)["file"]).unlink(missing_ok=True)
103
+ atomic_write(index, json.dumps(self.entries, indent=0, sort_keys=True))
proxyscraper/geo.py ADDED
@@ -0,0 +1,150 @@
1
+ """Countries of the exit IPs.
2
+
3
+ First offline from the DB-IP database (geodb.py) – instantly and without a limit. Only what's missing
4
+ there (or when the database couldn't be loaded) goes to the batch API of ip-api.com
5
+ (100 IPs per request, 15 requests/minute, runs in the background). API results are cached,
6
+ so known IPs are never queried again.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import contextlib
13
+ import json
14
+ import time
15
+ import warnings
16
+ from typing import Callable, Dict, List, Optional
17
+
18
+ from .geodb import CountryDB
19
+ from .netio import http_request
20
+ from .paths import DATA_DIR, atomic_write
21
+
22
+ GEO_CACHE_FILE = DATA_DIR / "geo_cache.json"
23
+ BATCH_URL = "http://ip-api.com/batch?fields=status,countryCode,query"
24
+ BATCH_SIZE = 100
25
+ MIN_INTERVAL = 4.2 # 60 s / 15 requests, plus a buffer
26
+ CACHE_TTL = 30 * 86400.0
27
+
28
+
29
+ def flag(country: str) -> str:
30
+ """'DE' -> 🇩🇪 (built from regional indicator characters)."""
31
+ if len(country) != 2 or not country.isalpha():
32
+ return " "
33
+ return "".join(chr(0x1F1E6 + ord(c) - ord("A")) for c in country.upper())
34
+
35
+
36
+ class GeoResolver:
37
+ def __init__(self, enabled: bool = True, on_resolved: Optional[Callable[[str, str], None]] = None,
38
+ offline: Optional[CountryDB] = None):
39
+ self.enabled = enabled
40
+ self.offline = offline
41
+ self.offline_hits = 0
42
+ # per run every exit IP gets exactly one country – no matter whether it comes from the database, the cache
43
+ # or ip-api, and whether the database only arrives half-way through
44
+ self.assigned: Dict[str, str] = {}
45
+ self.on_resolved = on_resolved
46
+ self.cache: Dict[str, List] = {} # ip -> [country, timestamp]
47
+ self.pending: List[str] = []
48
+ self._queued = set()
49
+ self._stop = asyncio.Event()
50
+ self.failed = False
51
+ if GEO_CACHE_FILE.exists():
52
+ try:
53
+ self.cache = json.loads(GEO_CACHE_FILE.read_text(encoding="utf-8"))
54
+ except (OSError, ValueError) as e:
55
+ warnings.warn(f"{GEO_CACHE_FILE.name} unreadable ({e})", stacklevel=2)
56
+
57
+ def lookup(self, ip: str) -> str:
58
+ hit = self.cache.get(ip)
59
+ return hit[0] if hit and time.time() - hit[1] < CACHE_TTL else ""
60
+
61
+ def use_offline(self, db: CountryDB) -> None:
62
+ """Take over a new database mid-run – also for IPs already waiting for ip-api,
63
+ so the same exit IP isn't classified one way once and another way the next time."""
64
+ self.offline = db
65
+ waiting, self.pending = self.pending, []
66
+ for ip in waiting:
67
+ country = db.lookup(ip)
68
+ if not country:
69
+ self.pending.append(ip)
70
+ continue
71
+ self.offline_hits += 1
72
+ self._assign(ip, country)
73
+
74
+ def _assign(self, ip: str, country: str) -> None:
75
+ if ip in self.assigned:
76
+ return
77
+ self.assigned[ip] = country
78
+ if self.on_resolved:
79
+ self.on_resolved(ip, country)
80
+
81
+ def request(self, ip: str) -> str:
82
+ """Country right away (offline or from the cache), otherwise queue it for the next batch request."""
83
+ if ip in self.assigned:
84
+ return self.assigned[ip]
85
+ country = ""
86
+ if self.enabled and self.offline:
87
+ country = self.offline.lookup(ip)
88
+ if country:
89
+ self.offline_hits += 1
90
+ country = country or self.lookup(ip)
91
+ if country:
92
+ self.assigned[ip] = country
93
+ elif self.enabled and ip not in self._queued:
94
+ self._queued.add(ip)
95
+ self.pending.append(ip)
96
+ return country
97
+
98
+ async def run(self) -> None:
99
+ """Background loop – ends after stop() as soon as nothing is pending."""
100
+ if not self.enabled:
101
+ return
102
+ while not (self._stop.is_set() and not self.pending):
103
+ if not self.pending:
104
+ with contextlib.suppress(asyncio.TimeoutError):
105
+ await asyncio.wait_for(self._stop.wait(), 0.5)
106
+ continue
107
+ batch, self.pending = self.pending[:BATCH_SIZE], self.pending[BATCH_SIZE:]
108
+ started = time.monotonic()
109
+ wait = await self._resolve(batch)
110
+ if self.failed:
111
+ return
112
+ await asyncio.sleep(max(wait, MIN_INTERVAL - (time.monotonic() - started)))
113
+
114
+ async def _resolve(self, batch: List[str]) -> float:
115
+ """One batch request; returns the wait time needed before the next one."""
116
+ try:
117
+ status, headers, body = await http_request(
118
+ BATCH_URL, timeout=10, method="POST", body=json.dumps(batch).encode(),
119
+ headers={"Content-Type": "application/json"},
120
+ )
121
+ except Exception: # API unreachable -> countries stay empty, the rest keeps running
122
+ self.failed = True
123
+ return 0.0
124
+ if status == 429:
125
+ self.pending = batch + self.pending
126
+ return float(headers.get(b"x-ttl", b"60") or 60)
127
+ if status != 200:
128
+ self.failed = True
129
+ return 0.0
130
+ now = time.time()
131
+ try:
132
+ rows = json.loads(body)
133
+ except ValueError:
134
+ return 0.0
135
+ for row in rows:
136
+ if row.get("status") == "success":
137
+ ip, country = row["query"], row["countryCode"]
138
+ self.cache[ip] = [country, now]
139
+ self._assign(ip, country) # already classified otherwise (e.g. by the database)? keep it
140
+ # if ip-api reports the limit is almost reached, wait for the reset
141
+ if headers.get(b"x-rl", b"1") == b"0":
142
+ return float(headers.get(b"x-ttl", b"60") or 60)
143
+ return 0.0
144
+
145
+ def stop(self) -> None:
146
+ self._stop.set()
147
+
148
+ def save(self) -> None:
149
+ if self.cache:
150
+ atomic_write(GEO_CACHE_FILE, json.dumps(self.cache, separators=(",", ":")))
proxyscraper/geodb.py ADDED
@@ -0,0 +1,143 @@
1
+ """Countries offline – with the free country database from DB-IP (DB-IP Lite, CC BY 4.0).
2
+
3
+ The CSV (≈ 4.5 MB gzip, new every month) is loaded once a month and turned into two number arrays
4
+ plus country codes; a lookup is then a binary search and takes microseconds. That way the tool no
5
+ longer depends on ip-api.com (15 requests/minute) – that's only a fallback for IPs missing here,
6
+ or when the database can't be loaded.
7
+
8
+ IP Geolocation by DB-IP: https://db-ip.com
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import gzip
14
+ import io
15
+ import socket
16
+ import struct
17
+ from array import array
18
+ from bisect import bisect_right
19
+ from datetime import date
20
+ from pathlib import Path
21
+ from typing import Optional
22
+
23
+ from .netio import http_get
24
+ from .paths import DATA_DIR
25
+
26
+ DB_FILE = DATA_DIR / "geo" / "dbip-country-ipv4.bin"
27
+ URL = "https://download.db-ip.com/free/dbip-country-lite-{month}.csv.gz"
28
+ MAGIC = b"PSGEO1"
29
+ HEADER = len(MAGIC) + 7 + 4 # magic, month, count
30
+
31
+
32
+ def _ip_to_int(ip: str) -> int:
33
+ return struct.unpack("!I", socket.inet_aton(ip))[0]
34
+
35
+
36
+ class CountryDB:
37
+ def __init__(self, starts: array, ends: array, codes: bytes, month: str):
38
+ self.starts = starts
39
+ self.ends = ends
40
+ self.codes = codes # 2 bytes per range
41
+ self.month = month
42
+
43
+ def __len__(self) -> int:
44
+ return len(self.starts)
45
+
46
+ def lookup(self, ip: str) -> str:
47
+ """'8.8.8.8' -> 'US'; '' if unknown or not a country (ZZ = reserved)."""
48
+ try:
49
+ n = _ip_to_int(ip)
50
+ except OSError:
51
+ return ""
52
+ i = bisect_right(self.starts, n) - 1
53
+ if i < 0 or n > self.ends[i]:
54
+ return ""
55
+ code = self.codes[2 * i:2 * i + 2].decode("ascii")
56
+ return "" if code == "ZZ" else code
57
+
58
+ @classmethod
59
+ def from_csv(cls, text: str, month: str) -> "CountryDB":
60
+ """DB-IP CSV ("start,end,country" for IPv4 and IPv6) -> only the IPv4 ranges."""
61
+ starts, ends, codes = array("I"), array("I"), bytearray()
62
+ for line in io.StringIO(text):
63
+ fields = line.rstrip("\r\n").split(",")
64
+ if len(fields) < 3 or ":" in fields[0]:
65
+ continue # IPv6 or a broken line
66
+ start, end, code = fields[0], fields[1], fields[2].strip() # evtl. weitere Spalten ignorieren
67
+ if len(code) != 2 or not (code.isascii() and code.isalpha()):
68
+ continue
69
+ try:
70
+ s, e = _ip_to_int(start), _ip_to_int(end)
71
+ except OSError:
72
+ continue # broken address – skip just this line
73
+ if codes and codes[-2:] == code.encode() and s == ends[-1] + 1:
74
+ ends[-1] = e # Nachbarbereich desselben Landes zusammenfassen
75
+ continue
76
+ starts.append(s)
77
+ ends.append(e)
78
+ codes += code.encode("ascii")
79
+ return cls(starts, ends, bytes(codes), month)
80
+
81
+ def save(self, path: Path = DB_FILE) -> None:
82
+ path.parent.mkdir(parents=True, exist_ok=True)
83
+ tmp = path.with_suffix(".tmp")
84
+ with tmp.open("wb") as fh:
85
+ fh.write(MAGIC + self.month.encode("ascii").ljust(7) + struct.pack("!I", len(self.starts)))
86
+ self.starts.tofile(fh)
87
+ self.ends.tofile(fh)
88
+ fh.write(self.codes)
89
+ tmp.replace(path)
90
+
91
+ @classmethod
92
+ def load(cls, path: Path = DB_FILE) -> Optional["CountryDB"]:
93
+ try:
94
+ size = path.stat().st_size
95
+ with path.open("rb") as fh:
96
+ if fh.read(len(MAGIC)) != MAGIC:
97
+ return None
98
+ month = fh.read(7).decode("ascii").strip()
99
+ (count,) = struct.unpack("!I", fh.read(4))
100
+ if size != HEADER + count * 10: # 2 × 4 bytes + 2 bytes of country per range
101
+ return None # truncated or broken – don't blindly allocate memory
102
+ starts, ends = array("I"), array("I")
103
+ starts.fromfile(fh, count)
104
+ ends.fromfile(fh, count)
105
+ codes = fh.read(2 * count)
106
+ except (OSError, EOFError, ValueError, struct.error, UnicodeDecodeError):
107
+ return None
108
+ if len(codes) != 2 * count or not (codes.isascii() and codes.isalpha()):
109
+ return None # broken country codes would only show up during a lookup
110
+ return cls(starts, ends, codes, month)
111
+
112
+
113
+ def _months(today: date):
114
+ """Current and previous month as 'YYYY-MM' – at the start of a month the new file may not be there yet."""
115
+ yield today.strftime("%Y-%m")
116
+ prev = date(today.year - (today.month == 1), 12 if today.month == 1 else today.month - 1, 1)
117
+ yield prev.strftime("%Y-%m")
118
+
119
+
120
+ def is_current(db: Optional[CountryDB], today: Optional[date] = None) -> bool:
121
+ return db is not None and db.month == (today or date.today()).strftime("%Y-%m")
122
+
123
+
124
+ async def load_country_db(path: Path = DB_FILE, today: Optional[date] = None, fetch=http_get) -> Optional[CountryDB]:
125
+ """Database from data/ – new from DB-IP once a month. None if there is none at all.
126
+
127
+ May download (up to two attempts of 30 s each) – so call it in the background."""
128
+ today = today or date.today()
129
+ current = CountryDB.load(path)
130
+ if current and current.month == today.strftime("%Y-%m"):
131
+ return current
132
+ for month in _months(today):
133
+ if current and current.month >= month:
134
+ return current # there's no newer one (yet)
135
+ try:
136
+ data = await fetch(URL.format(month=month), timeout=30)
137
+ db = CountryDB.from_csv(gzip.decompress(data).decode("ascii", "replace"), month)
138
+ except Exception: # unreachable or broken – try the next month or keep the old file
139
+ continue
140
+ if len(db) > 1000: # plausibility: the real file has more than 100,000 ranges
141
+ db.save(path)
142
+ return db
143
+ return current
@@ -0,0 +1,153 @@
1
+ """Connecting through HTTP, SOCKS4 and SOCKS5 proxies – for the checker and the proxy server.
2
+
3
+ Proxies with credentials appear as "user:pass@ip:port" in the key, user and password
4
+ URL-encoded (otherwise ":" or "@" in the password would mess everything up).
5
+
6
+ The functions get send/recv_exact instead of a stream because the HTTPS test works with
7
+ bare sockets and everything else with asyncio streams.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import base64
13
+ from dataclasses import dataclass
14
+ from typing import Awaitable, Callable
15
+ from urllib.parse import quote, unquote
16
+
17
+ Send = Callable[[bytes], Awaitable[None]]
18
+ RecvExact = Callable[[int], Awaitable[bytes]]
19
+
20
+ SOCKS5_NO_AUTH = 0x00
21
+ SOCKS5_USER_PASS = 0x02 # RFC 1929
22
+
23
+
24
+ @dataclass(frozen=True)
25
+ class Endpoint:
26
+ host: str
27
+ port: int
28
+ user: str = ""
29
+ password: str = ""
30
+
31
+ @property
32
+ def address(self) -> str:
33
+ return f"{self.host}:{self.port}"
34
+
35
+ @property
36
+ def has_auth(self) -> bool:
37
+ return bool(self.user)
38
+
39
+
40
+ def parse_endpoint(proxy: str) -> Endpoint:
41
+ """'user:pass@1.2.3.4:1080' or '1.2.3.4:1080' -> Endpoint."""
42
+ auth, _, address = proxy.rpartition("@")
43
+ host, _, port = address.rpartition(":")
44
+ user, _, password = auth.partition(":")
45
+ return Endpoint(host, int(port), unquote(user), unquote(password))
46
+
47
+
48
+ def format_auth(user: str, password: str) -> str:
49
+ """Encode user and password the way they appear in the key ('' without a user)."""
50
+ if not user:
51
+ return ""
52
+ return quote(user, safe="") + (":" + quote(password, safe="") if password else "")
53
+
54
+
55
+ def proxy_authorization(ep: Endpoint) -> bytes:
56
+ """Header line for HTTP proxies (incl. \\r\\n), empty without credentials."""
57
+ if not ep.has_auth:
58
+ return b""
59
+ token = base64.b64encode(f"{ep.user}:{ep.password}".encode()).decode()
60
+ return f"Proxy-Authorization: Basic {token}\r\n".encode()
61
+
62
+
63
+ def with_proxy_auth(request: bytes, ep: Endpoint) -> bytes:
64
+ """Appends Proxy-Authorization to the head of a finished HTTP request (ends with \\r\\n\\r\\n)."""
65
+ header = proxy_authorization(ep)
66
+ return request[:-2] + header + b"\r\n" if header else request
67
+
68
+
69
+ class ProxyRefused(Exception):
70
+ """The proxy answered, but the CONNECT to the target was refused – it spoke the protocol fine."""
71
+
72
+
73
+ async def socks4(send: Send, recv_exact: RecvExact, ep: Endpoint, ip_bytes: bytes, port: int) -> bool:
74
+ """SOCKS4 CONNECT; the user name goes into the user ID field (SOCKS4 has no password)."""
75
+ return await socks4_connect(send, recv_exact, ep, ip_bytes, port, strict=False)
76
+
77
+
78
+ async def socks4_connect(send: Send, recv_exact: RecvExact, ep: Endpoint, ip_bytes: bytes, port: int,
79
+ strict: bool = True) -> bool:
80
+ """strict: a proper SOCKS4 reply that refuses the CONNECT (0x5B–0x5D) raises ProxyRefused,
81
+ anything that isn't a SOCKS4 reply at all returns False."""
82
+ await send(b"\x04\x01" + port.to_bytes(2, "big") + ip_bytes + ep.user.encode()[:255] + b"\x00")
83
+ reply = await recv_exact(8)
84
+ if reply[1] == 0x5A:
85
+ return True
86
+ if strict and reply[0] == 0x00 and reply[1] in (0x5B, 0x5C, 0x5D):
87
+ raise ProxyRefused(f"SOCKS4 reply 0x{reply[1]:02x}")
88
+ return False
89
+
90
+
91
+ async def socks5(send: Send, recv_exact: RecvExact, ep: Endpoint, address: bytes, port: int) -> bool:
92
+ """SOCKS5 CONNECT to address (ATYP + address, see socks5_ipv4/socks5_domain)."""
93
+ return await socks5_connect(send, recv_exact, ep, address, port, strict=False)
94
+
95
+
96
+ async def socks5_connect(send: Send, recv_exact: RecvExact, ep: Endpoint, address: bytes, port: int,
97
+ strict: bool = True) -> bool:
98
+ """strict: greeting and login are the proxy's part (False on failure); a refused CONNECT after a
99
+ successful greeting raises ProxyRefused, because then the proxy works and only the target failed."""
100
+ methods = bytes([SOCKS5_NO_AUTH, SOCKS5_USER_PASS]) if ep.has_auth else bytes([SOCKS5_NO_AUTH])
101
+ await send(b"\x05" + bytes([len(methods)]) + methods)
102
+ reply = await recv_exact(2)
103
+ if reply[0] != 0x05:
104
+ return False
105
+ if reply[1] == SOCKS5_USER_PASS and ep.has_auth:
106
+ user, password = ep.user.encode(), ep.password.encode()
107
+ if len(user) > 255 or len(password) > 255:
108
+ return False
109
+ await send(b"\x01" + bytes([len(user)]) + user + bytes([len(password)]) + password)
110
+ if (await recv_exact(2))[1] != 0x00:
111
+ return False # login refused
112
+ elif reply[1] != SOCKS5_NO_AUTH:
113
+ return False
114
+ await send(b"\x05\x01\x00" + address + port.to_bytes(2, "big"))
115
+ if await socks5_reply_ok(recv_exact):
116
+ return True
117
+ if strict:
118
+ raise ProxyRefused("SOCKS5 refused the CONNECT")
119
+ return False
120
+
121
+
122
+ def socks5_ipv4(ip_bytes: bytes) -> bytes:
123
+ return b"\x01" + ip_bytes
124
+
125
+
126
+ def socks5_domain(host: str) -> bytes:
127
+ name = host.encode("idna")
128
+ return b"\x03" + bytes([len(name)]) + name
129
+
130
+
131
+ async def socks5_reply_ok(recv_exact: RecvExact) -> bool:
132
+ resp = await recv_exact(4)
133
+ if resp[1] != 0x00:
134
+ return False
135
+ atyp = resp[3]
136
+ if atyp == 1:
137
+ await recv_exact(4 + 2)
138
+ elif atyp == 4:
139
+ await recv_exact(16 + 2)
140
+ elif atyp == 3:
141
+ ln = (await recv_exact(1))[0]
142
+ await recv_exact(ln + 2)
143
+ else:
144
+ return False
145
+ return True
146
+
147
+
148
+ def stream_io(reader, writer):
149
+ """send/recv_exact for asyncio streams."""
150
+ async def send(data: bytes) -> None:
151
+ writer.write(data)
152
+ await writer.drain()
153
+ return send, reader.readexactly
@@ -0,0 +1,106 @@
1
+ """History of working proxies across runs.
2
+
3
+ A proxy that worked yesterday is much more likely to work today than any random list
4
+ entry – that's why known proxies are checked first.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import json
10
+ import time
11
+ import warnings
12
+ from dataclasses import asdict, dataclass
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional
15
+
16
+ from .paths import DATA_DIR, atomic_write
17
+
18
+ HISTORY_FILE = DATA_DIR / "proxy_history.json"
19
+ DAY = 86400.0
20
+ FORGET_AFTER = 14 * DAY # not working for this long -> forget it
21
+ MAX_FAIL_STREAK = 4 # so oft hintereinander tot -> vergessen
22
+
23
+
24
+ @dataclass
25
+ class ProxyRecord:
26
+ first_ok: float = 0.0
27
+ last_ok: float = 0.0
28
+ ok: int = 0
29
+ fail: int = 0
30
+ fail_streak: int = 0
31
+ latency: int = 0
32
+ exit_ip: str = ""
33
+ country: str = ""
34
+ anonymity: str = ""
35
+ https: Optional[bool] = None
36
+
37
+ @property
38
+ def reliability(self) -> float:
39
+ """Share of successful checks, smoothed (1 out of 1 is not 100 %)."""
40
+ return (self.ok + 1) / (self.ok + self.fail + 2)
41
+
42
+
43
+ class ProxyHistory:
44
+ def __init__(self, path: Path = HISTORY_FILE):
45
+ self.path = path
46
+ self.records: Dict[str, ProxyRecord] = {}
47
+ if path.exists():
48
+ try:
49
+ raw = json.loads(path.read_text(encoding="utf-8"))
50
+ self.records = {k: ProxyRecord(**v) for k, v in raw.items()}
51
+ except (OSError, ValueError, TypeError) as e:
52
+ warnings.warn(f"{path.name} unreadable ({e}), starting without proxy history", stacklevel=2)
53
+
54
+ @staticmethod
55
+ def exists(path: Path = HISTORY_FILE) -> bool:
56
+ """Is there a non-empty history? Without loading all of it."""
57
+ try:
58
+ return path.stat().st_size > 2 # "{}" = leer
59
+ except OSError:
60
+ return False
61
+
62
+ def __len__(self) -> int:
63
+ return len(self.records)
64
+
65
+ def __contains__(self, key: str) -> bool:
66
+ return key in self.records
67
+
68
+ def get(self, key: str) -> Optional[ProxyRecord]:
69
+ return self.records.get(key)
70
+
71
+ def ranked_keys(self) -> List[str]:
72
+ """Known proxies, the most reliable and most recently successful first."""
73
+ return sorted(self.records, key=lambda k: (-self.records[k].reliability, -self.records[k].last_ok))
74
+
75
+ def record_ok(self, key: str, latency: int, exit_ip: str, now: Optional[float] = None, **details) -> None:
76
+ now = time.time() if now is None else now
77
+ rec = self.records.setdefault(key, ProxyRecord(first_ok=now))
78
+ rec.last_ok = now
79
+ rec.ok += 1
80
+ rec.fail_streak = 0
81
+ rec.latency = latency
82
+ rec.exit_ip = exit_ip
83
+ for name, value in details.items():
84
+ if value not in (None, ""):
85
+ setattr(rec, name, value)
86
+
87
+ def record_fail(self, key: str) -> None:
88
+ """Only for proxies that are already known – otherwise the history fills up with millions of dead ones."""
89
+ rec = self.records.get(key)
90
+ if rec:
91
+ rec.fail += 1
92
+ rec.fail_streak += 1
93
+
94
+ def prune(self, now: Optional[float] = None) -> int:
95
+ now = time.time() if now is None else now
96
+ dead = [
97
+ k for k, r in self.records.items()
98
+ if r.fail_streak >= MAX_FAIL_STREAK or now - r.last_ok > FORGET_AFTER
99
+ ]
100
+ for k in dead:
101
+ del self.records[k]
102
+ return len(dead)
103
+
104
+ def save(self) -> None:
105
+ payload = {k: asdict(r) for k, r in self.records.items()}
106
+ atomic_write(self.path, json.dumps(payload, separators=(",", ":")))