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,595 @@
1
+ """Setup wizard: pick with the arrow keys at startup which proxies to look for.
2
+
3
+ The logic is deliberately separate from the keyboard – `Wizard.handle()` receives key names
4
+ ("up", "space", "enter" …), `Wizard` itself is a rich renderable. That way the whole flow can be
5
+ tested without a terminal.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import re
12
+ import unicodedata
13
+ from dataclasses import dataclass, replace
14
+ from typing import Any, Callable, List, Optional, Sequence, Set
15
+
16
+ from rich import box
17
+ from rich.console import Group, RenderableType
18
+ from rich.padding import Padding
19
+ from rich.panel import Panel
20
+ from rich.table import Table
21
+ from rich.text import Text
22
+
23
+ from ..geo import flag
24
+ from ..options import DEFAULT_SERVE_PORT, RunOptions
25
+ from ..parsing import PROXY_TYPES
26
+ from ..targets import SUGGESTIONS, target_label
27
+ from .widgets import ACCENT, GOOD, MUTED, TYPE_STYLE, WARN, fmt
28
+
29
+ NEXT, BACK = "next", "back"
30
+ VISIBLE_ROWS = 9
31
+
32
+ COUNTRIES = (
33
+ ("DE", "Germany"), ("AT", "Austria"), ("CH", "Switzerland"), ("NL", "Netherlands"),
34
+ ("FR", "France"), ("GB", "United Kingdom"), ("PL", "Poland"), ("SE", "Sweden"),
35
+ ("IT", "Italy"), ("ES", "Spain"), ("US", "USA"), ("CA", "Canada"), ("BR", "Brazil"),
36
+ ("JP", "Japan"), ("SG", "Singapore"), ("HK", "Hong Kong"), ("KR", "South Korea"), ("IN", "India"),
37
+ ("ID", "Indonesia"), ("TH", "Thailand"), ("VN", "Vietnam"), ("TR", "Turkey"),
38
+ ("RU", "Russia"), ("UA", "Ukraine"),
39
+ )
40
+ TYPE_HINTS = {
41
+ "http": "web proxies, HTTPS via CONNECT",
42
+ "socks4": "older, TCP only, no DNS through the proxy",
43
+ "socks5": "universal – browsers, apps, games",
44
+ }
45
+ ANON_TEXT = {"": "any", "anonymous": "at least anonymous", "elite": "elite only"}
46
+
47
+
48
+ @dataclass
49
+ class Option:
50
+ label: str
51
+ hint: str = ""
52
+ value: Any = None
53
+
54
+ @property
55
+ def search_text(self) -> str:
56
+ """Text for jumping by letter: without leading flags/symbols, accents as base letters (Ö -> o)."""
57
+ text = re.sub(r"^\W+", "", self.label)
58
+ return unicodedata.normalize("NFD", text).encode("ascii", "ignore").decode().lower()
59
+
60
+
61
+ # --------------------------------------------------------------------------- #
62
+ # Steps
63
+ # --------------------------------------------------------------------------- #
64
+
65
+ class Step:
66
+ title = ""
67
+ subtitle = ""
68
+ keys_help = "↑↓ select · Enter next · Esc back · q quit"
69
+
70
+ def load(self, opts: RunOptions) -> None:
71
+ """Preselect the current setting."""
72
+
73
+ def apply(self, opts: RunOptions) -> None:
74
+ """Apply the selection to the settings."""
75
+
76
+ def handle(self, key: str) -> Optional[str]:
77
+ raise NotImplementedError
78
+
79
+ def body(self) -> RenderableType:
80
+ raise NotImplementedError
81
+
82
+
83
+ class _ListStep(Step):
84
+ def __init__(self, title: str, subtitle: str, options: Sequence[Option]):
85
+ self.title, self.subtitle = title, subtitle
86
+ self.options = list(options)
87
+ self.cursor = 0
88
+ self.message = ""
89
+
90
+ def _move(self, key: str) -> bool:
91
+ n = len(self.options)
92
+ if key in ("up", "k"):
93
+ self.cursor = (self.cursor - 1) % n
94
+ elif key in ("down", "j"):
95
+ self.cursor = (self.cursor + 1) % n
96
+ elif key in ("home", "pageup"):
97
+ self.cursor = 0
98
+ elif key in ("end", "pagedown"):
99
+ self.cursor = n - 1
100
+ elif len(key) == 1 and key.isalpha() and key not in "jkq":
101
+ # typing jumps to the next entry starting with that letter
102
+ order = list(range(self.cursor + 1, n)) + list(range(self.cursor + 1))
103
+ hit = next((i for i in order if self.options[i].search_text.startswith(key.lower())), None)
104
+ if hit is None:
105
+ return False
106
+ self.cursor = hit
107
+ else:
108
+ return False
109
+ self.message = ""
110
+ return True
111
+
112
+ def _window(self) -> range:
113
+ n = len(self.options)
114
+ if n <= VISIBLE_ROWS:
115
+ return range(n)
116
+ start = min(max(self.cursor - VISIBLE_ROWS // 2, 0), n - VISIBLE_ROWS)
117
+ return range(start, start + VISIBLE_ROWS)
118
+
119
+ numbered = False
120
+
121
+ def _rows(self, marker: Callable[[int], Text]) -> Table:
122
+ numbers = self.numbered and len(self.options) <= 9
123
+ # fixed widths for arrow, number, marker and label – when space runs out, rich only shortens the hint
124
+ grid = Table.grid(padding=(0, 1), expand=True)
125
+ grid.add_column(width=1)
126
+ if numbers:
127
+ grid.add_column(width=1, style=MUTED)
128
+ grid.add_column(width=marker(0).cell_len)
129
+ grid.add_column(min_width=max(len(o.label) for o in self.options), no_wrap=True)
130
+ grid.add_column(style=MUTED, overflow="ellipsis", no_wrap=True, ratio=1)
131
+ window = self._window()
132
+ pad = [""] if numbers else []
133
+ if window.start > 0:
134
+ grid.add_row("", *pad, "", Text(f"↑ {window.start} more", style=MUTED), "")
135
+ for i in window:
136
+ opt = self.options[i]
137
+ active = i == self.cursor
138
+ grid.add_row(
139
+ Text("❯", style=f"bold {ACCENT}") if active else "",
140
+ *([str(i + 1)] if numbers else []),
141
+ marker(i),
142
+ Text(opt.label, style=f"bold {ACCENT}" if active else ""),
143
+ opt.hint,
144
+ )
145
+ rest = len(self.options) - window.stop
146
+ if rest > 0:
147
+ grid.add_row("", *pad, "", Text(f"↓ {rest} more", style=MUTED), "")
148
+ return grid
149
+
150
+ def body(self) -> RenderableType:
151
+ parts: List[RenderableType] = [self._rows(self._marker)]
152
+ if self.message:
153
+ parts.append(Text(f"\n{self.message}", style=WARN))
154
+ return Group(*parts)
155
+
156
+ def _marker(self, i: int) -> Text:
157
+ raise NotImplementedError
158
+
159
+
160
+ class SelectStep(_ListStep):
161
+ """Single choice – numbers 1–9 pick directly."""
162
+
163
+ numbered = True
164
+
165
+ def __init__(self, title, subtitle, options, read: Optional[Callable[[RunOptions], Any]] = None,
166
+ write: Optional[Callable[[RunOptions, Any], None]] = None):
167
+ super().__init__(title, subtitle, options)
168
+ self.read, self.write = read, write
169
+
170
+ @property
171
+ def value(self) -> Any:
172
+ return self.options[self.cursor].value
173
+
174
+ def load(self, opts: RunOptions) -> None:
175
+ if self.read:
176
+ current = self.read(opts)
177
+ self.cursor = next((i for i, o in enumerate(self.options) if o.value == current), 0)
178
+
179
+ def apply(self, opts: RunOptions) -> None:
180
+ if self.write:
181
+ self.write(opts, self.value)
182
+
183
+ def handle(self, key: str) -> Optional[str]:
184
+ if key == "enter":
185
+ return NEXT
186
+ if key.isdigit() and 0 < int(key) <= len(self.options):
187
+ self.cursor = int(key) - 1
188
+ return NEXT
189
+ self._move(key)
190
+ return None
191
+
192
+ def _marker(self, i: int) -> Text:
193
+ return Text("◉", style=f"bold {ACCENT}") if i == self.cursor else Text("○", style=MUTED)
194
+
195
+
196
+ class MultiStep(_ListStep):
197
+ """Multiple choice with the space bar."""
198
+
199
+ keys_help = "↑↓ select · Space on/off · a all/none · Enter next · Esc back"
200
+
201
+ def __init__(self, title, subtitle, options, read: Callable[[RunOptions], Set],
202
+ write: Callable[[RunOptions, List], None], min_selected: int = 0, empty_hint: str = ""):
203
+ super().__init__(title, subtitle, options)
204
+ self.read, self.write = read, write
205
+ self.min_selected = min_selected
206
+ self.empty_hint = empty_hint
207
+ self.checked: Set[int] = set()
208
+
209
+ def load(self, opts: RunOptions) -> None:
210
+ current = self.read(opts)
211
+ self.checked = {i for i, o in enumerate(self.options) if o.value in current}
212
+
213
+ def apply(self, opts: RunOptions) -> None:
214
+ self.write(opts, [o.value for i, o in enumerate(self.options) if i in self.checked])
215
+
216
+ def handle(self, key: str) -> Optional[str]:
217
+ if key == "space":
218
+ self.checked ^= {self.cursor}
219
+ self.message = ""
220
+ elif key == "a":
221
+ self.checked = set() if len(self.checked) == len(self.options) else set(range(len(self.options)))
222
+ self.message = ""
223
+ elif key == "enter":
224
+ if len(self.checked) < self.min_selected:
225
+ self.message = f"Please select at least {self.min_selected} (space bar)."
226
+ return None
227
+ return NEXT
228
+ else:
229
+ self._move(key)
230
+ return None
231
+
232
+ def _marker(self, i: int) -> Text:
233
+ return Text("[✔]", style=f"bold {GOOD}") if i in self.checked else Text("[ ]", style=MUTED)
234
+
235
+ def body(self) -> RenderableType:
236
+ chosen = [self.options[i].value for i in sorted(self.checked)]
237
+ status = Text(f"{len(chosen)} selected", style=GOOD) if chosen else Text(self.empty_hint, style=MUTED)
238
+ return Group(super().body(), Text(""), status)
239
+
240
+
241
+ class TargetStep(SelectStep):
242
+ """Pick a target site. Targets from the command line that aren't suggestions stay available as an option."""
243
+
244
+ def __init__(self):
245
+ super().__init__(
246
+ "Does a specific site have to work?",
247
+ "Many proxies can't reach Google, Discord and the like – this actually tries it.",
248
+ [Option("No", "any site is fine", [])]
249
+ + [Option(name, target_label(url), [url]) for name, url in SUGGESTIONS],
250
+ read=lambda o: o.filters.targets, write=lambda o, v: setattr(o.filters, "targets", list(v)),
251
+ )
252
+ self._fixed = len(self.options)
253
+
254
+ def load(self, opts: RunOptions) -> None:
255
+ del self.options[self._fixed:]
256
+ current = list(opts.filters.targets)
257
+ if current and all(o.value != current for o in self.options):
258
+ self.options.append(Option("As given", ", ".join(target_label(u) for u in current), current))
259
+ super().load(opts)
260
+
261
+
262
+ class ServeStep(SelectStep):
263
+ """Proxy server afterwards? A custom port from the command line (-i --serve 9000) stays selectable."""
264
+
265
+ def __init__(self):
266
+ super().__init__(
267
+ "Serve them as a proxy server afterwards?",
268
+ "A local proxy that sends every connection through a different proxy that was found.",
269
+ [Option("No", "just the result files", 0),
270
+ Option(f"Yes, on port {DEFAULT_SERVE_PORT}", f"http://127.0.0.1:{DEFAULT_SERVE_PORT} – runs until Ctrl+C",
271
+ DEFAULT_SERVE_PORT)],
272
+ read=lambda o: o.serve, write=lambda o, v: setattr(o, "serve", v),
273
+ )
274
+
275
+ def load(self, opts: RunOptions) -> None:
276
+ del self.options[2:]
277
+ if opts.serve not in (0, DEFAULT_SERVE_PORT):
278
+ self.options.append(Option(f"Yes, on port {opts.serve}", "as given", opts.serve))
279
+ super().load(opts)
280
+
281
+
282
+ class SummaryStep(SelectStep):
283
+ START, ADJUST, CANCEL = "start", "adjust", "cancel"
284
+
285
+ def __init__(self):
286
+ super().__init__("All set?", "This is what will be searched – Enter starts.", [
287
+ Option("Start the search", "", self.START),
288
+ Option("Adjust …", "change every setting one by one", self.ADJUST),
289
+ Option("Cancel", "", self.CANCEL),
290
+ ])
291
+ self.opts = RunOptions()
292
+
293
+ def load(self, opts: RunOptions) -> None:
294
+ self.opts = opts
295
+ self.cursor = 0
296
+
297
+ def body(self) -> RenderableType:
298
+ grid = Table.grid(padding=(0, 2))
299
+ grid.add_column(style=MUTED, no_wrap=True)
300
+ grid.add_column()
301
+ for label, value in describe(self.opts):
302
+ grid.add_row(label, value)
303
+ command = Padding(Text(self.opts.to_command(), style=f"italic {MUTED}"), (0, 0, 0, 2))
304
+ warnings = [Text(f"⚠ {w}", style=WARN) for w in warnings_for(self.opts)]
305
+ return Group(grid, Text(""), *warnings, Text("Start the same directly:", style=MUTED), command,
306
+ Text(""), self._rows(self._marker))
307
+
308
+
309
+ # --------------------------------------------------------------------------- #
310
+ # Describing the settings
311
+ # --------------------------------------------------------------------------- #
312
+
313
+ def describe(opts: RunOptions) -> List[tuple]:
314
+ f = opts.filters
315
+ if opts.recheck is not None:
316
+ rows = [("Mode", Text("recheck the last hits + history", style="bold"))]
317
+ else:
318
+ rows = [("Mode", Text("collect and check all sources", style="bold"))]
319
+ types = Text()
320
+ for i, t in enumerate(t for t in PROXY_TYPES if t in opts.types):
321
+ if i:
322
+ types.append(" · ", style=MUTED)
323
+ types.append(t, style=f"bold {TYPE_STYLE[t]}")
324
+ rows.append(("Protocols", types))
325
+ rows.append(("Countries", Text(" ".join(f"{flag(c)} {c}" for c in sorted(f.countries)) if f.countries else "all")))
326
+ rows.append(("Anonymity", Text(ANON_TEXT[f.min_anonymity])))
327
+ rows.append(("HTTPS", Text("HTTPS-capable only", style=GOOD) if f.https_only else Text("any")))
328
+ rows.append(("Target site", Text(", ".join(target_label(u) for u in f.targets), style=ACCENT) if f.targets
329
+ else Text("any")))
330
+ rows.append(("Latency", Text(f"under {fmt_seconds(f.max_latency)}") if f.max_latency else Text("any")))
331
+ rows.append(("Amount", Text(f"stops at {fmt(opts.want)}") if opts.want else Text("as many as possible")))
332
+ if opts.serve:
333
+ rows.append(("Afterwards", Text(f"proxy server on 127.0.0.1:{opts.serve}", style=f"bold {ACCENT}")))
334
+ rows.append(("Checks", Text("thorough – with HTTPS test") if opts.details
335
+ else Text("fast – without HTTPS test")))
336
+ return rows
337
+
338
+
339
+ def warnings_for(opts: RunOptions) -> List[str]:
340
+ out = []
341
+ f = opts.filters
342
+ if opts.fast and f.needs_details:
343
+ out.append("The HTTPS filter needs the HTTPS test – thorough checks stay on.")
344
+ if "socks4" in opts.types and len(opts.types) == 1 and f.https_only:
345
+ out.append("SOCKS4 can tunnel HTTPS, but usually finds only a few matching proxies.")
346
+ return out
347
+
348
+
349
+ def short_description(opts: RunOptions) -> str:
350
+ """One-line description for "Same as last time", e.g. "socks5 · DE,AT · HTTPS only · 50 proxies"."""
351
+ f = opts.filters
352
+ parts = [" + ".join(opts.types) if opts.types != list(PROXY_TYPES) else "all protocols"]
353
+ if opts.recheck is not None:
354
+ parts.insert(0, "Recheck")
355
+ if f.countries:
356
+ parts.append(",".join(sorted(f.countries)))
357
+ if f.https_only:
358
+ parts.append("HTTPS only")
359
+ if f.min_anonymity:
360
+ parts.append(ANON_TEXT[f.min_anonymity])
361
+ if f.max_latency:
362
+ parts.append(f"< {fmt_seconds(f.max_latency)}")
363
+ if f.targets:
364
+ parts.append("→ " + ", ".join(target_label(u) for u in f.targets))
365
+ if opts.want:
366
+ parts.append(f"{fmt(opts.want)} proxies")
367
+ if opts.fast:
368
+ parts.append("fast")
369
+ return " · ".join(parts)
370
+
371
+
372
+ def fmt_seconds(ms: int) -> str:
373
+ return f"{ms / 1000:g} s"
374
+
375
+
376
+ # --------------------------------------------------------------------------- #
377
+ # Wizard
378
+ # --------------------------------------------------------------------------- #
379
+
380
+ def _set_types(opts: RunOptions, values: List[str]) -> None:
381
+ opts.types = values
382
+
383
+
384
+ def _set_countries(opts: RunOptions, values: List[str]) -> None:
385
+ opts.filters.countries = set(values)
386
+
387
+
388
+ def _set_filter(name: str) -> Callable[[RunOptions, Any], None]:
389
+ return lambda opts, value: setattr(opts.filters, name, value)
390
+
391
+
392
+ def custom_steps() -> List[Step]:
393
+ return [
394
+ MultiStep(
395
+ "Which protocols?", "Pick several with the space bar.",
396
+ [Option(t.upper(), TYPE_HINTS[t], t) for t in PROXY_TYPES],
397
+ read=lambda o: set(o.types), write=_set_types, min_selected=1,
398
+ ),
399
+ MultiStep(
400
+ "From which countries?", "Typing jumps to the letter. Nothing selected = all countries.",
401
+ [Option(f"{flag(cc)} {name}", cc, cc) for cc, name in COUNTRIES],
402
+ read=lambda o: o.filters.countries, write=_set_countries, empty_hint="no selection = all countries",
403
+ ),
404
+ SelectStep(
405
+ "How anonymous?", "SOCKS proxies are always elite – they don't touch your traffic.",
406
+ [Option("Any", "also transparent proxies that pass on your IP", ""),
407
+ Option("At least anonymous", "hide your IP, but identify themselves as a proxy", "anonymous"),
408
+ Option("Elite only", "not recognizable as a proxy", "elite")],
409
+ read=lambda o: o.filters.min_anonymity, write=_set_filter("min_anonymity"),
410
+ ),
411
+ SelectStep(
412
+ "Do you need HTTPS?", "Needed for almost every website – the proxy has to tunnel encrypted connections.",
413
+ [Option("Any", "", False), Option("HTTPS-capable only", "checked with a real TLS handshake", True)],
414
+ read=lambda o: o.filters.https_only, write=_set_filter("https_only"),
415
+ ),
416
+ TargetStep(),
417
+ SelectStep(
418
+ "How fast?", "Slow proxies aren't even checked to the end.",
419
+ [Option("Any", "", 0), Option("Under 0.5 s", "very strict", 500), Option("Under 1 s", "snappy", 1000),
420
+ Option("Under 2 s", "good compromise", 2000), Option("Under 5 s", "almost all", 5000)],
421
+ read=lambda o: o.filters.max_latency, write=_set_filter("max_latency"),
422
+ ),
423
+ SelectStep(
424
+ "How many?", "The search stops as soon as enough matching proxies are found.",
425
+ [Option("As many as possible", "checks every candidate", 0), Option("10", "", 10), Option("25", "", 25),
426
+ Option("50", "", 50), Option("100", "", 100), Option("500", "", 500)],
427
+ read=lambda o: o.want, write=lambda o, v: setattr(o, "want", v),
428
+ ),
429
+ ServeStep(),
430
+ SelectStep(
431
+ "How thorough?", "Anonymity and country are always included – the HTTPS test costs a TLS connection.",
432
+ [Option("Thorough", "with an HTTPS test for every hit", False),
433
+ Option("Fast", "without HTTPS test (--fast)", True)],
434
+ read=lambda o: o.fast, write=lambda o, v: setattr(o, "fast", v),
435
+ ),
436
+ ]
437
+
438
+
439
+ class Wizard:
440
+ """State machine of the wizard. `result` is set when finished, `cancelled` when aborted."""
441
+
442
+ CUSTOM, LAST = "custom", "last"
443
+
444
+ def __init__(self, initial: RunOptions, last: Optional[RunOptions] = None, can_recheck: bool = False):
445
+ self.initial = initial
446
+ self.last = last
447
+ self.opts = copy.deepcopy(initial)
448
+ self.custom = custom_steps()
449
+ self.summary = SummaryStep()
450
+ self.start = SelectStep("What are you looking for?", "Quick pick – or set everything yourself.",
451
+ self._presets(can_recheck))
452
+ self.step: Step = self.start
453
+ self.history: List[Step] = []
454
+ self.result: Optional[RunOptions] = None
455
+ self.cancelled = False
456
+
457
+ @property
458
+ def done(self) -> bool:
459
+ return self.result is not None or self.cancelled
460
+
461
+ def _preset(self, types: Optional[Sequence[str]] = None, want: Optional[int] = None,
462
+ recheck: Optional[str] = None, **filters) -> RunOptions:
463
+ """Preset = starting values + only what the preset itself sets.
464
+
465
+ That way options from the command line (e.g. -i --country DE -c 500) are kept
466
+ as long as the preset doesn't explicitly change them.
467
+ """
468
+ opts = replace(copy.deepcopy(self.initial), recheck=recheck)
469
+ if types is not None:
470
+ opts.types = list(types)
471
+ if want is not None:
472
+ opts.want = want
473
+ for name, value in filters.items():
474
+ setattr(opts.filters, name, value)
475
+ return opts
476
+
477
+ def _presets(self, can_recheck: bool) -> List[Option]:
478
+ everything = self._preset(types=PROXY_TYPES)
479
+ presets = [
480
+ Option("Find everything", "all protocols – maximum yield", everything),
481
+ Option("Browsing & web", "HTTP + SOCKS5, HTTPS-capable, at least anonymous, under 3 s", self._preset(
482
+ types=["http", "socks5"], https_only=True, min_anonymity="anonymous", max_latency=3000)),
483
+ Option("Maximum anonymity", "elite SOCKS5 with HTTPS only – so the operator can't read along", self._preset(
484
+ types=["socks5"], https_only=True, min_anonymity="elite")),
485
+ Option("Fast & stable", "only proxies under 1 s latency", self._preset(max_latency=1000)),
486
+ Option("A few right now", "stops after 25 hits", self._preset(want=25)),
487
+ ]
488
+ if can_recheck:
489
+ presets.append(Option("Recheck the last hits", "no collecting – takes only seconds",
490
+ self._preset(recheck="")))
491
+ quick_server = self._preset(recheck="")
492
+ quick_server.serve = DEFAULT_SERVE_PORT
493
+ presets.append(Option("Proxy server right away", f"recheck the last hits, then serve them on "
494
+ f":{DEFAULT_SERVE_PORT}", quick_server))
495
+ # only offer it if it differs from "Find everything" – otherwise the same thing shows up twice
496
+ if self.last is not None and self.last != everything:
497
+ presets.append(Option("Same as last time", short_description(self.last), self.LAST))
498
+ presets.append(Option("Custom …", "set everything step by step", self.CUSTOM))
499
+ return presets
500
+
501
+ def _go(self, step: Step) -> None:
502
+ self.history.append(self.step)
503
+ self.step = step
504
+ step.load(self.opts)
505
+
506
+ def handle(self, key: str) -> None:
507
+ if key in ("q", "ctrl-c"):
508
+ self.cancelled = True
509
+ return
510
+ if key in ("esc", "left", "backspace"):
511
+ if self.history:
512
+ self.step = self.history.pop()
513
+ self.step.load(self.opts)
514
+ return
515
+ if self.step.handle(key) != NEXT:
516
+ return
517
+ self.step.apply(self.opts)
518
+ self._advance()
519
+
520
+ def _advance(self) -> None:
521
+ step = self.step
522
+ if step is self.start:
523
+ choice = self.start.value
524
+ if choice == self.CUSTOM:
525
+ # start with what was already on the command line (e.g. -i --country DE)
526
+ self.opts = replace(copy.deepcopy(self.initial), recheck=None)
527
+ self._go(self.custom[0])
528
+ else:
529
+ self.opts = copy.deepcopy(self.last if choice == self.LAST else choice)
530
+ self._go(self.summary)
531
+ elif step is self.summary:
532
+ choice = self.summary.value
533
+ if choice == SummaryStep.START:
534
+ self.result = self.opts
535
+ elif choice == SummaryStep.ADJUST:
536
+ self.opts.recheck = None
537
+ self._go(self.custom[0])
538
+ else:
539
+ self.cancelled = True
540
+ else:
541
+ i = self.custom.index(step)
542
+ self._go(self.custom[i + 1] if i + 1 < len(self.custom) else self.summary)
543
+
544
+ # ------------------------------------------------------------------ rendering
545
+
546
+ def _position(self) -> Text:
547
+ if self.step is self.start:
548
+ return Text("Start", style=MUTED)
549
+ if self.step is self.summary:
550
+ return Text("Summary", style=MUTED)
551
+ i = self.custom.index(self.step)
552
+ dots = Text()
553
+ for j in range(len(self.custom)):
554
+ dots.append("●" if j <= i else "○", style=ACCENT if j <= i else MUTED)
555
+ return dots + Text(f" {i + 1}/{len(self.custom)}", style=MUTED)
556
+
557
+ def __rich__(self) -> RenderableType:
558
+ step = self.step
559
+ title = Table.grid(expand=True)
560
+ title.add_column()
561
+ title.add_column(justify="right")
562
+ title.add_row(Text(step.title, style="bold"), self._position())
563
+ content = Group(
564
+ title,
565
+ Text(step.subtitle, style=MUTED),
566
+ Text(""),
567
+ step.body(),
568
+ )
569
+ panel = Panel(
570
+ content, box=box.ROUNDED, border_style=ACCENT, padding=(1, 2),
571
+ title=Text(" Setup ", style=f"bold {ACCENT}"), # the banner above already says what this is
572
+ title_align="left",
573
+ )
574
+ help_line = Text(" " + step.keys_help, style=MUTED)
575
+ if step is self.start or step is self.summary:
576
+ help_line = Text(" ↑↓ select · Enter confirm · number = pick directly · Esc back · q quit",
577
+ style=MUTED)
578
+ return Group(panel, help_line)
579
+
580
+
581
+ def run_wizard(initial: RunOptions, last: Optional[RunOptions], can_recheck: bool, console) -> Optional[RunOptions]:
582
+ """Shows the wizard in the terminal; None when cancelled."""
583
+ from rich.live import Live
584
+
585
+ from .keys import raw_keys
586
+
587
+ wizard = Wizard(initial, last, can_recheck)
588
+ try:
589
+ with raw_keys() as read_key, Live(wizard, console=console, auto_refresh=False, transient=True) as live:
590
+ while not wizard.done:
591
+ wizard.handle(read_key())
592
+ live.refresh()
593
+ except KeyboardInterrupt:
594
+ return None
595
+ return wizard.result