proxy-scraper-cli 1.8.0__tar.gz → 1.8.1__tar.gz

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 (104) hide show
  1. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/PKG-INFO +1 -1
  2. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxy_scraper_cli.egg-info/PKG-INFO +1 -1
  3. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxy_scraper_cli.egg-info/SOURCES.txt +1 -0
  4. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/__init__.py +1 -1
  5. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/fetchcache.py +7 -3
  6. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/pages.py +10 -1
  7. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/parsing.py +15 -4
  8. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/pipeline.py +1 -0
  9. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/site/index.html +60 -5
  10. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/sources.py +117 -6
  11. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_fetchcache.py +13 -0
  12. proxy_scraper_cli-1.8.1/tests/test_mine_sources.py +102 -0
  13. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_pages.py +8 -0
  14. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_parsing.py +11 -0
  15. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_proxy_sources.py +14 -0
  16. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/LICENSE +0 -0
  17. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/README.md +0 -0
  18. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxy_scraper_cli.egg-info/dependency_links.txt +0 -0
  19. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxy_scraper_cli.egg-info/entry_points.txt +0 -0
  20. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxy_scraper_cli.egg-info/requires.txt +0 -0
  21. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxy_scraper_cli.egg-info/top_level.txt +0 -0
  22. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/__main__.py +0 -0
  23. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/agent.py +0 -0
  24. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/api.py +0 -0
  25. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/app.py +0 -0
  26. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/asndb.py +0 -0
  27. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/blocklist.py +0 -0
  28. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/charts.py +0 -0
  29. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/checker.py +0 -0
  30. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/cli.py +0 -0
  31. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/compat.py +0 -0
  32. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/completion.py +0 -0
  33. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/exporters.py +0 -0
  34. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/geo.py +0 -0
  35. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/geodb.py +0 -0
  36. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/handshake.py +0 -0
  37. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/history.py +0 -0
  38. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/judges.py +0 -0
  39. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/mcp_entry.py +0 -0
  40. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/mcp_server.py +0 -0
  41. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/netio.py +0 -0
  42. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/options.py +0 -0
  43. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/output.py +0 -0
  44. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/paths.py +0 -0
  45. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/preferences.py +0 -0
  46. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/publish.py +0 -0
  47. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/server/__init__.py +0 -0
  48. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/server/core.py +0 -0
  49. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/server/http.py +0 -0
  50. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/server/pool.py +0 -0
  51. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/server/socks.py +0 -0
  52. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/server/status.py +0 -0
  53. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/server/upstream.py +0 -0
  54. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/site/apple-touch-icon.png +0 -0
  55. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/site/googleaac1161b7853c5b5.html +0 -0
  56. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/site/logo.png +0 -0
  57. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/site/og.png +0 -0
  58. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/sites.py +0 -0
  59. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/sources.json +0 -0
  60. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/targets.py +0 -0
  61. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/ui/__init__.py +0 -0
  62. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/ui/dashboard.py +0 -0
  63. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/ui/keys.py +0 -0
  64. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/ui/report.py +0 -0
  65. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/ui/serve.py +0 -0
  66. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/ui/widgets.py +0 -0
  67. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/proxyscraper/ui/wizard.py +0 -0
  68. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/pyproject.toml +0 -0
  69. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/setup.cfg +0 -0
  70. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_agent.py +0 -0
  71. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_api.py +0 -0
  72. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_app.py +0 -0
  73. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_asndb.py +0 -0
  74. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_auth.py +0 -0
  75. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_blocklist.py +0 -0
  76. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_charts.py +0 -0
  77. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_checker.py +0 -0
  78. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_compat.py +0 -0
  79. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_completion.py +0 -0
  80. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_discovery_growth.py +0 -0
  81. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_exporters.py +0 -0
  82. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_geodb.py +0 -0
  83. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_judges.py +0 -0
  84. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_learning.py +0 -0
  85. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_live_api.py +0 -0
  86. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_mcp_server.py +0 -0
  87. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_options.py +0 -0
  88. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_output_stdout.py +0 -0
  89. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_packaging.py +0 -0
  90. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_paths.py +0 -0
  91. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_pipeline.py +0 -0
  92. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_publish.py +0 -0
  93. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_robustness.py +0 -0
  94. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_serve_password.py +0 -0
  95. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_serve_refill.py +0 -0
  96. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_serve_v2.py +0 -0
  97. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_server.py +0 -0
  98. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_server_fixes.py +0 -0
  99. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_sites.py +0 -0
  100. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_tamper.py +0 -0
  101. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_targets.py +0 -0
  102. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_ui.py +0 -0
  103. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_uptime.py +0 -0
  104. {proxy_scraper_cli-1.8.0 → proxy_scraper_cli-1.8.1}/tests/test_wizard.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxy-scraper-cli
3
- Version: 1.8.0
3
+ Version: 1.8.1
4
4
  Summary: Finds free HTTP, SOCKS4 and SOCKS5 proxies that actually work: 700+ sources, real checks, honeypot filtering and a rotating local proxy server.
5
5
  Author: Maximilian Feix
6
6
  License-Expression: MIT
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxy-scraper-cli
3
- Version: 1.8.0
3
+ Version: 1.8.1
4
4
  Summary: Finds free HTTP, SOCKS4 and SOCKS5 proxies that actually work: 700+ sources, real checks, honeypot filtering and a rotating local proxy server.
5
5
  Author: Maximilian Feix
6
6
  License-Expression: MIT
@@ -78,6 +78,7 @@ tests/test_judges.py
78
78
  tests/test_learning.py
79
79
  tests/test_live_api.py
80
80
  tests/test_mcp_server.py
81
+ tests/test_mine_sources.py
81
82
  tests/test_options.py
82
83
  tests/test_output_stdout.py
83
84
  tests/test_packaging.py
@@ -1,7 +1,7 @@
1
1
  """proxy-scraper: collects public HTTP/SOCKS4/SOCKS5 proxies from hundreds of sources, checks them
2
2
  in parallel with its own protocol handshakes and learns which sources deliver good proxies."""
3
3
 
4
- __version__ = "1.8.0"
4
+ __version__ = "1.8.1"
5
5
 
6
6
 
7
7
  def __getattr__(name):
@@ -5,7 +5,7 @@ On the next run the download asks with If-None-Match / If-Modified-Since; if 304
5
5
  the keys come from the cache. They are stored unfiltered (all types), because --types can
6
6
  change between two runs.
7
7
 
8
- data/fetch-cache/index.json url -> {etag, modified, type, file, used}
8
+ data/fetch-cache/index.json url -> {etag, modified, type, file, used, parser}
9
9
  data/fetch-cache/<sha1>.txt.gz the keys, one per line
10
10
  """
11
11
 
@@ -19,6 +19,7 @@ import zlib
19
19
  from pathlib import Path
20
20
  from typing import Dict, Optional
21
21
 
22
+ from . import parsing
22
23
  from .paths import DATA_DIR, atomic_write
23
24
 
24
25
  CACHE_DIR = DATA_DIR / "fetch-cache"
@@ -53,7 +54,9 @@ class FetchCache:
53
54
  ptype is the type the list is parsed with: if it changes (e.g. http -> auto),
54
55
  the stored keys no longer fit and the list is loaded again."""
55
56
  entry = self.entries.get(url) if self.enabled else None
56
- if not entry or entry.get("type") != ptype or not (self.dir / entry["file"]).is_file():
57
+ # keys from an older parser may miss proxies the current one finds -> load the list again
58
+ if (not entry or entry.get("type") != ptype or entry.get("parser") != parsing.PARSER_VERSION
59
+ or not (self.dir / entry["file"]).is_file()):
57
60
  return {}
58
61
  headers = {}
59
62
  if entry.get("etag"):
@@ -91,7 +94,8 @@ class FetchCache:
91
94
  tmp = self.dir / (name + ".tmp")
92
95
  tmp.write_bytes(gzip.compress(keys.encode("utf-8"), compresslevel=5))
93
96
  tmp.replace(self.dir / name)
94
- self.entries[url] = {"etag": etag, "modified": modified, "type": ptype, "file": name, "used": time.time()}
97
+ self.entries[url] = {"etag": etag, "modified": modified, "type": ptype, "file": name, "used": time.time(),
98
+ "parser": parsing.PARSER_VERSION}
95
99
 
96
100
  def save(self, now: Optional[float] = None) -> None:
97
101
  index = self.dir / "index.json"
@@ -100,6 +100,15 @@ footer { color: var(--muted); font-size: 14px; display: grid; gap: 6px; }
100
100
  """
101
101
 
102
102
 
103
+ def _list_query(path: str) -> str:
104
+ """The same filter on the full list: the website reads its filters from the address."""
105
+ kind = path.strip("/")
106
+ if kind.startswith("country/"):
107
+ return f"?country={kind.split('/')[1].upper()}"
108
+ return {"http": "?types=http", "socks4": "?types=socks4", "socks5": "?types=socks5", "https": "?https=1",
109
+ "elite": "?elite=1"}.get(kind, "")
110
+
111
+
103
112
  def _render(path: str, title: str, what: str, rows: List[dict], updated: datetime, nav: str) -> str:
104
113
  depth = path.count("/")
105
114
  root = "../" * depth
@@ -157,7 +166,7 @@ def _render(path: str, title: str, what: str, rows: List[dict], updated: datetim
157
166
  <p class="lede">{escape(lede)}</p>
158
167
  <div class="actions">
159
168
  <a class="btn primary" href="proxies.txt" download>Download {total:,} as .txt</a>
160
- <a class="btn" href="{root}#list">Filter the full list</a>
169
+ <a class="btn" href="{root}{_list_query(path)}#list">Filter the full list</a>
161
170
  <a class="btn" href="{REPO_URL}">Check them from your network</a>
162
171
  </div>
163
172
  </main>
@@ -43,6 +43,11 @@ RawCandidate = Tuple[str, bytes, bytes, bytes] # type, ip, port, credentials (b
43
43
  _SPACE_SEPARATED_RE = re.compile(rb"\d\.\d{1,3}[ \t]+\d{2,5}(?![\d.])")
44
44
 
45
45
 
46
+ # Bump whenever parsing finds different proxies in the same bytes: the fetch cache stores parsed keys and
47
+ # reloads every list once when this changes (2: plain lists of "auto" sources are read as http)
48
+ PARSER_VERSION = 2
49
+
50
+
46
51
  def _needs_full_regex(data: bytes) -> bool:
47
52
  """Only space/HTML lists need the slower regex (sample from the start of the file)."""
48
53
  sample = data[:8192]
@@ -53,7 +58,9 @@ def extract_candidates(data: bytes, default_type: str) -> Set[RawCandidate]:
53
58
  """Finds proxies in text, HTML tables and JSON.
54
59
 
55
60
  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.
61
+ For sources of type "auto" the plain lines only count when they are the majority: a generic proxies.txt of
62
+ plain ip:port lines is read as HTTP, the most common kind (the checks sort out the rest). In a list of mostly
63
+ prefixed lines, the odd plain one has an unknown type and is left out.
57
64
  """
58
65
  out: Set[RawCandidate] = set()
59
66
  with_scheme = set()
@@ -63,14 +70,18 @@ def extract_candidates(data: bytes, default_type: str) -> Set[RawCandidate]:
63
70
  if ptype:
64
71
  out.add((ptype, ip, port, auth))
65
72
  with_scheme.add((ip, port))
66
- if default_type == "auto":
67
- return out
73
+ auto = default_type == "auto"
74
+ if auto:
75
+ default_type = "http"
68
76
  regex = PROXY_RE if _needs_full_regex(data) else PLAIN_RE
69
77
  pairs = set(regex.findall(data))
70
78
  if b'"ip"' in data:
71
79
  pairs.update(JSON_IP_PORT_RE.findall(data))
72
80
  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)
81
+ plain = pairs - with_scheme
82
+ if auto and len(plain) <= len(out):
83
+ return out
84
+ out.update((default_type, ip, port, b"") for ip, port in plain)
74
85
  return out
75
86
 
76
87
 
@@ -65,6 +65,7 @@ async def collect_sources(opts: RunOptions, quality: srcs.SourceStats) -> Source
65
65
  found = await srcs.discover_github(
66
66
  http_get, token, max_repos,
67
67
  on_progress=lambda msg: status.update(f"[bold {ACCENT}]GitHub-Discovery:[/] {msg}"),
68
+ known=sources, # curated ones needn't be tried; found ones are, so they stay fresh
68
69
  )
69
70
  if found:
70
71
  srcs.save_discovered(found)
@@ -562,7 +562,7 @@
562
562
  <select class="field" id="f-site" aria-label="Gets through to" title="Checked every hour: most free proxies get a captcha or a 403 on these sites">
563
563
  <option value="">Any site</option><option value="google">Gets through to Google</option><option value="reddit">Gets through to Reddit</option><option value="amazon">Gets through to Amazon</option></select>
564
564
  <label class="range">Up to <input type="range" id="f-lat" min="200" max="8000" step="100" value="8000" aria-label="Maximum latency"><output id="f-lat-v" for="f-lat">any</output></label>
565
- <input class="field" type="search" id="f-q" placeholder="Search IP, port, country or provider /" aria-label="Search" aria-keyshortcuts="/">
565
+ <input class="field" type="search" id="f-q" placeholder="Search IP, country or provider /" aria-label="Search" aria-keyshortcuts="/">
566
566
  </div>
567
567
  <div class="table-panel">
568
568
  <div class="table-scroll" data-lenis-prevent-horizontal>
@@ -587,6 +587,7 @@
587
587
  <span class="count" id="count" aria-live="polite"></span>
588
588
  <div class="actions">
589
589
  <button class="btn small" id="more" type="button" hidden>Show more</button>
590
+ <button class="btn small" id="share" type="button" title="The filters are in the address, so the link opens this exact view">Share view</button>
590
591
  <button class="btn small" id="copy-all" type="button">Copy filtered</button>
591
592
  <button class="btn small primary" id="download" type="button">Download filtered</button>
592
593
  </div>
@@ -755,6 +756,11 @@ proxy-scraper --want 50 --https-only</pre><button class="icon-btn" type="button"
755
756
  const fmt = (n) => Number(n).toLocaleString("en-US");
756
757
  const esc = (s) => String(s ?? "").replace(/[&<>"']/g, c => ({"&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;"}[c]));
757
758
  const flag = (cc) => /^[A-Z]{2}$/.test(cc || "") ? String.fromCodePoint(...[...cc].map(c => 0x1F1E6 + c.charCodeAt(0) - 65)) : "";
759
+ // "DE" -> "Germany" (the browser knows the names; without Intl.DisplayNames the code stays)
760
+ const countryName = (() => {
761
+ try { const names = new Intl.DisplayNames(["en"], { type: "region" }); return (cc) => { try { return names.of(cc) || cc; } catch (e) { return cc; } }; }
762
+ catch (e) { return (cc) => cc; }
763
+ })();
758
764
  const pct = (n, of) => Math.round(100 * n / Math.max(of, 1));
759
765
  const short = (org) => { const s = String(org || "").replace(/,? (Inc|LLC|Ltd|GmbH|Co|Corporation|Limited|S\.?A|B\.?V)\.?$/i, ""); return s.length > 28 ? s.slice(0, 27) + "…" : s; };
760
766
  const upFor = (n) => { const h = (n || 1) * RUN_HOURS; return h < 48 ? `${h} h` : `${Math.round(h / 24)} days`; };
@@ -912,7 +918,7 @@ proxy-scraper --want 50 --https-only</pre><button class="icon-btn" type="button"
912
918
  const rows = state.rows.filter(r => state.types.has(r.ptype) && (!state.https || r.https) && (!state.elite || r.anonymity === "elite")
913
919
  && (!state.noDc || !r.hosting) && (!state.noBl || !r.blocklisted) && (!state.stable || (r.streak || 0) >= STABLE) && (!state.country || r.country === state.country) && (!state.site || (r.sites || {})[state.site] === true)
914
920
  && (state.maxLat >= 8000 || r.latency <= state.maxLat) // all the way right = no limit
915
- && (!q || `${r.ptype} ${r.proxy} ${r.country} ${r.exit_ip} ${r.org || ""} AS${r.asn || ""}`.toLowerCase().includes(q)));
921
+ && (!q || `${r.ptype} ${r.proxy} ${r.country} ${r.country ? countryName(r.country) : ""} ${r.exit_ip} ${r.org || ""} AS${r.asn || ""}`.toLowerCase().includes(q)));
916
922
  const k = state.sort, d = state.dir;
917
923
  return rows.sort((a, b) => (a[k] > b[k] ? 1 : a[k] < b[k] ? -1 : 0) * d || a.latency - b.latency);
918
924
  }
@@ -921,7 +927,7 @@ proxy-scraper --want 50 --https-only</pre><button class="icon-btn" type="button"
921
927
  $("#rows").innerHTML = rows.slice(0, state.shown).map(r => `<tr>
922
928
  <td><span class="t-type" title="${esc(r.ptype)}"><span class="dot ${esc(r.ptype)}"></span><span class="name">${esc(r.ptype)}</span></span></td>
923
929
  <td><button class="addr-btn" type="button" data-url="${esc(r.url)}" aria-label="Copy ${esc(r.url)}">${esc(r.proxy)}<svg aria-hidden="true"><use href="#i-copy"/></svg></button></td>
924
- <td>${flag(r.country)} ${esc(r.country || "–")}</td>
930
+ <td title="${esc(r.country ? countryName(r.country) : "")}">${flag(r.country)} ${esc(r.country || "–")}</td>
925
931
  <td>${latency(r.latency)}</td>
926
932
  <td class="hide-sm">${r.https ? "Yes" : '<span class="quiet">No</span>'}</td>
927
933
  <td class="hide-sm">${share(r)}</td>
@@ -931,9 +937,56 @@ proxy-scraper --want 50 --https-only</pre><button class="icon-btn" type="button"
931
937
  || '<tr><td colspan="9" class="empty">No proxy matches these filters. Try a higher latency limit or fewer filters.</td></tr>';
932
938
  $("#count").textContent = rows.length ? `Showing ${fmt(Math.min(state.shown, rows.length))} of ${fmt(rows.length)} matching proxies` : "";
933
939
  $("#more").hidden = rows.length <= state.shown;
940
+ saveView();
934
941
  $$("th[data-key]").forEach(th => th.dataset.key === state.sort ? th.setAttribute("aria-sort", state.dir > 0 ? "ascending" : "descending") : th.removeAttribute("aria-sort"));
935
942
  }
936
943
  const refresh = () => { state.shown = PAGE; render(); };
944
+
945
+ // The filters live in the address bar, so a view can be shared or bookmarked
946
+ const ALL_TYPES = ["http", "socks4", "socks5"], FLAGS = { https: "https", elite: "elite", noDc: "nodc", noBl: "nobl", stable: "stable" };
947
+ function viewUrl() {
948
+ const q = new URLSearchParams();
949
+ if (state.types.size !== ALL_TYPES.length) q.set("types", [...state.types].join(","));
950
+ Object.entries(FLAGS).forEach(([key, name]) => { if (state[key]) q.set(name, "1"); });
951
+ if (state.country) q.set("country", state.country);
952
+ if (state.site) q.set("site", state.site);
953
+ if (state.maxLat < 8000) q.set("latency", state.maxLat);
954
+ if (state.q.trim()) q.set("q", state.q.trim());
955
+ if (state.sort !== "latency" || state.dir !== 1) q.set("sort", (state.dir < 0 ? "-" : "") + state.sort);
956
+ return location.pathname + (q.toString() ? `?${q}` : "") + location.hash;
957
+ }
958
+ // debounced: browsers limit replaceState (Safari throws after ~100 calls in 30 s, e.g. while dragging the slider)
959
+ let saveTimer = 0;
960
+ function saveView() {
961
+ clearTimeout(saveTimer);
962
+ saveTimer = setTimeout(() => {
963
+ const url = viewUrl();
964
+ if (url !== location.pathname + location.search + location.hash) {
965
+ try { history.replaceState(history.state, "", url); } catch (e) {}
966
+ }
967
+ }, 400);
968
+ }
969
+ function loadView() {
970
+ const q = new URLSearchParams(location.search);
971
+ if (q.has("types")) state.types = new Set(q.get("types").split(",").filter(t => ALL_TYPES.includes(t)));
972
+ Object.entries(FLAGS).forEach(([key, name]) => { state[key] = q.get(name) === "1"; });
973
+ state.country = /^[A-Z]{2}$/.test(q.get("country") || "") ? q.get("country") : "";
974
+ state.site = ["google", "reddit", "amazon"].includes(q.get("site")) ? q.get("site") : "";
975
+ const lat = parseInt(q.get("latency"), 10);
976
+ state.maxLat = lat >= 200 && lat < 8000 ? Math.round(lat / 100) * 100 : 8000;
977
+ state.q = (q.get("q") || "").slice(0, 100);
978
+ const sort = (q.get("sort") || "").replace(/^-/, "");
979
+ if ($(`th[data-key="${CSS.escape(sort)}"]`)) { state.sort = sort; state.dir = q.get("sort").startsWith("-") ? -1 : 1; }
980
+ // show it on the controls
981
+ $$(".chip[data-type]").forEach(b => b.setAttribute("aria-pressed", state.types.has(b.dataset.type)));
982
+ Object.keys(FLAGS).forEach((key) => { const b = $({ https: "#f-https", elite: "#f-elite", noDc: "#f-dc", noBl: "#f-bl", stable: "#f-stable" }[key]); b.setAttribute("aria-pressed", state[key]); });
983
+ $("#f-site").value = state.site;
984
+ $("#f-lat").value = state.maxLat;
985
+ $("#f-lat-v").textContent = state.maxLat >= 8000 ? "any" : `${fmt(state.maxLat)} ms`;
986
+ $("#f-q").value = state.q;
987
+ }
988
+ loadView();
989
+ $("#share").onclick = () => copy(new URL(viewUrl(), location.href).href, "Link to this view copied");
937
990
  // chips, the country and sorting let the rows settle in; typing and the slider stay instant
938
991
  const morph = (fn) => {
939
992
  fn();
@@ -1139,11 +1192,13 @@ proxy-scraper --want 50 --https-only</pre><button class="icon-btn" type="button"
1139
1192
  const byCountry = {};
1140
1193
  rows.forEach(r => { if (/^[A-Z]{2}$/.test(r.country || "")) byCountry[r.country] = (byCountry[r.country] || 0) + 1; });
1141
1194
  const countries = Object.entries(byCountry).sort((a, b) => b[1] - a[1]);
1142
- bars($("#countries"), countries.slice(0, 7), (cc) => `${flag(cc)} ${cc}`);
1195
+ bars($("#countries"), countries.slice(0, 7), (cc) => `${flag(cc)} ${countryName(cc)}`);
1143
1196
  const buckets = [[300, "< 0.3 s"], [700, "< 0.7 s"], [1500, "< 1.5 s"], [3000, "< 3 s"], [Infinity, "3 s +"]];
1144
1197
  bars($("#latency"), buckets.map(([max, label], i) => [label, rows.filter(r => r.latency < max && (i === 0 || r.latency >= buckets[i - 1][0])).length]), (l) => l);
1145
1198
  const sel = $("#f-country");
1146
- countries.forEach(([cc, n]) => sel.add(new Option(`${flag(cc)} ${cc} (${n})`, cc)));
1199
+ countries.forEach(([cc, n]) => sel.add(new Option(`${flag(cc)} ${countryName(cc)} (${n})`, cc)));
1200
+ sel.value = state.country; // from a shared link
1201
+ if (sel.value !== state.country) state.country = ""; // a country that has no proxies this hour
1147
1202
  render();
1148
1203
  });
1149
1204
  })();
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
 
13
13
  import asyncio
14
14
  import hashlib
15
+ import ipaddress
15
16
  import json
16
17
  import os
17
18
  import re
@@ -19,13 +20,13 @@ import subprocess
19
20
  import time
20
21
  import warnings
21
22
  from collections import Counter
22
- from dataclasses import asdict, dataclass
23
+ from dataclasses import asdict, dataclass, fields
23
24
  from datetime import datetime, timedelta, timezone
24
25
  from pathlib import Path
25
- from typing import Awaitable, Callable, Dict, Iterable, List, Optional, Tuple
26
- from urllib.parse import quote, urlencode
26
+ from typing import Awaitable, Callable, Dict, Iterable, List, Optional, Set, Tuple
27
+ from urllib.parse import quote, urlencode, urlsplit
27
28
 
28
- from .parsing import PROXY_TYPES, TYPE_ALIASES
29
+ from .parsing import PROXY_TYPES, TYPE_ALIASES, extract_candidates, validate_candidates
29
30
  from .paths import DATA_DIR, PACKAGE_DIR, atomic_write
30
31
 
31
32
  SOURCES_FILE = PACKAGE_DIR / "sources.json"
@@ -262,6 +263,19 @@ _FILE_GENERIC_RE = re.compile(r"(^|[/_\-.])(proxy|proxies|all)[^/]*\.txt$")
262
263
  # repos that collect VPN configs: their addresses aren't HTTP/SOCKS proxies, only dead candidates
263
264
  _REPO_REJECT_RE = re.compile(r"v2ray|vpn|xray|clash|sing-?box|nekobox|vless|vmess|subscri|config", re.I)
264
265
  MAX_FILES_PER_REPO = 12
266
+ # the source lists of other proxy scrapers (sources.json, config.toml, urls.txt …) are full of list URLs
267
+ # (not in hidden folders: .github/ISSUE_TEMPLATE/config.yml or .vscode/settings.json would take the slots)
268
+ _CONFIG_PATH_RE = re.compile(
269
+ r"^(?!(?:.*/)?\.)(?:.*/)?(sources?|urls?|providers?|proxy[_-]?sources?|sites|config|settings)[^/]*"
270
+ r"\.(txt|json|ya?ml|toml|py|js|ts)$", re.I)
271
+ _URL_RE = re.compile(rb"https?://[^\s\"'<>)\]}`,]+")
272
+ _LISTISH_RE = re.compile(r"proxy|proxies|socks|\.txt$", re.I)
273
+ MAX_CONFIGS_PER_REPO = 3
274
+ MAX_MINED_CHECKS = 600 # URLs from those files fetched once per discovery to see whether they hold proxies
275
+ MIN_MINED_PROXIES = 20 # ... and kept only with at least this many
276
+ MINE_TIMEOUT = 10.0 # per URL
277
+ MINE_DEADLINE = 120.0 # for all of them: slow hosts from someone else's list mustn't stall the run
278
+ _LOCAL_HOST_RE = re.compile(r"(^|\.)(localhost|local|internal|lan|home|intranet)$", re.I)
265
279
  MAX_REPOS_PER_OWNER = 3 # against spam accounts with dozens of identical clone repos
266
280
 
267
281
 
@@ -290,14 +304,63 @@ def github_token() -> Optional[str]:
290
304
  return res.stdout.strip() or None
291
305
 
292
306
 
307
+ def mined_urls(data: bytes) -> List[str]:
308
+ """URLs in another scraper's source file that could be proxy lists (not templates, not VPN configs)."""
309
+ urls = []
310
+ for raw in _URL_RE.findall(data):
311
+ url = raw.decode("utf-8", "replace").rstrip(".;:")
312
+ if "{" in url or "}" in url or "$" in url or _REPO_REJECT_RE.search(url):
313
+ continue
314
+ parts = urlsplit(url)
315
+ if not _public_host(parts): # someone else's file must not point us at localhost or the local network
316
+ continue
317
+ path = parts.path.lower()
318
+ if not _LISTISH_RE.search(url) or _PATH_REJECT_RE.search(path) or path.endswith((".md", ".html", ".png")):
319
+ continue
320
+ url = normalize_url(url) or url
321
+ if url not in urls:
322
+ urls.append(url)
323
+ return urls
324
+
325
+
326
+ def _public_host(parts) -> bool:
327
+ try:
328
+ port = parts.port
329
+ except ValueError:
330
+ return False
331
+ host = (parts.hostname or "").rstrip(".")
332
+ if parts.scheme not in ("http", "https") or port not in (None, 80, 443) or not host or _LOCAL_HOST_RE.search(host):
333
+ return False
334
+ try:
335
+ return ipaddress.ip_address(host).is_global
336
+ except ValueError:
337
+ return "." in host # a name – single labels only resolve on a local network
338
+
339
+
340
+ def _mined_type(url: str) -> str:
341
+ """The type a URL names (in the path, or an API's query string like ?protocol=socks4), else "auto"."""
342
+ parts = urlsplit(url)
343
+ m = _PATH_TYPE_RE.search(f"{parts.path}?{parts.query}".lower())
344
+ return TYPE_ALIASES[m[1]] if m else "auto"
345
+
346
+
347
+ def _readable_type(url: str, data: bytes) -> Optional[str]:
348
+ """The type to keep a mined list under (the one the scrape will read it with) – None if it doesn't hold
349
+ enough proxies that way."""
350
+ ptype = _mined_type(url)
351
+ return ptype if len(validate_candidates(extract_candidates(data, ptype))) >= MIN_MINED_PROXIES else None
352
+
353
+
293
354
  async def discover_github(
294
355
  get: Getter,
295
356
  token: Optional[str],
296
357
  max_repos: int,
297
358
  days: int = 3,
298
359
  on_progress: Optional[Callable[[str], None]] = None,
360
+ known: Iterable[str] = (),
299
361
  ) -> SourceMap:
300
- """Looks for actively maintained proxy list repos and their list files."""
362
+ """Looks for actively maintained proxy list repos and their list files – and for the source lists of other
363
+ scrapers, whose URLs are kept if they really hold proxies. `known`: sources that needn't be tried again."""
301
364
  headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
302
365
  if token:
303
366
  headers["Authorization"] = f"Bearer {token}"
@@ -366,17 +429,62 @@ async def discover_github(
366
429
  if ptype:
367
430
  files.append((entry["path"].count("/"), entry["path"], ptype))
368
431
  files.sort() # shallow paths first – those are usually the complete lists
432
+ configs = sorted((e["path"].count("/"), e["path"]) for e in tree
433
+ if e.get("type") == "blob" and 100 <= e.get("size", 0) <= 300_000
434
+ and e["path"].count("/") <= 3 and _CONFIG_PATH_RE.search(e["path"]))
435
+ configs_found.extend(f"{GH_RAW}/{name}/{branch}/{quote(path)}" for _d, path in configs[:MAX_CONFIGS_PER_REPO])
369
436
  return {
370
437
  f"{GH_RAW}/{name}/{branch}/{quote(path)}": ptype
371
438
  for _depth, path, ptype in files[:MAX_FILES_PER_REPO]
372
439
  }
373
440
 
441
+ configs_found: List[str] = []
374
442
  found: SourceMap = {}
375
443
  for res in await asyncio.gather(*(scan(n, b) for n, b in chosen)):
376
444
  found.update(res)
445
+ found.update(await _mine(get, configs_found, set(known) | set(found), on_progress))
377
446
  return found
378
447
 
379
448
 
449
+ async def _mine(get: Getter, configs: List[str], known: Set[str],
450
+ on_progress: Optional[Callable[[str], None]] = None) -> SourceMap:
451
+ """Read other scrapers' source files, then fetch each new URL once: only real proxy lists are kept."""
452
+ sem = asyncio.Semaphore(32)
453
+
454
+ async def fetch(url: str) -> bytes:
455
+ async with sem:
456
+ try:
457
+ return await get(url, timeout=MINE_TIMEOUT)
458
+ except Exception:
459
+ return b""
460
+
461
+ candidates: List[str] = []
462
+ for data in await asyncio.gather(*(fetch(u) for u in configs)):
463
+ for url in mined_urls(data):
464
+ if url not in known and url not in candidates:
465
+ candidates.append(url)
466
+ candidates = candidates[:MAX_MINED_CHECKS]
467
+ checked = 0
468
+
469
+ async def check(url: str) -> Tuple[str, Optional[str]]:
470
+ nonlocal checked
471
+ data = await fetch(url)
472
+ ptype = await asyncio.to_thread(_readable_type, url, data) if data else None
473
+ checked += 1
474
+ if on_progress and checked % 50 == 0:
475
+ on_progress(f"{checked}/{len(candidates)} lists from other scrapers checked")
476
+ return url, ptype
477
+
478
+ if not candidates:
479
+ return {}
480
+ tasks = [asyncio.ensure_future(check(u)) for u in candidates]
481
+ done, pending = await asyncio.wait(tasks, timeout=MINE_DEADLINE)
482
+ for task in pending: # whatever hasn't answered by now gets another chance at the next discovery
483
+ task.cancel()
484
+ await asyncio.gather(*pending, return_exceptions=True)
485
+ return {url: ptype for url, ptype in (t.result() for t in done) if ptype}
486
+
487
+
380
488
  # --------------------------------------------------------------------------- #
381
489
  # learning the quality of each source
382
490
  # --------------------------------------------------------------------------- #
@@ -416,7 +524,10 @@ class SourceStats:
416
524
  if path.exists():
417
525
  try:
418
526
  raw = json.loads(path.read_text(encoding="utf-8"))
419
- self.records = {url: SourceRecord(**rec) for url, rec in raw.items()}
527
+ # fields from another version (newer or older) are left out instead of losing everything
528
+ known = {f.name for f in fields(SourceRecord)}
529
+ self.records = {url: SourceRecord(**{k: v for k, v in rec.items() if k in known})
530
+ for url, rec in raw.items() if isinstance(rec, dict)}
420
531
  except (OSError, ValueError, TypeError) as e:
421
532
  # broken statistics are no reason to abort – we just learn again
422
533
  warnings.warn(f"{path.name} unreadable ({e}), starting without source statistics", stacklevel=2)
@@ -153,6 +153,19 @@ def test_changed_source_type_invalidates_the_entry(tmp_path):
153
153
  assert cache.conditional_headers("http://x/list", "socks5") == {}
154
154
 
155
155
 
156
+ def test_a_newer_parser_reloads_lists_once(tmp_path, monkeypatch):
157
+ # the cache holds parsed keys: after a parser change, a 304 would keep serving what the old parser found
158
+ from proxyscraper import fetchcache, parsing
159
+ cache = FetchCache(tmp_path / "cache")
160
+ cache.store("http://x/list", {b"etag": b'"a"'}, "http 1.1.1.1:80", "http")
161
+ cache.save()
162
+ monkeypatch.setattr(parsing, "PARSER_VERSION", parsing.PARSER_VERSION + 1)
163
+ again = fetchcache.FetchCache(tmp_path / "cache")
164
+ assert again.conditional_headers("http://x/list", "http") == {}
165
+ again.store("http://x/list", {b"etag": b'"a"'}, "http 1.1.1.1:80", "http")
166
+ assert again.conditional_headers("http://x/list", "http") == {"If-None-Match": '"a"'}
167
+
168
+
156
169
  @pytest.mark.parametrize("method, headers, body, allowed", [
157
170
  ("GET", None, None, True), # public list
158
171
  ("GET", {"If-None-Match": '"a"'}, None, True), # conditional fetch for the cache
@@ -0,0 +1,102 @@
1
+ """Discovery also reads the source lists of other proxy scrapers – and keeps only URLs that really hold proxies."""
2
+
3
+ import asyncio
4
+ import json
5
+
6
+ from proxyscraper import sources as srcs
7
+
8
+ LIST = "\n".join(f"8.8.{i // 250}.{i % 250 + 1}:8080" for i in range(30)).encode()
9
+ CONFIG = json.dumps({"sources": [
10
+ "https://raw.githubusercontent.com/someone/lists/main/socks5.txt", # a real list -> kept, socks5
11
+ "https://example.org/api/proxies?format=text", # a real list, type unknown -> auto
12
+ "https://www.some-proxy-site.com/free-proxy-list/", # an HTML page -> dropped
13
+ "https://example.org/api/{protocol}.txt", # a template -> never fetched
14
+ "https://raw.githubusercontent.com/someone/v2ray-configs/main/all.txt", # VPN configs -> never fetched
15
+ "https://known.example/http.txt", # already a source -> not fetched again
16
+ ]}).encode()
17
+
18
+
19
+ def fake_get(fetched):
20
+ async def get(url, timeout=0, headers=None):
21
+ fetched.append(url)
22
+ if "search/repositories" in url:
23
+ return json.dumps({"items": [{"full_name": "dev/scraper", "default_branch": "main",
24
+ "stargazers_count": 5}]}).encode()
25
+ if "/git/trees/" in url:
26
+ return json.dumps({"tree": [
27
+ {"type": "blob", "path": "config/sources.json", "size": 800},
28
+ {"type": "blob", "path": "main.py", "size": 800},
29
+ ]}).encode()
30
+ if url.endswith("config/sources.json"):
31
+ return CONFIG
32
+ if "some-proxy-site" in url:
33
+ return b"<html><body>no proxies in plain text here</body></html>"
34
+ return LIST
35
+ return get
36
+
37
+
38
+ def test_urls_from_scraper_configs_are_checked_before_they_count():
39
+ fetched = []
40
+ found = asyncio.run(srcs.discover_github(fake_get(fetched), token=None, max_repos=10,
41
+ known={"https://known.example/http.txt"}))
42
+ assert found == {
43
+ "https://raw.githubusercontent.com/someone/lists/main/socks5.txt": "socks5",
44
+ "https://example.org/api/proxies?format=text": "auto", # plain ip:port lines: auto reads them as http
45
+ }
46
+ assert not any("{protocol}" in u or "v2ray" in u or "known.example" in u for u in fetched)
47
+ # only files that look like source lists are read
48
+ assert "https://raw.githubusercontent.com/dev/scraper/main/main.py" not in fetched
49
+
50
+
51
+ def test_mined_urls_are_capped():
52
+ many = json.dumps([f"https://example.org/list{i}.txt" for i in range(srcs.MAX_MINED_CHECKS + 50)]).encode()
53
+ fetched = []
54
+ base = fake_get(fetched)
55
+
56
+ async def get(url, timeout=0, headers=None):
57
+ if url.endswith("config/sources.json"):
58
+ fetched.append(url)
59
+ return many
60
+ return await base(url, timeout, headers)
61
+
62
+ found = asyncio.run(srcs.discover_github(get, token=None, max_repos=10))
63
+ assert len(found) == srcs.MAX_MINED_CHECKS
64
+
65
+
66
+ def test_the_type_can_sit_in_the_query_string():
67
+ assert srcs._mined_type("https://api.example.com/v2/?request=get&protocol=socks4") == "socks4"
68
+
69
+
70
+ def test_scheme_lists_stay_auto():
71
+ data = "\n".join(f"socks5://8.8.{i // 250}.{i % 250 + 1}:1080" for i in range(30)).encode()
72
+ assert srcs._readable_type("https://example.org/mixed", data) == "auto"
73
+ assert srcs._readable_type("https://example.org/mixed", b"nothing") is None
74
+
75
+
76
+ def test_urls_to_local_or_private_hosts_are_never_fetched():
77
+ config = json.dumps(["http://127.0.0.1/proxy.txt", "http://localhost/proxies.txt",
78
+ "http://169.254.169.254/latest/proxy", "http://10.0.0.5/socks5.txt",
79
+ "http://printer.local/proxy.txt", "https://example.org:8443/proxy.txt",
80
+ "https://example.org/proxy.txt"]).encode()
81
+ assert srcs.mined_urls(config) == ["https://example.org/proxy.txt"]
82
+
83
+
84
+ def test_repo_meta_files_dont_take_the_config_slots():
85
+ assert not srcs._CONFIG_PATH_RE.search(".github/ISSUE_TEMPLATE/config.yml")
86
+ assert not srcs._CONFIG_PATH_RE.search(".vscode/settings.json")
87
+ assert srcs._CONFIG_PATH_RE.search("src/sources.py")
88
+
89
+
90
+ def test_slow_hosts_cant_stall_discovery(monkeypatch):
91
+ monkeypatch.setattr(srcs, "MINE_DEADLINE", 0.2)
92
+ config = json.dumps([f"https://slow{i}.example/proxy.txt" for i in range(5)]
93
+ + ["https://fast.example/proxy.txt"]).encode()
94
+
95
+ async def get(url, timeout=0, headers=None):
96
+ if "slow" in url:
97
+ await asyncio.sleep(30)
98
+ return config if url == "cfg" else LIST
99
+
100
+ async def go():
101
+ return await asyncio.wait_for(srcs._mine(get, ["cfg"], set()), 5)
102
+ assert asyncio.run(go()) == {"https://fast.example/proxy.txt": "auto"}
@@ -50,3 +50,11 @@ def test_pages_link_to_each_other(tmp_path):
50
50
  assert '<a href="../socks5/" aria-current="page">SOCKS5 (1)</a>' in socks5
51
51
  assert '<a href="../country/de/">Germany (3)</a>' in socks5
52
52
  assert "Japan" not in socks5 # empty countries aren't linked
53
+
54
+
55
+ def test_filter_button_opens_the_list_with_the_same_filter(tmp_path):
56
+ pages.write_pages(ROWS, tmp_path, NOW)
57
+ expected = {"socks5": "?types=socks5", "https": "?https=1", "elite": "?elite=1", "country/de": "?country=DE"}
58
+ for path, query in expected.items():
59
+ html = (tmp_path / path / "index.html").read_text(encoding="utf-8")
60
+ assert f'href="{"../" * (path.count("/") + 1)}{query}#list">Filter the full list' in html
@@ -37,6 +37,17 @@ def test_auto_source_only_uses_prefixed_lines():
37
37
  assert parse(b"socks4://5.6.7.8:1080\n1.2.3.4:80\n", "auto") == ["socks4 5.6.7.8:1080"]
38
38
 
39
39
 
40
+ def test_auto_source_mostly_plain_reads_the_plain_lines_as_http():
41
+ data = b"socks5://9.9.9.9:1080\n" + b"".join(f"1.2.3.{i}:80\n".encode() for i in range(1, 6))
42
+ got = parse(data, "auto")
43
+ assert "socks5 9.9.9.9:1080" in got and "http 1.2.3.1:80" in got and len(got) == 6
44
+
45
+
46
+ def test_auto_source_without_any_prefix_is_read_as_http():
47
+ # a generic proxies.txt with plain ip:port lines: before, it gave nothing and got paused as "unreachable"
48
+ assert parse(b"1.2.3.4:80\n5.6.7.8:3128\n", "auto") == ["http 1.2.3.4:80", "http 5.6.7.8:3128"]
49
+
50
+
40
51
  def test_private_and_invalid_addresses_are_dropped():
41
52
  data = b"10.0.0.1:80\n192.168.1.1:8080\n300.1.1.1:80\n1.1.1.1:70000\n001.002.003.004:0080\n"
42
53
  assert parse(data) == ["http 1.2.3.4:80"]
@@ -133,3 +133,17 @@ def test_curated_sources_file_is_valid():
133
133
  assert len(sources) > 100
134
134
  assert set(sources.values()) <= set(srcs.SOURCE_TYPES)
135
135
  assert meta and all("url" in m for m in meta)
136
+
137
+
138
+ def test_stats_with_fields_from_another_version_are_still_read(tmp_path):
139
+ path = tmp_path / "s.json"
140
+ path.write_text(json.dumps({"u": {"first_seen": 1.0, "count": 5, "some_future_field": "x"}}))
141
+ st = srcs.SourceStats(path)
142
+ assert st.get("u").count == 5
143
+
144
+
145
+ def test_broken_entries_in_the_stats_file_dont_crash(tmp_path):
146
+ path = tmp_path / "s.json"
147
+ path.write_text(json.dumps({"u": None, "v": [1], "w": {"count": 3}}))
148
+ st = srcs.SourceStats(path)
149
+ assert st.get("w").count == 3 and "u" not in st.records