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,344 @@
|
|
|
1
|
+
"""Live views while collecting and checking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import Counter, deque
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Deque, Dict, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from rich import box
|
|
11
|
+
from rich.align import Align
|
|
12
|
+
from rich.console import Group
|
|
13
|
+
from rich.panel import Panel
|
|
14
|
+
from rich.progress import (
|
|
15
|
+
BarColumn,
|
|
16
|
+
Progress,
|
|
17
|
+
SpinnerColumn,
|
|
18
|
+
TaskProgressColumn,
|
|
19
|
+
TextColumn,
|
|
20
|
+
TimeElapsedColumn,
|
|
21
|
+
TimeRemainingColumn,
|
|
22
|
+
)
|
|
23
|
+
from rich.table import Table
|
|
24
|
+
from rich.text import Text
|
|
25
|
+
|
|
26
|
+
from ..checker import CheckResult
|
|
27
|
+
from ..parsing import PROXY_TYPES
|
|
28
|
+
from ..targets import target_label
|
|
29
|
+
from . import widgets
|
|
30
|
+
from .widgets import (
|
|
31
|
+
ACCENT,
|
|
32
|
+
ANON_LABEL,
|
|
33
|
+
ANON_STYLE,
|
|
34
|
+
BAD,
|
|
35
|
+
BLOCKED_HIT_RATE,
|
|
36
|
+
BORDER,
|
|
37
|
+
GOOD,
|
|
38
|
+
LATENCY_EDGES,
|
|
39
|
+
LATENCY_LABELS,
|
|
40
|
+
MUTED,
|
|
41
|
+
TYPE_STYLE,
|
|
42
|
+
WARN,
|
|
43
|
+
CountColumn,
|
|
44
|
+
anon_cell,
|
|
45
|
+
bar,
|
|
46
|
+
card,
|
|
47
|
+
country_cell,
|
|
48
|
+
fmt,
|
|
49
|
+
header,
|
|
50
|
+
https_cell,
|
|
51
|
+
latency_style,
|
|
52
|
+
panel_title,
|
|
53
|
+
pct,
|
|
54
|
+
row,
|
|
55
|
+
shown_proxy,
|
|
56
|
+
sparkline,
|
|
57
|
+
table,
|
|
58
|
+
type_badge,
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------- #
|
|
62
|
+
# Phase 2: collecting
|
|
63
|
+
# --------------------------------------------------------------------------- #
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class CollectView:
|
|
67
|
+
def __init__(self, total_sources: int, started: float):
|
|
68
|
+
self.started = started
|
|
69
|
+
self.ok = 0
|
|
70
|
+
self.failed = 0
|
|
71
|
+
self.bytes = 0
|
|
72
|
+
self.cached = 0 # lists that were unchanged per ETag
|
|
73
|
+
self.unique = 0
|
|
74
|
+
self.progress = Progress(
|
|
75
|
+
SpinnerColumn(style=ACCENT),
|
|
76
|
+
TextColumn("[bold]Loading & parsing sources"),
|
|
77
|
+
BarColumn(bar_width=None, complete_style=ACCENT, finished_style=GOOD),
|
|
78
|
+
CountColumn(),
|
|
79
|
+
TimeElapsedColumn(),
|
|
80
|
+
expand=True,
|
|
81
|
+
)
|
|
82
|
+
self.task = self.progress.add_task("", total=total_sources)
|
|
83
|
+
|
|
84
|
+
def advance(self) -> None:
|
|
85
|
+
self.progress.advance(self.task)
|
|
86
|
+
|
|
87
|
+
def __rich__(self):
|
|
88
|
+
elapsed = max(time.perf_counter() - self.started, 1e-6)
|
|
89
|
+
stats = row(
|
|
90
|
+
card("Sources OK", fmt(self.ok), f"{fmt(self.failed)} failed", f"bold {GOOD}"),
|
|
91
|
+
card("Loaded", f"{self.bytes / 2**20:,.0f} MB",
|
|
92
|
+
f"{self.bytes / 2**20 / elapsed:.1f} MB/s"
|
|
93
|
+
+ (f" · {fmt(self.cached)} from cache" if self.cached else "")),
|
|
94
|
+
card("Proxies", fmt(self.unique), "unique", f"bold {ACCENT}"),
|
|
95
|
+
)
|
|
96
|
+
return Group(
|
|
97
|
+
header(1, self.started),
|
|
98
|
+
Panel(Group(self.progress, stats), box=box.ROUNDED, border_style=ACCENT,
|
|
99
|
+
title=panel_title("Collecting", ACCENT), title_align="left"),
|
|
100
|
+
)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
# --------------------------------------------------------------------------- #
|
|
104
|
+
# Phase 3: checking
|
|
105
|
+
# --------------------------------------------------------------------------- #
|
|
106
|
+
|
|
107
|
+
class LiveStats:
|
|
108
|
+
def __init__(self, jobs_by_type: Dict[str, int]):
|
|
109
|
+
self.total = sum(jobs_by_type.values())
|
|
110
|
+
self.total_by_type = dict(jobs_by_type)
|
|
111
|
+
self.checked = 0
|
|
112
|
+
self.checked_by_type: Counter = Counter()
|
|
113
|
+
self.working_by_type: Counter = Counter()
|
|
114
|
+
self.latency_hist = [0] * len(LATENCY_LABELS)
|
|
115
|
+
self.latency_sum = 0
|
|
116
|
+
self.fastest: Optional[int] = None
|
|
117
|
+
self.countries: Counter = Counter()
|
|
118
|
+
self.https_ok = 0
|
|
119
|
+
self.anonymity: Counter = Counter()
|
|
120
|
+
self.passing = 0
|
|
121
|
+
self.fakes = 0 # passed the basic check, but not the confirmation
|
|
122
|
+
self.tampered = 0 # confirmed, but modified a known page (scripts, ads)
|
|
123
|
+
self.hosting = 0 # hits that (probably) exit from a datacenter
|
|
124
|
+
self.blocklisted = 0 # hits whose exit IP is on the SpamCop blocklist
|
|
125
|
+
self.details_saved = 0 # HTTPS tests that could be skipped thanks to filters
|
|
126
|
+
self.targets_ok: Counter = Counter() # target site URL -> number of proxies that reach it
|
|
127
|
+
self.recent: Deque[CheckResult] = deque(maxlen=8)
|
|
128
|
+
self.start = time.perf_counter()
|
|
129
|
+
self.speed: Deque[float] = deque(maxlen=60)
|
|
130
|
+
self._sample_at = self.start
|
|
131
|
+
self._sample_checked = 0
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def found(self) -> int:
|
|
135
|
+
return sum(self.working_by_type.values())
|
|
136
|
+
|
|
137
|
+
def add_checked(self, ptype: str) -> None:
|
|
138
|
+
self.checked += 1
|
|
139
|
+
self.checked_by_type[ptype] += 1
|
|
140
|
+
|
|
141
|
+
def add_working(self, r: CheckResult) -> None:
|
|
142
|
+
"""Confirmed hit – the anonymity is known from here on."""
|
|
143
|
+
if r.anonymity:
|
|
144
|
+
self.anonymity[r.anonymity] += 1
|
|
145
|
+
if r.hosting:
|
|
146
|
+
self.hosting += 1
|
|
147
|
+
if r.blocklisted:
|
|
148
|
+
self.blocklisted += 1
|
|
149
|
+
self.working_by_type[r.ptype] += 1
|
|
150
|
+
self.latency_sum += r.latency
|
|
151
|
+
self.fastest = r.latency if self.fastest is None else min(self.fastest, r.latency)
|
|
152
|
+
self.latency_hist[sum(r.latency >= e for e in LATENCY_EDGES)] += 1
|
|
153
|
+
self.recent.append(r)
|
|
154
|
+
|
|
155
|
+
def add_details(self, r: CheckResult) -> None:
|
|
156
|
+
if r.https:
|
|
157
|
+
self.https_ok += 1
|
|
158
|
+
for url, ok in r.targets.items():
|
|
159
|
+
# count 0 too: a target site that no proxy reaches should stay visible in the report
|
|
160
|
+
self.targets_ok[url] += int(ok)
|
|
161
|
+
|
|
162
|
+
def sample_speed(self) -> float:
|
|
163
|
+
now = time.perf_counter()
|
|
164
|
+
if now - self._sample_at >= 1.0:
|
|
165
|
+
self.speed.append((self.checked - self._sample_checked) / (now - self._sample_at))
|
|
166
|
+
self._sample_at, self._sample_checked = now, self.checked
|
|
167
|
+
return self.speed[-1] if self.speed else 0.0
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def hit_line(found: int, checked: int, fakes: int) -> Text:
|
|
171
|
+
""""1.5% hits" – with filtered fake proxies short enough for narrow cards: "1.5% · 638 fakes"."""
|
|
172
|
+
if not fakes:
|
|
173
|
+
return Text(f"{pct(found, checked)} hits", style=MUTED)
|
|
174
|
+
return Text.assemble((f"{pct(found, checked)} · ", MUTED), (f"{fmt(fakes)} fakes", WARN))
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
class CheckDashboard:
|
|
178
|
+
def __init__(self, stats: LiveStats, outfile: Path, concurrency: int, details: bool,
|
|
179
|
+
filters_text: str = "", want: int = 0, targets: Sequence[str] = ()):
|
|
180
|
+
self.s = stats
|
|
181
|
+
self.targets = list(targets)
|
|
182
|
+
self.outfile = outfile
|
|
183
|
+
self.concurrency = concurrency
|
|
184
|
+
self.details = details
|
|
185
|
+
self.filters_text = filters_text
|
|
186
|
+
self.want = want
|
|
187
|
+
self.judge = ""
|
|
188
|
+
self.judge_note = ""
|
|
189
|
+
self.rechecks = 0
|
|
190
|
+
self.progress = Progress(
|
|
191
|
+
TextColumn("[bold]Progress"),
|
|
192
|
+
BarColumn(bar_width=None, complete_style=ACCENT, finished_style=GOOD),
|
|
193
|
+
TaskProgressColumn(text_format=f"[bold {ACCENT}]{{task.percentage:>3.0f}}%"),
|
|
194
|
+
CountColumn(),
|
|
195
|
+
TextColumn(f"[{MUTED}]· left"),
|
|
196
|
+
TimeRemainingColumn(),
|
|
197
|
+
expand=True,
|
|
198
|
+
)
|
|
199
|
+
self.task = self.progress.add_task("", total=stats.total)
|
|
200
|
+
|
|
201
|
+
def advance(self) -> None:
|
|
202
|
+
self.progress.advance(self.task)
|
|
203
|
+
|
|
204
|
+
def judge_changed(self, host: str) -> None:
|
|
205
|
+
"""The check target went down and was switched."""
|
|
206
|
+
self.judge = host
|
|
207
|
+
self.judge_note = f"check target switched → {host}"
|
|
208
|
+
|
|
209
|
+
def add_rechecks(self, keys: Sequence[str]) -> None:
|
|
210
|
+
"""Proxies that get checked again because of an outage – they add to the total."""
|
|
211
|
+
for key in keys:
|
|
212
|
+
self.s.total_by_type[key.split(" ", 1)[0]] += 1
|
|
213
|
+
self.rechecks += len(keys)
|
|
214
|
+
self.s.total += len(keys)
|
|
215
|
+
self.progress.update(self.task, total=self.s.total)
|
|
216
|
+
|
|
217
|
+
def __rich__(self):
|
|
218
|
+
s = self.s
|
|
219
|
+
width = widgets.console.size.width
|
|
220
|
+
speed = s.sample_speed()
|
|
221
|
+
avg = s.checked / max(time.perf_counter() - s.start, 1e-6)
|
|
222
|
+
found = s.found
|
|
223
|
+
|
|
224
|
+
wide = width >= 100
|
|
225
|
+
kpis = row(
|
|
226
|
+
card("Checked", fmt(s.checked), f"of {fmt(s.total)}"),
|
|
227
|
+
card("Found", fmt(found), hit_line(found, s.checked, s.fakes), f"bold {GOOD}"),
|
|
228
|
+
card("Speed", f"{fmt(speed or avg)}/s", sparkline(s.speed, max(width // 4 - 6, 8)), f"bold {ACCENT}"),
|
|
229
|
+
card(
|
|
230
|
+
"Ø latency", f"{s.latency_sum / found:,.0f} ms" if found else "–",
|
|
231
|
+
f"min. {fmt(s.fastest)} ms" if s.fastest is not None else "none yet",
|
|
232
|
+
f"bold {latency_style(s.latency_sum // found)}" if found else "bold",
|
|
233
|
+
),
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
panel_w = width * 4 // 11 # protocols/latency get 4 of 11 shares each
|
|
237
|
+
bar_w = max(panel_w - 18, 4)
|
|
238
|
+
proto = Table.grid(padding=(0, 1))
|
|
239
|
+
proto.add_column()
|
|
240
|
+
proto.add_column()
|
|
241
|
+
proto.add_column(justify="right")
|
|
242
|
+
for t in PROXY_TYPES:
|
|
243
|
+
if s.total_by_type.get(t):
|
|
244
|
+
proto.add_row(
|
|
245
|
+
type_badge(t),
|
|
246
|
+
bar(s.checked_by_type[t], s.total_by_type[t], bar_w, TYPE_STYLE[t]),
|
|
247
|
+
Text(fmt(s.working_by_type[t]), style=f"bold {GOOD}"),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
hist_w = max(panel_w - 19, 4)
|
|
251
|
+
hist = Table.grid(padding=(0, 1))
|
|
252
|
+
hist.add_column(style=MUTED)
|
|
253
|
+
hist.add_column()
|
|
254
|
+
hist.add_column(justify="right")
|
|
255
|
+
top = max(s.latency_hist) or 1
|
|
256
|
+
colors = (GOOD, GOOD, GOOD, WARN, WARN, BAD)
|
|
257
|
+
for label, n, color in zip(LATENCY_LABELS, s.latency_hist, colors):
|
|
258
|
+
hist.add_row(label, bar(n, top, hist_w, color), fmt(n))
|
|
259
|
+
|
|
260
|
+
side = Table.grid(padding=(0, 1))
|
|
261
|
+
side.add_column()
|
|
262
|
+
side.add_column(justify="right")
|
|
263
|
+
if self.details:
|
|
264
|
+
side.add_row(Text("✔ HTTPS", style=GOOD), fmt(s.https_ok))
|
|
265
|
+
for url in self.targets:
|
|
266
|
+
side.add_row(Text(f"🎯 {target_label(url, self.targets)}", style=ACCENT, overflow="ellipsis", no_wrap=True),
|
|
267
|
+
fmt(s.targets_ok[url]))
|
|
268
|
+
for level in ("elite", "anonymous", "transparent"):
|
|
269
|
+
letter, style = ANON_STYLE[level]
|
|
270
|
+
side.add_row(Text(f"{letter} {ANON_LABEL[level]}", style=style), fmt(s.anonymity[level]))
|
|
271
|
+
if s.hosting:
|
|
272
|
+
side.add_row(Text("▣ datacenter", style=MUTED), fmt(s.hosting))
|
|
273
|
+
if s.blocklisted:
|
|
274
|
+
side.add_row(Text("⊘ blocklisted", style=MUTED), fmt(s.blocklisted))
|
|
275
|
+
if s.details_saved:
|
|
276
|
+
side.add_row(Text("⏭ skipped", style=MUTED), fmt(s.details_saved))
|
|
277
|
+
for cc, n in s.countries.most_common(max(len(LATENCY_LABELS) - side.row_count, 0)):
|
|
278
|
+
side.add_row(country_cell(cc), fmt(n))
|
|
279
|
+
if not side.row_count:
|
|
280
|
+
side.add_row(Text("–", style=MUTED), "")
|
|
281
|
+
|
|
282
|
+
height = len(LATENCY_LABELS) + 2 # all three the same height
|
|
283
|
+
middle = row(
|
|
284
|
+
Panel(proto, title=panel_title("Protocols"), title_align="left",
|
|
285
|
+
subtitle=Text(" bar = checked ", style=MUTED),
|
|
286
|
+
subtitle_align="left", box=box.ROUNDED, border_style=BORDER, height=height),
|
|
287
|
+
Panel(hist, title=panel_title("Latency"), title_align="left", box=box.ROUNDED, border_style=BORDER,
|
|
288
|
+
height=height),
|
|
289
|
+
Panel(side, title=panel_title("Details & countries" if wide else "Details"),
|
|
290
|
+
title_align="left", box=box.ROUNDED, border_style=BORDER, height=height),
|
|
291
|
+
ratios=(4, 4, 3),
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
recent = table()
|
|
295
|
+
recent.add_column("Type", width=6)
|
|
296
|
+
recent.add_column("Proxy", min_width=21, ratio=3, no_wrap=True)
|
|
297
|
+
recent.add_column("Ctry", width=5)
|
|
298
|
+
if self.details:
|
|
299
|
+
recent.add_column("TLS", width=3, justify="center")
|
|
300
|
+
if self.targets:
|
|
301
|
+
recent.add_column("Site", width=4, justify="center")
|
|
302
|
+
recent.add_column("Anon", width=4, justify="center")
|
|
303
|
+
if wide:
|
|
304
|
+
recent.add_column("Exit-IP", ratio=2, style=MUTED, no_wrap=True)
|
|
305
|
+
recent.add_column("Latency", justify="right", width=8)
|
|
306
|
+
for r in reversed(s.recent):
|
|
307
|
+
cells = [type_badge(r.ptype), shown_proxy(r.proxy), country_cell(r.country)]
|
|
308
|
+
if self.details:
|
|
309
|
+
cells.append(https_cell(r.https))
|
|
310
|
+
if self.targets:
|
|
311
|
+
cells.append(https_cell(all(r.targets.get(u) for u in self.targets) if r.targets else None))
|
|
312
|
+
cells.append(anon_cell(r.anonymity))
|
|
313
|
+
if wide:
|
|
314
|
+
cells.append(r.exit_ip)
|
|
315
|
+
cells.append(Text(f"{fmt(r.latency)} ms", style=latency_style(r.latency)))
|
|
316
|
+
recent.add_row(*cells)
|
|
317
|
+
|
|
318
|
+
footer = Text(" Ctrl+C stops and saves", style=MUTED)
|
|
319
|
+
footer.append(f" · {fmt(self.concurrency)} parallel", style=MUTED)
|
|
320
|
+
if s.tampered:
|
|
321
|
+
footer.append(f" · {fmt(s.tampered)} dropped for tampering", style=WARN)
|
|
322
|
+
if self.judge:
|
|
323
|
+
footer.append(f" · target {self.judge}", style=MUTED)
|
|
324
|
+
if self.judge_note:
|
|
325
|
+
footer.append(f"\n ⚠ {self.judge_note}, {fmt(self.rechecks)} proxies are checked again", style=WARN)
|
|
326
|
+
if self.want:
|
|
327
|
+
footer.append(f" · goal {fmt(min(s.passing, self.want))} / {fmt(self.want)}", style=f"bold {ACCENT}")
|
|
328
|
+
if self.filters_text:
|
|
329
|
+
footer.append(f"\n Filter: {self.filters_text}", style=WARN)
|
|
330
|
+
footer.append(f" · {fmt(s.passing)} matching", style=MUTED)
|
|
331
|
+
footer.append(f"\n → {self.outfile}", style=MUTED)
|
|
332
|
+
if s.checked >= 2000 and found + s.fakes + s.tampered < s.checked * BLOCKED_HIT_RATE:
|
|
333
|
+
footer.append("\n ⚠ Hardly any hits – is your network (firewall) blocking proxy connections?",
|
|
334
|
+
style=f"bold {WARN}")
|
|
335
|
+
|
|
336
|
+
return Group(
|
|
337
|
+
header(2, s.start),
|
|
338
|
+
kpis,
|
|
339
|
+
middle,
|
|
340
|
+
Panel(self.progress, box=box.ROUNDED, border_style=ACCENT, padding=(0, 1)),
|
|
341
|
+
Panel(recent if s.recent else Align.center(Text("waiting for the first hit …", style=MUTED)),
|
|
342
|
+
title=panel_title("Found recently", GOOD), title_align="left", box=box.ROUNDED, border_style=GOOD),
|
|
343
|
+
footer,
|
|
344
|
+
)
|
proxyscraper/ui/keys.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""Read single key presses – without Enter, without an extra library, on Unix and Windows.
|
|
2
|
+
|
|
3
|
+
Returns names like "up", "down", "enter", "space", "esc", "backspace" or the character itself.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import os
|
|
9
|
+
import sys
|
|
10
|
+
from contextlib import contextmanager
|
|
11
|
+
from typing import Callable, Iterator
|
|
12
|
+
|
|
13
|
+
from ..compat import IS_WINDOWS
|
|
14
|
+
|
|
15
|
+
# escape sequences of the arrow keys (xterm and "application mode")
|
|
16
|
+
_ESCAPES = {
|
|
17
|
+
"[A": "up", "[B": "down", "[C": "right", "[D": "left",
|
|
18
|
+
"OA": "up", "OB": "down", "OC": "right", "OD": "left",
|
|
19
|
+
"[H": "home", "[F": "end", "[5~": "pageup", "[6~": "pagedown",
|
|
20
|
+
}
|
|
21
|
+
_WINDOWS_SPECIAL = {
|
|
22
|
+
"H": "up", "P": "down", "M": "right", "K": "left",
|
|
23
|
+
"G": "home", "O": "end", "I": "pageup", "Q": "pagedown",
|
|
24
|
+
}
|
|
25
|
+
_SIMPLE = {
|
|
26
|
+
"\r": "enter", "\n": "enter", " ": "space", "\t": "tab",
|
|
27
|
+
"\x7f": "backspace", "\x08": "backspace", "\x03": "ctrl-c",
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def is_interactive() -> bool:
|
|
32
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def decode(seq: str) -> str:
|
|
36
|
+
"""Raw character sequence of a key press -> key name."""
|
|
37
|
+
if seq.startswith("\x1b"):
|
|
38
|
+
return _ESCAPES.get(seq[1:], "esc")
|
|
39
|
+
return _SIMPLE.get(seq, seq)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@contextmanager
|
|
43
|
+
def raw_keys() -> Iterator[Callable[[], str]]:
|
|
44
|
+
"""Switch the terminal to raw mode; returns a function that reads the next key press."""
|
|
45
|
+
if IS_WINDOWS:
|
|
46
|
+
import msvcrt
|
|
47
|
+
|
|
48
|
+
def read_windows() -> str:
|
|
49
|
+
ch = msvcrt.getwch()
|
|
50
|
+
if ch in ("\x00", "\xe0"): # Sondertaste: zweites Zeichen sagt welche
|
|
51
|
+
return _WINDOWS_SPECIAL.get(msvcrt.getwch(), "")
|
|
52
|
+
if ch == "\x1b":
|
|
53
|
+
return "esc"
|
|
54
|
+
return decode(ch)
|
|
55
|
+
|
|
56
|
+
yield read_windows
|
|
57
|
+
return
|
|
58
|
+
|
|
59
|
+
import select
|
|
60
|
+
import termios
|
|
61
|
+
import tty
|
|
62
|
+
|
|
63
|
+
fd = sys.stdin.fileno()
|
|
64
|
+
saved = termios.tcgetattr(fd)
|
|
65
|
+
|
|
66
|
+
def read_unix() -> str:
|
|
67
|
+
ch = os.read(fd, 1).decode("utf-8", "ignore")
|
|
68
|
+
if ch != "\x1b":
|
|
69
|
+
return decode(ch)
|
|
70
|
+
# ESC alone or the start of a sequence? Wait briefly to see if more comes
|
|
71
|
+
seq = ch
|
|
72
|
+
while select.select([fd], [], [], 0.03)[0]:
|
|
73
|
+
seq += os.read(fd, 1).decode("utf-8", "ignore")
|
|
74
|
+
if seq[-1].isalpha() or seq[-1] == "~":
|
|
75
|
+
break
|
|
76
|
+
return decode(seq)
|
|
77
|
+
|
|
78
|
+
try:
|
|
79
|
+
tty.setcbreak(fd) # Ctrl+C stays a signal – cancelling works as usual
|
|
80
|
+
yield read_unix
|
|
81
|
+
finally:
|
|
82
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Final report after a run and the source ranking."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import time
|
|
6
|
+
from collections import Counter
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Dict, Iterable, List, Sequence, Tuple
|
|
9
|
+
|
|
10
|
+
from rich.table import Table
|
|
11
|
+
from rich.text import Text
|
|
12
|
+
|
|
13
|
+
from ..checker import CheckResult
|
|
14
|
+
from ..parsing import PROXY_TYPES
|
|
15
|
+
from ..targets import target_label
|
|
16
|
+
from . import widgets
|
|
17
|
+
from .dashboard import LiveStats, hit_line
|
|
18
|
+
from .widgets import (
|
|
19
|
+
ACCENT,
|
|
20
|
+
ANON_LABEL,
|
|
21
|
+
ANON_STYLE,
|
|
22
|
+
BAD,
|
|
23
|
+
GOOD,
|
|
24
|
+
LATENCY_LABELS,
|
|
25
|
+
MUTED,
|
|
26
|
+
TYPE_STYLE,
|
|
27
|
+
WARN,
|
|
28
|
+
anon_cell,
|
|
29
|
+
bar,
|
|
30
|
+
card,
|
|
31
|
+
country_cell,
|
|
32
|
+
fmt,
|
|
33
|
+
fmt_duration,
|
|
34
|
+
header,
|
|
35
|
+
https_cell,
|
|
36
|
+
latency_style,
|
|
37
|
+
note,
|
|
38
|
+
panel,
|
|
39
|
+
pct,
|
|
40
|
+
row,
|
|
41
|
+
short_url,
|
|
42
|
+
shown_proxy,
|
|
43
|
+
table,
|
|
44
|
+
type_badge,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
# --------------------------------------------------------------------------- #
|
|
48
|
+
# Phase 4: report
|
|
49
|
+
# --------------------------------------------------------------------------- #
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def render_summary(
|
|
53
|
+
stats: LiveStats,
|
|
54
|
+
results: List[CheckResult],
|
|
55
|
+
kept: List[CheckResult],
|
|
56
|
+
best_sources: Iterable[Tuple[float, int, int, str]],
|
|
57
|
+
files: Dict[str, Path],
|
|
58
|
+
details: bool,
|
|
59
|
+
filters_text: str,
|
|
60
|
+
next_steps: Sequence[Tuple[str, str]] = (),
|
|
61
|
+
) -> None:
|
|
62
|
+
width = widgets.console.size.width
|
|
63
|
+
wide = width >= 110
|
|
64
|
+
elapsed = time.perf_counter() - stats.start
|
|
65
|
+
found = stats.found
|
|
66
|
+
widgets.console.print()
|
|
67
|
+
widgets.console.print(header(3, stats.start))
|
|
68
|
+
|
|
69
|
+
widgets.console.print(row(
|
|
70
|
+
card("Working", fmt(found), hit_line(found, stats.checked, stats.fakes), f"bold {GOOD}"),
|
|
71
|
+
card("Saved", fmt(len(kept)), filters_text or "no filters", f"bold {ACCENT}"),
|
|
72
|
+
card("Duration", fmt_duration(elapsed), f"{fmt(stats.checked / max(elapsed, 1e-6))} checks/s"),
|
|
73
|
+
card(
|
|
74
|
+
"Ø latency", f"{stats.latency_sum / found:,.0f} ms" if found else "–",
|
|
75
|
+
f"min. {fmt(stats.fastest)} ms" if stats.fastest is not None else "",
|
|
76
|
+
f"bold {latency_style(stats.latency_sum // found)}" if found else "bold",
|
|
77
|
+
),
|
|
78
|
+
))
|
|
79
|
+
|
|
80
|
+
proto = table()
|
|
81
|
+
proto.add_column("Type", no_wrap=True, min_width=16)
|
|
82
|
+
proto.add_column("OK", justify="right", style=GOOD)
|
|
83
|
+
proto.add_column("checked", justify="right", style=MUTED)
|
|
84
|
+
proto.add_column("Rate", justify="right")
|
|
85
|
+
for t in PROXY_TYPES:
|
|
86
|
+
if stats.total_by_type.get(t):
|
|
87
|
+
proto.add_row(type_badge(t), fmt(stats.working_by_type[t]), fmt(stats.checked_by_type[t]),
|
|
88
|
+
pct(stats.working_by_type[t], stats.checked_by_type[t]))
|
|
89
|
+
if found:
|
|
90
|
+
proto.add_row("", "", "", "")
|
|
91
|
+
if details:
|
|
92
|
+
proto.add_row(Text("✔ HTTPS", style=GOOD), fmt(stats.https_ok), "", pct(stats.https_ok, found))
|
|
93
|
+
for url, ok in sorted(stats.targets_ok.items(), key=lambda kv: -kv[1]):
|
|
94
|
+
label = target_label(url, stats.targets_ok)
|
|
95
|
+
proto.add_row(Text(f"🎯 {label}", style=ACCENT), fmt(ok), "", pct(ok, found))
|
|
96
|
+
for level in ("elite", "anonymous", "transparent"):
|
|
97
|
+
letter, style = ANON_STYLE[level]
|
|
98
|
+
proto.add_row(Text(f"{letter} {ANON_LABEL[level]}", style=style), fmt(stats.anonymity[level]), "",
|
|
99
|
+
pct(stats.anonymity[level], found))
|
|
100
|
+
if stats.hosting:
|
|
101
|
+
proto.add_row(Text("▣ Datacenter", style=MUTED), fmt(stats.hosting), "", pct(stats.hosting, found))
|
|
102
|
+
if stats.blocklisted:
|
|
103
|
+
proto.add_row(Text("⊘ Blocklisted", style=MUTED), fmt(stats.blocklisted), "", pct(stats.blocklisted, found))
|
|
104
|
+
if stats.details_saved:
|
|
105
|
+
proto.add_row(Text("⏭ HTTPS tests skipped", style=MUTED), fmt(stats.details_saved), "", "")
|
|
106
|
+
|
|
107
|
+
# three columns in wide terminals, otherwise protocols on top and countries/latency below
|
|
108
|
+
bar_w = max((width // 3 if wide else width // 2) - 24, 4)
|
|
109
|
+
lands = table()
|
|
110
|
+
lands.add_column("Country")
|
|
111
|
+
lands.add_column("Count", justify="right")
|
|
112
|
+
lands.add_column("")
|
|
113
|
+
top_countries = stats.countries.most_common(len(LATENCY_LABELS))
|
|
114
|
+
peak = top_countries[0][1] if top_countries else 1
|
|
115
|
+
for cc, n in top_countries:
|
|
116
|
+
lands.add_row(country_cell(cc), fmt(n), bar(n, peak, bar_w, ACCENT))
|
|
117
|
+
if not top_countries:
|
|
118
|
+
lands.add_row(Text("no country data", style=MUTED), "", "")
|
|
119
|
+
|
|
120
|
+
hist = table()
|
|
121
|
+
hist.add_column("Latency")
|
|
122
|
+
hist.add_column("Count", justify="right")
|
|
123
|
+
hist.add_column("")
|
|
124
|
+
peak_l = max(stats.latency_hist) or 1
|
|
125
|
+
for label, n, color in zip(LATENCY_LABELS, stats.latency_hist, (GOOD, GOOD, GOOD, WARN, WARN, BAD)):
|
|
126
|
+
hist.add_row(label, fmt(n), bar(n, peak_l, bar_w, color))
|
|
127
|
+
|
|
128
|
+
if wide:
|
|
129
|
+
widgets.console.print(row(panel(proto, "Protocols"), panel(lands, "Countries"), panel(hist, "Latency")))
|
|
130
|
+
else:
|
|
131
|
+
widgets.console.print(panel(proto, "Protocols"))
|
|
132
|
+
widgets.console.print(row(panel(lands, "Countries"), panel(hist, "Latency")))
|
|
133
|
+
|
|
134
|
+
if kept:
|
|
135
|
+
fastest = table()
|
|
136
|
+
fastest.add_column("#", justify="right", style=MUTED, width=3)
|
|
137
|
+
fastest.add_column("Proxy", ratio=3, no_wrap=True)
|
|
138
|
+
fastest.add_column("Ctry", width=5)
|
|
139
|
+
if details:
|
|
140
|
+
fastest.add_column("TLS", width=3, justify="center")
|
|
141
|
+
fastest.add_column("Anon", width=4, justify="center")
|
|
142
|
+
fastest.add_column("Latency", justify="right", width=8)
|
|
143
|
+
for n, r in enumerate(sorted(kept, key=lambda r: r.latency)[:10], 1):
|
|
144
|
+
url = Text.assemble((f"{r.ptype}://", TYPE_STYLE[r.ptype]), shown_proxy(r.proxy))
|
|
145
|
+
cells = [str(n), url, country_cell(r.country)]
|
|
146
|
+
if details:
|
|
147
|
+
cells += [https_cell(r.https), anon_cell(r.anonymity)]
|
|
148
|
+
cells.append(Text(f"{fmt(r.latency)} ms", style=latency_style(r.latency)))
|
|
149
|
+
fastest.add_row(*cells)
|
|
150
|
+
widgets.console.print(panel(fastest, "The 10 fastest", GOOD))
|
|
151
|
+
|
|
152
|
+
best = list(best_sources)
|
|
153
|
+
if best:
|
|
154
|
+
src = table()
|
|
155
|
+
src.add_column("Source", ratio=1, no_wrap=True, overflow="ellipsis")
|
|
156
|
+
src.add_column("Hit rate", justify="right", style=GOOD)
|
|
157
|
+
src.add_column("OK / checked", justify="right", style=MUTED)
|
|
158
|
+
for rate, w, c, url in best:
|
|
159
|
+
src.add_row(short_url(url), f"{rate * 100:.1f}%", f"{fmt(w)} / {fmt(c)}")
|
|
160
|
+
widgets.console.print(panel(src, "Best sources of this run"))
|
|
161
|
+
|
|
162
|
+
if files:
|
|
163
|
+
grid = Table.grid(padding=(0, 2))
|
|
164
|
+
grid.add_column(style="bold", no_wrap=True)
|
|
165
|
+
grid.add_column(overflow="fold")
|
|
166
|
+
for label, path in files.items():
|
|
167
|
+
grid.add_row(label, Text(_display_path(path), style=ACCENT))
|
|
168
|
+
widgets.console.print(panel(grid, "Files", ACCENT))
|
|
169
|
+
|
|
170
|
+
if next_steps:
|
|
171
|
+
grid = Table.grid(padding=(0, 2))
|
|
172
|
+
grid.add_column(style=MUTED, no_wrap=True)
|
|
173
|
+
grid.add_column(overflow="fold")
|
|
174
|
+
for label, command in next_steps:
|
|
175
|
+
grid.add_row(label, Text("$ ", style=MUTED) + Text(command, style="bold"))
|
|
176
|
+
widgets.console.print(panel(grid, "Next steps", GOOD))
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _display_path(path: Path) -> str:
|
|
180
|
+
"""Relative to the current folder if possible – shorter and clickable."""
|
|
181
|
+
try:
|
|
182
|
+
return str(path.resolve().relative_to(Path.cwd().resolve()))
|
|
183
|
+
except ValueError:
|
|
184
|
+
return str(path)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def render_source_ranking(rows: List[Tuple[str, object, str]], total_known: int, reasons: Counter) -> None:
|
|
188
|
+
ranking = table()
|
|
189
|
+
ranking.add_column("#", justify="right", style=MUTED)
|
|
190
|
+
ranking.add_column("Source", no_wrap=True, overflow="ellipsis", ratio=1)
|
|
191
|
+
ranking.add_column("Hit rate", justify="right")
|
|
192
|
+
ranking.add_column("", width=10)
|
|
193
|
+
ranking.add_column("checked", justify="right", style=MUTED)
|
|
194
|
+
ranking.add_column("Entries", justify="right", style=MUTED)
|
|
195
|
+
ranking.add_column("Status")
|
|
196
|
+
best = max((r.working / r.checked for _, r, _ in rows if r.checked), default=1) or 1
|
|
197
|
+
for n, (url, rec, status) in enumerate(rows, 1):
|
|
198
|
+
rate = rec.working / rec.checked if rec.checked else 0
|
|
199
|
+
ranking.add_row(
|
|
200
|
+
str(n), short_url(url), f"{rate * 100:.1f}%", bar(rate, best, 10, GOOD),
|
|
201
|
+
fmt(rec.checked), fmt(rec.count),
|
|
202
|
+
Text(status, style=GOOD if status == "active" else BAD),
|
|
203
|
+
)
|
|
204
|
+
title = f"Sources by hit rate · {len(rows)} rated, {fmt(total_known)} known"
|
|
205
|
+
widgets.console.print(panel(ranking, title, ACCENT))
|
|
206
|
+
if not rows:
|
|
207
|
+
note("No ratings yet – hit rates only exist after a checking run.", MUTED, "ℹ")
|
|
208
|
+
widgets.console.print(Text(" Status of all sources: ", style=MUTED) + Text(
|
|
209
|
+
", ".join(f"{n} {r}" for r, n in reasons.most_common())))
|