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/app.py ADDED
@@ -0,0 +1,595 @@
1
+ """One complete run in clear phases: network → jobs → check → learn & report."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import ipaddress
8
+ import socket
9
+ import sys
10
+ import tempfile
11
+ import time
12
+ from collections import Counter
13
+ from dataclasses import replace
14
+ from pathlib import Path
15
+ from typing import List, Optional, Tuple
16
+
17
+ from rich.live import Live
18
+ from rich.text import Text
19
+
20
+ from . import sources as srcs
21
+ from .asndb import AsnDB, ProviderLookup, load_asn_db
22
+ from .asndb import is_current as asn_is_current
23
+ from .blocklist import Blocklist
24
+ from .checker import (
25
+ CONFIRM_HOST,
26
+ CONFIRM_PORT,
27
+ DETAIL_CONNECTIONS,
28
+ JUDGE_HOST,
29
+ Checker,
30
+ CheckResult,
31
+ integrity_reference,
32
+ probe_confirm_target,
33
+ )
34
+ from .compat import on_interrupt, raise_fd_limit
35
+ from .fetchcache import FetchCache
36
+ from .geo import GeoResolver
37
+ from .geodb import CountryDB, is_current, load_country_db
38
+ from .handshake import parse_endpoint
39
+ from .history import ProxyHistory
40
+ from .judges import JudgeProbe, JudgeWatch, rank_judges
41
+ from .netio import INSECURE_HOSTS, http_get
42
+ from .options import STDOUT, RunOptions
43
+ from .output import ResultWriter, latest_results
44
+ from .parsing import PROXY_TYPES, parse_keys, split_key
45
+ from .paths import is_checkout
46
+ from .pipeline import (
47
+ CheckRun,
48
+ ScrapeResult,
49
+ attribute_results,
50
+ best_sources,
51
+ collect_sources,
52
+ prioritize,
53
+ run_checks,
54
+ scrape,
55
+ )
56
+ from .publish import RAW_BASE
57
+ from .server import ProxyPool, RotatingServer
58
+ from .targets import Target, parse_target
59
+ from .ui import (
60
+ ACCENT,
61
+ BAD,
62
+ BLOCKED_HIT_RATE,
63
+ GOOD,
64
+ MUTED,
65
+ WARN,
66
+ CheckDashboard,
67
+ CollectView,
68
+ LiveStats,
69
+ banner,
70
+ fmt,
71
+ fmt_duration,
72
+ info,
73
+ note,
74
+ pct,
75
+ render_summary,
76
+ section,
77
+ section_end,
78
+ widgets,
79
+ )
80
+ from .ui.serve import ServeDashboard
81
+ from .ui.widgets import shown_proxy
82
+
83
+ OWN_IP_URLS = (
84
+ f"https://{JUDGE_HOST}/", # HTTPS first: relays and corporate proxies don't redirect it
85
+ "https://api.ipify.org/",
86
+ f"http://{JUDGE_HOST}/", # port 80 may take a different route (a different IP)
87
+ )
88
+
89
+
90
+ async def get_own_ips() -> List[str]:
91
+ """Every IP you are visible under from the outside – the most important one first."""
92
+
93
+ async def one(url: str) -> str:
94
+ try:
95
+ ip = (await http_get(url, timeout=6)).strip().decode("ascii", "ignore")
96
+ ipaddress.IPv4Address(ip)
97
+ return ip
98
+ except Exception: # service unreachable -> the next one counts
99
+ return ""
100
+
101
+ ips = await asyncio.gather(*(one(u) for u in OWN_IP_URLS))
102
+ return list(dict.fromkeys(ip for ip in ips if ip))
103
+
104
+
105
+ async def confirm_target() -> Optional[str]:
106
+ """IP of the confirmation target – only if exactly this IP answers the way the confirmation expects."""
107
+ try:
108
+ infos = await asyncio.get_running_loop().getaddrinfo(CONFIRM_HOST, CONFIRM_PORT, family=socket.AF_INET)
109
+ except OSError: # can't be resolved -> continue without confirmation, with a note
110
+ return None
111
+ ip = infos[0][4][0]
112
+ return ip if await probe_confirm_target(ip, timeout=8) else None
113
+
114
+
115
+ def load_recheck_jobs(target: str, types, history: ProxyHistory) -> List[str]:
116
+ """--recheck: a file, otherwise the last run plus history."""
117
+ if target:
118
+ lines = Path(target).expanduser().read_text(encoding="utf-8").splitlines()
119
+ # lines without type:// in files like http.txt take the type from the file name
120
+ default = next((t for t in PROXY_TYPES if t in Path(target).name.lower()), None)
121
+ keys = parse_keys(lines, default)
122
+ else:
123
+ keys = parse_keys(latest_results()) + history.ranked_keys()
124
+ keys = list(dict.fromkeys(keys))
125
+ return [k for k in keys if split_key(k)[0] in types]
126
+
127
+
128
+ LIVE = "live" # --recheck live: the public live list as the starting point
129
+ REFILL_CONCURRENCY = 500 # checks at once during --serve-refill, so the running server stays responsive
130
+ LIVE_URL = f"{RAW_BASE}/all.txt"
131
+
132
+
133
+ async def load_live_jobs(types, history: ProxyHistory, fetch=http_get) -> List[str]:
134
+ """Load the live list (only addresses that worked in the last GitHub Actions run) plus your own hits
135
+ from the history – everything is then checked locally, from your own network."""
136
+ try:
137
+ text = (await fetch(LIVE_URL, timeout=30)).decode("utf-8", "replace")
138
+ except Exception:
139
+ note("Live list unreachable – using the last run and history instead.", WARN, "⚠")
140
+ return load_recheck_jobs("", types, history)
141
+ keys = list(dict.fromkeys(parse_keys(text.splitlines()) + history.ranked_keys()))
142
+ return [k for k in keys if split_key(k)[0] in types]
143
+
144
+
145
+ def is_network_blocked(stats: LiveStats) -> bool:
146
+ """If (almost) nothing gets through, a firewall is probably blocking proxy connections.
147
+
148
+ Fake proxies count too: they pass the basic check, so the connection works – a network full of
149
+ honeypots is not a blocked network, and the run should still be learned from.
150
+ """
151
+ reached = stats.found + stats.fakes + stats.tampered
152
+ return stats.checked >= 1000 and reached < stats.checked * BLOCKED_HIT_RATE
153
+
154
+
155
+ def next_steps(opts: RunOptions, kept: List[CheckResult]) -> List[Tuple[str, str]]:
156
+ """Ready-made commands for what people usually do next after a run."""
157
+ program = "python3 proxy_scraper.py" if is_checkout() else "proxy-scraper"
158
+ steps: List[Tuple[str, str]] = []
159
+ if kept:
160
+ # only suggest HTTPS if the proxy passed the HTTPS test – otherwise the command fails
161
+ secure = [r for r in kept if r.https]
162
+ pool = secure or kept
163
+ # prefer proxies without a login – the command ends up in the terminal, no password belongs there
164
+ best = min([r for r in pool if "@" not in r.proxy] or pool, key=lambda r: r.latency)
165
+ scheme = "socks5h" if best.ptype == "socks5" else best.ptype
166
+ target = "https://api.ipify.org" if best.https else "http://api.ipify.org"
167
+ steps.append(("Test the fastest", f"curl -x {scheme}://{shown_proxy(best.proxy)} {target}"))
168
+ if not opts.serve:
169
+ steps.append(("As a proxy server", f"{program} --recheck --serve"))
170
+ steps.append(("Recheck later", f"{program} --recheck"))
171
+ return steps
172
+
173
+
174
+ def pool_recheck(checker: Checker):
175
+ """Recheck for the proxy server's disabled proxies: the normal check, but bypassing the cache."""
176
+ async def recheck(result: CheckResult) -> bool:
177
+ # the checker remembers unreachable addresses for the rest of the run – exactly those should be
178
+ # tried again here
179
+ checker.unreachable.discard(parse_endpoint(result.proxy).address)
180
+ return await checker.check(result.key) is not None
181
+ return recheck
182
+
183
+
184
+ def is_loopback(host: str) -> bool:
185
+ try:
186
+ return ipaddress.ip_address(host).is_loopback
187
+ except ValueError:
188
+ return host == "localhost"
189
+
190
+
191
+ class Run:
192
+ def __init__(self, opts: RunOptions, show_banner: bool = True):
193
+ self.opts = opts
194
+ self.show_banner = show_banner
195
+ self.started = time.perf_counter()
196
+ self.quality = srcs.SourceStats()
197
+ self.history = ProxyHistory()
198
+ self.scraped: Optional[ScrapeResult] = None
199
+ self.judges: List[JudgeProbe] = [] # reachable check targets, fastest first
200
+ self.integrity: Optional[bytes] = None # hash of the reference page (see Checker.tampers)
201
+ self.checker: Optional[Checker] = None
202
+ self.kept: List[CheckResult] = [] # hits that pass every filter (for the Python API)
203
+ self.confirm_ip: Optional[str] = None
204
+ self.targets: List[Tuple[Target, str]] = []
205
+ self.own_ips: List[str] = []
206
+ self.blocklist: Optional[Blocklist] = None
207
+
208
+ async def execute(self) -> int:
209
+ if self.show_banner:
210
+ widgets.console.print(banner())
211
+ section("Setup")
212
+ try:
213
+ with widgets.console.status("Checking the network …", spinner="dots", spinner_style=ACCENT):
214
+ ready = await self.prepare_network()
215
+ if not ready:
216
+ return 1
217
+ self.show_mode()
218
+ jobs = await self.gather_jobs()
219
+ if not jobs:
220
+ note("No proxies to check.", BAD, "✘")
221
+ return 1
222
+ finally:
223
+ section_end()
224
+ await self.check_and_report(jobs)
225
+ return 0
226
+
227
+ # ------------------------------------------------------------------ phase 0: network
228
+
229
+ async def prepare_network(self) -> bool:
230
+ blocklist = Blocklist() if not self.opts.no_dnsbl else None
231
+ self.judges, self.own_ips, _ = await asyncio.gather(
232
+ rank_judges(), get_own_ips(), blocklist.probe() if blocklist else asyncio.sleep(0))
233
+ if blocklist and blocklist.usable:
234
+ self.blocklist = blocklist
235
+ if not self.judges:
236
+ note("No check target reachable (checkip.amazonaws.com, ifconfig.me, …) – check your internet connection.",
237
+ BAD, "✘")
238
+ return False
239
+ if not self.own_ips: # the usual services for your own IP are gone – the check targets have seen it
240
+ # IPv4 only – the checker compares against IPv4 exit IPs; an IPv6 (e.g. iCloud Private Relay) never
241
+ # matches and would only swallow the "own IP unknown" warning
242
+ self.own_ips = list(dict.fromkeys(j.seen_ip for j in self.judges if j.seen_ip and "." in j.seen_ip))
243
+ ips = self.own_ips
244
+ info("Your IP", Text.assemble(
245
+ (ips[0], "bold") if ips else ("unknown", WARN),
246
+ (f" (on port 80 also {', '.join(ips[1:])})" if len(ips) > 1 else "", MUTED),
247
+ ))
248
+ if not await self.resolve_targets():
249
+ return False
250
+ self.confirm_ip = await confirm_target()
251
+ if self.confirm_ip: # reference page for the check against modified content
252
+ self.integrity = await integrity_reference(timeout=8)
253
+ best, reserve = self.judges[0], [j.judge.host for j in self.judges[1:]]
254
+ info("Check target", Text.assemble(
255
+ (f"{best.judge.host} ({best.ip}, {best.latency} ms)", MUTED),
256
+ (f" · fallback: {', '.join(reserve)}", MUTED) if reserve else "",
257
+ (f" · confirmed via {CONFIRM_HOST}", MUTED) if self.confirm_ip else "",
258
+ ))
259
+ if self.confirm_ip and self.integrity is None:
260
+ note(f"Reference page from {CONFIRM_HOST} unreachable – proxies that modify content "
261
+ "won't be detected in this run.")
262
+ if not self.confirm_ip:
263
+ note(f"{CONFIRM_HOST} unreachable – without a second confirmation fake proxies (honeypots) "
264
+ "can slip through, and anonymity stays unknown.")
265
+ if not ips:
266
+ note("Own IP unknown – transparent proxies (they reveal your IP) won't be filtered out.")
267
+ if blocklist and not blocklist.usable:
268
+ note("SpamCop doesn't answer through your DNS resolver (large public resolvers are refused) – "
269
+ "blocklist info is skipped" + (", so --no-blocklisted can't filter anything." if
270
+ self.opts.filters.no_blocklisted else "."), MUTED, "ℹ")
271
+ return True
272
+
273
+ async def resolve_targets(self) -> bool:
274
+ """Resolve the target sites once – SOCKS4 needs IPs, and a typo should show up right away."""
275
+ loop = asyncio.get_running_loop()
276
+ for url in self.opts.filters.targets:
277
+ target = parse_target(url)
278
+ try:
279
+ infos = await loop.getaddrinfo(target.host, target.port, family=socket.AF_INET)
280
+ except OSError:
281
+ note(f"Target site {target.host} can't be resolved – a typo?", BAD, "✘")
282
+ return False
283
+ self.targets.append((target, infos[0][4][0]))
284
+ if self.targets:
285
+ info("Target sites", ", ".join(t.label for t, _ in self.targets))
286
+ return True
287
+
288
+ def show_mode(self) -> None:
289
+ opts = self.opts
290
+ modes = ["with HTTPS test" if opts.details else "without HTTPS test (--fast)"]
291
+ modes.append("countries" if opts.geo else "no countries")
292
+ if opts.filters.active:
293
+ modes.append(f"filter: {opts.filters.describe()}")
294
+ if opts.want:
295
+ modes.append(f"stops at {fmt(opts.want)} hits")
296
+ if opts.check_timeout < opts.timeout:
297
+ modes.append(f"timeout {opts.check_timeout:g} s thanks to the latency limit")
298
+ info("Mode", " · ".join(modes))
299
+
300
+ # ------------------------------------------------------------------ phase 1+2: jobs
301
+
302
+ async def gather_jobs(self) -> List[str]:
303
+ opts = self.opts
304
+ if opts.recheck == LIVE:
305
+ jobs = await load_live_jobs(opts.types, self.history)
306
+ info("Recheck", f"{fmt(len(jobs))} proxies from the live list (checked every hour by GitHub Actions)")
307
+ elif opts.recheck is not None:
308
+ try:
309
+ jobs = load_recheck_jobs(opts.recheck, opts.types, self.history)
310
+ except (OSError, UnicodeDecodeError) as e:
311
+ reason = e.strerror if isinstance(e, OSError) and e.strerror else "not a text file"
312
+ note(f"Can't read {opts.recheck}: {reason}.", BAD, "✘")
313
+ return []
314
+ info("Recheck", f"{fmt(len(jobs))} proxies from {opts.recheck or 'the last run + history'}")
315
+ else:
316
+ jobs = await self._scrape_jobs()
317
+
318
+ known = sum(1 for k in jobs if k in self.history) if len(self.history) else 0
319
+ if known:
320
+ info("History", f"{fmt(known)} proxies that worked before are checked first")
321
+ if opts.limit:
322
+ jobs = jobs[: opts.limit]
323
+ info("Limit", f"checking the {fmt(len(jobs))} most promising")
324
+ return jobs
325
+
326
+ async def _scrape_jobs(self) -> List[str]:
327
+ plan = await collect_sources(self.opts, self.quality)
328
+ info("Sources", Text.assemble(
329
+ (fmt(len(plan.sources)), f"bold {ACCENT}"), " active ",
330
+ (f"({fmt(plan.n_curated)} curated · {fmt(plan.n_meta)} from {plan.meta_ok}/{plan.meta_total} "
331
+ f"meta lists · {fmt(plan.n_discovered)} discovered)", MUTED),
332
+ ))
333
+ if plan.discovery_ran and not plan.discovery_token:
334
+ note("GitHub discovery is limited without a token – run `gh auth login` or set GITHUB_TOKEN.",
335
+ MUTED, "ℹ")
336
+ if plan.skipped:
337
+ parts = ", ".join(f"{n} {reason}" for reason, n in plan.skipped.most_common())
338
+ info("Skipped", Text.assemble(parts, (" (force all: --all-sources)", MUTED)))
339
+
340
+ t0 = time.perf_counter()
341
+ view = CollectView(len(plan.sources), self.started)
342
+ with Live(view, console=widgets.console, refresh_per_second=10, transient=True):
343
+ cache = FetchCache(enabled=not self.opts.no_cache)
344
+ self.scraped = await scrape(plan.sources, self.opts.types, self.quality, view, cache)
345
+ self.quality.save()
346
+ cache.save()
347
+ res = self.scraped
348
+ info("Collected", Text.assemble(
349
+ (fmt(len(res.index)), f"bold {GOOD}"), " unique proxies from ",
350
+ f"{res.ok_sources}/{len(plan.sources)} sources",
351
+ (f" ({view.bytes / 2**20:.0f} MB in {fmt_duration(time.perf_counter() - t0)}"
352
+ + (f", {view.cached} unchanged from the cache" if view.cached else "") + ")", MUTED),
353
+ ))
354
+ if INSECURE_HOSTS:
355
+ hosts = sorted(INSECURE_HOSTS)
356
+ note(f"Certificate could not be verified (TLS inspection on this network?), loaded anyway: "
357
+ f"{', '.join(hosts[:4])}{' …' if len(hosts) > 4 else ''}", MUTED, "ℹ")
358
+ return prioritize(res, self.quality, self.history, self.opts.types)
359
+
360
+ # ------------------------------------------------------------------ phase 3+4: check, learn, report
361
+
362
+ async def check_and_report(self, jobs: List[str]) -> None:
363
+ # workers + capped detail connections + headroom for sources, geo and the like
364
+ fd = raise_fd_limit(self.opts.concurrency + DETAIL_CONNECTIONS + 512)
365
+ opts = replace(self.opts, concurrency=min(self.opts.concurrency, max(fd - DETAIL_CONNECTIONS - 256, 64)))
366
+
367
+ to_stdout = opts.output == STDOUT
368
+ writer = ResultWriter(extra_file=Path(opts.output) if opts.output and not to_stdout else None,
369
+ exports=opts.exports, stdout=sys.stdout if to_stdout else None)
370
+ stats = LiveStats(Counter(split_key(k)[0] for k in jobs))
371
+ dashboard = CheckDashboard(stats, writer.live_path, opts.concurrency, opts.details,
372
+ opts.filters.describe(), opts.want, opts.filters.targets)
373
+ judge = self.judges[0]
374
+ checker = Checker(judge.ip, self.own_ips, opts.check_timeout, opts.check_connect_timeout,
375
+ self.confirm_ip, detail_timeout=opts.timeout,
376
+ detail_connect_timeout=opts.connect_timeout, targets=self.targets,
377
+ https_test=not opts.fast or opts.filters.https_only, judge=judge.judge,
378
+ integrity_reference=self.integrity)
379
+ self.checker = checker # for the proxy server: rechecking disabled proxies
380
+ watch = JudgeWatch(self.judges, lambda new: checker.use_judge(new.judge, new.ip))
381
+ dashboard.judge = judge.judge.host
382
+ # country database straight from data/ (2 ms); if it's old or missing, reload it in the background –
383
+ # until then ip-api.com takes over, nobody waits for the download
384
+ country_db = CountryDB.load() if opts.geo else None
385
+ geo = GeoResolver(enabled=opts.geo, offline=country_db)
386
+ refresh = None
387
+ if opts.geo and not is_current(country_db):
388
+ refresh = asyncio.ensure_future(self.refresh_country_db(geo))
389
+ # providers of the exit IPs the same way: straight from data/, old or missing -> reload in the background.
390
+ # Independent of --no-geo – that only concerns countries, providers come from the file anyway.
391
+ providers = ProviderLookup(AsnDB.load())
392
+ providers_refresh = None
393
+ if not asn_is_current(providers.db):
394
+ providers_refresh = asyncio.ensure_future(self.refresh_asn_db(providers))
395
+ if opts.filters.no_datacenter and providers.db is None:
396
+ # explicitly without datacenters, but no database at all yet: load it before checking –
397
+ # otherwise unknown providers would pass as "not a datacenter" and --want would stop too early
398
+ with widgets.console.status("Loading the provider database (DB-IP) …", spinner="dots"), \
399
+ contextlib.suppress(asyncio.TimeoutError):
400
+ await asyncio.wait_for(asyncio.shield(providers_refresh), 90)
401
+ widgets.console.print()
402
+ run = await run_checks(
403
+ jobs, checker, opts, dashboard, writer, geo,
404
+ live_factory=lambda renderable: Live(renderable, console=widgets.console, refresh_per_second=6),
405
+ watch=watch,
406
+ providers=providers,
407
+ blocklist=self.blocklist,
408
+ )
409
+
410
+ if refresh and not refresh.done():
411
+ refresh.cancel() # country download still running – next run then (ip-api took over)
412
+ if providers_refresh and not providers_refresh.done():
413
+ # provider database still loading (first run or new month): wait briefly and fill in afterwards –
414
+ # otherwise the files lack providers and --no-datacenter would let datacenters through
415
+ with widgets.console.status("Loading the provider database (DB-IP) …", spinner="dots"), \
416
+ contextlib.suppress(asyncio.TimeoutError):
417
+ await asyncio.wait_for(asyncio.shield(providers_refresh), 30)
418
+ if not providers_refresh.done():
419
+ providers_refresh.cancel() # takes too long – next run then
420
+ if providers.db:
421
+ for r in run.results:
422
+ if not r.asn:
423
+ providers.annotate(r)
424
+ if r.hosting:
425
+ stats.hosting += 1 # otherwise dashboard and note would show too few datacenters
426
+ kept = [r for r in run.results if opts.filters.accepts(r)]
427
+ self.kept = kept
428
+ files = writer.finalize(kept)
429
+ network_blocked = is_network_blocked(stats)
430
+ per_source = self.learn(run, network_blocked)
431
+ geo.save()
432
+
433
+ render_summary(stats, run.results, kept, best_sources(per_source), files, opts.details,
434
+ opts.filters.describe(), next_steps(opts, kept))
435
+ self.final_notes(run, stats, kept, geo, network_blocked)
436
+ if opts.serve:
437
+ await self.serve(kept)
438
+
439
+ async def serve(self, proxies: List[CheckResult]) -> None:
440
+ """Serve the proxies found as a local rotating proxy server until Ctrl+C."""
441
+ if not proxies:
442
+ note("No matching proxy found – the proxy server doesn't start.", BAD, "✘")
443
+ return
444
+ pool = ProxyPool(proxies, strategy=self.opts.rotate, sticky_seconds=self.opts.sticky)
445
+ server = RotatingServer(pool, host=self.opts.serve_host, port=self.opts.serve, timeout=self.opts.timeout,
446
+ password=self.opts.serve_password)
447
+ if not is_loopback(self.opts.serve_host) and not self.opts.serve_password:
448
+ note(f"The proxy server listens on {self.opts.serve_host} – without a password. Anyone who can reach "
449
+ "it can use it. Set PROXY_SCRAPER_SERVE_PASSWORD, or keep it behind a firewall "
450
+ "or in a container with -p 127.0.0.1:…", WARN, "⚠")
451
+ try:
452
+ await server.start()
453
+ except OSError as e:
454
+ note(f"Port {self.opts.serve} is not available ({e.strerror or e}) – pick another one with --serve PORT.",
455
+ BAD, "✘")
456
+ return
457
+ stop = asyncio.Event()
458
+ widgets.console.print()
459
+ fresh = refills = None
460
+ if self.checker: # recheck disabled proxies every 5 minutes and bring them back if they work
461
+ fresh = asyncio.ensure_future(server.keep_fresh(pool_recheck(self.checker)))
462
+ if self.opts.serve_refill:
463
+ refills = asyncio.ensure_future(self.keep_refilling(server, self.opts.serve_refill * 3600))
464
+ try:
465
+ with on_interrupt(asyncio.get_running_loop(), stop.set), \
466
+ Live(ServeDashboard(server), console=widgets.console, refresh_per_second=4):
467
+ await stop.wait()
468
+ finally:
469
+ for task in (fresh, refills):
470
+ if task:
471
+ task.cancel()
472
+ await server.close()
473
+ st = server.stats
474
+ note(f"Proxy server stopped – {fmt(st.requests)} requests, {fmt(st.ok)} successful.", GOOD, "✔")
475
+
476
+ async def keep_refilling(self, server: RotatingServer, interval: float) -> None:
477
+ """--serve-refill: every `interval` seconds check fresh candidates and merge the hits into the pool."""
478
+ while True:
479
+ await asyncio.sleep(interval)
480
+ try:
481
+ server.refilled += await self.refill(server.pool)
482
+ except Exception as e: # noqa: BLE001 – the server keeps running with what it has
483
+ note(f"Refill failed ({e.__class__.__name__}: {e}) – trying again next time.", WARN, "⚠")
484
+ server.last_refill = time.time()
485
+
486
+ async def refill(self, pool: ProxyPool) -> int:
487
+ """One refill: the same checks and filters as the first run, quietly in the background. -> proxies added."""
488
+ opts = replace(self.opts, concurrency=min(self.opts.concurrency, REFILL_CONCURRENCY))
489
+ if opts.recheck == LIVE:
490
+ jobs = await load_live_jobs(opts.types, self.history)
491
+ else:
492
+ jobs = load_recheck_jobs("", opts.types, self.history)
493
+ serving = {e.result.key for e in pool.usable} # working right now – no need to check them again
494
+ jobs = [k for k in jobs if k not in serving]
495
+ if not jobs:
496
+ return 0
497
+ # the check target picked at startup may be gone hours later – rank again and watch it like the first run
498
+ judges = await rank_judges()
499
+ if not judges:
500
+ return 0
501
+ checker = self.checker
502
+ checker.use_judge(judges[0].judge, judges[0].ip)
503
+ watch = JudgeWatch(judges, lambda new: checker.use_judge(new.judge, new.ip))
504
+ checker.unreachable.clear() # hours later, addresses that were down may be back
505
+ stats = LiveStats(Counter(split_key(k)[0] for k in jobs))
506
+ geo = GeoResolver(enabled=opts.geo, offline=CountryDB.load() if opts.geo else None)
507
+ # a scratch folder: "latest" stays the full run, not the handful of new hits from this round
508
+ with tempfile.TemporaryDirectory(prefix="proxy-scraper-refill-") as scratch:
509
+ writer = ResultWriter(run_dir=Path(scratch))
510
+ dashboard = CheckDashboard(stats, writer.live_path, opts.concurrency, opts.details,
511
+ opts.filters.describe(), opts.want, opts.filters.targets)
512
+ try:
513
+ run = await run_checks(jobs, checker, opts, dashboard, writer, geo,
514
+ live_factory=lambda _view: contextlib.nullcontext(), watch=watch,
515
+ providers=ProviderLookup(AsnDB.load()), blocklist=self.blocklist, quiet=True)
516
+ finally:
517
+ writer.close()
518
+ kept = [r for r in run.results if opts.filters.accepts(r)]
519
+ blocked = is_network_blocked(stats)
520
+ self.learn(run, blocked, sources=False) # the source ranking only learns from full scans
521
+ geo.save()
522
+ return 0 if blocked else pool.merge(kept) # a blocked network proves nothing about the pool
523
+
524
+ @staticmethod
525
+ async def refresh_asn_db(providers: ProviderLookup) -> None:
526
+ try:
527
+ db = await load_asn_db()
528
+ except Exception:
529
+ return
530
+ if db is not None:
531
+ providers.db = db
532
+
533
+ @staticmethod
534
+ async def refresh_country_db(geo: GeoResolver) -> None:
535
+ """Load a new country database and use it right away – errors don't matter, ip-api stays in charge."""
536
+ try:
537
+ db = await load_country_db()
538
+ except Exception:
539
+ return
540
+ if db is not None:
541
+ geo.use_offline(db)
542
+
543
+ def learn(self, run: CheckRun, network_blocked: bool, sources: bool = True):
544
+ """Update source statistics and history – on a blocked network only the hits."""
545
+ per_source = attribute_results(self.scraped, run.checked, run.working) if self.scraped and sources else {}
546
+ if not network_blocked:
547
+ # on a blocked network every source and known proxy would wrongly count as "dead"
548
+ self.quality.record_checks(per_source)
549
+ for key in run.checked:
550
+ if key not in run.working:
551
+ self.history.record_fail(key)
552
+ for r in run.results:
553
+ self.history.record_ok(r.key, r.latency, r.exit_ip, country=r.country,
554
+ anonymity=r.anonymity, https=r.https)
555
+ self.history.prune()
556
+ self.history.save()
557
+ self.quality.save()
558
+ return per_source
559
+
560
+ def final_notes(self, run: CheckRun, stats: LiveStats, kept, geo: GeoResolver, network_blocked: bool) -> None:
561
+ opts = self.opts
562
+ if network_blocked:
563
+ note(
564
+ f"[bold]{fmt(stats.checked)} proxies checked, only {fmt(stats.found)} work.[/] "
565
+ "Your network (company or school firewall) is probably blocking proxy connections – try "
566
+ f"another network, e.g. a phone hotspot. [{MUTED}]Statistics and history were not "
567
+ "downgraded for this run.[/]"
568
+ )
569
+ if opts.geo and geo.failed:
570
+ if geo.offline:
571
+ note("ip-api.com unreachable – addresses that aren't in the DB-IP database "
572
+ "stay without a country.", MUTED, "ℹ")
573
+ else:
574
+ note("Country database and ip-api.com unreachable – countries are missing.", MUTED, "ℹ")
575
+ if opts.filters.countries and not kept and run.results:
576
+ note("No hit in the requested country – loosen the filter or let it run longer.", MUTED, "ℹ")
577
+ if run.judge_switches:
578
+ note(f"Check target went down, switched: {', '.join(run.judge_switches)}. {fmt(run.rechecked)} proxies "
579
+ "were checked again and don't count for the source statistics.", WARN, "⚠")
580
+ if stats.tampered:
581
+ note(f"{fmt(stats.tampered)} proxies modified a test page (usually by injecting scripts) "
582
+ "and were dropped.", WARN, "⚠")
583
+ if stats.hosting and run.results and not opts.filters.no_datacenter:
584
+ note(f"{fmt(stats.hosting)} of {fmt(len(run.results))} hits ({pct(stats.hosting, len(run.results))}) "
585
+ "probably exit from datacenters – those often get blocked sooner. "
586
+ "Only the others: --no-datacenter", MUTED, "ℹ")
587
+ if stats.blocklisted and run.results and not opts.filters.no_blocklisted:
588
+ share = pct(stats.blocklisted, len(run.results))
589
+ note(f"{fmt(stats.blocklisted)} of {fmt(len(run.results))} hits ({share}) "
590
+ "exit from an IP on the SpamCop blocklist – sites that use it show captchas or block them. "
591
+ "Only the others: --no-blocklisted", MUTED, "ℹ")
592
+ if run.reached_goal:
593
+ note(f"Goal of {fmt(opts.want)} hits reached – stopped early.", GOOD, "✔")
594
+ elif run.interrupted:
595
+ note("Interrupted – the hits so far were saved.")