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
proxyscraper/cli.py ADDED
@@ -0,0 +1,269 @@
1
+ """Command line: read the arguments and start the matching flow."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import argparse
6
+ import asyncio
7
+ import os
8
+ import sys
9
+ from collections import Counter
10
+ from typing import List, Optional
11
+
12
+ from rich.console import Console
13
+ from rich.text import Text
14
+
15
+ from . import __version__
16
+ from . import sources as srcs
17
+ from .app import Run
18
+ from .compat import ensure_utf8_output
19
+ from .completion import CompletionAction
20
+ from .exporters import EXPORTERS, parse_exports
21
+ from .history import ProxyHistory
22
+ from .options import (
23
+ DEFAULT_CONCURRENCY,
24
+ DEFAULT_CONNECT_TIMEOUT,
25
+ DEFAULT_DISCOVER_REPOS,
26
+ DEFAULT_SERVE_PORT,
27
+ DEFAULT_TIMEOUT,
28
+ STDOUT,
29
+ RunOptions,
30
+ )
31
+ from .output import has_latest_results
32
+ from .parsing import PROXY_TYPES
33
+ from .preferences import load_last_argv, save_last_argv
34
+ from .server.pool import STRATEGIES
35
+ from .targets import parse_target
36
+ from .ui import ACCENT, BAD, MUTED, banner, note, render_source_ranking, widgets
37
+ from .ui.keys import is_interactive
38
+ from .ui.wizard import run_wizard
39
+
40
+
41
+ def list_sources(limit: int) -> int:
42
+ """Ranking of every known source by learned hit rate."""
43
+ quality = srcs.SourceStats()
44
+ curated, _meta = srcs.load_source_file()
45
+ urls = set(curated) | set(srcs.load_discovered()) | set(quality.records)
46
+ ranking = quality.ranking(urls)
47
+ rows = [(u, r, quality.skip_reason(u) or "active") for u, r in ranking if r.runs][:limit]
48
+ reasons = Counter(quality.skip_reason(u) or "active" for u, _ in ranking)
49
+ widgets.console.print(banner())
50
+ render_source_ranking(rows, len(urls), reasons)
51
+ return 0
52
+
53
+
54
+ def _number(kind, minimum, strict=False, maximum=None):
55
+ """argparse type for numbers with limits – with an understandable error message."""
56
+ def parse(text: str):
57
+ try:
58
+ value = kind(text)
59
+ except ValueError:
60
+ raise argparse.ArgumentTypeError(f"not a number: {text!r}") from None
61
+ if value < minimum or (strict and value == minimum):
62
+ raise argparse.ArgumentTypeError(f"must be {'greater than' if strict else 'at least'} {minimum}")
63
+ if maximum is not None and value > maximum:
64
+ raise argparse.ArgumentTypeError(f"must be at most {maximum}")
65
+ return value
66
+ return parse
67
+
68
+
69
+ def target_url(text: str) -> str:
70
+ """argparse type for --target: normalized URL or an understandable error message."""
71
+ try:
72
+ return parse_target(text).url
73
+ except ValueError as e:
74
+ raise argparse.ArgumentTypeError(str(e)) from None
75
+
76
+
77
+ def export_list(text: str) -> List[str]:
78
+ """argparse type for --export: "proxychains,clash" or "all"."""
79
+ try:
80
+ return parse_exports(text)
81
+ except ValueError as e:
82
+ raise argparse.ArgumentTypeError(str(e)) from None
83
+
84
+
85
+ positive_int = _number(int, 1)
86
+ port_number = _number(int, 1, maximum=65535)
87
+ non_negative_int = _number(int, 0)
88
+ positive_float = _number(float, 0, strict=True)
89
+ non_negative_float = _number(float, 0)
90
+
91
+
92
+ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
93
+ p = argparse.ArgumentParser(
94
+ prog="proxy-scraper",
95
+ description="Fast asynchronous proxy scraper & checker that learns which sources are worth it",
96
+ formatter_class=argparse.RawDescriptionHelpFormatter,
97
+ epilog=(
98
+ "Examples:\n"
99
+ " proxy-scraper setup wizard (in a terminal)\n"
100
+ " proxy-scraper -y collect and check everything right away\n"
101
+ " proxy-scraper --want 50 --https-only stop after 50 HTTPS-capable proxies\n"
102
+ " proxy-scraper --country DE,AT,CH -l 20000 DACH only, the 20,000 best candidates\n"
103
+ " proxy-scraper --types socks5 --anonymity elite --max-latency 1500\n"
104
+ " proxy-scraper --target google.com --target discord.com --want 20\n"
105
+ " proxy-scraper --recheck recheck the last hits + history\n"
106
+ " proxy-scraper --recheck --serve turn them into a rotating proxy right away\n"
107
+ " proxy-scraper --list-sources source ranking\n"
108
+ ),
109
+ )
110
+ p.add_argument("-V", "--version", action="version", version=f"proxy-scraper {__version__}")
111
+ p.add_argument("--mcp", action="store_true",
112
+ help="run as an MCP server over stdio, for AI agents like Claude Code (same as proxy-scraper-mcp; "
113
+ 'needs pip install "proxy-scraper-cli[mcp]")')
114
+ p.add_argument("--completion", action=CompletionAction, metavar="SHELL",
115
+ help="print tab completion for bash, zsh or fish, e.g. eval \"$(proxy-scraper "
116
+ "--completion zsh)\"")
117
+ m = p.add_argument_group("Start")
118
+ m.add_argument("-i", "--interactive", action="store_true",
119
+ help="setup wizard: pick with the arrow keys what to look for "
120
+ "(shows up automatically without arguments; other arguments are used as starting values)")
121
+ m.add_argument("-y", "--yes", action="store_true", help="skip the wizard and start right away with the defaults")
122
+
123
+ g = p.add_argument_group("Checks")
124
+ g.add_argument("-c", "--concurrency", type=positive_int, default=DEFAULT_CONCURRENCY,
125
+ help=f"concurrent checks (default: {DEFAULT_CONCURRENCY})")
126
+ g.add_argument("-t", "--timeout", type=positive_float, default=DEFAULT_TIMEOUT,
127
+ help=f"timeout per proxy in seconds (default: {DEFAULT_TIMEOUT:g})")
128
+ g.add_argument("--connect-timeout", type=positive_float, default=DEFAULT_CONNECT_TIMEOUT,
129
+ help=f"max. time for the TCP connect in seconds (default: {DEFAULT_CONNECT_TIMEOUT:g})")
130
+ g.add_argument("--types", nargs="+", choices=list(PROXY_TYPES), default=list(PROXY_TYPES),
131
+ help="which protocols (default: all)")
132
+ g.add_argument("-l", "--limit", type=non_negative_int, default=0,
133
+ help="only check the N most promising proxies (by history & source quality)")
134
+ g.add_argument("--want", type=non_negative_int, default=0, metavar="N",
135
+ help="stop as soon as N matching proxies are found")
136
+ g.add_argument("--fast", action="store_true",
137
+ help="skip the HTTPS test (faster); confirmation and anonymity still run")
138
+ g.add_argument("--no-geo", action="store_true", help="don't look up countries")
139
+ g.add_argument("--no-dnsbl", action="store_true",
140
+ help="don't look up whether exit IPs are on the SpamCop blocklist")
141
+ g.add_argument("--recheck", nargs="?", const="", metavar="FILE",
142
+ help="only check proxies from FILE – without FILE: last run + history; "
143
+ "'live': the live list from GitHub (seconds instead of minutes, e.g. with --serve)")
144
+
145
+ f = p.add_argument_group("Filters (for the result files)")
146
+ f.add_argument("--country", metavar="CC", help="only these countries, e.g. DE,AT,CH")
147
+ f.add_argument("--https-only", action="store_true", help="only proxies that can tunnel HTTPS sites")
148
+ f.add_argument("--anonymity", choices=["anonymous", "elite"], help="minimum anonymity")
149
+ f.add_argument("--max-latency", type=non_negative_int, default=0, metavar="MS",
150
+ help="only proxies up to this latency")
151
+ f.add_argument("--no-datacenter", action="store_true",
152
+ help="no proxies that exit from datacenters (cloud/hosting) – those often get blocked sooner")
153
+ f.add_argument("--no-blocklisted", action="store_true",
154
+ help="no proxies whose exit IP is on the SpamCop blocklist – those often get captchas")
155
+ f.add_argument("--target", action="append", type=target_url, metavar="URL",
156
+ help="only proxies that reach this site (repeatable), e.g. --target google.com")
157
+
158
+ v = p.add_argument_group("Proxy server")
159
+ v.add_argument("--serve", nargs="?", const=DEFAULT_SERVE_PORT, default=0, type=port_number, metavar="PORT",
160
+ help=f"serve the hits as a rotating proxy on 127.0.0.1:PORT after the run "
161
+ f"(default port: {DEFAULT_SERVE_PORT}); quick to start with --recheck")
162
+ v.add_argument("--rotate", choices=STRATEGIES, default="weighted",
163
+ help="which proxy comes next: weighted (fast & reliable preferred, default), "
164
+ "random, round-robin or fastest")
165
+ v.add_argument("--serve-host", default="127.0.0.1", metavar="ADDRESS",
166
+ help="address of the proxy server (default: 127.0.0.1). 0.0.0.0 makes it reachable from outside – "
167
+ "only for Docker with -p 127.0.0.1:8899:8899 or behind a firewall")
168
+ v.add_argument("--serve-password", default=os.environ.get("PROXY_SCRAPER_SERVE_PASSWORD", ""), metavar="SECRET",
169
+ help="clients must send this password in the proxy login (HTTP and SOCKS5); better set "
170
+ "PROXY_SCRAPER_SERVE_PASSWORD, so it doesn't show up in the process list")
171
+ v.add_argument("--serve-refill", type=non_negative_float, default=0, metavar="HOURS",
172
+ help="every HOURS check fresh proxies in the background (the live list with --recheck live, "
173
+ "otherwise the last run + history) and add the hits to the running server")
174
+ v.add_argument("--sticky", type=non_negative_int, default=0, metavar="SEC",
175
+ help="the same target site keeps the same proxy for this long (e.g. for logins); "
176
+ "per request this also works with the user name session-NAME")
177
+
178
+ o = p.add_argument_group("Output")
179
+ o.add_argument("-o", "--output", metavar="FILE",
180
+ help="also write all hits as type://ip:port to this file; - prints them to stdout "
181
+ "(the interface moves to stderr then)")
182
+ o.add_argument("--export", type=export_list, metavar="FORMATS",
183
+ help=f"extra formats in the results folder: {', '.join(EXPORTERS)} or all "
184
+ "(e.g. --export proxychains,clash)")
185
+
186
+ s = p.add_argument_group("Sources")
187
+ s.add_argument("--discover", action="store_true",
188
+ help="look for new proxy lists on GitHub now (with a GitHub token this also runs once a day)")
189
+ s.add_argument("--no-discover", action="store_true", help="no automatic GitHub search")
190
+ s.add_argument("--discover-repos", type=non_negative_int, default=DEFAULT_DISCOVER_REPOS,
191
+ help=f"max. repos during discovery (default: {DEFAULT_DISCOVER_REPOS}, 40 without a token)")
192
+ s.add_argument("--all-sources", action="store_true", help="also load dead, outdated and unreachable sources")
193
+ s.add_argument("--no-cache", action="store_true",
194
+ help="reload every list completely (otherwise unchanged ones are skipped via ETag)")
195
+ s.add_argument("--list-sources", nargs="?", const=50, type=positive_int, metavar="N",
196
+ help="show the source ranking by hit rate (default: top 50) and exit")
197
+ return p.parse_args(argv)
198
+
199
+
200
+ def last_options() -> Optional[RunOptions]:
201
+ argv = load_last_argv()
202
+ if argv is None:
203
+ return None
204
+ try:
205
+ return RunOptions.from_args(parse_args(argv))
206
+ except SystemExit: # the saved choice no longer fits the current options
207
+ return None
208
+
209
+
210
+ def choose_interactively(initial: RunOptions) -> Optional[RunOptions]:
211
+ widgets.console.print(banner())
212
+ can_recheck = has_latest_results() or ProxyHistory.exists()
213
+ opts = run_wizard(initial, last_options(), can_recheck, widgets.console)
214
+ if opts is None:
215
+ return None
216
+ save_last_argv(opts.to_argv())
217
+ widgets.console.print(Text.assemble(
218
+ (" ▸ ", ACCENT), ("Next time, directly: ", MUTED), (opts.to_command(), "bold"),
219
+ ))
220
+ return opts
221
+
222
+
223
+ def wants_wizard(args: argparse.Namespace, argv: List[str]) -> bool:
224
+ """-i forces the wizard; without arguments it only shows up in a terminal. -y skips it."""
225
+ if args.interactive:
226
+ return True
227
+ return not argv and not args.yes and is_interactive()
228
+
229
+
230
+ def install_uvloop() -> None:
231
+ try:
232
+ import uvloop # optional (pip install "proxy-scraper-cli[fast]"), makes asyncio even faster
233
+ except ImportError:
234
+ return
235
+ uvloop.install()
236
+
237
+
238
+ def run(argv: Optional[List[str]] = None) -> int:
239
+ ensure_utf8_output()
240
+ install_uvloop()
241
+ argv = sys.argv[1:] if argv is None else argv
242
+ args = parse_args(argv)
243
+ if args.mcp: # before anything prints: stdout belongs to the MCP protocol from here on
244
+ from .mcp_entry import main as mcp_main
245
+ return mcp_main()
246
+ if args.list_sources is not None:
247
+ return list_sources(args.list_sources)
248
+ opts = RunOptions.from_args(args)
249
+ if opts.output == STDOUT:
250
+ widgets.console = Console(highlight=False, stderr=True) # stdout only carries the hits
251
+ use_wizard = wants_wizard(args, argv)
252
+ if use_wizard:
253
+ if not is_interactive():
254
+ note("The setup wizard (-i) needs a terminal.", BAD, "✘")
255
+ return 2
256
+ opts = choose_interactively(opts)
257
+ if opts is None:
258
+ note("Cancelled – nothing was started.", MUTED, "ℹ")
259
+ return 0
260
+ try:
261
+ # after the wizard the banner is already on screen
262
+ return asyncio.run(Run(opts, show_banner=not use_wizard).execute())
263
+ except KeyboardInterrupt:
264
+ note("Interrupted.")
265
+ return 130
266
+
267
+
268
+ if __name__ == "__main__":
269
+ sys.exit(run())
proxyscraper/compat.py ADDED
@@ -0,0 +1,83 @@
1
+ """Platform differences in one place: file limits, Ctrl+C, console encoding.
2
+
3
+ Windows has no `resource` module and no `loop.add_signal_handler`; asyncio programs run on the
4
+ ProactorEventLoop (IOCP) there, which has no select() limit for sockets.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import contextlib
11
+ import signal
12
+ import sys
13
+ from contextlib import contextmanager
14
+ from typing import Callable, Iterator
15
+
16
+ IS_WINDOWS = sys.platform == "win32"
17
+
18
+ try:
19
+ import resource
20
+ except ImportError: # Windows
21
+ resource = None
22
+
23
+
24
+ def raise_fd_limit(wanted: int) -> int:
25
+ """Raise the allowed open files/sockets; returns the actual limit."""
26
+ if resource is None:
27
+ # no RLIMIT_NOFILE: the ProactorEventLoop doesn't limit sockets through such a limit
28
+ return wanted
29
+ soft, hard = resource.getrlimit(resource.RLIMIT_NOFILE)
30
+ target = wanted if hard == resource.RLIM_INFINITY else min(wanted, hard)
31
+ if soft < target:
32
+ for t in (target, 10240, 4096):
33
+ try:
34
+ resource.setrlimit(resource.RLIMIT_NOFILE, (t, hard))
35
+ return t
36
+ except (ValueError, OSError):
37
+ continue
38
+ return resource.getrlimit(resource.RLIMIT_NOFILE)[0]
39
+
40
+
41
+ @contextmanager
42
+ def on_interrupt(loop: asyncio.AbstractEventLoop, callback: Callable[[], None]) -> Iterator[None]:
43
+ """Ctrl+C calls `callback` in the event loop instead of raising KeyboardInterrupt.
44
+
45
+ Unix: loop.add_signal_handler. Windows doesn't have that – there a classic
46
+ signal handler passes the callback into the loop thread-safely.
47
+ Afterwards the previous handler is active again in both cases.
48
+ """
49
+ previous = signal.getsignal(signal.SIGINT)
50
+ try:
51
+ loop.add_signal_handler(signal.SIGINT, callback)
52
+ except (NotImplementedError, RuntimeError):
53
+ try:
54
+ signal.signal(signal.SIGINT, lambda signum, frame: loop.call_soon_threadsafe(callback))
55
+ except ValueError: # not in the main thread -> Ctrl+C keeps its default behavior
56
+ yield
57
+ return
58
+ try:
59
+ yield
60
+ finally:
61
+ if previous is not None:
62
+ signal.signal(signal.SIGINT, previous)
63
+ return
64
+ try:
65
+ yield
66
+ finally:
67
+ # remove_signal_handler() resets SIGINT to default_int_handler,
68
+ # not to a handler that was installed before
69
+ loop.remove_signal_handler(signal.SIGINT)
70
+ if previous is not None:
71
+ signal.signal(signal.SIGINT, previous)
72
+
73
+
74
+ def ensure_utf8_output() -> None:
75
+ """Switch output to UTF-8 if the console uses something else (e.g. cp1252 on Windows).
76
+
77
+ Otherwise characters like ✔ or ▁ can raise a UnicodeEncodeError when redirecting to a file.
78
+ """
79
+ for stream in (sys.stdout, sys.stderr):
80
+ encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
81
+ if encoding != "utf8" and hasattr(stream, "reconfigure"):
82
+ with contextlib.suppress(ValueError, OSError): # e.g. an already closed or foreign stream
83
+ stream.reconfigure(encoding="utf-8", errors="replace")
@@ -0,0 +1,196 @@
1
+ """Tab completion for bash, zsh and fish – generated straight from the argparse parser.
2
+
3
+ That way the script can never go stale: every new option shows up automatically, with its choices
4
+ (--types, --rotate, …) and the start of its help text as the description (zsh, fish).
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import argparse
10
+ import re
11
+ import sys
12
+ from dataclasses import dataclass
13
+ from typing import List, Optional, Tuple
14
+
15
+ SHELLS = ("bash", "zsh", "fish")
16
+ PROG = "proxy-scraper"
17
+ FILE_OPTIONS = {"--recheck": ("live",), "--output": ()} # the value is a file path (or one of these words)
18
+ OWN_HELP = {"help": "show help", "version": "show the version"} # shorter than argparse's own texts
19
+
20
+
21
+ @dataclass
22
+ class Option:
23
+ flags: List[str]
24
+ help: str
25
+ takes_value: bool
26
+ optional_value: bool # nargs="?": the value may be missing (--recheck, --serve)
27
+ choices: Tuple[str, ...]
28
+ is_file: bool
29
+ extra: Tuple[str, ...] = () # words that work instead of a file (--recheck live)
30
+ multi: bool = False # nargs="+": several values in a row (--types http socks5)
31
+
32
+ @property
33
+ def long(self) -> Optional[str]:
34
+ return next((f for f in self.flags if f.startswith("--")), None)
35
+
36
+ @property
37
+ def short(self) -> Optional[str]:
38
+ return next((f for f in self.flags if not f.startswith("--")), None)
39
+
40
+
41
+ def short_help(text: Optional[str], width: int = 60) -> str:
42
+ """Only the start of the help text: up to the first parenthesis, dash, semicolon or example."""
43
+ text = re.split(r" \(| – |; |,? e\.g\.", text or "")[0].strip()
44
+ return text if len(text) <= width else text[:width - 1].rstrip() + "…"
45
+
46
+
47
+ def options(parser: argparse.ArgumentParser) -> List[Option]:
48
+ found = []
49
+ for action in parser._actions: # argparse offers no public list
50
+ if not action.option_strings or action.help == argparse.SUPPRESS:
51
+ continue
52
+ takes_value = action.nargs != 0
53
+ found.append(Option(
54
+ flags=list(action.option_strings),
55
+ help=OWN_HELP.get(action.dest) or short_help(action.help),
56
+ takes_value=takes_value,
57
+ optional_value=action.nargs == "?",
58
+ choices=tuple(str(c) for c in action.choices or ()),
59
+ is_file=takes_value and any(f in FILE_OPTIONS for f in action.option_strings),
60
+ extra=next((FILE_OPTIONS[f] for f in action.option_strings if f in FILE_OPTIONS), ()),
61
+ multi=action.nargs in ("+", "*"),
62
+ ))
63
+ return found
64
+
65
+
66
+ def bash(opts: List[Option]) -> str:
67
+ words = " ".join(f for o in opts for f in o.flags)
68
+ cases, multi = [], []
69
+ for o in opts:
70
+ if not o.takes_value or o.optional_value and not o.is_file:
71
+ continue
72
+ pattern = "|".join(o.flags)
73
+ if o.choices:
74
+ reply = f'COMPREPLY=($(compgen -W "{" ".join(o.choices)}" -- "$cur")); return ;;'
75
+ cases.append(f" {pattern}) {reply}")
76
+ if o.multi:
77
+ multi.append(f" {pattern}) {reply}")
78
+ elif o.is_file:
79
+ # line by line, so "file with spaces" stays one entry
80
+ extra = f'$(compgen -W "{" ".join(o.extra)}" -- "$cur") ' if o.extra else ""
81
+ cases.append(f" {pattern}) compopt -o filenames 2>/dev/null; local IFS=$'\\n'; "
82
+ f'COMPREPLY=({extra}$(compgen -f -- "$cur")); return ;;')
83
+ else:
84
+ cases.append(f" {pattern}) return ;; # freier Wert")
85
+ return f"""# bash completion for {PROG}
86
+ # enable: eval "$({PROG} --completion bash)" (e.g. in ~/.bashrc)
87
+ _proxy_scraper() {{
88
+ local cur="${{COMP_WORDS[COMP_CWORD]}}" prev="${{COMP_WORDS[COMP_CWORD-1]}}"
89
+ COMPREPLY=()
90
+ case "$prev" in
91
+ {chr(10).join(cases)}
92
+ esac
93
+ # more values for options like --types http socks5: the last option before counts
94
+ if [[ "$cur" != -* ]]; then
95
+ local i
96
+ for ((i = COMP_CWORD - 1; i > 0; i--)); do
97
+ [[ "${{COMP_WORDS[i]}}" == -* ]] || continue
98
+ case "${{COMP_WORDS[i]}}" in
99
+ {chr(10).join(multi)}
100
+ esac
101
+ break
102
+ done
103
+ fi
104
+ COMPREPLY=($(compgen -W "{words}" -- "$cur"))
105
+ }}
106
+ complete -F _proxy_scraper {PROG}
107
+ """
108
+
109
+
110
+ def _zsh_escape(text: str) -> str:
111
+ return text.replace("\\", "\\\\").replace("'", "'\\''").replace("[", "\\[").replace("]", "\\]")
112
+
113
+
114
+ def zsh(opts: List[Option]) -> str:
115
+ specs = []
116
+ for o in opts:
117
+ repeat = "*" if o.long == "--target" else ""
118
+ # '(-c --concurrency)'{-c,--concurrency}'[…]' – the braces {} must not be inside quotes
119
+ spec = f"({' '.join(o.flags)}){repeat}'{{{','.join(o.flags)}}}'" if len(o.flags) > 1 else repeat + o.flags[0]
120
+ spec += f"[{_zsh_escape(o.help)}]"
121
+ if o.takes_value:
122
+ colon = "::" if o.optional_value else ":"
123
+ if o.choices:
124
+ action = f"({' '.join(o.choices)})"
125
+ elif o.is_file:
126
+ action = "{_files; compadd " + " ".join(o.extra) + "}" if o.extra else "_files"
127
+ else:
128
+ action = " "
129
+ if o.multi:
130
+ colon += "*-*:" # every word up to the next option belongs to it
131
+ spec += f"{colon}{o.long.lstrip('-')}:{action}"
132
+ specs.append(f" '{spec}'")
133
+ body = " \\\n".join(specs)
134
+ return f"""#compdef {PROG}
135
+ # zsh completion for {PROG}
136
+ # enable: eval "$({PROG} --completion zsh)" (in ~/.zshrc, after compinit)
137
+ _proxy_scraper() {{
138
+ _arguments -s \\
139
+ {body}
140
+ }}
141
+ compdef _proxy_scraper {PROG}
142
+ """
143
+
144
+
145
+ def _fish_quote(text: str) -> str:
146
+ return "'" + text.replace("\\", "\\\\").replace("'", "\\'") + "'"
147
+
148
+
149
+ def fish(opts: List[Option]) -> str:
150
+ lines = [f"# fish completion for {PROG}",
151
+ f"# enable: {PROG} --completion fish > ~/.config/fish/completions/{PROG}.fish",
152
+ f"complete -c {PROG} -f",
153
+ "# is the last option before the cursor $argv[1]? (for several values like --types http socks5)",
154
+ "function __proxy_scraper_after",
155
+ " set -l tokens (commandline -opc)",
156
+ " test (count $tokens) -gt 1; or return 1",
157
+ " for token in $tokens[-1..2]",
158
+ " string match -q -- '-*' $token; or continue",
159
+ " test $token = $argv[1]; return",
160
+ " end",
161
+ " return 1",
162
+ "end"]
163
+ for o in opts:
164
+ parts = [f"complete -c {PROG}"]
165
+ if o.long:
166
+ parts.append(f"-l {o.long[2:]}")
167
+ if o.short:
168
+ parts.append(f"-s {o.short[1:]}")
169
+ if o.help:
170
+ parts.append(f"-d {_fish_quote(o.help)}")
171
+ if o.choices:
172
+ parts.append(f"-xa {_fish_quote(' '.join(o.choices))}")
173
+ elif o.is_file:
174
+ parts.append("-rF" + (f" -a {_fish_quote(' '.join(o.extra))}" if o.extra else ""))
175
+ elif o.takes_value and not o.optional_value:
176
+ parts.append("-x")
177
+ lines.append(" ".join(parts))
178
+ if o.multi and o.choices:
179
+ lines.append(f"complete -c {PROG} -n {_fish_quote('__proxy_scraper_after ' + o.long)} "
180
+ f"-xa {_fish_quote(' '.join(o.choices))}")
181
+ return "\n".join(lines) + "\n"
182
+
183
+
184
+ def script(parser: argparse.ArgumentParser, shell: str) -> str:
185
+ return {"bash": bash, "zsh": zsh, "fish": fish}[shell](options(parser))
186
+
187
+
188
+ class CompletionAction(argparse.Action):
189
+ """--completion SHELL: print the script and exit – like --version, before anything starts."""
190
+
191
+ def __init__(self, option_strings, dest, **kwargs):
192
+ super().__init__(option_strings, dest, choices=SHELLS, **kwargs)
193
+
194
+ def __call__(self, parser, namespace, values, option_string=None):
195
+ sys.stdout.write(script(parser, values))
196
+ parser.exit()
@@ -0,0 +1,118 @@
1
+ """Extra formats for common tools (--export).
2
+
3
+ proxychains proxychains.conf with random_chain – ready to use with `proxychains4 -f`
4
+ clash clash.yaml for Clash / Mihomo: proxies plus a url-test group
5
+ (both without HTTP proxies that can't CONNECT, see tunnels())
6
+ curl curl.txt, one URL per line in the format for `curl -x` (SOCKS5 with DNS through the proxy)
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ from datetime import datetime
13
+ from typing import Callable, Dict, List, Sequence, Tuple
14
+
15
+ from .checker import CheckResult
16
+ from .handshake import parse_endpoint
17
+
18
+ Exporter = Callable[[Sequence[CheckResult], datetime], str]
19
+
20
+
21
+ def tunnels(r: CheckResult) -> bool:
22
+ """proxychains and Clash only talk to HTTP proxies via CONNECT. Whatever didn't pass the HTTPS test
23
+ usually can't do that – SOCKS always works, untested ones (--fast) stay in."""
24
+ return r.ptype != "http" or r.https is not False
25
+
26
+
27
+ def proxychains(rows: Sequence[CheckResult], now: datetime) -> str:
28
+ entries = []
29
+ for r in rows:
30
+ ep = parse_endpoint(r.proxy)
31
+ creds = [ep.user, ep.password] if ep.has_auth else []
32
+ if not tunnels(r) or any(not c or c.split() != [c] for c in creds):
33
+ continue # proxychains splits on spaces, empty fields don't work either
34
+ entries.append(" ".join([r.ptype, ep.host, str(ep.port), *creds]))
35
+ lines = [
36
+ f"# proxy-scraper, {now:%Y-%m-%d %H:%M}, {len(entries)} proxies (fastest first, HTTP only with CONNECT)",
37
+ "# usage: proxychains4 -f proxychains.conf curl https://api.ipify.org",
38
+ "random_chain",
39
+ "chain_len = 1",
40
+ "proxy_dns",
41
+ "tcp_read_time_out 15000",
42
+ "tcp_connect_time_out 8000",
43
+ "",
44
+ "[ProxyList]",
45
+ ]
46
+ return "\n".join(lines + entries) + "\n"
47
+
48
+
49
+ CLASH_TYPES = {"http": "http", "socks5": "socks5"} # Clash doesn't know SOCKS4
50
+
51
+
52
+ def clash(rows: Sequence[CheckResult], now: datetime) -> str:
53
+ # JSON strings are valid YAML – so no YAML library is needed and nothing has to be escaped
54
+ q = json.dumps
55
+ usable = [r for r in rows if r.ptype in CLASH_TYPES and tunnels(r)]
56
+ names: List[str] = []
57
+ taken = set()
58
+ lines = [f"# proxy-scraper, {now:%Y-%m-%d %H:%M}, {len(usable)} proxies (Clash can't do SOCKS4)", "proxies:"]
59
+ for r in usable:
60
+ ep = parse_endpoint(r.proxy)
61
+ base = name = f"{r.country or '??'} {r.ptype} {ep.address}"
62
+ n = 1
63
+ while name in taken: # the same proxy with different credentials
64
+ n += 1
65
+ name = f"{base} #{n}"
66
+ taken.add(name)
67
+ names.append(name)
68
+ lines += [
69
+ f" - name: {q(name)}",
70
+ f" type: {CLASH_TYPES[r.ptype]}",
71
+ f" server: {q(ep.host)}",
72
+ f" port: {ep.port}",
73
+ ]
74
+ if ep.has_auth:
75
+ lines += [f" username: {q(ep.user)}", f" password: {q(ep.password)}"]
76
+ if r.ptype == "socks5":
77
+ lines.append(" udp: false")
78
+ if not usable:
79
+ lines[-1] = "proxies: []"
80
+ return "\n".join(lines) + "\n"
81
+ lines += [
82
+ "proxy-groups:",
83
+ " - name: proxy-scraper",
84
+ " type: url-test",
85
+ " url: http://www.gstatic.com/generate_204",
86
+ " interval: 300",
87
+ " tolerance: 100",
88
+ " proxies:",
89
+ *(f" - {q(n)}" for n in names),
90
+ "rules:",
91
+ " - MATCH,proxy-scraper",
92
+ ]
93
+ return "\n".join(lines) + "\n"
94
+
95
+
96
+ CURL_SCHEMES = {"socks5": "socks5h", "socks4": "socks4", "http": "http"}
97
+
98
+
99
+ def curl(rows: Sequence[CheckResult], now: datetime) -> str:
100
+ return "".join(f"{CURL_SCHEMES[r.ptype]}://{r.proxy}\n" for r in rows)
101
+
102
+
103
+ EXPORTERS: Dict[str, Tuple[str, Exporter]] = {
104
+ "proxychains": ("proxychains.conf", proxychains),
105
+ "clash": ("clash.yaml", clash),
106
+ "curl": ("curl.txt", curl),
107
+ }
108
+
109
+
110
+ def parse_exports(value: str) -> List[str]:
111
+ """"clash,curl" -> ["clash", "curl"]; "all" -> all of them. Unknown names -> ValueError."""
112
+ names = [n.strip().lower() for n in value.split(",") if n.strip()]
113
+ unknown = [n for n in names if n not in EXPORTERS and n != "all"]
114
+ if unknown: # check before "all" – otherwise a typo in "all,clsh" would go unnoticed
115
+ raise ValueError(f"unknown format: {', '.join(unknown)} (possible: {', '.join(EXPORTERS)}, all)")
116
+ if "all" in names:
117
+ return list(EXPORTERS)
118
+ return [n for n in EXPORTERS if n in names] # fixed order, no duplicates