proxy-scraper-cli 1.7.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- proxy_scraper_cli-1.7.1.dist-info/METADATA +794 -0
- proxy_scraper_cli-1.7.1.dist-info/RECORD +57 -0
- proxy_scraper_cli-1.7.1.dist-info/WHEEL +5 -0
- proxy_scraper_cli-1.7.1.dist-info/entry_points.txt +4 -0
- proxy_scraper_cli-1.7.1.dist-info/licenses/LICENSE +21 -0
- proxy_scraper_cli-1.7.1.dist-info/top_level.txt +1 -0
- proxyscraper/__init__.py +12 -0
- proxyscraper/__main__.py +7 -0
- proxyscraper/agent.py +635 -0
- proxyscraper/api.py +139 -0
- proxyscraper/app.py +595 -0
- proxyscraper/asndb.py +181 -0
- proxyscraper/blocklist.py +92 -0
- proxyscraper/checker.py +514 -0
- proxyscraper/cli.py +269 -0
- proxyscraper/compat.py +83 -0
- proxyscraper/completion.py +196 -0
- proxyscraper/exporters.py +118 -0
- proxyscraper/fetchcache.py +103 -0
- proxyscraper/geo.py +150 -0
- proxyscraper/geodb.py +143 -0
- proxyscraper/handshake.py +153 -0
- proxyscraper/history.py +106 -0
- proxyscraper/judges.py +159 -0
- proxyscraper/mcp_entry.py +34 -0
- proxyscraper/mcp_server.py +220 -0
- proxyscraper/netio.py +167 -0
- proxyscraper/options.py +274 -0
- proxyscraper/output.py +181 -0
- proxyscraper/pages.py +212 -0
- proxyscraper/parsing.py +192 -0
- proxyscraper/paths.py +55 -0
- proxyscraper/pipeline.py +434 -0
- proxyscraper/preferences.py +24 -0
- proxyscraper/publish.py +236 -0
- proxyscraper/server/__init__.py +41 -0
- proxyscraper/server/core.py +518 -0
- proxyscraper/server/http.py +164 -0
- proxyscraper/server/pool.py +195 -0
- proxyscraper/server/socks.py +65 -0
- proxyscraper/server/status.py +108 -0
- proxyscraper/server/upstream.py +119 -0
- proxyscraper/site/apple-touch-icon.png +0 -0
- proxyscraper/site/googleaac1161b7853c5b5.html +1 -0
- proxyscraper/site/index.html +934 -0
- proxyscraper/site/logo.png +0 -0
- proxyscraper/site/og.png +0 -0
- proxyscraper/sources.json +395 -0
- proxyscraper/sources.py +491 -0
- proxyscraper/targets.py +76 -0
- proxyscraper/ui/__init__.py +54 -0
- proxyscraper/ui/dashboard.py +344 -0
- proxyscraper/ui/keys.py +82 -0
- proxyscraper/ui/report.py +209 -0
- proxyscraper/ui/serve.py +142 -0
- proxyscraper/ui/widgets.py +250 -0
- proxyscraper/ui/wizard.py +595 -0
proxyscraper/asndb.py
ADDED
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
"""Providers (ASN) of the exit IPs offline – with the free ASN database from DB-IP (DB-IP Lite, CC BY 4.0).
|
|
2
|
+
|
|
3
|
+
Like geodb.py: load once a month (≈ 7 MB gzip, ~400,000 IPv4 ranges), turn it into arrays,
|
|
4
|
+
look up by binary search. Plus a guess whether the provider is a datacenter/hoster – proxies
|
|
5
|
+
that exit from datacenters get blocked by many sites sooner than those on home or mobile
|
|
6
|
+
connections.
|
|
7
|
+
|
|
8
|
+
The guess is a heuristic over the provider name (cloud, hosting, known hosters), not a
|
|
9
|
+
certainty. Measured on 316 working proxies, ~45 % exited from datacenters this way.
|
|
10
|
+
|
|
11
|
+
IP Geolocation by DB-IP: https://db-ip.com
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import csv
|
|
17
|
+
import gzip
|
|
18
|
+
import io
|
|
19
|
+
import json
|
|
20
|
+
import re
|
|
21
|
+
import socket
|
|
22
|
+
import struct
|
|
23
|
+
from array import array
|
|
24
|
+
from bisect import bisect_right
|
|
25
|
+
from dataclasses import dataclass
|
|
26
|
+
from datetime import date
|
|
27
|
+
from pathlib import Path
|
|
28
|
+
from typing import List, Optional
|
|
29
|
+
|
|
30
|
+
from .geodb import _months
|
|
31
|
+
from .netio import http_get
|
|
32
|
+
from .paths import DATA_DIR
|
|
33
|
+
|
|
34
|
+
DB_FILE = DATA_DIR / "geo" / "dbip-asn-ipv4.bin"
|
|
35
|
+
URL = "https://download.db-ip.com/free/dbip-asn-lite-{month}.csv.gz"
|
|
36
|
+
MAGIC = b"PSASN1"
|
|
37
|
+
|
|
38
|
+
HOSTING_RE = re.compile(
|
|
39
|
+
r"host|cloud|data ?cent|server|\bvps\b|colo|amazon|\baws\b|google|microsoft|azure|"
|
|
40
|
+
r"digitalocean|ovh|hetzner|linode|akamai|vultr|choopa|contabo|leaseweb|m247|alibaba|tencent|oracle|"
|
|
41
|
+
r"scaleway|ionos|godaddy|fastly|cloudflare|datacamp|cdn77|psychz|colocrossing|quadranet|hostwinds|"
|
|
42
|
+
r"kamatera|upcloud|netcup|strato|hosteurope|constant company|g-core|gcore|stark industries|aeza|"
|
|
43
|
+
r"pq hosting|serverius|worldstream|zenlayer|ucloud|huawei|baidu|kingsoft|performive|nocix|datasource|"
|
|
44
|
+
r"frantech|buyvm|ramnode|namecheap|dreamhost|packet|equinix|dataforest|servinga|hydra communications",
|
|
45
|
+
re.IGNORECASE,
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
def is_hosting(org: str) -> bool:
|
|
50
|
+
return bool(HOSTING_RE.search(org or ""))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _ip_to_int(ip: str) -> int:
|
|
54
|
+
return struct.unpack("!I", socket.inet_aton(ip))[0]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass(frozen=True)
|
|
58
|
+
class Provider:
|
|
59
|
+
asn: int
|
|
60
|
+
org: str
|
|
61
|
+
hosting: bool
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class AsnDB:
|
|
65
|
+
def __init__(self, starts: array, ends: array, asns: array, org_index: array, orgs: List[str], month: str):
|
|
66
|
+
self.starts, self.ends, self.asns, self.org_index = starts, ends, asns, org_index
|
|
67
|
+
self.orgs = orgs
|
|
68
|
+
self.hosting = [is_hosting(o) for o in orgs]
|
|
69
|
+
self.month = month
|
|
70
|
+
|
|
71
|
+
def __len__(self) -> int:
|
|
72
|
+
return len(self.starts)
|
|
73
|
+
|
|
74
|
+
def lookup(self, ip: str) -> Optional[Provider]:
|
|
75
|
+
try:
|
|
76
|
+
n = _ip_to_int(ip)
|
|
77
|
+
except OSError:
|
|
78
|
+
return None
|
|
79
|
+
i = bisect_right(self.starts, n) - 1
|
|
80
|
+
if i < 0 or n > self.ends[i]:
|
|
81
|
+
return None
|
|
82
|
+
k = self.org_index[i]
|
|
83
|
+
return Provider(self.asns[i], self.orgs[k], self.hosting[k])
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def from_csv(cls, text: str, month: str) -> "AsnDB":
|
|
87
|
+
"""DB-IP CSV ("start,end,asn,\\"provider\\"") -> IPv4 only. Provider names are stored just once."""
|
|
88
|
+
starts, ends, asns, org_index = array("I"), array("I"), array("I"), array("I")
|
|
89
|
+
orgs: List[str] = []
|
|
90
|
+
seen = {}
|
|
91
|
+
for row in csv.reader(io.StringIO(text)):
|
|
92
|
+
if len(row) < 4 or ":" in row[0] or not row[2].isdigit():
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
s, e = _ip_to_int(row[0]), _ip_to_int(row[1])
|
|
96
|
+
except OSError:
|
|
97
|
+
continue
|
|
98
|
+
org = row[3].strip()
|
|
99
|
+
k = seen.get(org)
|
|
100
|
+
if k is None:
|
|
101
|
+
k = seen[org] = len(orgs)
|
|
102
|
+
orgs.append(org)
|
|
103
|
+
starts.append(s)
|
|
104
|
+
ends.append(e)
|
|
105
|
+
asns.append(int(row[2]))
|
|
106
|
+
org_index.append(k)
|
|
107
|
+
return cls(starts, ends, asns, org_index, orgs, month)
|
|
108
|
+
|
|
109
|
+
def save(self, path: Path = DB_FILE) -> None:
|
|
110
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
111
|
+
table = json.dumps(self.orgs, ensure_ascii=False).encode("utf-8")
|
|
112
|
+
tmp = path.with_suffix(".tmp")
|
|
113
|
+
with tmp.open("wb") as fh:
|
|
114
|
+
fh.write(MAGIC + self.month.encode("ascii").ljust(7) + struct.pack("!II", len(self.starts), len(table)))
|
|
115
|
+
for arr in (self.starts, self.ends, self.asns, self.org_index):
|
|
116
|
+
arr.tofile(fh)
|
|
117
|
+
fh.write(table)
|
|
118
|
+
tmp.replace(path)
|
|
119
|
+
|
|
120
|
+
@classmethod
|
|
121
|
+
def load(cls, path: Path = DB_FILE) -> Optional["AsnDB"]:
|
|
122
|
+
try:
|
|
123
|
+
size = path.stat().st_size
|
|
124
|
+
with path.open("rb") as fh:
|
|
125
|
+
if fh.read(len(MAGIC)) != MAGIC:
|
|
126
|
+
return None
|
|
127
|
+
month = fh.read(7).decode("ascii").strip()
|
|
128
|
+
count, table_len = struct.unpack("!II", fh.read(8))
|
|
129
|
+
if size != len(MAGIC) + 7 + 8 + count * 16 + table_len:
|
|
130
|
+
return None # truncated or broken – don't blindly allocate memory
|
|
131
|
+
arrays = []
|
|
132
|
+
for _ in range(4):
|
|
133
|
+
arr = array("I")
|
|
134
|
+
arr.fromfile(fh, count)
|
|
135
|
+
arrays.append(arr)
|
|
136
|
+
orgs = json.loads(fh.read(table_len).decode("utf-8"))
|
|
137
|
+
except (OSError, EOFError, ValueError, struct.error, UnicodeDecodeError):
|
|
138
|
+
return None
|
|
139
|
+
if not isinstance(orgs, list) or not all(isinstance(o, str) for o in orgs) \
|
|
140
|
+
or any(k >= len(orgs) for k in arrays[3]):
|
|
141
|
+
return None
|
|
142
|
+
return cls(*arrays, orgs, month)
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_current(db: Optional[AsnDB], today: Optional[date] = None) -> bool:
|
|
146
|
+
return db is not None and db.month == (today or date.today()).strftime("%Y-%m")
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def load_asn_db(path: Path = DB_FILE, today: Optional[date] = None, fetch=http_get) -> Optional[AsnDB]:
|
|
150
|
+
"""Database from data/ – new from DB-IP once a month. May download, so call it in the background."""
|
|
151
|
+
today = today or date.today()
|
|
152
|
+
current = AsnDB.load(path)
|
|
153
|
+
if is_current(current, today):
|
|
154
|
+
return current
|
|
155
|
+
for month in _months(today):
|
|
156
|
+
if current and current.month >= month:
|
|
157
|
+
return current
|
|
158
|
+
try:
|
|
159
|
+
data = await fetch(URL.format(month=month), timeout=30)
|
|
160
|
+
db = AsnDB.from_csv(gzip.decompress(data).decode("utf-8", "replace"), month)
|
|
161
|
+
except Exception: # unreachable or broken – try the next month or keep the old file
|
|
162
|
+
continue
|
|
163
|
+
if len(db) > 1000:
|
|
164
|
+
db.save(path)
|
|
165
|
+
return db
|
|
166
|
+
return current
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
class ProviderLookup:
|
|
170
|
+
"""Holds the database (which may only be loaded later) and fills in the provider of hits."""
|
|
171
|
+
|
|
172
|
+
def __init__(self, db: Optional[AsnDB] = None):
|
|
173
|
+
self.db = db
|
|
174
|
+
|
|
175
|
+
def annotate(self, r) -> None:
|
|
176
|
+
provider = self.db.lookup(r.exit_ip) if self.db else None
|
|
177
|
+
if provider:
|
|
178
|
+
r.asn, r.org, r.hosting = provider.asn, provider.org, provider.hosting
|
|
179
|
+
|
|
180
|
+
def __bool__(self) -> bool:
|
|
181
|
+
return True
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Is the exit IP on a spam blocklist? Sites that use such lists answer those proxies with captchas or not at all.
|
|
2
|
+
|
|
3
|
+
One DNS lookup per exit IP against SpamCop (bl.spamcop.net), cached for the run. Measured on the live
|
|
4
|
+
list: 29 % of exit IPs were listed there. DroneBL lists 59 % (it is essentially a list of open proxies),
|
|
5
|
+
too many to be useful as a filter, so it isn't used.
|
|
6
|
+
|
|
7
|
+
Some blocklist operators refuse queries coming from large public resolvers and answer every query then.
|
|
8
|
+
Before the run, the documented test address 127.0.0.2 is looked up: only if it comes back as listed and
|
|
9
|
+
a normal address doesn't, the answers are trusted. Otherwise every result stays unknown (None).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import socket
|
|
16
|
+
from typing import Awaitable, Callable, Dict, Optional
|
|
17
|
+
|
|
18
|
+
ZONE = "bl.spamcop.net"
|
|
19
|
+
CONCURRENT_LOOKUPS = 50
|
|
20
|
+
LOOKUP_TIMEOUT = 5.0
|
|
21
|
+
NOT_LISTED = "NXDOMAIN" # the name doesn't exist: the only answer that means "not on the list"
|
|
22
|
+
LISTED_PREFIX = "127.0.0." # a listed IP resolves to 127.0.0.x; 127.255.255.x means "query refused"
|
|
23
|
+
|
|
24
|
+
Resolve = Callable[[str], Awaitable[Optional[str]]]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
_NX_ERRORS = {getattr(socket, n) for n in ("EAI_NONAME", "EAI_NODATA") if hasattr(socket, n)}
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def system_resolve(name: str) -> Optional[str]:
|
|
31
|
+
"""First IPv4 address for name, NOT_LISTED if the name doesn't exist, None for any other error
|
|
32
|
+
(timeout, SERVFAIL, no network) – those must not be mistaken for "not listed"."""
|
|
33
|
+
try:
|
|
34
|
+
infos = await asyncio.get_running_loop().getaddrinfo(name, None, family=socket.AF_INET)
|
|
35
|
+
except socket.gaierror as e:
|
|
36
|
+
return NOT_LISTED if e.errno in _NX_ERRORS else None
|
|
37
|
+
except OSError:
|
|
38
|
+
return None
|
|
39
|
+
return infos[0][4][0] if infos else None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def query_name(ip: str, zone: str = ZONE) -> Optional[str]:
|
|
43
|
+
parts = ip.split(".")
|
|
44
|
+
if len(parts) != 4 or not all(p.isdigit() and 0 <= int(p) <= 255 for p in parts):
|
|
45
|
+
return None
|
|
46
|
+
return ".".join(reversed(parts)) + "." + zone
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
class Blocklist:
|
|
50
|
+
def __init__(self, resolve: Resolve = system_resolve, zone: str = ZONE):
|
|
51
|
+
self.resolve = resolve
|
|
52
|
+
self.zone = zone
|
|
53
|
+
self.usable: Optional[bool] = None # None until probed
|
|
54
|
+
self.listed = 0
|
|
55
|
+
self._cache: Dict[str, Optional[bool]] = {}
|
|
56
|
+
self._slots: Optional[asyncio.Semaphore] = None
|
|
57
|
+
|
|
58
|
+
async def probe(self) -> bool:
|
|
59
|
+
"""Does the resolver get real answers? 127.0.0.2 must be listed, 127.0.0.1 must not."""
|
|
60
|
+
test = await self._ask(query_name("127.0.0.2", self.zone))
|
|
61
|
+
clean = await self._ask(query_name("127.0.0.1", self.zone))
|
|
62
|
+
self.usable = bool(test and test.startswith(LISTED_PREFIX)) and clean == NOT_LISTED
|
|
63
|
+
return self.usable
|
|
64
|
+
|
|
65
|
+
async def _ask(self, name: str) -> Optional[str]:
|
|
66
|
+
try:
|
|
67
|
+
return await asyncio.wait_for(self.resolve(name), LOOKUP_TIMEOUT)
|
|
68
|
+
except asyncio.TimeoutError:
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
async def lookup(self, ip: str) -> Optional[bool]:
|
|
72
|
+
"""True = listed, False = not listed, None = unknown (not probed, refused or not an IPv4)."""
|
|
73
|
+
if not self.usable:
|
|
74
|
+
return None
|
|
75
|
+
if ip in self._cache:
|
|
76
|
+
return self._cache[ip]
|
|
77
|
+
name = query_name(ip, self.zone)
|
|
78
|
+
if name is None:
|
|
79
|
+
return None
|
|
80
|
+
if self._slots is None: # created here: before Python 3.10 a semaphore is bound to the event loop
|
|
81
|
+
self._slots = asyncio.Semaphore(CONCURRENT_LOOKUPS)
|
|
82
|
+
async with self._slots:
|
|
83
|
+
answer = await self._ask(name)
|
|
84
|
+
if answer == NOT_LISTED:
|
|
85
|
+
result: Optional[bool] = False
|
|
86
|
+
elif answer and answer.startswith(LISTED_PREFIX):
|
|
87
|
+
result = True
|
|
88
|
+
else:
|
|
89
|
+
result = None # an error, a timeout, or 127.255.255.x (the operator refused to answer)
|
|
90
|
+
self._cache[ip] = result
|
|
91
|
+
self.listed += bool(result)
|
|
92
|
+
return result
|