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,491 @@
1
+ """
2
+ Source management for the proxy scraper:
3
+
4
+ - loads the curated source list (sources.json) and sources found with --discover,
5
+ - resolves meta sources (lists of source URLs maintained by others),
6
+ - finds new proxy lists on GitHub,
7
+ - remembers per source how many of its proxies really work, and
8
+ skips dead, outdated or unreachable sources.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import hashlib
15
+ import json
16
+ import os
17
+ import re
18
+ import subprocess
19
+ import time
20
+ import warnings
21
+ from collections import Counter
22
+ from dataclasses import asdict, dataclass
23
+ from datetime import datetime, timedelta, timezone
24
+ from pathlib import Path
25
+ from typing import Awaitable, Callable, Dict, Iterable, List, Optional, Tuple
26
+ from urllib.parse import quote, urlencode
27
+
28
+ from .parsing import PROXY_TYPES, TYPE_ALIASES
29
+ from .paths import DATA_DIR, PACKAGE_DIR, atomic_write
30
+
31
+ SOURCES_FILE = PACKAGE_DIR / "sources.json"
32
+ DISCOVERED_FILE = DATA_DIR / "sources_discovered.json"
33
+ STATS_FILE = DATA_DIR / "source_stats.json"
34
+
35
+ GH_RAW = "https://raw.githubusercontent.com"
36
+ # "auto" = the list contains type://ip:port lines, the type is given per line
37
+ SOURCE_TYPES = (*PROXY_TYPES, "auto")
38
+
39
+ # url -> Typ
40
+ SourceMap = Dict[str, str]
41
+ Getter = Callable[..., Awaitable[bytes]]
42
+
43
+
44
+ # --------------------------------------------------------------------------- #
45
+ # URLs & source lists
46
+ # --------------------------------------------------------------------------- #
47
+
48
+ _GH_BLOB_RE = re.compile(r"^https://github\.com/([^/]+)/([^/]+)/(?:raw|blob)/(?:refs/heads/)?(.+)$")
49
+
50
+
51
+ def normalize_url(url: str) -> Optional[str]:
52
+ """Normalizes source URLs so the same file isn't loaded several times.
53
+
54
+ github.com/…/raw/… -> raw.githubusercontent.com/…, '/refs/heads/' is dropped,
55
+ format suffixes like ',,ColonURL' are cut off. Templates with {…} -> None.
56
+ """
57
+ url = url.strip().split(",", 1)[0].strip()
58
+ if not url.startswith(("http://", "https://")) or "{" in url:
59
+ return None
60
+ m = _GH_BLOB_RE.match(url)
61
+ if m:
62
+ url = f"{GH_RAW}/{m[1]}/{m[2]}/{m[3]}"
63
+ if url.startswith(GH_RAW):
64
+ url = url.replace("/refs/heads/", "/", 1)
65
+ return url
66
+
67
+
68
+ def _add(target: SourceMap, url: str, ptype: str) -> None:
69
+ ptype = TYPE_ALIASES.get(ptype.lower(), "")
70
+ url = normalize_url(url) if url else None
71
+ if url and ptype:
72
+ target.setdefault(url, ptype)
73
+
74
+
75
+ def load_source_file(path: Path = SOURCES_FILE) -> Tuple[SourceMap, List[dict]]:
76
+ """Reads sources.json -> (sources, meta sources)."""
77
+ data = json.loads(path.read_text(encoding="utf-8"))
78
+ sources: SourceMap = {}
79
+ for ptype, urls in data.get("sources", {}).items():
80
+ for url in urls:
81
+ _add(sources, url, ptype)
82
+ return sources, list(data.get("meta", []))
83
+
84
+
85
+ def load_discovered(path: Path = DISCOVERED_FILE, now: Optional[datetime] = None) -> SourceMap:
86
+ """Found sources the search saw in the last DISCOVERED_KEEP_DAYS – also when no search ran since (no token,
87
+ GitHub down), so old finds expire either way. VPN-config repos from before the filter existed are dropped."""
88
+ if not path.exists():
89
+ return {}
90
+ try:
91
+ data = json.loads(path.read_text(encoding="utf-8"))
92
+ except (OSError, ValueError):
93
+ return {}
94
+ now = now or datetime.now(timezone.utc)
95
+ seen = data.get("last_seen", {}) if isinstance(data.get("last_seen"), dict) else {}
96
+ out: SourceMap = {}
97
+ for url, ptype in data.get("sources", {}).items():
98
+ if _age_days(seen.get(url) or data.get("generated"), now) > DISCOVERED_KEEP_DAYS or _is_vpn_repo(url):
99
+ continue
100
+ _add(out, url, ptype)
101
+ return out
102
+
103
+
104
+ def _age_days(stamp, now: datetime) -> float:
105
+ try:
106
+ return (now - datetime.fromisoformat(stamp)).total_seconds() / DAY
107
+ except (TypeError, ValueError):
108
+ return float("inf")
109
+
110
+
111
+ def _is_vpn_repo(url: str) -> bool:
112
+ parts = url.split("/")
113
+ return url.startswith(GH_RAW) and len(parts) > 4 and bool(_REPO_REJECT_RE.search("/".join(parts[3:5])))
114
+
115
+
116
+ def discovered_age_days(path: Path = DISCOVERED_FILE) -> Optional[float]:
117
+ """Age of the last discovery in days, None if there hasn't been one yet."""
118
+ try:
119
+ generated = json.loads(path.read_text(encoding="utf-8"))["generated"]
120
+ return (datetime.now(timezone.utc) - datetime.fromisoformat(generated)).total_seconds() / DAY
121
+ except (OSError, ValueError, KeyError, TypeError):
122
+ return None
123
+
124
+
125
+ DISCOVERED_KEEP_DAYS = 21 # a found source stays this long after the search last saw it
126
+
127
+
128
+ def save_discovered(sources: SourceMap, path: Path = DISCOVERED_FILE, now: Optional[datetime] = None) -> SourceMap:
129
+ """Adds this run's finds to what earlier runs found – the search only sees repos pushed in the last few days,
130
+ so a list that was quiet for a day would otherwise drop out. Whatever the search hasn't seen for
131
+ DISCOVERED_KEEP_DAYS goes; dead sources are skipped by the learning long before that. -> everything kept."""
132
+ now = now or datetime.now(timezone.utc)
133
+ stamp = now.isoformat(timespec="seconds")
134
+ try:
135
+ old = json.loads(path.read_text(encoding="utf-8"))
136
+ except (OSError, ValueError):
137
+ old = {}
138
+ old_sources = old.get("sources", {}) if isinstance(old, dict) else {}
139
+ seen = old.get("last_seen", {}) if isinstance(old, dict) else {}
140
+ kept: SourceMap = {}
141
+ last_seen: Dict[str, str] = {}
142
+ for url, ptype in old_sources.items():
143
+ when = seen.get(url) or old.get("generated") # files from before last_seen: count from their date
144
+ if _age_days(when, now) <= DISCOVERED_KEEP_DAYS and isinstance(ptype, str) and not _is_vpn_repo(url):
145
+ kept[url], last_seen[url] = ptype, when
146
+ for url, ptype in sources.items():
147
+ kept[url], last_seen[url] = ptype, stamp
148
+ payload = {"generated": stamp, "sources": kept, "last_seen": last_seen}
149
+ atomic_write(path, json.dumps(payload, indent=1, sort_keys=True))
150
+ return kept
151
+
152
+
153
+ # --------------------------------------------------------------------------- #
154
+ # meta sources: lists of source URLs maintained by others
155
+ # --------------------------------------------------------------------------- #
156
+
157
+ def parse_url_list(data: bytes, ptype: str) -> SourceMap:
158
+ """One source URL per line (e.g. gfpcom/free-proxy-list/sources/http.txt)."""
159
+ out: SourceMap = {}
160
+ for line in data.decode("utf-8", "ignore").splitlines():
161
+ line = line.strip()
162
+ if line and not line.startswith("#"):
163
+ _add(out, line, ptype)
164
+ return out
165
+
166
+
167
+ _TOML_SECTION_RE = re.compile(r"^\s*\[scraping\.(\w+)\]")
168
+ _TOML_URL_RE = re.compile(r'"(https?://[^"]+)"')
169
+
170
+
171
+ def parse_monosans_toml(data: bytes) -> SourceMap:
172
+ """URLs from the [scraping.<type>] sections of monosans/proxy-scraper-checker's config.toml.
173
+
174
+ Python 3.9 has no tomllib – a line parser is enough for this flat structure.
175
+ """
176
+ out: SourceMap = {}
177
+ section = None
178
+ for line in data.decode("utf-8", "ignore").splitlines():
179
+ m = _TOML_SECTION_RE.match(line)
180
+ if m:
181
+ section = m[1]
182
+ continue
183
+ if line.lstrip().startswith("["):
184
+ section = None
185
+ elif section in PROXY_TYPES and not line.lstrip().startswith("#"):
186
+ for url in _TOML_URL_RE.findall(line):
187
+ _add(out, url, section)
188
+ return out
189
+
190
+
191
+ META_PARSERS: Dict[str, Callable[[bytes, dict], SourceMap]] = {
192
+ "url-list": lambda data, meta: parse_url_list(data, meta.get("type", "auto")),
193
+ "monosans-toml": lambda data, meta: parse_monosans_toml(data),
194
+ }
195
+
196
+
197
+ async def resolve_meta(meta: List[dict], get: Getter) -> Tuple[SourceMap, int]:
198
+ """Loads every meta source -> (sources found, number of meta sources loaded successfully)."""
199
+
200
+ async def one(entry: dict) -> Optional[SourceMap]:
201
+ parser = META_PARSERS.get(entry.get("format", "url-list"))
202
+ if not parser:
203
+ return None
204
+ try:
205
+ return parser(await get(entry["url"], timeout=20), entry)
206
+ except Exception: # meta source unreachable -> just fewer sources, no abort
207
+ return None
208
+
209
+ found: SourceMap = {}
210
+ ok = 0
211
+ for res in await asyncio.gather(*(one(m) for m in meta)):
212
+ if res is not None:
213
+ ok += 1
214
+ for url, ptype in res.items():
215
+ found.setdefault(url, ptype)
216
+ return found, ok
217
+
218
+
219
+ # --------------------------------------------------------------------------- #
220
+ # GitHub-Discovery
221
+ # --------------------------------------------------------------------------- #
222
+
223
+ DISCOVERY_QUERIES = (
224
+ "topic:proxy-list",
225
+ "topic:free-proxy-list",
226
+ "topic:proxy-lists",
227
+ "topic:free-proxy",
228
+ "topic:free-proxies",
229
+ "topic:proxylist",
230
+ "topic:proxies-list",
231
+ "topic:socks5-proxy",
232
+ "topic:socks5-proxy-list",
233
+ "topic:socks4-proxy",
234
+ "topic:http-proxy-list",
235
+ "topic:https-proxy",
236
+ "topic:proxy-scraper",
237
+ "topic:proxy-checker",
238
+ "topic:proxy-pool",
239
+ "proxy list in:name",
240
+ "proxy-list in:name",
241
+ "free proxy in:name,description",
242
+ "proxies in:name",
243
+ "socks5 in:name",
244
+ "socks4 in:name",
245
+ "fresh proxy in:name,description",
246
+ "proxy updated every in:description",
247
+ )
248
+ SEARCH_PAGES = 3 # GitHub returns 100 per page; most queries end earlier
249
+ RATE_LIMIT_WAIT = 61.0 # the search API allows 30 requests a minute with a token (10 without)
250
+ RATE_LIMIT_RETRIES = 3 # waits per discovery at most
251
+ RATE_LIMIT_ERRORS = {"HTTP 403", "HTTP 429"} # how netio.http_get reports GitHub's rate limit
252
+ # paths that end in .txt but aren't (complete) proxy lists:
253
+ # VPN configs, splits by country/ASN (subsets only), archives, blocklists …
254
+ _PATH_REJECT_RE = re.compile(
255
+ r"(v2ray|vmess|vless|trojan|shadowsocks|(^|[/_\-])ssr?([/_\-.]|$)|clash|mtproto|wireguard|hysteria|tuic"
256
+ r"|readme|license|requirements|block|countr|geo|/asn/|archive|history|backup|old/|test|bench|corpus"
257
+ r"|subscri|(^|/)subs/|config"
258
+ r"|(^|/)[a-z]{2}\.txt$)"
259
+ )
260
+ _PATH_TYPE_RE = re.compile(r"(socks5|socks4|https?)")
261
+ _FILE_GENERIC_RE = re.compile(r"(^|[/_\-.])(proxy|proxies|all)[^/]*\.txt$")
262
+ # repos that collect VPN configs: their addresses aren't HTTP/SOCKS proxies, only dead candidates
263
+ _REPO_REJECT_RE = re.compile(r"v2ray|vpn|xray|clash|sing-?box|nekobox|vless|vmess|subscri|config", re.I)
264
+ MAX_FILES_PER_REPO = 12
265
+ MAX_REPOS_PER_OWNER = 3 # against spam accounts with dozens of identical clone repos
266
+
267
+
268
+ def classify_path(path: str) -> Optional[str]:
269
+ """Proxy type of a file from its path, or None if it doesn't look like a proxy list."""
270
+ p = path.lower()
271
+ if not p.endswith(".txt") or p.count("/") > 3 or _PATH_REJECT_RE.search(p):
272
+ return None
273
+ m = _PATH_TYPE_RE.search(p)
274
+ if m:
275
+ return TYPE_ALIASES[m[1]]
276
+ return "auto" if _FILE_GENERIC_RE.search(p) else None
277
+
278
+
279
+ def github_token() -> Optional[str]:
280
+ """Token from GITHUB_TOKEN/GH_TOKEN or the gh CLI – raises the API limit from 60 to 5000 requests/h."""
281
+ for key in ("GITHUB_TOKEN", "GH_TOKEN"):
282
+ if os.environ.get(key):
283
+ return os.environ[key].strip()
284
+ try:
285
+ res = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, timeout=5)
286
+ except (OSError, subprocess.SubprocessError):
287
+ return None
288
+ if res.returncode != 0:
289
+ return None
290
+ return res.stdout.strip() or None
291
+
292
+
293
+ async def discover_github(
294
+ get: Getter,
295
+ token: Optional[str],
296
+ max_repos: int,
297
+ days: int = 3,
298
+ on_progress: Optional[Callable[[str], None]] = None,
299
+ ) -> SourceMap:
300
+ """Looks for actively maintained proxy list repos and their list files."""
301
+ headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
302
+ if token:
303
+ headers["Authorization"] = f"Bearer {token}"
304
+ since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
305
+
306
+ repos: Dict[str, Tuple[str, int]] = {}
307
+ waits_left = RATE_LIMIT_RETRIES
308
+
309
+ async def search(query: str):
310
+ nonlocal waits_left
311
+ while True:
312
+ try:
313
+ return json.loads(await get(f"https://api.github.com/search/repositories?{query}", headers=headers))
314
+ except Exception as e:
315
+ # only GitHub's rate limit (403/429) is worth waiting for – a bad token or no network won't get better
316
+ if waits_left <= 0 or str(e) not in RATE_LIMIT_ERRORS:
317
+ return None
318
+ waits_left -= 1
319
+ if on_progress:
320
+ on_progress("rate limit – waiting a minute")
321
+ await asyncio.sleep(RATE_LIMIT_WAIT)
322
+
323
+ for q in DISCOVERY_QUERIES:
324
+ for page in range(1, SEARCH_PAGES + 1):
325
+ query = urlencode({"q": f"{q} pushed:>{since}", "sort": "stars", "per_page": 100, "page": page})
326
+ data = await search(query)
327
+ if data is None:
328
+ break
329
+ items = data.get("items", [])
330
+ for it in items:
331
+ repos.setdefault(it["full_name"], (it["default_branch"], it["stargazers_count"]))
332
+ if on_progress:
333
+ on_progress(f"{len(repos)} repos found")
334
+ if len(items) < 100:
335
+ break
336
+
337
+ per_owner: Counter = Counter()
338
+ chosen: List[Tuple[str, str]] = []
339
+ for name, (branch, _stars) in sorted(repos.items(), key=lambda kv: -kv[1][1]):
340
+ if _REPO_REJECT_RE.search(name):
341
+ continue
342
+ owner = name.split("/", 1)[0].lower()
343
+ if per_owner[owner] < MAX_REPOS_PER_OWNER:
344
+ per_owner[owner] += 1
345
+ chosen.append((name, branch))
346
+ chosen = chosen[:max_repos]
347
+
348
+ sem = asyncio.Semaphore(8)
349
+ done = 0
350
+
351
+ async def scan(name: str, branch: str) -> SourceMap:
352
+ nonlocal done
353
+ url = f"https://api.github.com/repos/{name}/git/trees/{quote(branch, safe='')}?recursive=1"
354
+ async with sem:
355
+ try:
356
+ tree = json.loads(await get(url, headers=headers, timeout=30)).get("tree", [])
357
+ except Exception:
358
+ tree = []
359
+ done += 1
360
+ if on_progress:
361
+ on_progress(f"{done}/{len(chosen)} repos searched")
362
+ files = []
363
+ for entry in tree:
364
+ if entry.get("type") == "blob" and entry.get("size", 0) >= 200:
365
+ ptype = classify_path(entry["path"])
366
+ if ptype:
367
+ files.append((entry["path"].count("/"), entry["path"], ptype))
368
+ files.sort() # shallow paths first – those are usually the complete lists
369
+ return {
370
+ f"{GH_RAW}/{name}/{branch}/{quote(path)}": ptype
371
+ for _depth, path, ptype in files[:MAX_FILES_PER_REPO]
372
+ }
373
+
374
+ found: SourceMap = {}
375
+ for res in await asyncio.gather(*(scan(n, b) for n, b in chosen)):
376
+ found.update(res)
377
+ return found
378
+
379
+
380
+ # --------------------------------------------------------------------------- #
381
+ # learning the quality of each source
382
+ # --------------------------------------------------------------------------- #
383
+
384
+ DAY = 86400.0
385
+ DECAY = 0.5 # older runs count half as much with every run
386
+ PRIOR_HITS, PRIOR_MISSES = 1.0, 30.0 # new sources start at a ~3 % hit rate
387
+ STALE_AFTER = 7 * DAY # content unchanged for a week -> the list is no longer maintained
388
+ DEAD_MIN_CHECKED = 300 # this many checks without a hit -> the source counts as dead
389
+ UNREACHABLE_STREAK = 3 # failed to load this many times in a row -> pause
390
+ UNREACHABLE_PAUSE = 2 * DAY
391
+ STALE_RECHECK = 3 * DAY # an outdated list is still looked at this often – it may be maintained again
392
+
393
+
394
+ @dataclass
395
+ class SourceRecord:
396
+ first_seen: float = 0.0
397
+ last_fetch: float = 0.0
398
+ fail_streak: int = 0
399
+ count: int = 0
400
+ content_hash: str = ""
401
+ last_change: float = 0.0
402
+ checked: float = 0.0 # decaying sums over the runs
403
+ working: float = 0.0
404
+ runs: int = 0
405
+
406
+ @property
407
+ def score(self) -> float:
408
+ """Estimated hit rate (Bayesian smoothing, so 1 out of 1 doesn't mean 100 %)."""
409
+ return (self.working + PRIOR_HITS) / (self.checked + PRIOR_HITS + PRIOR_MISSES)
410
+
411
+
412
+ class SourceStats:
413
+ def __init__(self, path: Path = STATS_FILE):
414
+ self.path = path
415
+ self.records: Dict[str, SourceRecord] = {}
416
+ if path.exists():
417
+ try:
418
+ raw = json.loads(path.read_text(encoding="utf-8"))
419
+ self.records = {url: SourceRecord(**rec) for url, rec in raw.items()}
420
+ except (OSError, ValueError, TypeError) as e:
421
+ # broken statistics are no reason to abort – we just learn again
422
+ warnings.warn(f"{path.name} unreadable ({e}), starting without source statistics", stacklevel=2)
423
+
424
+ def get(self, url: str) -> SourceRecord:
425
+ return self.records.get(url) or SourceRecord()
426
+
427
+ def score(self, url: str) -> float:
428
+ return self.get(url).score
429
+
430
+ def skip_reason(self, url: str, now: Optional[float] = None) -> Optional[str]:
431
+ rec = self.records.get(url)
432
+ if rec is None:
433
+ return None
434
+ now = time.time() if now is None else now
435
+ if rec.fail_streak >= UNREACHABLE_STREAK and now - rec.last_fetch < UNREACHABLE_PAUSE:
436
+ return "unreachable"
437
+ if rec.last_change and now - rec.last_change > STALE_AFTER and now - rec.first_seen > STALE_AFTER:
438
+ return "outdated"
439
+ if rec.runs >= 2 and rec.checked >= DEAD_MIN_CHECKED and rec.working < 0.5:
440
+ return "dead"
441
+ return None
442
+
443
+ def skip_now(self, url: str, now: Optional[float] = None) -> Optional[str]:
444
+ """Like skip_reason, but an outdated list still gets fetched every STALE_RECHECK – it may be
445
+ maintained again. skip_reason stays the status for display, so it doesn't flip back and forth."""
446
+ now = time.time() if now is None else now
447
+ reason = self.skip_reason(url, now)
448
+ if reason == "outdated" and now - self.records[url].last_fetch >= STALE_RECHECK:
449
+ return None
450
+ return reason
451
+
452
+ def record_fetch(self, url: str, data: Optional[bytes], count: int, now: Optional[float] = None,
453
+ unchanged: bool = False, parsed: Optional[int] = None) -> None:
454
+ """unchanged=True: the server answered 304 – reachable, same content as last time.
455
+ Hash and last_change stay as they are, so the outdated detection keeps working normally.
456
+
457
+ count: proxies of the types this run wants; parsed: proxies of any type. A list that has proxies,
458
+ just none of the requested types (e.g. --types http on a socks5-only list), did answer fine."""
459
+ now = time.time() if now is None else now
460
+ rec = self.records.setdefault(url, SourceRecord(first_seen=now))
461
+ rec.last_fetch = now
462
+ if unchanged: # also with 0 matching proxies (e.g. other --types): the source did answer
463
+ rec.fail_streak = 0
464
+ rec.count = count
465
+ return
466
+ if data is None or (count if parsed is None else parsed) == 0:
467
+ rec.fail_streak += 1
468
+ return
469
+ rec.fail_streak = 0
470
+ rec.count = count
471
+ digest = hashlib.sha1(data).hexdigest()
472
+ if digest != rec.content_hash:
473
+ rec.content_hash = digest
474
+ rec.last_change = now
475
+
476
+ def record_checks(self, results: Dict[str, Tuple[int, int]]) -> None:
477
+ """results: url -> (checked, working) in this run."""
478
+ for url, (checked, working) in results.items():
479
+ if not checked:
480
+ continue
481
+ rec = self.records.setdefault(url, SourceRecord(first_seen=time.time()))
482
+ rec.checked = rec.checked * DECAY + checked
483
+ rec.working = rec.working * DECAY + working
484
+ rec.runs += 1
485
+
486
+ def ranking(self, urls: Iterable[str]) -> List[Tuple[str, SourceRecord]]:
487
+ return sorted(((u, self.get(u)) for u in urls), key=lambda x: -x[1].score)
488
+
489
+ def save(self) -> None:
490
+ payload = {url: asdict(rec) for url, rec in sorted(self.records.items())}
491
+ atomic_write(self.path, json.dumps(payload, indent=1))
@@ -0,0 +1,76 @@
1
+ """Target sites for --target: a proxy only counts if it really reaches these sites."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from urllib.parse import urlsplit
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class Target:
11
+ url: str
12
+ host: str
13
+ port: int
14
+ path: str
15
+ tls: bool
16
+
17
+ @property
18
+ def host_header(self) -> str:
19
+ """Host header: with the port if it differs from the default – otherwise you may end up in the wrong vhost."""
20
+ return self.host if self.port == (443 if self.tls else 80) else f"{self.host}:{self.port}"
21
+
22
+ @property
23
+ def label(self) -> str:
24
+ """Short name for display: https://www.google.com/ -> google.com"""
25
+ host = self.host[4:] if self.host.startswith("www.") else self.host
26
+ default = 443 if self.tls else 80
27
+ return host if self.port == default else f"{host}:{self.port}"
28
+
29
+
30
+ def parse_target(text: str) -> Target:
31
+ """'google.com' -> https://google.com/ ; raises ValueError for anything unusable."""
32
+ text = text.strip()
33
+ if "://" not in text:
34
+ text = "https://" + text
35
+ u = urlsplit(text)
36
+ if u.scheme not in ("http", "https"):
37
+ raise ValueError(f"only http:// and https:// are possible, not {u.scheme}://")
38
+ if not u.hostname:
39
+ raise ValueError(f"no host in {text!r}")
40
+ tls = u.scheme == "https"
41
+ try:
42
+ port = u.port
43
+ except ValueError as e: # port outside 0–65535 or similar
44
+ raise ValueError(f"invalid port in {text!r}") from e
45
+ if port is None:
46
+ port = 443 if tls else 80
47
+ elif port <= 0: # don't silently replace ":0" with the default port
48
+ raise ValueError(f"invalid port in {text!r}")
49
+ path = (u.path or "/") + (f"?{u.query}" if u.query else "")
50
+ default = 443 if tls else 80
51
+ netloc = u.hostname if port == default else f"{u.hostname}:{port}"
52
+ return Target(url=f"{u.scheme}://{netloc}{path}", host=u.hostname, port=port, path=path, tls=tls)
53
+
54
+
55
+ def target_label(url: str, others=()) -> str:
56
+ """Short name; with the scheme when it could be confused (http:// and https:// of the same site)."""
57
+ try:
58
+ label = parse_target(url).label
59
+ except ValueError:
60
+ return url
61
+ if any(o != url and target_label(o) == label for o in others):
62
+ return url.split("/", 3)[0] + "//" + label
63
+ return label
64
+
65
+
66
+ # suggestions for the setup wizard
67
+ SUGGESTIONS = (
68
+ ("Google", "https://www.google.com/"),
69
+ ("YouTube", "https://www.youtube.com/"),
70
+ ("Discord", "https://discord.com/"),
71
+ ("Instagram", "https://www.instagram.com/"),
72
+ ("X / Twitter", "https://x.com/"),
73
+ ("Reddit", "https://www.reddit.com/"),
74
+ ("TikTok", "https://www.tiktok.com/"),
75
+ ("Amazon", "https://www.amazon.de/"),
76
+ )
@@ -0,0 +1,54 @@
1
+ """Terminal UI: building blocks (widgets), live views (dashboard) and the final report (report).
2
+
3
+ The console is deliberately not re-exported: whatever prints uses `widgets.console` at runtime,
4
+ so it can be swapped (e.g. for recording screenshots or in tests).
5
+ """
6
+
7
+ from . import widgets
8
+ from .dashboard import CheckDashboard, CollectView, LiveStats
9
+ from .report import render_source_ranking, render_summary
10
+ from .widgets import (
11
+ ACCENT,
12
+ BAD,
13
+ BLOCKED_HIT_RATE,
14
+ BORDER,
15
+ GOOD,
16
+ MUTED,
17
+ WARN,
18
+ banner,
19
+ bar,
20
+ fmt,
21
+ fmt_duration,
22
+ info,
23
+ note,
24
+ pct,
25
+ section,
26
+ section_end,
27
+ short_url,
28
+ )
29
+
30
+ __all__ = [
31
+ "ACCENT",
32
+ "BAD",
33
+ "BLOCKED_HIT_RATE",
34
+ "BORDER",
35
+ "GOOD",
36
+ "MUTED",
37
+ "WARN",
38
+ "CheckDashboard",
39
+ "CollectView",
40
+ "LiveStats",
41
+ "banner",
42
+ "bar",
43
+ "fmt",
44
+ "fmt_duration",
45
+ "info",
46
+ "note",
47
+ "pct",
48
+ "render_source_ranking",
49
+ "render_summary",
50
+ "section",
51
+ "section_end",
52
+ "short_url",
53
+ "widgets",
54
+ ]