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/app.py
ADDED
|
@@ -0,0 +1,595 @@
|
|
|
1
|
+
"""One complete run in clear phases: network → jobs → check → learn & report."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import ipaddress
|
|
8
|
+
import socket
|
|
9
|
+
import sys
|
|
10
|
+
import tempfile
|
|
11
|
+
import time
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from dataclasses import replace
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import List, Optional, Tuple
|
|
16
|
+
|
|
17
|
+
from rich.live import Live
|
|
18
|
+
from rich.text import Text
|
|
19
|
+
|
|
20
|
+
from . import sources as srcs
|
|
21
|
+
from .asndb import AsnDB, ProviderLookup, load_asn_db
|
|
22
|
+
from .asndb import is_current as asn_is_current
|
|
23
|
+
from .blocklist import Blocklist
|
|
24
|
+
from .checker import (
|
|
25
|
+
CONFIRM_HOST,
|
|
26
|
+
CONFIRM_PORT,
|
|
27
|
+
DETAIL_CONNECTIONS,
|
|
28
|
+
JUDGE_HOST,
|
|
29
|
+
Checker,
|
|
30
|
+
CheckResult,
|
|
31
|
+
integrity_reference,
|
|
32
|
+
probe_confirm_target,
|
|
33
|
+
)
|
|
34
|
+
from .compat import on_interrupt, raise_fd_limit
|
|
35
|
+
from .fetchcache import FetchCache
|
|
36
|
+
from .geo import GeoResolver
|
|
37
|
+
from .geodb import CountryDB, is_current, load_country_db
|
|
38
|
+
from .handshake import parse_endpoint
|
|
39
|
+
from .history import ProxyHistory
|
|
40
|
+
from .judges import JudgeProbe, JudgeWatch, rank_judges
|
|
41
|
+
from .netio import INSECURE_HOSTS, http_get
|
|
42
|
+
from .options import STDOUT, RunOptions
|
|
43
|
+
from .output import ResultWriter, latest_results
|
|
44
|
+
from .parsing import PROXY_TYPES, parse_keys, split_key
|
|
45
|
+
from .paths import is_checkout
|
|
46
|
+
from .pipeline import (
|
|
47
|
+
CheckRun,
|
|
48
|
+
ScrapeResult,
|
|
49
|
+
attribute_results,
|
|
50
|
+
best_sources,
|
|
51
|
+
collect_sources,
|
|
52
|
+
prioritize,
|
|
53
|
+
run_checks,
|
|
54
|
+
scrape,
|
|
55
|
+
)
|
|
56
|
+
from .publish import RAW_BASE
|
|
57
|
+
from .server import ProxyPool, RotatingServer
|
|
58
|
+
from .targets import Target, parse_target
|
|
59
|
+
from .ui import (
|
|
60
|
+
ACCENT,
|
|
61
|
+
BAD,
|
|
62
|
+
BLOCKED_HIT_RATE,
|
|
63
|
+
GOOD,
|
|
64
|
+
MUTED,
|
|
65
|
+
WARN,
|
|
66
|
+
CheckDashboard,
|
|
67
|
+
CollectView,
|
|
68
|
+
LiveStats,
|
|
69
|
+
banner,
|
|
70
|
+
fmt,
|
|
71
|
+
fmt_duration,
|
|
72
|
+
info,
|
|
73
|
+
note,
|
|
74
|
+
pct,
|
|
75
|
+
render_summary,
|
|
76
|
+
section,
|
|
77
|
+
section_end,
|
|
78
|
+
widgets,
|
|
79
|
+
)
|
|
80
|
+
from .ui.serve import ServeDashboard
|
|
81
|
+
from .ui.widgets import shown_proxy
|
|
82
|
+
|
|
83
|
+
OWN_IP_URLS = (
|
|
84
|
+
f"https://{JUDGE_HOST}/", # HTTPS first: relays and corporate proxies don't redirect it
|
|
85
|
+
"https://api.ipify.org/",
|
|
86
|
+
f"http://{JUDGE_HOST}/", # port 80 may take a different route (a different IP)
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
async def get_own_ips() -> List[str]:
|
|
91
|
+
"""Every IP you are visible under from the outside – the most important one first."""
|
|
92
|
+
|
|
93
|
+
async def one(url: str) -> str:
|
|
94
|
+
try:
|
|
95
|
+
ip = (await http_get(url, timeout=6)).strip().decode("ascii", "ignore")
|
|
96
|
+
ipaddress.IPv4Address(ip)
|
|
97
|
+
return ip
|
|
98
|
+
except Exception: # service unreachable -> the next one counts
|
|
99
|
+
return ""
|
|
100
|
+
|
|
101
|
+
ips = await asyncio.gather(*(one(u) for u in OWN_IP_URLS))
|
|
102
|
+
return list(dict.fromkeys(ip for ip in ips if ip))
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def confirm_target() -> Optional[str]:
|
|
106
|
+
"""IP of the confirmation target – only if exactly this IP answers the way the confirmation expects."""
|
|
107
|
+
try:
|
|
108
|
+
infos = await asyncio.get_running_loop().getaddrinfo(CONFIRM_HOST, CONFIRM_PORT, family=socket.AF_INET)
|
|
109
|
+
except OSError: # can't be resolved -> continue without confirmation, with a note
|
|
110
|
+
return None
|
|
111
|
+
ip = infos[0][4][0]
|
|
112
|
+
return ip if await probe_confirm_target(ip, timeout=8) else None
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def load_recheck_jobs(target: str, types, history: ProxyHistory) -> List[str]:
|
|
116
|
+
"""--recheck: a file, otherwise the last run plus history."""
|
|
117
|
+
if target:
|
|
118
|
+
lines = Path(target).expanduser().read_text(encoding="utf-8").splitlines()
|
|
119
|
+
# lines without type:// in files like http.txt take the type from the file name
|
|
120
|
+
default = next((t for t in PROXY_TYPES if t in Path(target).name.lower()), None)
|
|
121
|
+
keys = parse_keys(lines, default)
|
|
122
|
+
else:
|
|
123
|
+
keys = parse_keys(latest_results()) + history.ranked_keys()
|
|
124
|
+
keys = list(dict.fromkeys(keys))
|
|
125
|
+
return [k for k in keys if split_key(k)[0] in types]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
LIVE = "live" # --recheck live: the public live list as the starting point
|
|
129
|
+
REFILL_CONCURRENCY = 500 # checks at once during --serve-refill, so the running server stays responsive
|
|
130
|
+
LIVE_URL = f"{RAW_BASE}/all.txt"
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
async def load_live_jobs(types, history: ProxyHistory, fetch=http_get) -> List[str]:
|
|
134
|
+
"""Load the live list (only addresses that worked in the last GitHub Actions run) plus your own hits
|
|
135
|
+
from the history – everything is then checked locally, from your own network."""
|
|
136
|
+
try:
|
|
137
|
+
text = (await fetch(LIVE_URL, timeout=30)).decode("utf-8", "replace")
|
|
138
|
+
except Exception:
|
|
139
|
+
note("Live list unreachable – using the last run and history instead.", WARN, "⚠")
|
|
140
|
+
return load_recheck_jobs("", types, history)
|
|
141
|
+
keys = list(dict.fromkeys(parse_keys(text.splitlines()) + history.ranked_keys()))
|
|
142
|
+
return [k for k in keys if split_key(k)[0] in types]
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def is_network_blocked(stats: LiveStats) -> bool:
|
|
146
|
+
"""If (almost) nothing gets through, a firewall is probably blocking proxy connections.
|
|
147
|
+
|
|
148
|
+
Fake proxies count too: they pass the basic check, so the connection works – a network full of
|
|
149
|
+
honeypots is not a blocked network, and the run should still be learned from.
|
|
150
|
+
"""
|
|
151
|
+
reached = stats.found + stats.fakes + stats.tampered
|
|
152
|
+
return stats.checked >= 1000 and reached < stats.checked * BLOCKED_HIT_RATE
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def next_steps(opts: RunOptions, kept: List[CheckResult]) -> List[Tuple[str, str]]:
|
|
156
|
+
"""Ready-made commands for what people usually do next after a run."""
|
|
157
|
+
program = "python3 proxy_scraper.py" if is_checkout() else "proxy-scraper"
|
|
158
|
+
steps: List[Tuple[str, str]] = []
|
|
159
|
+
if kept:
|
|
160
|
+
# only suggest HTTPS if the proxy passed the HTTPS test – otherwise the command fails
|
|
161
|
+
secure = [r for r in kept if r.https]
|
|
162
|
+
pool = secure or kept
|
|
163
|
+
# prefer proxies without a login – the command ends up in the terminal, no password belongs there
|
|
164
|
+
best = min([r for r in pool if "@" not in r.proxy] or pool, key=lambda r: r.latency)
|
|
165
|
+
scheme = "socks5h" if best.ptype == "socks5" else best.ptype
|
|
166
|
+
target = "https://api.ipify.org" if best.https else "http://api.ipify.org"
|
|
167
|
+
steps.append(("Test the fastest", f"curl -x {scheme}://{shown_proxy(best.proxy)} {target}"))
|
|
168
|
+
if not opts.serve:
|
|
169
|
+
steps.append(("As a proxy server", f"{program} --recheck --serve"))
|
|
170
|
+
steps.append(("Recheck later", f"{program} --recheck"))
|
|
171
|
+
return steps
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def pool_recheck(checker: Checker):
|
|
175
|
+
"""Recheck for the proxy server's disabled proxies: the normal check, but bypassing the cache."""
|
|
176
|
+
async def recheck(result: CheckResult) -> bool:
|
|
177
|
+
# the checker remembers unreachable addresses for the rest of the run – exactly those should be
|
|
178
|
+
# tried again here
|
|
179
|
+
checker.unreachable.discard(parse_endpoint(result.proxy).address)
|
|
180
|
+
return await checker.check(result.key) is not None
|
|
181
|
+
return recheck
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def is_loopback(host: str) -> bool:
|
|
185
|
+
try:
|
|
186
|
+
return ipaddress.ip_address(host).is_loopback
|
|
187
|
+
except ValueError:
|
|
188
|
+
return host == "localhost"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
class Run:
|
|
192
|
+
def __init__(self, opts: RunOptions, show_banner: bool = True):
|
|
193
|
+
self.opts = opts
|
|
194
|
+
self.show_banner = show_banner
|
|
195
|
+
self.started = time.perf_counter()
|
|
196
|
+
self.quality = srcs.SourceStats()
|
|
197
|
+
self.history = ProxyHistory()
|
|
198
|
+
self.scraped: Optional[ScrapeResult] = None
|
|
199
|
+
self.judges: List[JudgeProbe] = [] # reachable check targets, fastest first
|
|
200
|
+
self.integrity: Optional[bytes] = None # hash of the reference page (see Checker.tampers)
|
|
201
|
+
self.checker: Optional[Checker] = None
|
|
202
|
+
self.kept: List[CheckResult] = [] # hits that pass every filter (for the Python API)
|
|
203
|
+
self.confirm_ip: Optional[str] = None
|
|
204
|
+
self.targets: List[Tuple[Target, str]] = []
|
|
205
|
+
self.own_ips: List[str] = []
|
|
206
|
+
self.blocklist: Optional[Blocklist] = None
|
|
207
|
+
|
|
208
|
+
async def execute(self) -> int:
|
|
209
|
+
if self.show_banner:
|
|
210
|
+
widgets.console.print(banner())
|
|
211
|
+
section("Setup")
|
|
212
|
+
try:
|
|
213
|
+
with widgets.console.status("Checking the network …", spinner="dots", spinner_style=ACCENT):
|
|
214
|
+
ready = await self.prepare_network()
|
|
215
|
+
if not ready:
|
|
216
|
+
return 1
|
|
217
|
+
self.show_mode()
|
|
218
|
+
jobs = await self.gather_jobs()
|
|
219
|
+
if not jobs:
|
|
220
|
+
note("No proxies to check.", BAD, "✘")
|
|
221
|
+
return 1
|
|
222
|
+
finally:
|
|
223
|
+
section_end()
|
|
224
|
+
await self.check_and_report(jobs)
|
|
225
|
+
return 0
|
|
226
|
+
|
|
227
|
+
# ------------------------------------------------------------------ phase 0: network
|
|
228
|
+
|
|
229
|
+
async def prepare_network(self) -> bool:
|
|
230
|
+
blocklist = Blocklist() if not self.opts.no_dnsbl else None
|
|
231
|
+
self.judges, self.own_ips, _ = await asyncio.gather(
|
|
232
|
+
rank_judges(), get_own_ips(), blocklist.probe() if blocklist else asyncio.sleep(0))
|
|
233
|
+
if blocklist and blocklist.usable:
|
|
234
|
+
self.blocklist = blocklist
|
|
235
|
+
if not self.judges:
|
|
236
|
+
note("No check target reachable (checkip.amazonaws.com, ifconfig.me, …) – check your internet connection.",
|
|
237
|
+
BAD, "✘")
|
|
238
|
+
return False
|
|
239
|
+
if not self.own_ips: # the usual services for your own IP are gone – the check targets have seen it
|
|
240
|
+
# IPv4 only – the checker compares against IPv4 exit IPs; an IPv6 (e.g. iCloud Private Relay) never
|
|
241
|
+
# matches and would only swallow the "own IP unknown" warning
|
|
242
|
+
self.own_ips = list(dict.fromkeys(j.seen_ip for j in self.judges if j.seen_ip and "." in j.seen_ip))
|
|
243
|
+
ips = self.own_ips
|
|
244
|
+
info("Your IP", Text.assemble(
|
|
245
|
+
(ips[0], "bold") if ips else ("unknown", WARN),
|
|
246
|
+
(f" (on port 80 also {', '.join(ips[1:])})" if len(ips) > 1 else "", MUTED),
|
|
247
|
+
))
|
|
248
|
+
if not await self.resolve_targets():
|
|
249
|
+
return False
|
|
250
|
+
self.confirm_ip = await confirm_target()
|
|
251
|
+
if self.confirm_ip: # reference page for the check against modified content
|
|
252
|
+
self.integrity = await integrity_reference(timeout=8)
|
|
253
|
+
best, reserve = self.judges[0], [j.judge.host for j in self.judges[1:]]
|
|
254
|
+
info("Check target", Text.assemble(
|
|
255
|
+
(f"{best.judge.host} ({best.ip}, {best.latency} ms)", MUTED),
|
|
256
|
+
(f" · fallback: {', '.join(reserve)}", MUTED) if reserve else "",
|
|
257
|
+
(f" · confirmed via {CONFIRM_HOST}", MUTED) if self.confirm_ip else "",
|
|
258
|
+
))
|
|
259
|
+
if self.confirm_ip and self.integrity is None:
|
|
260
|
+
note(f"Reference page from {CONFIRM_HOST} unreachable – proxies that modify content "
|
|
261
|
+
"won't be detected in this run.")
|
|
262
|
+
if not self.confirm_ip:
|
|
263
|
+
note(f"{CONFIRM_HOST} unreachable – without a second confirmation fake proxies (honeypots) "
|
|
264
|
+
"can slip through, and anonymity stays unknown.")
|
|
265
|
+
if not ips:
|
|
266
|
+
note("Own IP unknown – transparent proxies (they reveal your IP) won't be filtered out.")
|
|
267
|
+
if blocklist and not blocklist.usable:
|
|
268
|
+
note("SpamCop doesn't answer through your DNS resolver (large public resolvers are refused) – "
|
|
269
|
+
"blocklist info is skipped" + (", so --no-blocklisted can't filter anything." if
|
|
270
|
+
self.opts.filters.no_blocklisted else "."), MUTED, "ℹ")
|
|
271
|
+
return True
|
|
272
|
+
|
|
273
|
+
async def resolve_targets(self) -> bool:
|
|
274
|
+
"""Resolve the target sites once – SOCKS4 needs IPs, and a typo should show up right away."""
|
|
275
|
+
loop = asyncio.get_running_loop()
|
|
276
|
+
for url in self.opts.filters.targets:
|
|
277
|
+
target = parse_target(url)
|
|
278
|
+
try:
|
|
279
|
+
infos = await loop.getaddrinfo(target.host, target.port, family=socket.AF_INET)
|
|
280
|
+
except OSError:
|
|
281
|
+
note(f"Target site {target.host} can't be resolved – a typo?", BAD, "✘")
|
|
282
|
+
return False
|
|
283
|
+
self.targets.append((target, infos[0][4][0]))
|
|
284
|
+
if self.targets:
|
|
285
|
+
info("Target sites", ", ".join(t.label for t, _ in self.targets))
|
|
286
|
+
return True
|
|
287
|
+
|
|
288
|
+
def show_mode(self) -> None:
|
|
289
|
+
opts = self.opts
|
|
290
|
+
modes = ["with HTTPS test" if opts.details else "without HTTPS test (--fast)"]
|
|
291
|
+
modes.append("countries" if opts.geo else "no countries")
|
|
292
|
+
if opts.filters.active:
|
|
293
|
+
modes.append(f"filter: {opts.filters.describe()}")
|
|
294
|
+
if opts.want:
|
|
295
|
+
modes.append(f"stops at {fmt(opts.want)} hits")
|
|
296
|
+
if opts.check_timeout < opts.timeout:
|
|
297
|
+
modes.append(f"timeout {opts.check_timeout:g} s thanks to the latency limit")
|
|
298
|
+
info("Mode", " · ".join(modes))
|
|
299
|
+
|
|
300
|
+
# ------------------------------------------------------------------ phase 1+2: jobs
|
|
301
|
+
|
|
302
|
+
async def gather_jobs(self) -> List[str]:
|
|
303
|
+
opts = self.opts
|
|
304
|
+
if opts.recheck == LIVE:
|
|
305
|
+
jobs = await load_live_jobs(opts.types, self.history)
|
|
306
|
+
info("Recheck", f"{fmt(len(jobs))} proxies from the live list (checked every hour by GitHub Actions)")
|
|
307
|
+
elif opts.recheck is not None:
|
|
308
|
+
try:
|
|
309
|
+
jobs = load_recheck_jobs(opts.recheck, opts.types, self.history)
|
|
310
|
+
except (OSError, UnicodeDecodeError) as e:
|
|
311
|
+
reason = e.strerror if isinstance(e, OSError) and e.strerror else "not a text file"
|
|
312
|
+
note(f"Can't read {opts.recheck}: {reason}.", BAD, "✘")
|
|
313
|
+
return []
|
|
314
|
+
info("Recheck", f"{fmt(len(jobs))} proxies from {opts.recheck or 'the last run + history'}")
|
|
315
|
+
else:
|
|
316
|
+
jobs = await self._scrape_jobs()
|
|
317
|
+
|
|
318
|
+
known = sum(1 for k in jobs if k in self.history) if len(self.history) else 0
|
|
319
|
+
if known:
|
|
320
|
+
info("History", f"{fmt(known)} proxies that worked before are checked first")
|
|
321
|
+
if opts.limit:
|
|
322
|
+
jobs = jobs[: opts.limit]
|
|
323
|
+
info("Limit", f"checking the {fmt(len(jobs))} most promising")
|
|
324
|
+
return jobs
|
|
325
|
+
|
|
326
|
+
async def _scrape_jobs(self) -> List[str]:
|
|
327
|
+
plan = await collect_sources(self.opts, self.quality)
|
|
328
|
+
info("Sources", Text.assemble(
|
|
329
|
+
(fmt(len(plan.sources)), f"bold {ACCENT}"), " active ",
|
|
330
|
+
(f"({fmt(plan.n_curated)} curated · {fmt(plan.n_meta)} from {plan.meta_ok}/{plan.meta_total} "
|
|
331
|
+
f"meta lists · {fmt(plan.n_discovered)} discovered)", MUTED),
|
|
332
|
+
))
|
|
333
|
+
if plan.discovery_ran and not plan.discovery_token:
|
|
334
|
+
note("GitHub discovery is limited without a token – run `gh auth login` or set GITHUB_TOKEN.",
|
|
335
|
+
MUTED, "ℹ")
|
|
336
|
+
if plan.skipped:
|
|
337
|
+
parts = ", ".join(f"{n} {reason}" for reason, n in plan.skipped.most_common())
|
|
338
|
+
info("Skipped", Text.assemble(parts, (" (force all: --all-sources)", MUTED)))
|
|
339
|
+
|
|
340
|
+
t0 = time.perf_counter()
|
|
341
|
+
view = CollectView(len(plan.sources), self.started)
|
|
342
|
+
with Live(view, console=widgets.console, refresh_per_second=10, transient=True):
|
|
343
|
+
cache = FetchCache(enabled=not self.opts.no_cache)
|
|
344
|
+
self.scraped = await scrape(plan.sources, self.opts.types, self.quality, view, cache)
|
|
345
|
+
self.quality.save()
|
|
346
|
+
cache.save()
|
|
347
|
+
res = self.scraped
|
|
348
|
+
info("Collected", Text.assemble(
|
|
349
|
+
(fmt(len(res.index)), f"bold {GOOD}"), " unique proxies from ",
|
|
350
|
+
f"{res.ok_sources}/{len(plan.sources)} sources",
|
|
351
|
+
(f" ({view.bytes / 2**20:.0f} MB in {fmt_duration(time.perf_counter() - t0)}"
|
|
352
|
+
+ (f", {view.cached} unchanged from the cache" if view.cached else "") + ")", MUTED),
|
|
353
|
+
))
|
|
354
|
+
if INSECURE_HOSTS:
|
|
355
|
+
hosts = sorted(INSECURE_HOSTS)
|
|
356
|
+
note(f"Certificate could not be verified (TLS inspection on this network?), loaded anyway: "
|
|
357
|
+
f"{', '.join(hosts[:4])}{' …' if len(hosts) > 4 else ''}", MUTED, "ℹ")
|
|
358
|
+
return prioritize(res, self.quality, self.history, self.opts.types)
|
|
359
|
+
|
|
360
|
+
# ------------------------------------------------------------------ phase 3+4: check, learn, report
|
|
361
|
+
|
|
362
|
+
async def check_and_report(self, jobs: List[str]) -> None:
|
|
363
|
+
# workers + capped detail connections + headroom for sources, geo and the like
|
|
364
|
+
fd = raise_fd_limit(self.opts.concurrency + DETAIL_CONNECTIONS + 512)
|
|
365
|
+
opts = replace(self.opts, concurrency=min(self.opts.concurrency, max(fd - DETAIL_CONNECTIONS - 256, 64)))
|
|
366
|
+
|
|
367
|
+
to_stdout = opts.output == STDOUT
|
|
368
|
+
writer = ResultWriter(extra_file=Path(opts.output) if opts.output and not to_stdout else None,
|
|
369
|
+
exports=opts.exports, stdout=sys.stdout if to_stdout else None)
|
|
370
|
+
stats = LiveStats(Counter(split_key(k)[0] for k in jobs))
|
|
371
|
+
dashboard = CheckDashboard(stats, writer.live_path, opts.concurrency, opts.details,
|
|
372
|
+
opts.filters.describe(), opts.want, opts.filters.targets)
|
|
373
|
+
judge = self.judges[0]
|
|
374
|
+
checker = Checker(judge.ip, self.own_ips, opts.check_timeout, opts.check_connect_timeout,
|
|
375
|
+
self.confirm_ip, detail_timeout=opts.timeout,
|
|
376
|
+
detail_connect_timeout=opts.connect_timeout, targets=self.targets,
|
|
377
|
+
https_test=not opts.fast or opts.filters.https_only, judge=judge.judge,
|
|
378
|
+
integrity_reference=self.integrity)
|
|
379
|
+
self.checker = checker # for the proxy server: rechecking disabled proxies
|
|
380
|
+
watch = JudgeWatch(self.judges, lambda new: checker.use_judge(new.judge, new.ip))
|
|
381
|
+
dashboard.judge = judge.judge.host
|
|
382
|
+
# country database straight from data/ (2 ms); if it's old or missing, reload it in the background –
|
|
383
|
+
# until then ip-api.com takes over, nobody waits for the download
|
|
384
|
+
country_db = CountryDB.load() if opts.geo else None
|
|
385
|
+
geo = GeoResolver(enabled=opts.geo, offline=country_db)
|
|
386
|
+
refresh = None
|
|
387
|
+
if opts.geo and not is_current(country_db):
|
|
388
|
+
refresh = asyncio.ensure_future(self.refresh_country_db(geo))
|
|
389
|
+
# providers of the exit IPs the same way: straight from data/, old or missing -> reload in the background.
|
|
390
|
+
# Independent of --no-geo – that only concerns countries, providers come from the file anyway.
|
|
391
|
+
providers = ProviderLookup(AsnDB.load())
|
|
392
|
+
providers_refresh = None
|
|
393
|
+
if not asn_is_current(providers.db):
|
|
394
|
+
providers_refresh = asyncio.ensure_future(self.refresh_asn_db(providers))
|
|
395
|
+
if opts.filters.no_datacenter and providers.db is None:
|
|
396
|
+
# explicitly without datacenters, but no database at all yet: load it before checking –
|
|
397
|
+
# otherwise unknown providers would pass as "not a datacenter" and --want would stop too early
|
|
398
|
+
with widgets.console.status("Loading the provider database (DB-IP) …", spinner="dots"), \
|
|
399
|
+
contextlib.suppress(asyncio.TimeoutError):
|
|
400
|
+
await asyncio.wait_for(asyncio.shield(providers_refresh), 90)
|
|
401
|
+
widgets.console.print()
|
|
402
|
+
run = await run_checks(
|
|
403
|
+
jobs, checker, opts, dashboard, writer, geo,
|
|
404
|
+
live_factory=lambda renderable: Live(renderable, console=widgets.console, refresh_per_second=6),
|
|
405
|
+
watch=watch,
|
|
406
|
+
providers=providers,
|
|
407
|
+
blocklist=self.blocklist,
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
if refresh and not refresh.done():
|
|
411
|
+
refresh.cancel() # country download still running – next run then (ip-api took over)
|
|
412
|
+
if providers_refresh and not providers_refresh.done():
|
|
413
|
+
# provider database still loading (first run or new month): wait briefly and fill in afterwards –
|
|
414
|
+
# otherwise the files lack providers and --no-datacenter would let datacenters through
|
|
415
|
+
with widgets.console.status("Loading the provider database (DB-IP) …", spinner="dots"), \
|
|
416
|
+
contextlib.suppress(asyncio.TimeoutError):
|
|
417
|
+
await asyncio.wait_for(asyncio.shield(providers_refresh), 30)
|
|
418
|
+
if not providers_refresh.done():
|
|
419
|
+
providers_refresh.cancel() # takes too long – next run then
|
|
420
|
+
if providers.db:
|
|
421
|
+
for r in run.results:
|
|
422
|
+
if not r.asn:
|
|
423
|
+
providers.annotate(r)
|
|
424
|
+
if r.hosting:
|
|
425
|
+
stats.hosting += 1 # otherwise dashboard and note would show too few datacenters
|
|
426
|
+
kept = [r for r in run.results if opts.filters.accepts(r)]
|
|
427
|
+
self.kept = kept
|
|
428
|
+
files = writer.finalize(kept)
|
|
429
|
+
network_blocked = is_network_blocked(stats)
|
|
430
|
+
per_source = self.learn(run, network_blocked)
|
|
431
|
+
geo.save()
|
|
432
|
+
|
|
433
|
+
render_summary(stats, run.results, kept, best_sources(per_source), files, opts.details,
|
|
434
|
+
opts.filters.describe(), next_steps(opts, kept))
|
|
435
|
+
self.final_notes(run, stats, kept, geo, network_blocked)
|
|
436
|
+
if opts.serve:
|
|
437
|
+
await self.serve(kept)
|
|
438
|
+
|
|
439
|
+
async def serve(self, proxies: List[CheckResult]) -> None:
|
|
440
|
+
"""Serve the proxies found as a local rotating proxy server until Ctrl+C."""
|
|
441
|
+
if not proxies:
|
|
442
|
+
note("No matching proxy found – the proxy server doesn't start.", BAD, "✘")
|
|
443
|
+
return
|
|
444
|
+
pool = ProxyPool(proxies, strategy=self.opts.rotate, sticky_seconds=self.opts.sticky)
|
|
445
|
+
server = RotatingServer(pool, host=self.opts.serve_host, port=self.opts.serve, timeout=self.opts.timeout,
|
|
446
|
+
password=self.opts.serve_password)
|
|
447
|
+
if not is_loopback(self.opts.serve_host) and not self.opts.serve_password:
|
|
448
|
+
note(f"The proxy server listens on {self.opts.serve_host} – without a password. Anyone who can reach "
|
|
449
|
+
"it can use it. Set PROXY_SCRAPER_SERVE_PASSWORD, or keep it behind a firewall "
|
|
450
|
+
"or in a container with -p 127.0.0.1:…", WARN, "⚠")
|
|
451
|
+
try:
|
|
452
|
+
await server.start()
|
|
453
|
+
except OSError as e:
|
|
454
|
+
note(f"Port {self.opts.serve} is not available ({e.strerror or e}) – pick another one with --serve PORT.",
|
|
455
|
+
BAD, "✘")
|
|
456
|
+
return
|
|
457
|
+
stop = asyncio.Event()
|
|
458
|
+
widgets.console.print()
|
|
459
|
+
fresh = refills = None
|
|
460
|
+
if self.checker: # recheck disabled proxies every 5 minutes and bring them back if they work
|
|
461
|
+
fresh = asyncio.ensure_future(server.keep_fresh(pool_recheck(self.checker)))
|
|
462
|
+
if self.opts.serve_refill:
|
|
463
|
+
refills = asyncio.ensure_future(self.keep_refilling(server, self.opts.serve_refill * 3600))
|
|
464
|
+
try:
|
|
465
|
+
with on_interrupt(asyncio.get_running_loop(), stop.set), \
|
|
466
|
+
Live(ServeDashboard(server), console=widgets.console, refresh_per_second=4):
|
|
467
|
+
await stop.wait()
|
|
468
|
+
finally:
|
|
469
|
+
for task in (fresh, refills):
|
|
470
|
+
if task:
|
|
471
|
+
task.cancel()
|
|
472
|
+
await server.close()
|
|
473
|
+
st = server.stats
|
|
474
|
+
note(f"Proxy server stopped – {fmt(st.requests)} requests, {fmt(st.ok)} successful.", GOOD, "✔")
|
|
475
|
+
|
|
476
|
+
async def keep_refilling(self, server: RotatingServer, interval: float) -> None:
|
|
477
|
+
"""--serve-refill: every `interval` seconds check fresh candidates and merge the hits into the pool."""
|
|
478
|
+
while True:
|
|
479
|
+
await asyncio.sleep(interval)
|
|
480
|
+
try:
|
|
481
|
+
server.refilled += await self.refill(server.pool)
|
|
482
|
+
except Exception as e: # noqa: BLE001 – the server keeps running with what it has
|
|
483
|
+
note(f"Refill failed ({e.__class__.__name__}: {e}) – trying again next time.", WARN, "⚠")
|
|
484
|
+
server.last_refill = time.time()
|
|
485
|
+
|
|
486
|
+
async def refill(self, pool: ProxyPool) -> int:
|
|
487
|
+
"""One refill: the same checks and filters as the first run, quietly in the background. -> proxies added."""
|
|
488
|
+
opts = replace(self.opts, concurrency=min(self.opts.concurrency, REFILL_CONCURRENCY))
|
|
489
|
+
if opts.recheck == LIVE:
|
|
490
|
+
jobs = await load_live_jobs(opts.types, self.history)
|
|
491
|
+
else:
|
|
492
|
+
jobs = load_recheck_jobs("", opts.types, self.history)
|
|
493
|
+
serving = {e.result.key for e in pool.usable} # working right now – no need to check them again
|
|
494
|
+
jobs = [k for k in jobs if k not in serving]
|
|
495
|
+
if not jobs:
|
|
496
|
+
return 0
|
|
497
|
+
# the check target picked at startup may be gone hours later – rank again and watch it like the first run
|
|
498
|
+
judges = await rank_judges()
|
|
499
|
+
if not judges:
|
|
500
|
+
return 0
|
|
501
|
+
checker = self.checker
|
|
502
|
+
checker.use_judge(judges[0].judge, judges[0].ip)
|
|
503
|
+
watch = JudgeWatch(judges, lambda new: checker.use_judge(new.judge, new.ip))
|
|
504
|
+
checker.unreachable.clear() # hours later, addresses that were down may be back
|
|
505
|
+
stats = LiveStats(Counter(split_key(k)[0] for k in jobs))
|
|
506
|
+
geo = GeoResolver(enabled=opts.geo, offline=CountryDB.load() if opts.geo else None)
|
|
507
|
+
# a scratch folder: "latest" stays the full run, not the handful of new hits from this round
|
|
508
|
+
with tempfile.TemporaryDirectory(prefix="proxy-scraper-refill-") as scratch:
|
|
509
|
+
writer = ResultWriter(run_dir=Path(scratch))
|
|
510
|
+
dashboard = CheckDashboard(stats, writer.live_path, opts.concurrency, opts.details,
|
|
511
|
+
opts.filters.describe(), opts.want, opts.filters.targets)
|
|
512
|
+
try:
|
|
513
|
+
run = await run_checks(jobs, checker, opts, dashboard, writer, geo,
|
|
514
|
+
live_factory=lambda _view: contextlib.nullcontext(), watch=watch,
|
|
515
|
+
providers=ProviderLookup(AsnDB.load()), blocklist=self.blocklist, quiet=True)
|
|
516
|
+
finally:
|
|
517
|
+
writer.close()
|
|
518
|
+
kept = [r for r in run.results if opts.filters.accepts(r)]
|
|
519
|
+
blocked = is_network_blocked(stats)
|
|
520
|
+
self.learn(run, blocked, sources=False) # the source ranking only learns from full scans
|
|
521
|
+
geo.save()
|
|
522
|
+
return 0 if blocked else pool.merge(kept) # a blocked network proves nothing about the pool
|
|
523
|
+
|
|
524
|
+
@staticmethod
|
|
525
|
+
async def refresh_asn_db(providers: ProviderLookup) -> None:
|
|
526
|
+
try:
|
|
527
|
+
db = await load_asn_db()
|
|
528
|
+
except Exception:
|
|
529
|
+
return
|
|
530
|
+
if db is not None:
|
|
531
|
+
providers.db = db
|
|
532
|
+
|
|
533
|
+
@staticmethod
|
|
534
|
+
async def refresh_country_db(geo: GeoResolver) -> None:
|
|
535
|
+
"""Load a new country database and use it right away – errors don't matter, ip-api stays in charge."""
|
|
536
|
+
try:
|
|
537
|
+
db = await load_country_db()
|
|
538
|
+
except Exception:
|
|
539
|
+
return
|
|
540
|
+
if db is not None:
|
|
541
|
+
geo.use_offline(db)
|
|
542
|
+
|
|
543
|
+
def learn(self, run: CheckRun, network_blocked: bool, sources: bool = True):
|
|
544
|
+
"""Update source statistics and history – on a blocked network only the hits."""
|
|
545
|
+
per_source = attribute_results(self.scraped, run.checked, run.working) if self.scraped and sources else {}
|
|
546
|
+
if not network_blocked:
|
|
547
|
+
# on a blocked network every source and known proxy would wrongly count as "dead"
|
|
548
|
+
self.quality.record_checks(per_source)
|
|
549
|
+
for key in run.checked:
|
|
550
|
+
if key not in run.working:
|
|
551
|
+
self.history.record_fail(key)
|
|
552
|
+
for r in run.results:
|
|
553
|
+
self.history.record_ok(r.key, r.latency, r.exit_ip, country=r.country,
|
|
554
|
+
anonymity=r.anonymity, https=r.https)
|
|
555
|
+
self.history.prune()
|
|
556
|
+
self.history.save()
|
|
557
|
+
self.quality.save()
|
|
558
|
+
return per_source
|
|
559
|
+
|
|
560
|
+
def final_notes(self, run: CheckRun, stats: LiveStats, kept, geo: GeoResolver, network_blocked: bool) -> None:
|
|
561
|
+
opts = self.opts
|
|
562
|
+
if network_blocked:
|
|
563
|
+
note(
|
|
564
|
+
f"[bold]{fmt(stats.checked)} proxies checked, only {fmt(stats.found)} work.[/] "
|
|
565
|
+
"Your network (company or school firewall) is probably blocking proxy connections – try "
|
|
566
|
+
f"another network, e.g. a phone hotspot. [{MUTED}]Statistics and history were not "
|
|
567
|
+
"downgraded for this run.[/]"
|
|
568
|
+
)
|
|
569
|
+
if opts.geo and geo.failed:
|
|
570
|
+
if geo.offline:
|
|
571
|
+
note("ip-api.com unreachable – addresses that aren't in the DB-IP database "
|
|
572
|
+
"stay without a country.", MUTED, "ℹ")
|
|
573
|
+
else:
|
|
574
|
+
note("Country database and ip-api.com unreachable – countries are missing.", MUTED, "ℹ")
|
|
575
|
+
if opts.filters.countries and not kept and run.results:
|
|
576
|
+
note("No hit in the requested country – loosen the filter or let it run longer.", MUTED, "ℹ")
|
|
577
|
+
if run.judge_switches:
|
|
578
|
+
note(f"Check target went down, switched: {', '.join(run.judge_switches)}. {fmt(run.rechecked)} proxies "
|
|
579
|
+
"were checked again and don't count for the source statistics.", WARN, "⚠")
|
|
580
|
+
if stats.tampered:
|
|
581
|
+
note(f"{fmt(stats.tampered)} proxies modified a test page (usually by injecting scripts) "
|
|
582
|
+
"and were dropped.", WARN, "⚠")
|
|
583
|
+
if stats.hosting and run.results and not opts.filters.no_datacenter:
|
|
584
|
+
note(f"{fmt(stats.hosting)} of {fmt(len(run.results))} hits ({pct(stats.hosting, len(run.results))}) "
|
|
585
|
+
"probably exit from datacenters – those often get blocked sooner. "
|
|
586
|
+
"Only the others: --no-datacenter", MUTED, "ℹ")
|
|
587
|
+
if stats.blocklisted and run.results and not opts.filters.no_blocklisted:
|
|
588
|
+
share = pct(stats.blocklisted, len(run.results))
|
|
589
|
+
note(f"{fmt(stats.blocklisted)} of {fmt(len(run.results))} hits ({share}) "
|
|
590
|
+
"exit from an IP on the SpamCop blocklist – sites that use it show captchas or block them. "
|
|
591
|
+
"Only the others: --no-blocklisted", MUTED, "ℹ")
|
|
592
|
+
if run.reached_goal:
|
|
593
|
+
note(f"Goal of {fmt(opts.want)} hits reached – stopped early.", GOOD, "✔")
|
|
594
|
+
elif run.interrupted:
|
|
595
|
+
note("Interrupted – the hits so far were saved.")
|