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
@@ -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
+ )
@@ -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())))