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