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