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