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
|
@@ -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
|
proxyscraper/history.py
ADDED
|
@@ -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=(",", ":")))
|