proxy-scraper-cli 1.11.0__tar.gz → 1.13.0__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 (116) hide show
  1. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/PKG-INFO +1 -1
  2. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxy_scraper_cli.egg-info/PKG-INFO +1 -1
  3. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxy_scraper_cli.egg-info/SOURCES.txt +1 -0
  4. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/__init__.py +1 -1
  5. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/checker.py +4 -0
  6. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/publish.py +3 -1
  7. proxy_scraper_cli-1.13.0/proxyscraper/runsteps.py +74 -0
  8. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/site/index.html +79 -3
  9. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/sites.py +14 -30
  10. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/speed.py +14 -25
  11. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_sites.py +2 -1
  12. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/LICENSE +0 -0
  13. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/README.md +0 -0
  14. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxy_scraper_cli.egg-info/dependency_links.txt +0 -0
  15. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxy_scraper_cli.egg-info/entry_points.txt +0 -0
  16. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxy_scraper_cli.egg-info/requires.txt +0 -0
  17. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxy_scraper_cli.egg-info/top_level.txt +0 -0
  18. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/__main__.py +0 -0
  19. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/action.py +0 -0
  20. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/agent.py +0 -0
  21. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/api.py +0 -0
  22. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/app.py +0 -0
  23. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/asndb.py +0 -0
  24. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/blocklist.py +0 -0
  25. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/charts.py +0 -0
  26. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/cli.py +0 -0
  27. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/compat.py +0 -0
  28. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/completion.py +0 -0
  29. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/exporters.py +0 -0
  30. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/fetchcache.py +0 -0
  31. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/geo.py +0 -0
  32. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/geodb.py +0 -0
  33. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/handshake.py +0 -0
  34. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/history.py +0 -0
  35. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/judges.py +0 -0
  36. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/mcp_entry.py +0 -0
  37. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/mcp_server.py +0 -0
  38. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/netio.py +0 -0
  39. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/options.py +0 -0
  40. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/output.py +0 -0
  41. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/pages.py +0 -0
  42. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/parsing.py +0 -0
  43. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/paths.py +0 -0
  44. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/pipeline.py +0 -0
  45. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/preferences.py +0 -0
  46. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/proxypages.py +0 -0
  47. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/report.py +0 -0
  48. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/server/__init__.py +0 -0
  49. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/server/core.py +0 -0
  50. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/server/http.py +0 -0
  51. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/server/pool.py +0 -0
  52. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/server/socks.py +0 -0
  53. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/server/status.py +0 -0
  54. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/server/upstream.py +0 -0
  55. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/site/apple-touch-icon.png +0 -0
  56. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/site/googleaac1161b7853c5b5.html +0 -0
  57. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/site/logo.png +0 -0
  58. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/site/og.png +0 -0
  59. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/sources.json +0 -0
  60. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/sources.py +0 -0
  61. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/targets.py +0 -0
  62. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/ui/__init__.py +0 -0
  63. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/ui/dashboard.py +0 -0
  64. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/ui/keys.py +0 -0
  65. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/ui/report.py +0 -0
  66. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/ui/serve.py +0 -0
  67. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/ui/widgets.py +0 -0
  68. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/proxyscraper/ui/wizard.py +0 -0
  69. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/pyproject.toml +0 -0
  70. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/setup.cfg +0 -0
  71. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_action.py +0 -0
  72. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_agent.py +0 -0
  73. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_api.py +0 -0
  74. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_app.py +0 -0
  75. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_asndb.py +0 -0
  76. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_auth.py +0 -0
  77. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_blocklist.py +0 -0
  78. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_charts.py +0 -0
  79. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_checker.py +0 -0
  80. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_compat.py +0 -0
  81. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_completion.py +0 -0
  82. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_discovery_growth.py +0 -0
  83. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_exporters.py +0 -0
  84. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_fetchcache.py +0 -0
  85. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_geodb.py +0 -0
  86. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_judges.py +0 -0
  87. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_learning.py +0 -0
  88. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_live_api.py +0 -0
  89. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_mcp_server.py +0 -0
  90. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_mine_sources.py +0 -0
  91. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_options.py +0 -0
  92. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_output_stdout.py +0 -0
  93. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_packaging.py +0 -0
  94. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_pages.py +0 -0
  95. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_parsing.py +0 -0
  96. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_parsing_fuzz.py +0 -0
  97. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_paths.py +0 -0
  98. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_pipeline.py +0 -0
  99. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_proxy_pages.py +0 -0
  100. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_proxy_sources.py +0 -0
  101. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_publish.py +0 -0
  102. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_report.py +0 -0
  103. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_robustness.py +0 -0
  104. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_serve_password.py +0 -0
  105. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_serve_refill.py +0 -0
  106. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_serve_v2.py +0 -0
  107. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_server.py +0 -0
  108. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_server_closed_writer.py +0 -0
  109. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_server_fixes.py +0 -0
  110. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_sources_json.py +0 -0
  111. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_speed.py +0 -0
  112. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_tamper.py +0 -0
  113. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_targets.py +0 -0
  114. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_ui.py +0 -0
  115. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/tests/test_uptime.py +0 -0
  116. {proxy_scraper_cli-1.11.0 → proxy_scraper_cli-1.13.0}/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.11.0
3
+ Version: 1.13.0
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.11.0
3
+ Version: 1.13.0
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
@@ -40,6 +40,7 @@ proxyscraper/preferences.py
40
40
  proxyscraper/proxypages.py
41
41
  proxyscraper/publish.py
42
42
  proxyscraper/report.py
43
+ proxyscraper/runsteps.py
43
44
  proxyscraper/sites.py
44
45
  proxyscraper/sources.json
45
46
  proxyscraper/sources.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.11.0"
4
+ __version__ = "1.13.0"
5
5
 
6
6
 
7
7
  def __getattr__(name):
@@ -334,6 +334,10 @@ class Checker:
334
334
  writer.close()
335
335
  return 200 <= status < 400
336
336
 
337
+ async def tls_tunnel(self, ptype: str, proxy: str, host: str, ip: str, port: int = 443):
338
+ """Public side of _tls_tunnel for the steps after a run (sites, speed): ip as text."""
339
+ return await self._tls_tunnel(ptype, proxy, host, socket.inet_aton(ip), port)
340
+
337
341
  async def _tls_tunnel(self, ptype: str, proxy: str, host: str, ip_bytes: bytes, port: int):
338
342
  """Tunnel through the proxy to host:port with verified TLS inside. None = tunnel refused or MITM."""
339
343
  loop = asyncio.get_running_loop()
@@ -70,7 +70,9 @@ def stats_for(rows: List[dict], now: datetime) -> dict:
70
70
  if any(r.get("blocklisted") is not None for r in rows) else None,
71
71
  "stable": sum(1 for r in rows if r.get("streak", 0) >= STABLE_RUNS),
72
72
  # proxies that got through to Google, Reddit, ... in the site check after the run (sites.py)
73
- "sites": {s.name: sum(1 for r in rows if (r.get("sites") or {}).get(s.name)) for s in SITES},
73
+ # None when a site wasn't checked at all (the step failed, its DNS lookup didn't work) – not a measured 0
74
+ "sites": {s.name: sum(1 for r in rows if (r.get("sites") or {}).get(s.name))
75
+ if any(s.name in (r.get("sites") or {}) for r in rows) else None for s in SITES},
74
76
  "run_hours": RUN_HOURS,
75
77
  "countries": dict(Counter(r["country"] for r in rows if r.get("country")).most_common(15)),
76
78
  "median_latency": round(statistics.median(r["latency"] for r in rows)) if rows else 0,
@@ -0,0 +1,74 @@
1
+ """Shared plumbing for the extra steps that run on a finished run's proxies.json in the hourly workflow –
2
+ which sites let a proxy through (sites.py), how fast it downloads (speed.py).
3
+
4
+ Each step loads the run's rows, tries the HTTPS-capable proxies through a verified TLS tunnel with a limit on how
5
+ many at once, and writes the file back in one go: if the workflow kills the step at its timeout, the publish step
6
+ still finds the old file intact.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import socket
14
+ from pathlib import Path
15
+ from typing import Awaitable, Callable, Iterable, List, Tuple
16
+
17
+ from .paths import atomic_write
18
+
19
+ # what a probe through a free proxy can run into – all of it just means "no answer from this one"
20
+ PROBE_ERRORS = (OSError, asyncio.TimeoutError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, ValueError)
21
+
22
+
23
+ def load_rows(run_dir: Path) -> Tuple[Path, List[dict]]:
24
+ path = run_dir / "proxies.json"
25
+ return path, json.loads(path.read_text(encoding="utf-8"))
26
+
27
+
28
+ def save_rows(path: Path, rows: List[dict]) -> None:
29
+ atomic_write(path, json.dumps(rows, indent=1, ensure_ascii=False))
30
+
31
+
32
+ def https_rows(rows: Iterable[dict]) -> List[dict]:
33
+ """Only these can tunnel to an https:// site – the others failed the HTTPS test in the run."""
34
+ return [r for r in rows if r.get("https") is True]
35
+
36
+
37
+ def resolve_ipv4(host: str, port: int = 443) -> str:
38
+ """Looked up once per step: SOCKS4 needs the IP, and a thousand lookups of the same host help nobody."""
39
+ return socket.getaddrinfo(host, port, family=socket.AF_INET)[0][4][0]
40
+
41
+
42
+ async def run_limited(jobs: Iterable[Callable[[], Awaitable[None]]], concurrency: int) -> None:
43
+ """Run the jobs with at most `concurrency` at a time."""
44
+ slots = asyncio.Semaphore(concurrency)
45
+
46
+ async def one(job: Callable[[], Awaitable[None]]) -> None:
47
+ async with slots:
48
+ await job()
49
+
50
+ await asyncio.gather(*(one(job) for job in jobs))
51
+
52
+
53
+ class Tunnels:
54
+ """Verified TLS tunnels to a host through a proxy – the same handshake code the checks use."""
55
+
56
+ def __init__(self, timeout: float):
57
+ from .checker import Checker # only here: the checker pulls in more than these steps need
58
+
59
+ self.checker = Checker(judge_ip="127.0.0.1", own_ips=(), timeout=timeout, connect_timeout=timeout / 2)
60
+
61
+ async def open(self, row: dict, host: str, ip: str, port: int = 443):
62
+ """(reader, writer) of the TLS connection, or None if the proxy refused the tunnel or broke up the TLS."""
63
+ return await self.checker.tls_tunnel(row["ptype"], row["proxy"], host, ip, port)
64
+
65
+
66
+ def browser_get(host: str, path: str, *extra: str) -> bytes:
67
+ """A plain GET the way a browser would send it (so sites answer as they would to a person)."""
68
+ from .netio import USER_AGENT
69
+
70
+ lines = [f"GET {path} HTTP/1.1", f"Host: {host}", f"User-Agent: {USER_AGENT}",
71
+ "Accept: text/html,application/xhtml+xml,*/*;q=0.8", "Accept-Language: en-US,en;q=0.8", *extra,
72
+ "Connection: close"]
73
+ return ("\r\n".join(lines) + "\r\n\r\n").encode()
74
+
@@ -22,6 +22,9 @@
22
22
  <meta property="og:description" content="Every proxy on this list passed a real handshake, a honeypot check and a content check. Re-checked every hour.">
23
23
  <link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 64 64'%3E%3Crect width='64' height='64' rx='18' fill='%23D4F77A'/%3E%3Cpath d='M13 31.5 26.5 44.5 51 18' fill='none' stroke='%23121113' stroke-width='5.5' stroke-linecap='round' stroke-linejoin='round'/%3E%3Ccircle cx='13' cy='31.5' r='4.6' fill='%23121113'/%3E%3Ccircle cx='51' cy='18' r='4.6' fill='%23121113'/%3E%3Ccircle cx='26.5' cy='44.5' r='7.6' fill='%23D4F77A' stroke='%23121113' stroke-width='5'/%3E%3C/svg%3E">
24
24
  <script type="application/ld+json">
25
+ {"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [{"@type": "Question", "name": "Are free proxies safe to use?", "acceptedAnswer": {"@type": "Answer", "text": "They're run by strangers, so treat them as untrusted. Anything that isn't encrypted can be read by whoever runs the proxy – never send passwords or personal data through one. Every proxy on this list has passed a content check (it didn't change a known page), and the HTTPS ones tunnel TLS that's verified end to end, so an https:// site can't be read or altered. That makes them fine for scraping public pages or seeing a site from another country, not for your bank."}}, {"@type": "Question", "name": "Why do most free proxies not work?", "acceptedAnswer": {"@type": "Answer", "text": "They don't live long. Of the proxies that drop off this list again, most are gone by the next hourly check, and only a few last a whole day – the weekly report tracks it. Many lists never re-check, so a large share of what they hold is long dead, and some of what still answers are honeypots or proxies that inject ads."}}, {"@type": "Question", "name": "How often is the list updated?", "acceptedAnswer": {"@type": "Answer", "text": "Every hour. GitHub Actions collects proxies from 700+ public lists, checks them and publishes whatever passed. This page loads the new list by itself when a run lands."}}, {"@type": "Question", "name": "Can I use it in my own projects?", "acceptedAnswer": {"@type": "Answer", "text": "Yes. The code is open source under the MIT license, and the lists are free to use, including in commercial projects. The proxies themselves belong to other people, so stick to the terms of the sites you visit through them."}}, {"@type": "Question", "name": "How do I use it from Python or an AI agent?", "acceptedAnswer": {"@type": "Answer", "text": "From Python: pip install proxy-scraper-cli, then live_proxies() returns the checked list with filters for country, protocol, uptime and the sites a proxy gets through to. AI agents like Claude Code or Cursor can use the MCP server, which gives them working proxies and can load pages through them."}}, {"@type": "Question", "name": "A proxy from the list doesn't work for me – why?", "acceptedAnswer": {"@type": "Answer", "text": "It worked from GitHub's servers in the last run, but free proxies come and go, and some block certain networks. proxy-scraper --recheck live checks the list again from your own connection in about 30 seconds and keeps what works from there."}}]}
26
+ </script>
27
+ <script type="application/ld+json">
25
28
  {"@context": "https://schema.org", "@type": "Dataset",
26
29
  "name": "Free proxy list, checked every hour",
27
30
  "description": "HTTP, SOCKS4 and SOCKS5 proxies from 700+ public lists that passed a real handshake, a honeypot check on two sites and a content check. With country, provider, latency, anonymity and HTTPS support.",
@@ -341,6 +344,27 @@
341
344
  .map-wait { display: grid; place-items: center; height: 100%; margin: 0; }
342
345
  .status .next { color: var(--muted); }
343
346
  .status .next:not(:empty)::before { content: " · "; }
347
+ .bookmarklet .btn { cursor: grab; margin: 6px 0 4px; }
348
+ .link-btn { font: inherit; color: var(--text); background: none; border: 0; padding: 0; text-decoration: underline; cursor: pointer; }
349
+ /* Why this list: one question per row, what a typical list says against what was measured here */
350
+ .ledger { border-top: 1px solid var(--line); }
351
+ .ledger-row { display: grid; grid-template-columns: minmax(0, 1.1fr) minmax(0, .9fr) minmax(0, 1.6fr); gap: 8px 32px; padding: 20px 0;
352
+ border-bottom: 1px solid var(--line); align-items: baseline; }
353
+ .ledger-head { padding: 14px 0; color: var(--muted); font-size: 14px; }
354
+ .ledger-row .q { font-size: 18px; font-weight: 500; letter-spacing: -.01em; }
355
+ .ledger-row .them { color: var(--muted); }
356
+ .ledger-row .us b { color: var(--signal-ink); font-weight: 600; font-variant-numeric: tabular-nums; }
357
+ /* FAQ: opens on click, nothing moves on its own */
358
+ .faq-list { border-top: 1px solid var(--line); }
359
+ .faq-item { border-bottom: 1px solid var(--line); }
360
+ .faq-item summary { list-style: none; cursor: pointer; display: flex; justify-content: space-between; align-items: center; gap: 24px; padding: 22px 0; }
361
+ .faq-item summary::-webkit-details-marker { display: none; }
362
+ .faq-item summary h3 { margin: 0; font-size: clamp(18px, 2vw, 21px); font-weight: 500; letter-spacing: -.01em; }
363
+ .faq-item summary::after { content: ""; flex: none; width: 12px; height: 12px; border-right: 1.5px solid var(--muted); border-bottom: 1.5px solid var(--muted);
364
+ transform: translateY(-3px) rotate(45deg); transition: transform .3s var(--ease); }
365
+ .faq-item[open] summary::after { transform: translateY(3px) rotate(225deg); }
366
+ .faq-item summary:focus-visible { outline: 2px solid var(--signal-ink); outline-offset: 4px; border-radius: 6px; }
367
+ .faq-item p { margin: 0 0 24px; max-width: 70ch; color: var(--muted); font-size: 17px; }
344
368
  .panel { background: var(--panel); border: 1px solid var(--line); border-radius: 22px; padding: clamp(22px, 3vw, 34px); }
345
369
 
346
370
  /* For AI agents: a real Claude Code exchange, revealed line by line once it scrolls in */
@@ -379,8 +403,10 @@
379
403
  .swatch { width: 16px; height: 3px; border-radius: 2px; background: var(--signal-ink); }
380
404
  .swatch.dashed { background: repeating-linear-gradient(90deg, var(--muted) 0 4px, transparent 4px 7px); }
381
405
  .bars { display: grid; gap: 12px; }
382
- .bar-row { display: grid; grid-template-columns: 76px minmax(0, 1fr) 44px; align-items: center; gap: 12px; font-size: 14px; }
383
- .bar-row .k { color: var(--muted); }
406
+ .bar-row { display: grid; grid-template-columns: minmax(76px, max-content) minmax(40px, 1fr) 44px; align-items: center; gap: 12px; font-size: 14px; }
407
+ .bars { display: grid; grid-template-columns: minmax(76px, max-content) minmax(40px, 1fr) 44px; gap: 10px 12px; }
408
+ .bars .bar-row { display: contents; } /* one grid for all rows: the labels share a column as wide as the longest */
409
+ .bar-row .k { color: var(--muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 150px; }
384
410
  .bar-row .v { text-align: right; font-variant-numeric: tabular-nums; }
385
411
  .bar { height: 10px; border-radius: 99px; background: var(--raised); overflow: hidden; }
386
412
  .bar i { display: block; height: 100%; width: 0; border-radius: 99px; background: var(--text); transition: width 1.2s var(--ease); }
@@ -436,6 +462,9 @@
436
462
  .ctas { justify-content: flex-start; }
437
463
  .figures { grid-template-columns: minmax(0, 1fr); }
438
464
  .head-row, .agent, .closing { grid-template-columns: minmax(0, 1fr); }
465
+ .ledger-row { grid-template-columns: minmax(0, 1fr); gap: 4px; }
466
+ .ledger-head { display: none; }
467
+ .ledger-row .them::before { content: "Typical list: "; }
439
468
  .tools li { grid-template-columns: minmax(0, 1fr); gap: 2px; }
440
469
  .transcript p { grid-template-columns: minmax(0, 1fr); gap: 4px; }
441
470
  .head-row .intro-text { justify-self: start; }
@@ -503,7 +532,7 @@
503
532
  <div class="wrap">
504
533
  <a class="brand" href="#top" aria-label="proxy-scraper, back to top"><svg class="mark" aria-hidden="true"><use href="#mark"/></svg>proxy-scraper</a>
505
534
  <nav class="nav-links" aria-label="Sections">
506
- <a href="#list">The list</a><a href="#agents">AI agents</a><a href="#checks">How it's checked</a><a href="#runs">Last runs</a><a href="#use">Use it</a>
535
+ <a href="#list">The list</a><a href="#agents">AI agents</a><a href="#checks">How it's checked</a><a href="#why">Why this one</a><a href="#runs">Last runs</a><a href="#use">Use it</a>
507
536
  </nav>
508
537
  <div class="nav-actions">
509
538
  <button class="toggle" id="theme" type="button" aria-label="Switch to light theme">
@@ -666,6 +695,23 @@
666
695
  </div>
667
696
  </div>
668
697
  </section>
698
+ <section class="block wrap" id="why" aria-labelledby="why-title">
699
+ <div class="head-row">
700
+ <h2 class="h2" id="why-title">Why this list, not another one</h2>
701
+ <p class="intro-text">Most free proxy lists are a dump of addresses that answered at some point. This is what you'd want to know before you use one – and what this list measured in the last run.</p>
702
+ </div>
703
+ <div class="ledger" role="table" aria-label="What a typical free proxy list tells you, and what this one measures">
704
+ <div class="ledger-row ledger-head" role="row"><span role="columnheader">What you'd want to know</span><span role="columnheader">A typical free list</span><span role="columnheader">Here, in the last run</span></div>
705
+ <div class="ledger-row" role="row"><span class="q" role="rowheader">Is it a proxy at all?</span><span class="them" role="cell">That the port answered once</span><span class="us" role="cell">A real HTTP/SOCKS handshake and a page fetched through it: <b data-live="total">–</b> passed in the last run</span></div>
706
+ <div class="ledger-row" role="row"><span class="q" role="rowheader">Is it a honeypot?</span><span class="them" role="cell">Often not checked</span><span class="us" role="cell">The exit IP is checked on two different sites and has to match both times</span></div>
707
+ <div class="ledger-row" role="row"><span class="q" role="rowheader">Does it change the pages you load?</span><span class="them" role="cell">Not checked</span><span class="us" role="cell">A known page has to arrive byte for byte. Proxies that inject scripts or ads are dropped</span></div>
708
+ <div class="ledger-row" role="row"><span class="q" role="rowheader">Does HTTPS work through it?</span><span class="them" role="cell">Sometimes a flag in the list</span><span class="us" role="cell">A verified TLS handshake through the tunnel: <b data-live="https">–</b> of them</span></div>
709
+ <div class="ledger-row" role="row"><span class="q" role="rowheader">Will it still be there tomorrow?</span><span class="them" role="cell">Unknown</span><span class="us" role="cell">Every proxy's week of hourly checks. <b data-live="stable">–</b> were on the list for 90&nbsp;%+ of the week</span></div>
710
+ <div class="ledger-row" role="row"><span class="q" role="rowheader">Does Google let it through?</span><span class="them" role="cell">Unknown</span><span class="us" role="cell">Tried on Google, Reddit, Amazon, Instagram and TikTok: <b data-live="google">–</b> got through to Google</span></div>
711
+ <div class="ledger-row" role="row"><span class="q" role="rowheader">Is it fast enough for real pages?</span><span class="them" role="cell">A latency number, if any</span><span class="us" role="cell">100&nbsp;KB downloaded through every HTTPS-capable one: half of them manage <b data-live="speed">–</b> or more</span></div>
712
+ <div class="ledger-row" role="row"><span class="q" role="rowheader">How fresh is it?</span><span class="them" role="cell">Scraped once, rarely checked again</span><span class="us" role="cell">Checked again every hour, the last run <b data-live="ago">–</b></span></div>
713
+ </div>
714
+ </section>
669
715
 
670
716
  <section class="block wrap" id="runs" aria-labelledby="runs-title">
671
717
  <div class="head-row">
@@ -703,6 +749,9 @@
703
749
  proxy-scraper --want 50 --https-only</pre><button class="icon-btn" type="button" data-copy-pre aria-label="Copy the install commands"><svg aria-hidden="true"><use href="#i-copy"/></svg></button></div></div>
704
750
  <div class="panel"><h3>Recheck from your network</h3><p>Starts from this list and checks it again from where you are. Takes about 30 seconds, then serves a rotating proxy on port 8899.</p>
705
751
  <div class="cmd"><pre>proxy-scraper --recheck live --serve</pre><button class="icon-btn" type="button" data-copy-pre aria-label="Copy the recheck command"><svg aria-hidden="true"><use href="#i-copy"/></svg></button></div></div>
752
+ <div class="panel bookmarklet"><h3>One click, on any site</h3><p>Drag this button to your bookmarks bar. Clicking it copies a proxy that worked in 90&nbsp;%+ of this week's checks – on whatever page you're on, nothing to install.</p>
753
+ <a class="btn primary" id="bookmarklet" href="javascript:(async()=>{const%20b=&quot;https://maximilianfeix.github.io/proxy-scraper/&quot;,g=async%20f=>{const%20r=await%20fetch(b+f,{cache:&quot;no-cache&quot;});if(!r.ok)throw%200;return(await%20r.text()).split(&quot;%5Cn&quot;).filter(l=>/%5E[a-z0-9]+:%5C/%5C/%5CS+%24/.test(l))};try{let%20l=await%20g(&quot;stable.txt&quot;);if(!l.length)l=(await%20g(&quot;https.txt&quot;)).slice(0,30);if(!l.length)throw%200;const%20p=l[Math.floor(Math.random()*l.length)];try{await%20navigator.clipboard.writeText(p);alert(&quot;Copied%20a%20working%20proxy:%5Cn&quot;+p)}catch(e){prompt(&quot;A%20working%20proxy%20%E2%80%93%20copy%20it:&quot;,p)}}catch(e){alert(&quot;proxy-scraper:%20the%20list%20could%20not%20be%20loaded&quot;)}})()" title="Drag me to your bookmarks bar">Get a proxy</a>
754
+ <p class="note">Or right here: <button class="link-btn" type="button" id="pick-one">copy a reliable one now</button>.</p></div>
706
755
  </div>
707
756
  <div class="panel"><h3>Plain lists</h3><p>One proxy per line, fastest first, updated with every run.</p><div class="raw" id="raw"></div></div>
708
757
  </div>
@@ -741,6 +790,20 @@ proxy-scraper --want 50 --https-only</pre><button class="icon-btn" type="button"
741
790
  }</pre><button class="icon-btn" type="button" data-copy-pre aria-label="Copy the example"><svg aria-hidden="true"><use href="#i-copy"/></svg></button></div>
742
791
  </div>
743
792
  </section>
793
+ <section class="block wrap faq" id="faq" aria-labelledby="faq-title">
794
+ <div class="head-row">
795
+ <h2 class="h2" id="faq-title">Questions people ask</h2>
796
+ <p class="intro-text">Short answers. The <a href="https://github.com/maximilianfeix/proxy-scraper#faq">README</a> has more.</p>
797
+ </div>
798
+ <div class="faq-list">
799
+ <details class="faq-item"><summary><h3>Are free proxies safe to use?</h3></summary><p>They're run by strangers, so treat them as untrusted. Anything that isn't encrypted can be read by whoever runs the proxy – never send passwords or personal data through one. Every proxy on this list has passed a content check (it didn't change a known page), and the HTTPS ones tunnel TLS that's verified end to end, so an https:// site can't be read or altered. That makes them fine for scraping public pages or seeing a site from another country, not for your bank.</p></details>
800
+ <details class="faq-item"><summary><h3>Why do most free proxies not work?</h3></summary><p>They don't live long. Of the proxies that drop off this list again, most are gone by the next hourly check, and only a few last a whole day – the weekly report tracks it. Many lists never re-check, so a large share of what they hold is long dead, and some of what still answers are honeypots or proxies that inject ads.</p></details>
801
+ <details class="faq-item"><summary><h3>How often is the list updated?</h3></summary><p>Every hour. GitHub Actions collects proxies from 700+ public lists, checks them and publishes whatever passed. This page loads the new list by itself when a run lands.</p></details>
802
+ <details class="faq-item"><summary><h3>Can I use it in my own projects?</h3></summary><p>Yes. The code is open source under the MIT license, and the lists are free to use, including in commercial projects. The proxies themselves belong to other people, so stick to the terms of the sites you visit through them.</p></details>
803
+ <details class="faq-item"><summary><h3>How do I use it from Python or an AI agent?</h3></summary><p>From Python: pip install proxy-scraper-cli, then live_proxies() returns the checked list with filters for country, protocol, uptime and the sites a proxy gets through to. AI agents like Claude Code or Cursor can use the MCP server, which gives them working proxies and can load pages through them.</p></details>
804
+ <details class="faq-item"><summary><h3>A proxy from the list doesn't work for me – why?</h3></summary><p>It worked from GitHub's servers in the last run, but free proxies come and go, and some block certain networks. proxy-scraper --recheck live checks the list again from your own connection in about 30 seconds and keeps what works from there.</p></details>
805
+ </div>
806
+ </section>
744
807
  <section class="block wrap closing" aria-labelledby="star-title">
745
808
  <div>
746
809
  <h2 class="h2" id="star-title">Saved you an afternoon?</h2>
@@ -1024,6 +1087,15 @@ proxy-scraper --want 50 --https-only</pre><button class="icon-btn" type="button"
1024
1087
  }
1025
1088
  loadView();
1026
1089
  $("#share").onclick = () => copy(new URL(viewUrl(), location.href).href, "Link to this view copied");
1090
+ // the same as the bookmarklet, without the round trip: a random proxy from the reliable ones in the list
1091
+ $("#pick-one").onclick = () => {
1092
+ const pool = state.rows.filter(r => (r.uptime_7d || 0) >= 90); // exactly what stable.txt holds
1093
+ const from = pool.length ? pool : state.rows.filter(r => r.https).slice(0, 30);
1094
+ if (!from.length) return toast("The list isn't loaded yet");
1095
+ const p = from[Math.floor(Math.random() * from.length)];
1096
+ copy(p.url, `Copied ${p.url}`);
1097
+ };
1098
+ $("#bookmarklet").onclick = (e) => { e.preventDefault(); toast("Drag it to your bookmarks bar – it works on any site from there"); };
1027
1099
  // chips, the country and sorting let the rows settle in; typing and the slider stay instant
1028
1100
  const morph = (fn) => {
1029
1101
  fn();
@@ -1295,6 +1367,10 @@ proxy-scraper --want 50 --https-only</pre><button class="icon-btn" type="button"
1295
1367
  sieve(rows, st.total);
1296
1368
  started = true;
1297
1369
  $("#final-n").textContent = fmt(st.total);
1370
+ const live = { total: fmt(st.total), https: fmt(st.https), stable: fmt(rows.filter(r => (r.uptime_7d || 0) >= 90).length),
1371
+ google: st.sites && st.sites.google != null ? fmt(st.sites.google) : "–",
1372
+ speed: st.median_speed_kbps ? `${fmt(st.median_speed_kbps)} KB/s` : "–", ago: ago(st.updated) };
1373
+ $$("[data-live]").forEach((el) => { el.textContent = live[el.dataset.live] ?? "–"; });
1298
1374
  const dc = st.datacenter != null ? ` ${pct(st.datacenter, st.total)} % exit from a datacenter` : "";
1299
1375
  const bl = st.blocklisted != null ? `${dc ? "," : ""} ${pct(st.blocklisted, st.total)} % from an IP on a spam blocklist` : "";
1300
1376
  $("#step5").textContent = `HTTPS with verified TLS, anonymity, country, provider and spam blocklists. This time ${fmt(st.https)} tunnel HTTPS and ${fmt(st.elite)} are elite.${dc}${bl}${dc || bl ? "." : ""}`;
@@ -12,15 +12,12 @@ from __future__ import annotations
12
12
 
13
13
  import argparse
14
14
  import asyncio
15
- import json
16
- import socket
17
15
  import sys
18
16
  from dataclasses import dataclass
19
17
  from pathlib import Path
20
18
  from typing import Awaitable, Callable, Dict, List, Optional, Tuple
21
19
 
22
- from .netio import USER_AGENT
23
- from .paths import atomic_write
20
+ from .runsteps import PROBE_ERRORS, Tunnels, browser_get, https_rows, load_rows, resolve_ipv4, run_limited, save_rows
24
21
 
25
22
  TIMEOUT = 10.0
26
23
  CONCURRENCY = 300
@@ -113,20 +110,16 @@ def page_complete(head: bytes, body: int, tail: bytes) -> bool:
113
110
 
114
111
  def real_probe(timeout: float = TIMEOUT) -> Probe:
115
112
  """Tunnel to the site through the proxy with verified TLS, send a browser-like GET, judge the answer."""
116
- from .checker import Checker
117
-
118
- checker = Checker(judge_ip="127.0.0.1", own_ips=(), timeout=timeout, connect_timeout=timeout / 2)
113
+ tunnels = Tunnels(timeout)
119
114
 
120
115
  async def probe(row: dict, site: Site, ip: str) -> Optional[bool]:
121
116
  async def go() -> Optional[bool]:
122
- opened = await checker._tls_tunnel(row["ptype"], row["proxy"], site.host, socket.inet_aton(ip), 443)
117
+ opened = await tunnels.open(row, site.host, ip)
123
118
  if opened is None:
124
119
  return None
125
120
  reader, writer = opened
126
121
  try:
127
- writer.write(f"GET {site.path} HTTP/1.1\r\nHost: {site.host}\r\nUser-Agent: {USER_AGENT}\r\n"
128
- "Accept: text/html,application/xhtml+xml,*/*;q=0.8\r\nAccept-Language: en-US,en;q=0.8\r\n"
129
- "Connection: close\r\n\r\n".encode())
122
+ writer.write(browser_get(site.host, site.path))
130
123
  await writer.drain()
131
124
  head = await reader.readuntil(b"\r\n\r\n")
132
125
  status, location = parse_head(head)
@@ -145,46 +138,37 @@ def real_probe(timeout: float = TIMEOUT) -> Probe:
145
138
  return site.verdict(status, location, body)
146
139
  try:
147
140
  return await asyncio.wait_for(go(), probe_timeout(site, timeout))
148
- except (OSError, asyncio.TimeoutError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, ValueError):
141
+ except PROBE_ERRORS:
149
142
  return None
150
143
  return probe
151
144
 
152
145
 
153
- def _resolve(host: str) -> str:
154
- return socket.getaddrinfo(host, 443, family=socket.AF_INET)[0][4][0]
155
-
156
-
157
- async def fill_run(run_dir: Path, probe: Optional[Probe] = None, resolve: Callable[[str], str] = _resolve,
146
+ async def fill_run(run_dir: Path, probe: Optional[Probe] = None, resolve: Callable[[str], str] = resolve_ipv4,
158
147
  concurrency: int = CONCURRENCY) -> Dict[str, int]:
159
148
  """Probe every HTTPS-capable proxy of a run against every site; writes "sites" into its proxies.json.
160
149
  Returns how many proxies got through to each site."""
161
150
  probe = probe or real_probe()
162
- path = run_dir / "proxies.json"
163
- rows: List[dict] = json.loads(path.read_text(encoding="utf-8"))
151
+ path, rows = load_rows(run_dir)
164
152
  ips = {}
165
153
  for site in SITES:
166
154
  try:
167
155
  ips[site.name] = resolve(site.host)
168
156
  except OSError:
169
157
  continue # this site can't be looked up right now – the others still can
170
- slots = asyncio.Semaphore(concurrency)
171
158
 
172
- async def one(row: dict, site: Site) -> None:
173
- async with slots:
159
+ def job(row: dict, site: Site):
160
+ async def run() -> None:
174
161
  verdict = await probe(row, site, ips[site.name])
175
- if verdict is not None:
176
- row["sites"][site.name] = verdict
162
+ if verdict is not None:
163
+ row["sites"][site.name] = verdict
164
+ return run
177
165
 
178
- jobs = []
179
166
  for row in rows:
180
167
  row["sites"] = {}
181
- if row.get("https") is True:
182
- jobs += [one(row, site) for site in SITES if site.name in ips]
183
- await asyncio.gather(*jobs)
168
+ await run_limited((job(row, site) for row in https_rows(rows) for site in SITES if site.name in ips), concurrency)
184
169
  for row in rows: # always in the same order, whichever answer came first
185
170
  row["sites"] = {s.name: row["sites"][s.name] for s in SITES if s.name in row["sites"]}
186
- # in one go: if the step gets killed (it has a timeout), the publish step still finds the old file intact
187
- atomic_write(path, json.dumps(rows, indent=1, ensure_ascii=False))
171
+ save_rows(path, rows)
188
172
  return {s.name: sum(1 for r in rows if r["sites"].get(s.name)) for s in SITES}
189
173
 
190
174
 
@@ -13,16 +13,13 @@ from __future__ import annotations
13
13
 
14
14
  import argparse
15
15
  import asyncio
16
- import json
17
- import socket
18
16
  import statistics
19
17
  import sys
20
18
  import time
21
19
  from pathlib import Path
22
20
  from typing import Awaitable, Callable, List, Optional
23
21
 
24
- from .netio import USER_AGENT
25
- from .paths import atomic_write
22
+ from .runsteps import PROBE_ERRORS, Tunnels, browser_get, https_rows, load_rows, resolve_ipv4, run_limited, save_rows
26
23
 
27
24
  HOST = "speed.cloudflare.com"
28
25
  BYTES = 100_000
@@ -42,9 +39,7 @@ def kbps(received: int, seconds: float) -> Optional[int]:
42
39
 
43
40
 
44
41
  def real_probe(deadline: float = DEADLINE) -> Probe:
45
- from .checker import Checker
46
-
47
- checker = Checker(judge_ip="127.0.0.1", own_ips=(), timeout=deadline, connect_timeout=deadline / 2)
42
+ tunnels = Tunnels(deadline)
48
43
 
49
44
  async def probe(row: dict, ip: str) -> Optional[int]:
50
45
  end = time.monotonic() + deadline
@@ -53,7 +48,7 @@ def real_probe(deadline: float = DEADLINE) -> Probe:
53
48
  return await asyncio.wait_for(coro, max(0.01, end - time.monotonic()))
54
49
 
55
50
  try:
56
- opened = await within(checker._tls_tunnel(row["ptype"], row["proxy"], HOST, socket.inet_aton(ip), 443))
51
+ opened = await within(tunnels.open(row, HOST, ip))
57
52
  if opened is None:
58
53
  return None
59
54
  reader, writer = opened
@@ -61,8 +56,7 @@ def real_probe(deadline: float = DEADLINE) -> Probe:
61
56
  # the clock runs from the request to the last byte: a start at the first body byte would miss
62
57
  # whatever already sat in the buffer with the head, and fast proxies would come out absurdly fast
63
58
  sent = time.monotonic()
64
- writer.write(f"GET {PATH} HTTP/1.1\r\nHost: {HOST}\r\nUser-Agent: {USER_AGENT}\r\n"
65
- "Accept-Encoding: identity\r\nConnection: close\r\n\r\n".encode())
59
+ writer.write(browser_get(HOST, PATH, "Accept-Encoding: identity"))
66
60
  await within(writer.drain())
67
61
  head = await within(reader.readuntil(b"\r\n\r\n"))
68
62
  if b" 200 " not in head.split(b"\r\n", 1)[0] + b" ":
@@ -79,38 +73,33 @@ def real_probe(deadline: float = DEADLINE) -> Probe:
79
73
  return kbps(received, time.monotonic() - sent)
80
74
  finally:
81
75
  writer.close()
82
- except (OSError, asyncio.TimeoutError, asyncio.IncompleteReadError, asyncio.LimitOverrunError, ValueError):
76
+ except PROBE_ERRORS:
83
77
  return None
84
78
  return probe
85
79
 
86
80
 
87
- def _resolve(host: str) -> str:
88
- return socket.getaddrinfo(host, 443, family=socket.AF_INET)[0][4][0]
89
-
90
-
91
- async def fill_run(run_dir: Path, probe: Optional[Probe] = None, resolve: Callable[[str], str] = _resolve,
81
+ async def fill_run(run_dir: Path, probe: Optional[Probe] = None, resolve: Callable[[str], str] = resolve_ipv4,
92
82
  concurrency: int = CONCURRENCY) -> Optional[int]:
93
83
  """Measure every HTTPS-capable proxy of a run, write speed_kbps into its proxies.json -> the median."""
94
84
  probe = probe or real_probe()
95
- path = run_dir / "proxies.json"
96
- rows: List[dict] = json.loads(path.read_text(encoding="utf-8"))
85
+ path, rows = load_rows(run_dir)
97
86
  try:
98
87
  ip = resolve(HOST)
99
88
  except OSError:
100
89
  return None
101
- slots = asyncio.Semaphore(concurrency)
102
90
 
103
- async def one(row: dict) -> None:
104
- async with slots:
91
+ def job(row: dict):
92
+ async def run() -> None:
105
93
  try: # the probe keeps its own deadline – this only catches one that doesn't
106
94
  value = await asyncio.wait_for(probe(row, ip), DEADLINE * 1.5)
107
95
  except asyncio.TimeoutError:
108
96
  value = None
109
- if value:
110
- row["speed_kbps"] = value
97
+ if value:
98
+ row["speed_kbps"] = value
99
+ return run
111
100
 
112
- await asyncio.gather(*(one(r) for r in rows if r.get("https") is True))
113
- atomic_write(path, json.dumps(rows, indent=1, ensure_ascii=False))
101
+ await run_limited((job(r) for r in https_rows(rows)), concurrency)
102
+ save_rows(path, rows)
114
103
  speeds = [r["speed_kbps"] for r in rows if r.get("speed_kbps")]
115
104
  return round(statistics.median(speeds)) if speeds else None
116
105
 
@@ -119,7 +119,8 @@ def test_publish_writes_a_list_per_site_and_counts(tmp_path):
119
119
  assert (out / "works-with" / "reddit.txt").read_text() == ""
120
120
  assert (out / "works-with" / "amazon.txt").read_text() == ""
121
121
  stats = json.loads((out / "stats.json").read_text())
122
- assert stats["sites"] == {"google": 1, "reddit": 0, "amazon": 0, "instagram": 0, "tiktok": 0}
122
+ # amazon, instagram and tiktok weren't checked at all in this run: that's "not measured", not zero
123
+ assert stats["sites"] == {"google": 1, "reddit": 0, "amazon": None, "instagram": None, "tiktok": None}
123
124
  assert (out / "works-with" / "tiktok.txt").read_text() == ""
124
125
  import csv
125
126
  with (out / "proxies.csv").open(newline="", encoding="utf-8") as fh: