proxy-scraper-cli 1.7.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. proxy_scraper_cli-1.7.1.dist-info/METADATA +794 -0
  2. proxy_scraper_cli-1.7.1.dist-info/RECORD +57 -0
  3. proxy_scraper_cli-1.7.1.dist-info/WHEEL +5 -0
  4. proxy_scraper_cli-1.7.1.dist-info/entry_points.txt +4 -0
  5. proxy_scraper_cli-1.7.1.dist-info/licenses/LICENSE +21 -0
  6. proxy_scraper_cli-1.7.1.dist-info/top_level.txt +1 -0
  7. proxyscraper/__init__.py +12 -0
  8. proxyscraper/__main__.py +7 -0
  9. proxyscraper/agent.py +635 -0
  10. proxyscraper/api.py +139 -0
  11. proxyscraper/app.py +595 -0
  12. proxyscraper/asndb.py +181 -0
  13. proxyscraper/blocklist.py +92 -0
  14. proxyscraper/checker.py +514 -0
  15. proxyscraper/cli.py +269 -0
  16. proxyscraper/compat.py +83 -0
  17. proxyscraper/completion.py +196 -0
  18. proxyscraper/exporters.py +118 -0
  19. proxyscraper/fetchcache.py +103 -0
  20. proxyscraper/geo.py +150 -0
  21. proxyscraper/geodb.py +143 -0
  22. proxyscraper/handshake.py +153 -0
  23. proxyscraper/history.py +106 -0
  24. proxyscraper/judges.py +159 -0
  25. proxyscraper/mcp_entry.py +34 -0
  26. proxyscraper/mcp_server.py +220 -0
  27. proxyscraper/netio.py +167 -0
  28. proxyscraper/options.py +274 -0
  29. proxyscraper/output.py +181 -0
  30. proxyscraper/pages.py +212 -0
  31. proxyscraper/parsing.py +192 -0
  32. proxyscraper/paths.py +55 -0
  33. proxyscraper/pipeline.py +434 -0
  34. proxyscraper/preferences.py +24 -0
  35. proxyscraper/publish.py +236 -0
  36. proxyscraper/server/__init__.py +41 -0
  37. proxyscraper/server/core.py +518 -0
  38. proxyscraper/server/http.py +164 -0
  39. proxyscraper/server/pool.py +195 -0
  40. proxyscraper/server/socks.py +65 -0
  41. proxyscraper/server/status.py +108 -0
  42. proxyscraper/server/upstream.py +119 -0
  43. proxyscraper/site/apple-touch-icon.png +0 -0
  44. proxyscraper/site/googleaac1161b7853c5b5.html +1 -0
  45. proxyscraper/site/index.html +934 -0
  46. proxyscraper/site/logo.png +0 -0
  47. proxyscraper/site/og.png +0 -0
  48. proxyscraper/sources.json +395 -0
  49. proxyscraper/sources.py +491 -0
  50. proxyscraper/targets.py +76 -0
  51. proxyscraper/ui/__init__.py +54 -0
  52. proxyscraper/ui/dashboard.py +344 -0
  53. proxyscraper/ui/keys.py +82 -0
  54. proxyscraper/ui/report.py +209 -0
  55. proxyscraper/ui/serve.py +142 -0
  56. proxyscraper/ui/widgets.py +250 -0
  57. proxyscraper/ui/wizard.py +595 -0
@@ -0,0 +1,192 @@
1
+ """Finds proxies in any list format and normalizes them.
2
+
3
+ Proxies are kept everywhere as the key "type ip:port" (one string) – with more than a million
4
+ entries that saves a lot of memory compared to tuples.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import ipaddress
10
+ import re
11
+ from typing import Iterable, List, Optional, Set, Tuple
12
+ from urllib.parse import unquote
13
+
14
+ from .handshake import format_auth
15
+
16
+ PROXY_TYPES = ("http", "socks4", "socks5")
17
+ TYPE_ALIASES = {
18
+ "http": "http", "https": "http",
19
+ "socks4": "socks4", "socks4a": "socks4",
20
+ "socks5": "socks5", "socks5h": "socks5", "socks5a": "socks5",
21
+ "auto": "auto", "mixed": "auto",
22
+ }
23
+
24
+ _IP = rb"((?:\d{1,3}\.){3}\d{1,3})"
25
+ # Schneller Standardfall: ip:port
26
+ PLAIN_RE = re.compile(rb"(?<![\d.])" + _IP + rb"\s*:\s*(\d{2,5})(?![\d.])")
27
+ # complete: additionally "ip port" and HTML tables like <td>1.2.3.4</td><td>8080</td>
28
+ PROXY_RE = re.compile(
29
+ rb"(?<![\d.])" + _IP + rb"(?:\s*:\s*|[ \t]+|\s*(?:<[^<>]{0,100}>\s*){1,6})(\d{2,5})(?![\d.])"
30
+ )
31
+ # JSON APIs like {"ip": "1.2.3.4", ..., "port": "8080"} – in both orders
32
+ JSON_IP_PORT_RE = re.compile(rb'"ip"\s*:\s*"' + _IP + rb'"[^{}]{0,500}?"port"\s*:\s*"?(\d{2,5})')
33
+ JSON_PORT_IP_RE = re.compile(rb'"port"\s*:\s*"?(\d{2,5})"?[^{}]{0,500}?"ip"\s*:\s*"' + _IP + rb'"')
34
+ # type://[user:pass@]ip:port – here the type is in the line itself
35
+ SCHEME_RE = re.compile(
36
+ rb"(?i)(?<![a-z0-9])(https?|socks4a?|socks5h?)://(?:([^\s@/]{1,100})@)?" + _IP + rb":(\d{2,5})(?!\d)"
37
+ )
38
+ SCHEME_TYPES = {k.encode(): v for k, v in TYPE_ALIASES.items() if v != "auto"}
39
+
40
+ RawCandidate = Tuple[str, bytes, bytes, bytes] # type, ip, port, credentials (b"" without)
41
+
42
+
43
+ _SPACE_SEPARATED_RE = re.compile(rb"\d\.\d{1,3}[ \t]+\d{2,5}(?![\d.])")
44
+
45
+
46
+ def _needs_full_regex(data: bytes) -> bool:
47
+ """Only space/HTML lists need the slower regex (sample from the start of the file)."""
48
+ sample = data[:8192]
49
+ return b"<" in sample or _SPACE_SEPARATED_RE.search(sample) is not None
50
+
51
+
52
+ def extract_candidates(data: bytes, default_type: str) -> Set[RawCandidate]:
53
+ """Finds proxies in text, HTML tables and JSON.
54
+
55
+ Lines with a type:// prefix keep their own type; everything else gets the type of the source.
56
+ For sources of type "auto" only lines with a prefix count.
57
+ """
58
+ out: Set[RawCandidate] = set()
59
+ with_scheme = set()
60
+ if b"://" in data:
61
+ for scheme, auth, ip, port in SCHEME_RE.findall(data):
62
+ ptype = SCHEME_TYPES.get(scheme.lower())
63
+ if ptype:
64
+ out.add((ptype, ip, port, auth))
65
+ with_scheme.add((ip, port))
66
+ if default_type == "auto":
67
+ return out
68
+ regex = PROXY_RE if _needs_full_regex(data) else PLAIN_RE
69
+ pairs = set(regex.findall(data))
70
+ if b'"ip"' in data:
71
+ pairs.update(JSON_IP_PORT_RE.findall(data))
72
+ pairs.update((ip, port) for port, ip in JSON_PORT_IP_RE.findall(data))
73
+ out.update((default_type, ip, port, b"") for ip, port in pairs - with_scheme)
74
+ return out
75
+
76
+
77
+ # networks that aren't publicly reachable (private, loopback, CGNAT, documentation, multicast, reserved …).
78
+ # ipaddress.is_private and friends are very slow per address, hence a table by first octet:
79
+ # True = the whole /8 is blocked, list = only check these subnets, empty = everything public.
80
+ _BLOCKED_BY_FIRST_OCTET: list = [[] for _ in range(256)]
81
+ for _net in map(ipaddress.IPv4Network, (
82
+ "0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
83
+ "172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.168.0.0/16", "198.18.0.0/15",
84
+ "198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4",
85
+ )):
86
+ _first = int(_net.network_address) >> 24
87
+ if _net.prefixlen <= 8:
88
+ for _o in range(_first, _first + 2 ** (8 - _net.prefixlen)):
89
+ _BLOCKED_BY_FIRST_OCTET[_o] = True
90
+ else:
91
+ _BLOCKED_BY_FIRST_OCTET[_first].append((int(_net.network_address), int(_net.netmask)))
92
+
93
+
94
+ def normalize_public_ip(ip: bytes) -> Optional[str]:
95
+ """'001.2.3.4' -> '1.2.3.4'; None for invalid or non-public addresses."""
96
+ a, b, c, d = map(int, ip.split(b"."))
97
+ if a > 255 or b > 255 or c > 255 or d > 255:
98
+ return None
99
+ rules = _BLOCKED_BY_FIRST_OCTET[a]
100
+ if rules is True:
101
+ return None
102
+ if rules:
103
+ n = a << 24 | b << 16 | c << 8 | d
104
+ if any(n & mask == net for net, mask in rules):
105
+ return None
106
+ return f"{a}.{b}.{c}.{d}"
107
+
108
+
109
+ def normalize_proxy(ip: bytes, port: bytes) -> Optional[str]:
110
+ """'001.2.3.4', b'080' -> '1.2.3.4:80'; None for an invalid port or a non-public IP."""
111
+ p = int(port)
112
+ if not 0 < p < 65536:
113
+ return None
114
+ ip_s = normalize_public_ip(ip)
115
+ return f"{ip_s}:{p}" if ip_s else None
116
+
117
+
118
+ def make_key(ptype: str, proxy: str) -> str:
119
+ return f"{ptype} {proxy}"
120
+
121
+
122
+ def split_key(key: str) -> Tuple[str, str]:
123
+ ptype, _, proxy = key.partition(" ")
124
+ return ptype, proxy
125
+
126
+
127
+ def normalize_auth(auth: str) -> str:
128
+ """'user:p%40ss' or 'user:p@ss' -> encoded uniformly ('user:p%40ss'); '' without a user."""
129
+ user, _, password = auth.partition(":")
130
+ return format_auth(unquote(user), unquote(password))
131
+
132
+
133
+ def validate_candidates(candidates: Iterable[RawCandidate]) -> Set[str]:
134
+ """{(type, ip, port, auth)} -> {"type [auth@]ip:port"} only for valid, public addresses."""
135
+ out = set()
136
+ for ptype, ip, port, auth in candidates:
137
+ proxy = normalize_proxy(ip, port)
138
+ if not proxy:
139
+ continue
140
+ if auth:
141
+ creds = normalize_auth(auth.decode("utf-8", "replace"))
142
+ if creds:
143
+ proxy = f"{creds}@{proxy}"
144
+ out.add(make_key(ptype, proxy))
145
+ return out
146
+
147
+
148
+ def parse_blob(data: bytes, default_type: str, wanted: Tuple[str, ...]) -> str:
149
+ """Complete parse step of a source for the process pool.
150
+
151
+ Returns the keys as a single string separated by \\n – one object can be passed
152
+ between processes much faster than hundreds of thousands of small ones.
153
+ """
154
+ keys = [k for k in validate_candidates(extract_candidates(data, default_type)) if k.split(" ", 1)[0] in wanted]
155
+ return "\n".join(keys)
156
+
157
+
158
+ def parse_proxy_line(line: str, default_type: Optional[str] = None) -> Optional[str]:
159
+ """A line from a result file ('socks5://user:pass@1.2.3.4:1080' or '1.2.3.4:80') -> key."""
160
+ line = line.strip()
161
+ if not line or line.startswith("#"):
162
+ return None
163
+ ptype = default_type
164
+ if "://" in line:
165
+ scheme, _, line = line.partition("://")
166
+ ptype = TYPE_ALIASES.get(scheme.lower())
167
+ if ptype not in PROXY_TYPES:
168
+ return None
169
+ parts = line.split()
170
+ if not parts: # "http://" with nothing after it
171
+ return None
172
+ line = parts[0]
173
+ auth, _, line = line.rpartition("@")
174
+ ip, _, port = line.partition(":")
175
+ port = port.rstrip("/")
176
+ if not port.isdigit() or ip.count(".") != 3 or not all(p.isdigit() for p in ip.split(".")):
177
+ return None
178
+ proxy = normalize_proxy(ip.encode(), port.encode())
179
+ if not proxy:
180
+ return None
181
+ creds = normalize_auth(auth) if auth else ""
182
+ return make_key(ptype, f"{creds}@{proxy}" if creds else proxy)
183
+
184
+
185
+ def parse_keys(lines: Iterable[str], default_type: Optional[str] = None) -> List[str]:
186
+ seen, out = set(), []
187
+ for line in lines:
188
+ key = parse_proxy_line(line, default_type)
189
+ if key and key not in seen:
190
+ seen.add(key)
191
+ out.append(key)
192
+ return out
proxyscraper/paths.py ADDED
@@ -0,0 +1,55 @@
1
+ """Where things are stored.
2
+
3
+ - source list: in the package (proxyscraper/sources.json)
4
+ - from a cloned repo: learned state in data/, results in results/ – both inside the project
5
+ - installed (pip/pipx): learned state in the system's user data folder, results in the current folder
6
+ - PROXY_SCRAPER_HOME overrides the folder for the learned state
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import os
12
+ import sys
13
+ from pathlib import Path
14
+ from typing import Optional
15
+
16
+ PACKAGE_DIR = Path(__file__).resolve().parent
17
+ PROJECT_DIR = PACKAGE_DIR.parent
18
+ APP_NAME = "proxy-scraper"
19
+
20
+
21
+ def is_checkout(project_dir: Path = PROJECT_DIR) -> bool:
22
+ """Is the program running straight from the repo (instead of an installation)?"""
23
+ return (project_dir / "pyproject.toml").is_file() and (project_dir / "proxy_scraper.py").is_file()
24
+
25
+
26
+ def user_data_dir(platform: str = sys.platform, env=os.environ, home: Optional[Path] = None) -> Path:
27
+ """The usual folder for application data on this system."""
28
+ home = home or Path.home()
29
+ if platform == "darwin":
30
+ return home / "Library" / "Application Support" / APP_NAME
31
+ if platform == "win32":
32
+ return Path(env.get("LOCALAPPDATA") or home / "AppData" / "Local") / APP_NAME
33
+ return Path(env.get("XDG_DATA_HOME") or home / ".local" / "share") / APP_NAME
34
+
35
+
36
+ def data_dir(env=os.environ) -> Path:
37
+ if env.get("PROXY_SCRAPER_HOME"):
38
+ return Path(env["PROXY_SCRAPER_HOME"]).expanduser()
39
+ return PROJECT_DIR / "data" if is_checkout() else user_data_dir(env=env)
40
+
41
+
42
+ def results_dir() -> Path:
43
+ return PROJECT_DIR / "results" if is_checkout() else Path.cwd() / "results"
44
+
45
+
46
+ DATA_DIR = data_dir()
47
+ RESULTS_DIR = results_dir()
48
+
49
+
50
+ def atomic_write(path: Path, text: str) -> None:
51
+ # write to a temporary file first, then rename – an interruption leaves no half-written file
52
+ path.parent.mkdir(parents=True, exist_ok=True)
53
+ tmp = path.with_suffix(path.suffix + ".tmp")
54
+ tmp.write_text(text, encoding="utf-8")
55
+ os.replace(tmp, path)
@@ -0,0 +1,434 @@
1
+ """Flow: assemble sources, load & parse in parallel, prioritize, check."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import contextlib
7
+ import os
8
+ import random
9
+ from collections import Counter
10
+ from concurrent.futures import ProcessPoolExecutor
11
+ from dataclasses import dataclass, field
12
+ from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple
13
+
14
+ from . import sources as srcs
15
+ from .asndb import ProviderLookup
16
+ from .blocklist import Blocklist
17
+ from .checker import Checker, CheckResult
18
+ from .compat import on_interrupt
19
+ from .fetchcache import FetchCache
20
+ from .geo import GeoResolver
21
+ from .history import ProxyHistory
22
+ from .judges import JudgeWatch
23
+ from .netio import http_get, http_request
24
+ from .options import RunOptions
25
+ from .output import ResultWriter
26
+ from .parsing import PROXY_TYPES, parse_blob, split_key
27
+ from .ui import ACCENT, CheckDashboard, CollectView, fmt, widgets
28
+
29
+ AUTO_DISCOVER_AFTER_DAYS = 1.0 # daily: found sources are kept, so every search adds to the pile
30
+ # parse small lists directly – the detour through another process costs more than it saves
31
+ INLINE_PARSE_BYTES = 256 * 1024
32
+ DOWNLOAD_CONCURRENCY = 64
33
+
34
+
35
+ # --------------------------------------------------------------------------- #
36
+ # Sources
37
+ # --------------------------------------------------------------------------- #
38
+
39
+ @dataclass
40
+ class SourcePlan:
41
+ sources: Dict[str, str]
42
+ skipped: Counter
43
+ n_curated: int
44
+ n_meta: int
45
+ meta_ok: int
46
+ meta_total: int
47
+ n_discovered: int
48
+ discovery_ran: bool = False
49
+ discovery_token: bool = False
50
+
51
+
52
+ async def collect_sources(opts: RunOptions, quality: srcs.SourceStats) -> SourcePlan:
53
+ """Curated + meta + discovered sources, minus the ones learned to be bad."""
54
+ sources, meta = srcs.load_source_file()
55
+ n_curated = len(sources)
56
+
57
+ age = srcs.discovered_age_days()
58
+ due = not opts.no_discover and (age is None or age > AUTO_DISCOVER_AFTER_DAYS)
59
+ # asking gh for a token takes up to 5 s – only when discovery can actually run, and off the event loop
60
+ token = await asyncio.to_thread(srcs.github_token) if (opts.discover or due) else None
61
+ run_discovery = opts.discover or (due and token is not None)
62
+ if run_discovery:
63
+ max_repos = opts.discover_repos if token else min(opts.discover_repos, 40)
64
+ with widgets.console.status(f"[bold {ACCENT}]Searching GitHub for new proxy lists …", spinner="dots") as status:
65
+ found = await srcs.discover_github(
66
+ http_get, token, max_repos,
67
+ on_progress=lambda msg: status.update(f"[bold {ACCENT}]GitHub-Discovery:[/] {msg}"),
68
+ )
69
+ if found:
70
+ srcs.save_discovered(found)
71
+ discovered = srcs.load_discovered()
72
+
73
+ with widgets.console.status(f"[bold {ACCENT}]Loading meta sources …", spinner="dots"):
74
+ meta_found, meta_ok = await srcs.resolve_meta(meta, http_get)
75
+ for extra in (meta_found, discovered):
76
+ for url, ptype in extra.items():
77
+ sources.setdefault(url, ptype)
78
+
79
+ wanted = set(opts.types)
80
+ sources = {u: t for u, t in sources.items() if t == "auto" or t in wanted}
81
+ skipped: Counter = Counter()
82
+ if not opts.all_sources:
83
+ active = {}
84
+ for url, ptype in sources.items():
85
+ reason = quality.skip_now(url)
86
+ if reason:
87
+ skipped[reason] += 1
88
+ else:
89
+ active[url] = ptype
90
+ sources = active
91
+ return SourcePlan(
92
+ sources, skipped, n_curated, len(meta_found), meta_ok, len(meta), len(discovered),
93
+ discovery_ran=run_discovery, discovery_token=token is not None,
94
+ )
95
+
96
+
97
+ @dataclass
98
+ class ScrapeResult:
99
+ urls: List[str]
100
+ # proxy key -> indices of the sources that list it (for priority and statistics)
101
+ index: Dict[str, List[int]] = field(default_factory=dict)
102
+ ok_sources: int = 0
103
+
104
+
105
+ async def scrape(sources: Dict[str, str], types, quality: srcs.SourceStats, view: CollectView,
106
+ cache: Optional[FetchCache] = None) -> ScrapeResult:
107
+ """Loads every source and parses large lists in parallel on all CPU cores.
108
+
109
+ That keeps the event loop free for more downloads instead of working through regexes for minutes.
110
+ With the cache, unchanged lists are skipped via ETag (see fetchcache.py).
111
+ """
112
+ res = ScrapeResult(list(sources))
113
+ cache = cache or FetchCache(enabled=False)
114
+ # always parse every type, filtering only happens when sorting them in: the cache stores all types, and
115
+ # the statistics must be able to tell "no proxies at all" from "none of the requested types"
116
+ parse_types = PROXY_TYPES
117
+ prefixes = tuple(f"{t} " for t in types)
118
+ loop = asyncio.get_running_loop()
119
+ sem = asyncio.Semaphore(DOWNLOAD_CONCURRENCY)
120
+ index = res.index
121
+
122
+ with ProcessPoolExecutor(max_workers=max(1, (os.cpu_count() or 2) - 1)) as pool:
123
+
124
+ async def download(url: str):
125
+ """-> (data, keys from the cache). Exactly one of the two is set."""
126
+ conditional = cache.conditional_headers(url, sources[url])
127
+ async with sem:
128
+ status, headers, body = await http_request(url, timeout=30, headers=conditional or None)
129
+ if status == 304 and conditional:
130
+ cached = cache.load(url)
131
+ if cached is not None:
132
+ return None, headers, cached
133
+ async with sem: # cache file broken -> just load it again
134
+ status, headers, body = await http_request(url, timeout=30)
135
+ if status != 200:
136
+ raise ConnectionError(f"HTTP {status}")
137
+ return body, headers, None
138
+
139
+ async def fetch(i: int, url: str) -> None:
140
+ data: Optional[bytes] = None
141
+ keys = ""
142
+ unchanged = False
143
+ try:
144
+ data, headers, cached = await download(url)
145
+ if cached is not None:
146
+ keys, unchanged = cached, True
147
+ view.cached += 1
148
+ else:
149
+ view.bytes += len(data)
150
+ if len(data) <= INLINE_PARSE_BYTES:
151
+ keys = parse_blob(data, sources[url], parse_types)
152
+ else:
153
+ keys = await loop.run_in_executor(pool, parse_blob, data, sources[url], parse_types)
154
+ cache.store(url, headers, keys, sources[url])
155
+ except Exception: # source unreachable/broken – counts as a failure in the statistics
156
+ pass
157
+ n = parsed = 0
158
+ if keys:
159
+ parsed = keys.count("\n") + 1 # every type, also the ones this run doesn't want
160
+ for key in keys.split("\n"):
161
+ if not key.startswith(prefixes):
162
+ continue
163
+ owners = index.get(key)
164
+ if owners is None:
165
+ index[key] = [i]
166
+ else:
167
+ owners.append(i)
168
+ n += 1
169
+ quality.record_fetch(url, data, n, unchanged=unchanged, parsed=parsed)
170
+ if n:
171
+ res.ok_sources += 1
172
+ view.ok += 1
173
+ else:
174
+ view.failed += 1
175
+ view.unique = len(index)
176
+ view.advance()
177
+
178
+ await asyncio.gather(*(fetch(i, u) for i, u in enumerate(res.urls)))
179
+ return res
180
+
181
+
182
+ def prioritize(res: ScrapeResult, quality: srcs.SourceStats, history: ProxyHistory, types) -> List[str]:
183
+ """Order of checking: known working proxies first, then by quality of the sources.
184
+
185
+ Within the same source quality, proxies that appear in more lists win.
186
+ """
187
+ wanted = set(types)
188
+ known = [k for k in history.ranked_keys() if split_key(k)[0] in wanted]
189
+ known_set = set(known)
190
+
191
+ scores = [quality.score(u) for u in res.urls]
192
+ keys = [k for k in res.index if k not in known_set]
193
+ random.shuffle(keys) # shuffle real ties so every type makes progress
194
+ index = res.index
195
+
196
+ def priority(key: str) -> float:
197
+ owners = index[key]
198
+ if len(owners) == 1:
199
+ return scores[owners[0]]
200
+ return max(map(scores.__getitem__, owners)) + 0.002 * min(len(owners), 10)
201
+
202
+ prio = list(map(priority, keys))
203
+ order = sorted(range(len(keys)), key=prio.__getitem__, reverse=True)
204
+ return known + [keys[i] for i in order]
205
+
206
+
207
+ def attribute_results(res: ScrapeResult, checked: List[str], working: Set[str]) -> Dict[str, Tuple[int, int]]:
208
+ """Attribute results to the sources -> url -> (checked, working)."""
209
+ n_checked = [0] * len(res.urls)
210
+ n_working = [0] * len(res.urls)
211
+ for key in checked:
212
+ ok = key in working
213
+ for i in res.index.get(key, ()):
214
+ n_checked[i] += 1
215
+ if ok:
216
+ n_working[i] += 1
217
+ return {u: (n_checked[i], n_working[i]) for i, u in enumerate(res.urls) if n_checked[i]}
218
+
219
+
220
+ def best_sources(per_source: Dict[str, Tuple[int, int]], limit: int = 10, min_checked: int = 30):
221
+ return sorted(
222
+ ((w / c, w, c, u) for u, (c, w) in per_source.items() if c >= min_checked and w),
223
+ reverse=True,
224
+ )[:limit]
225
+
226
+
227
+ # --------------------------------------------------------------------------- #
228
+ # Checking
229
+ # --------------------------------------------------------------------------- #
230
+
231
+ @dataclass
232
+ class CheckRun:
233
+ results: List[CheckResult] = field(default_factory=list)
234
+ checked: List[str] = field(default_factory=list)
235
+ working: Set[str] = field(default_factory=set)
236
+ interrupted: bool = False
237
+ reached_goal: bool = False
238
+ judge_switches: List[str] = field(default_factory=list) # "old → new"
239
+ rechecked: int = 0 # checks that were repeated because a check target went down
240
+
241
+
242
+ class JobQueue:
243
+ """Iterator over the jobs that still accepts stragglers after running empty.
244
+
245
+ A generator would be exhausted forever after the first StopIteration – a proxy that a still
246
+ running worker puts back after a check-target switch would then never be checked again."""
247
+
248
+ def __init__(self, jobs: Iterable[str]):
249
+ self.jobs = iter(jobs)
250
+ self.retry: List[str] = []
251
+
252
+ def __iter__(self):
253
+ return self
254
+
255
+ def __next__(self) -> str:
256
+ for key in self.jobs:
257
+ return key
258
+ if self.retry:
259
+ return self.retry.pop()
260
+ raise StopIteration
261
+
262
+
263
+ async def run_checks(
264
+ jobs: List[str],
265
+ checker: Checker,
266
+ opts: RunOptions,
267
+ dashboard: CheckDashboard,
268
+ writer: ResultWriter,
269
+ geo: GeoResolver,
270
+ live_factory: Callable,
271
+ watch: Optional[JudgeWatch] = None,
272
+ providers: Optional[ProviderLookup] = None,
273
+ blocklist: Optional[Blocklist] = None,
274
+ quiet: bool = False,
275
+ ) -> CheckRun:
276
+ """Checks `jobs` with `opts.concurrency` parallel workers until everything is done, the goal is
277
+ reached or Ctrl+C is pressed.
278
+
279
+ With `watch` the check target is monitored: if it goes down, the checks since the last successful
280
+ probe are repeated and don't count for the source statistics.
281
+
282
+ quiet: runs in the background next to another live view (--serve-refill) – no spinner, and Ctrl+C
283
+ belongs to whoever runs in the foreground."""
284
+ loop = asyncio.get_running_loop()
285
+ stats = dashboard.s
286
+ filters, details, want = opts.filters, opts.details, opts.want
287
+ run = CheckRun()
288
+ written: Set[str] = set()
289
+ enriched: Set[str] = set() # hits with a finished detail check (HTTPS may stay open)
290
+ by_exit_ip: Dict[str, List[CheckResult]] = {}
291
+ pending = JobQueue(jobs) # all workers pull from the same queue – safe in asyncio without a lock
292
+
293
+ # Check target outages: every check remembers which target ("generation") it started with and its number.
294
+ # On a switch the old generation becomes suspicious from the last good probe on – failures from it
295
+ # are repeated, including those that only finish after the switch. Hits are never suspicious
296
+ # (the proxy did work), so every check is counted exactly once.
297
+ generation = 0
298
+ # Order instead of clock time: every check gets a running number when it starts. The event loop clock
299
+ # is only accurate to ~15 ms on Windows – two events could easily get the same timestamp there.
300
+ started_count = 0
301
+ last_ok = 0 # this many checks had started at the last good probe (0 = start of the run)
302
+ suspect_since: Dict[int, int] = {} # replaced generation -> checks from this number on are suspicious
303
+ recent_failures: List[Tuple[int, int, str]] = [] # failures since the last good probe
304
+
305
+ def is_suspect(gen: int, started: int) -> bool:
306
+ return gen in suspect_since and started > suspect_since[gen]
307
+
308
+ def requeue(keys: List[str]) -> None:
309
+ pending.retry.extend(keys)
310
+ run.rechecked += len(keys)
311
+ dashboard.add_rechecks(keys)
312
+
313
+ def judge_ok() -> None:
314
+ nonlocal last_ok
315
+ last_ok = started_count
316
+ recent_failures.clear()
317
+
318
+ def judge_switched(old, new) -> None:
319
+ nonlocal generation, last_ok
320
+ suspect_since[generation] = last_ok
321
+ generation += 1
322
+ last_ok = started_count # the new target was just probed successfully – the baseline from here on
323
+ failed = {key for gen, started, key in recent_failures if is_suspect(gen, started)}
324
+ recent_failures.clear()
325
+ if failed:
326
+ run.checked[:] = [k for k in run.checked if k not in failed]
327
+ requeue(sorted(failed))
328
+ run.judge_switches.append(f"{old.judge.host} → {new.judge.host}")
329
+ dashboard.judge_changed(new.judge.host)
330
+
331
+ if watch:
332
+ watch.on_ok, watch.on_switch = judge_ok, judge_switched
333
+ all_workers: Optional[asyncio.Future] = None
334
+
335
+ def consider(r: CheckResult) -> None:
336
+ """Live file & goal counter, as soon as all the info the filters need is there."""
337
+ if r.key in written or (details and r.key not in enriched):
338
+ return
339
+ if filters.countries and not r.country:
340
+ return # country still coming – on_country calls again
341
+ if filters.accepts(r):
342
+ written.add(r.key)
343
+ stats.passing += 1
344
+ writer.add_live(r)
345
+ if want and stats.passing >= want and all_workers and not all_workers.done():
346
+ run.reached_goal = True
347
+ all_workers.cancel()
348
+
349
+ def on_country(ip: str, cc: str) -> None:
350
+ for r in by_exit_ip.get(ip, ()):
351
+ if not r.country:
352
+ r.country = cc
353
+ stats.countries[cc] += 1
354
+ consider(r)
355
+
356
+ geo.on_resolved = on_country
357
+ geo_task = asyncio.ensure_future(geo.run())
358
+ watch_task = asyncio.ensure_future(watch.run()) if watch else None
359
+
360
+ async def worker() -> None:
361
+ nonlocal started_count
362
+ for key in pending:
363
+ started_count += 1
364
+ gen, started = generation, started_count
365
+ r = await checker.check(key)
366
+ stats.add_checked(key.split(" ", 1)[0])
367
+ dashboard.advance()
368
+ if r is None and is_suspect(gen, started):
369
+ requeue([key]) # only finished after the switch – still a victim of the outage
370
+ continue
371
+ if r is None:
372
+ run.checked.append(key)
373
+ if watch:
374
+ recent_failures.append((gen, started, key))
375
+ continue
376
+ # A hit only counts as checked once the verdict is in: a worker cancelled during the next requests
377
+ # (--want reached, Ctrl+C) must not record a proxy that just worked as a failure.
378
+ # second, independent request – honeypots often pass the first check by chance
379
+ if not await checker.confirm(r):
380
+ run.checked.append(key)
381
+ stats.fakes += 1
382
+ continue
383
+ # third request: does a known page arrive unchanged? Otherwise the proxy injects something
384
+ if await checker.tampers(r):
385
+ run.checked.append(key)
386
+ stats.tampered += 1
387
+ continue
388
+ if providers:
389
+ providers.annotate(r)
390
+ if blocklist:
391
+ r.blocklisted = await blocklist.lookup(r.exit_ip)
392
+ run.checked.append(key)
393
+ run.results.append(r)
394
+ run.working.add(key)
395
+ by_exit_ip.setdefault(r.exit_ip, []).append(r)
396
+ r.country = geo.request(r.exit_ip)
397
+ if r.country:
398
+ stats.countries[r.country] += 1
399
+ stats.add_working(r)
400
+ if details:
401
+ # HTTPS test only if the proxy can still pass the filters at all
402
+ if filters.may_pass(r):
403
+ await checker.enrich(r)
404
+ enriched.add(r.key)
405
+ stats.add_details(r)
406
+ else:
407
+ stats.details_saved += 1
408
+ consider(r)
409
+
410
+ with live_factory(dashboard):
411
+ workers = [asyncio.ensure_future(worker()) for _ in range(min(opts.concurrency, len(jobs)))]
412
+ all_workers = asyncio.gather(*workers)
413
+ # Ctrl+C stops cleanly so the results still get saved (on Windows too)
414
+ with contextlib.nullcontext() if quiet else on_interrupt(loop, all_workers.cancel):
415
+ try:
416
+ await all_workers
417
+ except asyncio.CancelledError:
418
+ run.interrupted = not run.reached_goal
419
+
420
+ if watch_task:
421
+ watch_task.cancel()
422
+ if quiet and run.interrupted:
423
+ # nobody pressed Ctrl+C here – the task running us was cancelled (the server stops), so pass it on
424
+ geo_task.cancel()
425
+ raise asyncio.CancelledError
426
+ # wait for open country lookups (briefly at most)
427
+ geo.stop()
428
+ if geo.pending and not geo.failed:
429
+ message = f"[bold {ACCENT}]Looking up countries for {fmt(len(geo.pending))} exit IPs …"
430
+ spinner = contextlib.nullcontext() if quiet else widgets.console.status(message, spinner="dots")
431
+ with spinner, contextlib.suppress(asyncio.TimeoutError):
432
+ await asyncio.wait_for(asyncio.shield(geo_task), 30)
433
+ geo_task.cancel()
434
+ return run