proxy-scraper-cli 1.16.0__tar.gz → 1.18.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 (119) hide show
  1. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/PKG-INFO +7 -2
  2. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/README.md +6 -1
  3. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxy_scraper_cli.egg-info/PKG-INFO +7 -2
  4. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxy_scraper_cli.egg-info/SOURCES.txt +1 -0
  5. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/__init__.py +1 -1
  6. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/action.py +4 -2
  7. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/agent.py +2 -1
  8. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/api.py +9 -5
  9. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/checker.py +1 -0
  10. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/cli.py +6 -3
  11. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/pages.py +116 -9
  12. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/server/pool.py +37 -5
  13. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_action.py +13 -4
  14. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_pages.py +42 -0
  15. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_pick.py +7 -0
  16. proxy_scraper_cli-1.18.0/tests/test_speed_ranking.py +58 -0
  17. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/LICENSE +0 -0
  18. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxy_scraper_cli.egg-info/dependency_links.txt +0 -0
  19. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxy_scraper_cli.egg-info/entry_points.txt +0 -0
  20. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxy_scraper_cli.egg-info/requires.txt +0 -0
  21. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxy_scraper_cli.egg-info/top_level.txt +0 -0
  22. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/__main__.py +0 -0
  23. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/app.py +0 -0
  24. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/asndb.py +0 -0
  25. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/blocklist.py +0 -0
  26. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/charts.py +0 -0
  27. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/compat.py +0 -0
  28. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/completion.py +0 -0
  29. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/exporters.py +0 -0
  30. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/fetchcache.py +0 -0
  31. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/geo.py +0 -0
  32. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/geodb.py +0 -0
  33. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/handshake.py +0 -0
  34. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/history.py +0 -0
  35. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/judges.py +0 -0
  36. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/mcp_entry.py +0 -0
  37. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/mcp_server.py +0 -0
  38. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/netio.py +0 -0
  39. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/options.py +0 -0
  40. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/output.py +0 -0
  41. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/parsing.py +0 -0
  42. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/paths.py +0 -0
  43. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/pipeline.py +0 -0
  44. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/preferences.py +0 -0
  45. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/proxypages.py +0 -0
  46. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/publish.py +0 -0
  47. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/report.py +0 -0
  48. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/runsteps.py +0 -0
  49. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/server/__init__.py +0 -0
  50. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/server/core.py +0 -0
  51. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/server/http.py +0 -0
  52. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/server/socks.py +0 -0
  53. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/server/status.py +0 -0
  54. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/server/upstream.py +0 -0
  55. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/site/apple-touch-icon.png +0 -0
  56. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/site/googleaac1161b7853c5b5.html +0 -0
  57. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/site/index.html +0 -0
  58. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/site/logo.png +0 -0
  59. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/site/og.png +0 -0
  60. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/sites.py +0 -0
  61. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/sources.json +0 -0
  62. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/sources.py +0 -0
  63. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/speed.py +0 -0
  64. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/targets.py +0 -0
  65. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/ui/__init__.py +0 -0
  66. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/ui/dashboard.py +0 -0
  67. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/ui/keys.py +0 -0
  68. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/ui/report.py +0 -0
  69. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/ui/serve.py +0 -0
  70. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/ui/widgets.py +0 -0
  71. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/proxyscraper/ui/wizard.py +0 -0
  72. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/pyproject.toml +0 -0
  73. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/setup.cfg +0 -0
  74. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_agent.py +0 -0
  75. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_api.py +0 -0
  76. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_app.py +0 -0
  77. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_asndb.py +0 -0
  78. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_auth.py +0 -0
  79. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_blocklist.py +0 -0
  80. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_charts.py +0 -0
  81. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_checker.py +0 -0
  82. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_compat.py +0 -0
  83. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_completion.py +0 -0
  84. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_discovery_growth.py +0 -0
  85. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_exporters.py +0 -0
  86. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_fetchcache.py +0 -0
  87. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_geodb.py +0 -0
  88. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_judges.py +0 -0
  89. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_learning.py +0 -0
  90. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_live_api.py +0 -0
  91. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_mcp_server.py +0 -0
  92. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_mine_sources.py +0 -0
  93. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_options.py +0 -0
  94. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_output_stdout.py +0 -0
  95. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_packaging.py +0 -0
  96. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_parsing.py +0 -0
  97. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_parsing_fuzz.py +0 -0
  98. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_paths.py +0 -0
  99. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_pipeline.py +0 -0
  100. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_proxy_pages.py +0 -0
  101. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_proxy_sources.py +0 -0
  102. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_publish.py +0 -0
  103. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_report.py +0 -0
  104. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_robustness.py +0 -0
  105. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_rotator.py +0 -0
  106. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_serve_password.py +0 -0
  107. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_serve_refill.py +0 -0
  108. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_serve_v2.py +0 -0
  109. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_server.py +0 -0
  110. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_server_closed_writer.py +0 -0
  111. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_server_fixes.py +0 -0
  112. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_sites.py +0 -0
  113. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_sources_json.py +0 -0
  114. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_speed.py +0 -0
  115. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_tamper.py +0 -0
  116. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_targets.py +0 -0
  117. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_ui.py +0 -0
  118. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.0}/tests/test_uptime.py +0 -0
  119. {proxy_scraper_cli-1.16.0 → proxy_scraper_cli-1.18.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.16.0
3
+ Version: 1.18.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
@@ -704,6 +704,7 @@ pip download requests # git, pip, npm & co. now go through the pool
704
704
  types: socks5
705
705
  https: true
706
706
  min-uptime: 90 # on the list for 90 %+ of the week
707
+ min-speed: 100 # optional: downloaded 100+ KB/s in the last check
707
708
  works-on: google # optional: google, reddit, amazon
708
709
  recheck: true # optional: check them again from the runner
709
710
  - run: curl -x "${{ steps.proxies.outputs.proxy }}" https://api.ipify.org
@@ -716,8 +717,12 @@ pip download requests # git, pip, npm & co. now go through the pool
716
717
  ```bash
717
718
  curl -x "$(proxy-scraper --pick --https-only --min-uptime 90)" https://api.ipify.org
718
719
  proxy-scraper --pick 10 --country DE --works-on google > de.txt
720
+ proxy-scraper --pick 5 --min-speed 200 # only ones that downloaded 200+ KB/s
719
721
  ```
720
722
 
723
+ "Fastest first" means the first answer plus the download speed measured in the last check – in a test with real
724
+ pages, the 25 fastest by download loaded four times as many pages as the 25 quickest to answer a tiny request.
725
+
721
726
  **Without installing anything** – straight from the live list
722
727
 
723
728
  ```bash
@@ -762,7 +767,7 @@ curl.exe -x (Get-Content "$run\all.txt" -TotalCount 1) http://api.ipify.org
762
767
  | `--discover` | search GitHub for new sources right now |
763
768
  | `--no-cache` | download every list again (unchanged ones are normally skipped via ETag) |
764
769
  | `--list-sources [N]` | show the source ranking (add `--json` for scripts: url, status, hit rate, checks, last change) |
765
- | `--pick [N]` | print N proxies (default 1) from the hourly checked list and exit – no scan, same filters, plus `--min-uptime PERCENT` and `--works-on google,reddit,…` |
770
+ | `--pick [N]` | print N proxies (default 1) from the hourly checked list and exit – no scan, same filters, plus `--min-uptime PERCENT`, `--min-speed KBPS` and `--works-on google,reddit,…` |
766
771
  | `--serve-host ADDR` | where the proxy server listens (default `127.0.0.1`; `0.0.0.0` for Docker, with a warning) |
767
772
  | `--serve-password SECRET` | clients must send this password in the proxy login; better set `PROXY_SCRAPER_SERVE_PASSWORD` |
768
773
  | `--rotate STRATEGY` · `--sticky SEC` | how the proxy server picks proxies, see [above](#proxy-server) |
@@ -663,6 +663,7 @@ pip download requests # git, pip, npm & co. now go through the pool
663
663
  types: socks5
664
664
  https: true
665
665
  min-uptime: 90 # on the list for 90 %+ of the week
666
+ min-speed: 100 # optional: downloaded 100+ KB/s in the last check
666
667
  works-on: google # optional: google, reddit, amazon
667
668
  recheck: true # optional: check them again from the runner
668
669
  - run: curl -x "${{ steps.proxies.outputs.proxy }}" https://api.ipify.org
@@ -675,8 +676,12 @@ pip download requests # git, pip, npm & co. now go through the pool
675
676
  ```bash
676
677
  curl -x "$(proxy-scraper --pick --https-only --min-uptime 90)" https://api.ipify.org
677
678
  proxy-scraper --pick 10 --country DE --works-on google > de.txt
679
+ proxy-scraper --pick 5 --min-speed 200 # only ones that downloaded 200+ KB/s
678
680
  ```
679
681
 
682
+ "Fastest first" means the first answer plus the download speed measured in the last check – in a test with real
683
+ pages, the 25 fastest by download loaded four times as many pages as the 25 quickest to answer a tiny request.
684
+
680
685
  **Without installing anything** – straight from the live list
681
686
 
682
687
  ```bash
@@ -721,7 +726,7 @@ curl.exe -x (Get-Content "$run\all.txt" -TotalCount 1) http://api.ipify.org
721
726
  | `--discover` | search GitHub for new sources right now |
722
727
  | `--no-cache` | download every list again (unchanged ones are normally skipped via ETag) |
723
728
  | `--list-sources [N]` | show the source ranking (add `--json` for scripts: url, status, hit rate, checks, last change) |
724
- | `--pick [N]` | print N proxies (default 1) from the hourly checked list and exit – no scan, same filters, plus `--min-uptime PERCENT` and `--works-on google,reddit,…` |
729
+ | `--pick [N]` | print N proxies (default 1) from the hourly checked list and exit – no scan, same filters, plus `--min-uptime PERCENT`, `--min-speed KBPS` and `--works-on google,reddit,…` |
725
730
  | `--serve-host ADDR` | where the proxy server listens (default `127.0.0.1`; `0.0.0.0` for Docker, with a warning) |
726
731
  | `--serve-password SECRET` | clients must send this password in the proxy login; better set `PROXY_SCRAPER_SERVE_PASSWORD` |
727
732
  | `--rotate STRATEGY` · `--sticky SEC` | how the proxy server picks proxies, see [above](#proxy-server) |
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxy-scraper-cli
3
- Version: 1.16.0
3
+ Version: 1.18.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
@@ -704,6 +704,7 @@ pip download requests # git, pip, npm & co. now go through the pool
704
704
  types: socks5
705
705
  https: true
706
706
  min-uptime: 90 # on the list for 90 %+ of the week
707
+ min-speed: 100 # optional: downloaded 100+ KB/s in the last check
707
708
  works-on: google # optional: google, reddit, amazon
708
709
  recheck: true # optional: check them again from the runner
709
710
  - run: curl -x "${{ steps.proxies.outputs.proxy }}" https://api.ipify.org
@@ -716,8 +717,12 @@ pip download requests # git, pip, npm & co. now go through the pool
716
717
  ```bash
717
718
  curl -x "$(proxy-scraper --pick --https-only --min-uptime 90)" https://api.ipify.org
718
719
  proxy-scraper --pick 10 --country DE --works-on google > de.txt
720
+ proxy-scraper --pick 5 --min-speed 200 # only ones that downloaded 200+ KB/s
719
721
  ```
720
722
 
723
+ "Fastest first" means the first answer plus the download speed measured in the last check – in a test with real
724
+ pages, the 25 fastest by download loaded four times as many pages as the 25 quickest to answer a tiny request.
725
+
721
726
  **Without installing anything** – straight from the live list
722
727
 
723
728
  ```bash
@@ -762,7 +767,7 @@ curl.exe -x (Get-Content "$run\all.txt" -TotalCount 1) http://api.ipify.org
762
767
  | `--discover` | search GitHub for new sources right now |
763
768
  | `--no-cache` | download every list again (unchanged ones are normally skipped via ETag) |
764
769
  | `--list-sources [N]` | show the source ranking (add `--json` for scripts: url, status, hit rate, checks, last change) |
765
- | `--pick [N]` | print N proxies (default 1) from the hourly checked list and exit – no scan, same filters, plus `--min-uptime PERCENT` and `--works-on google,reddit,…` |
770
+ | `--pick [N]` | print N proxies (default 1) from the hourly checked list and exit – no scan, same filters, plus `--min-uptime PERCENT`, `--min-speed KBPS` and `--works-on google,reddit,…` |
766
771
  | `--serve-host ADDR` | where the proxy server listens (default `127.0.0.1`; `0.0.0.0` for Docker, with a warning) |
767
772
  | `--serve-password SECRET` | clients must send this password in the proxy login; better set `PROXY_SCRAPER_SERVE_PASSWORD` |
768
773
  | `--rotate STRATEGY` · `--sticky SEC` | how the proxy server picks proxies, see [above](#proxy-server) |
@@ -109,6 +109,7 @@ tests/test_server_fixes.py
109
109
  tests/test_sites.py
110
110
  tests/test_sources_json.py
111
111
  tests/test_speed.py
112
+ tests/test_speed_ranking.py
112
113
  tests/test_tamper.py
113
114
  tests/test_targets.py
114
115
  tests/test_ui.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.16.0"
4
+ __version__ = "1.18.0"
5
5
 
6
6
 
7
7
  def __getattr__(name):
@@ -63,7 +63,7 @@ def main() -> int:
63
63
  filters = {"types": _list("PS_TYPES") or ["http", "socks4", "socks5"],
64
64
  "countries": os.environ.get("PS_COUNTRIES", "").strip(),
65
65
  "https": _flag("PS_HTTPS"), "min_uptime": _number("PS_MIN_UPTIME", 0),
66
- "works_on": _list("PS_WORKS_ON")}
66
+ "min_speed": _number("PS_MIN_SPEED", 0), "works_on": _list("PS_WORKS_ON")}
67
67
  # a recheck starts from all of them – the limit applies to what still works from here
68
68
  found = live_proxies(**filters, limit=0 if recheck else limit)
69
69
  except (InputError, ValueError) as e:
@@ -75,7 +75,9 @@ def main() -> int:
75
75
  urls = [p.url for p in found]
76
76
  if recheck and urls:
77
77
  print(f"Checking {len(urls)} proxies again from this runner …")
78
- urls = [r.url for r in sorted(check_proxies(urls, https=filters["https"]), key=lambda r: r.latency)]
78
+ # in the live list's order: it's ranked by page load, the recheck only knows the first answer
79
+ working = {r.url for r in check_proxies(urls, https=filters["https"])}
80
+ urls = [u for u in urls if u in working]
79
81
  if limit:
80
82
  urls = urls[:limit]
81
83
  # a file of its own per step: a job may use the action twice (socks5 in one step, http in the next)
@@ -429,7 +429,8 @@ def _as_result(r: dict) -> Optional[CheckResult]:
429
429
  return CheckResult(make_key(r["ptype"], r["proxy"]), r["ptype"], r["proxy"], int(r.get("latency") or 0),
430
430
  str(r.get("exit_ip") or ""), https=r.get("https"), anonymity=r.get("anonymity") or "",
431
431
  country=r.get("country") or "", asn=int(r.get("asn") or 0), org=r.get("org") or "",
432
- hosting=r.get("hosting"), blocklisted=r.get("blocklisted"))
432
+ hosting=r.get("hosting"), blocklisted=r.get("blocklisted"),
433
+ speed_kbps=r["speed_kbps"] if type(r.get("speed_kbps")) is int else None)
433
434
  except (KeyError, TypeError, ValueError):
434
435
  return None
435
436
 
@@ -204,8 +204,9 @@ async def live_proxies_async(*, types: Iterable[str] = PROXY_TYPES, countries: I
204
204
  anonymity: str = "", max_latency: int = 0, no_datacenter: bool = False,
205
205
  no_blocklisted: bool = False, min_uptime: int = 0, works_on: Iterable[str] = (),
206
206
  min_speed: int = 0, limit: int = 0) -> List[LiveProxy]:
207
- """The proxies from the hourly list that pass the filters, fastest first. Nothing is checked here: they
208
- worked from GitHub's servers in the last run (at most an hour ago). find_proxies checks from your network.
207
+ """The proxies from the hourly list that pass the filters, fastest first – by first answer plus measured
208
+ download speed. Nothing is checked here: they worked from GitHub's servers in the last run (at most an hour
209
+ ago). find_proxies checks from your network.
209
210
 
210
211
  Same filters as find_proxies, plus
211
212
  min_uptime only proxies listed in at least this share (percent) of the week's runs, e.g. 90
@@ -215,6 +216,7 @@ async def live_proxies_async(*, types: Iterable[str] = PROXY_TYPES, countries: I
215
216
  limit at most this many (0 = all)
216
217
  """
217
218
  from .agent import AgentError, LiveSource
219
+ from .server.pool import page_ms, typical_speed
218
220
  from .sites import SITE
219
221
 
220
222
  types = _types(types)
@@ -237,9 +239,11 @@ async def live_proxies_async(*, types: Iterable[str] = PROXY_TYPES, countries: I
237
239
  data = await LiveSource(fetch=_live_fetch).get()
238
240
  except AgentError as e:
239
241
  raise ConnectionError(str(e)) from None
240
- found = sorted((_live_proxy(r, data.run_hours) for r in data.rows
241
- if r.get("ptype") in types and isinstance(r.get("proxy"), str)),
242
- key=lambda p: p.latency)
242
+ found = [_live_proxy(r, data.run_hours) for r in data.rows
243
+ if r.get("ptype") in types and isinstance(r.get("proxy"), str)]
244
+ # fastest first – by how quickly a page comes through when the list has download speeds (#186)
245
+ typical = typical_speed(found)
246
+ found.sort(key=lambda p: page_ms(p, typical))
243
247
  found = [p for p in found if opts.filters.accepts(p) and (not min_uptime or (p.uptime_7d or 0) >= min_uptime)
244
248
  and all(p.sites.get(s) for s in works_on) and (not min_speed or (p.speed_kbps or 0) >= min_speed)]
245
249
  return found[:limit] if limit else found
@@ -93,6 +93,7 @@ class CheckResult:
93
93
  org: str = ""
94
94
  hosting: Optional[bool] = None # exit probably in a datacenter? None = unknown
95
95
  blocklisted: Optional[bool] = None # exit IP on the SpamCop blocklist? None = unknown
96
+ speed_kbps: Optional[int] = None # download speed from the live list (KiB/s), None = not measured
96
97
 
97
98
  @property
98
99
  def url(self) -> str:
@@ -71,7 +71,8 @@ def pick(args) -> int:
71
71
  found = live_proxies(types=args.types, countries=args.country or "", https=args.https_only,
72
72
  anonymity=args.anonymity or "", max_latency=args.max_latency,
73
73
  no_datacenter=args.no_datacenter, no_blocklisted=args.no_blocklisted,
74
- min_uptime=args.min_uptime, works_on=args.works_on, limit=args.pick)
74
+ min_uptime=args.min_uptime, works_on=args.works_on, min_speed=args.min_speed,
75
+ limit=args.pick)
75
76
  except (ConnectionError, ValueError) as e:
76
77
  print(f"proxy-scraper: {e}", file=sys.stderr)
77
78
  return 1
@@ -204,6 +205,8 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
204
205
  "one URL per line, and exit – e.g. curl -x \"$(proxy-scraper --pick --https-only)\" …")
205
206
  live.add_argument("--min-uptime", type=percent, default=0, metavar="PERCENT",
206
207
  help="with --pick: only proxies on the list in at least this share of the week's checks")
208
+ live.add_argument("--min-speed", type=positive_int, default=0, metavar="KBPS",
209
+ help="with --pick: only proxies that downloaded at least this many KB/s in the last check")
207
210
  live.add_argument("--works-on", type=site_list, default=[], metavar="SITES",
208
211
  help=f"with --pick: only proxies that got through to these sites in the last check "
209
212
  f"({', '.join(s.name for s in SITES)})")
@@ -251,8 +254,8 @@ def parse_args(argv: Optional[List[str]] = None) -> argparse.Namespace:
251
254
  args = p.parse_args(argv)
252
255
  if args.json and args.list_sources is None:
253
256
  p.error("--json only works together with --list-sources")
254
- if (args.min_uptime or args.works_on) and args.pick is None:
255
- p.error("--min-uptime and --works-on only work together with --pick")
257
+ if (args.min_uptime or args.works_on or args.min_speed) and args.pick is None:
258
+ p.error("--min-uptime, --min-speed and --works-on only work together with --pick")
256
259
  if args.pick is not None: # --pick exits right away: options for a scan or the server would be silently ignored
257
260
  scan_only = [flag for dest, flag in (("target", "--target"), ("serve", "--serve"), ("recheck", "--recheck"),
258
261
  ("want", "--want"), ("output", "-o/--output"), ("export", "--export"))
@@ -7,6 +7,8 @@ that answers exactly that, and every page links back to the full, filterable lis
7
7
 
8
8
  from __future__ import annotations
9
9
 
10
+ import json
11
+ import statistics
10
12
  from datetime import datetime
11
13
  from html import escape
12
14
  from pathlib import Path
@@ -100,6 +102,22 @@ footer { color: var(--muted); font-size: 14px; display: grid; gap: 6px; }
100
102
  """
101
103
 
102
104
 
105
+ PAGE_CSS = """
106
+ .wrap > * { min-width: 0; } /* grid items default to their content's width: the wide table would push the page */
107
+ .facts { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 150px), 1fr)); gap: 1px; margin: 0;
108
+ background: var(--line); border: 1px solid var(--line); border-radius: 16px; overflow: hidden; }
109
+ .facts div { background: var(--panel); padding: 16px 18px; }
110
+ .facts dt { font-size: 26px; font-weight: 600; letter-spacing: -.02em; font-variant-numeric: tabular-nums; }
111
+ .facts dd { margin: 2px 0 0; color: var(--muted); font-size: 14px; }
112
+ h2 { font-size: 24px; letter-spacing: -.02em; margin: 0 0 12px; font-weight: 600; }
113
+ .use pre { background: var(--panel); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px;
114
+ overflow-x: auto; font-size: 14px; margin: 0 0 10px; }
115
+ .note { color: var(--muted); font-size: 14px; }
116
+ .faq h3 { font-size: 18px; font-weight: 500; margin: 22px 0 6px; }
117
+ .faq p { margin: 0; color: var(--muted); max-width: 70ch; }
118
+ """
119
+
120
+
103
121
  def _list_query(path: str) -> str:
104
122
  """The same filter on the full list: the website reads its filters from the address."""
105
123
  kind = path.strip("/")
@@ -109,6 +127,61 @@ def _list_query(path: str) -> str:
109
127
  "elite": "?elite=1"}.get(kind, "")
110
128
 
111
129
 
130
+ SITE_NAMES = {"tiktok": "TikTok"}
131
+
132
+
133
+ def _pick_flags(path: str) -> str:
134
+ """The --pick filters that give the same selection as this page."""
135
+ kind = path.strip("/")
136
+ if kind.startswith("country/"):
137
+ return f"--country {kind.split('/')[1].upper()}"
138
+ return {"http": "--types http", "socks4": "--types socks4", "socks5": "--types socks5", "https": "--https-only",
139
+ "elite": "--anonymity elite"}.get(kind, "")
140
+
141
+
142
+ def _short(path: str) -> str:
143
+ """How a page's proxies are called in a question: "proxies in Germany", "SOCKS5 proxies"."""
144
+ kind = path.strip("/")
145
+ if kind.startswith("country/"):
146
+ return f"proxies in {_short_name(COUNTRIES.get(kind.split('/')[1].upper(), kind.split('/')[1].upper()))}"
147
+ return {"https": "HTTPS proxies", "elite": "elite proxies"}.get(kind, f"{kind.upper()} proxies")
148
+
149
+
150
+ def _facts(rows: List[dict]) -> Dict[str, object]:
151
+ speeds = [r["speed_kbps"] for r in rows if r.get("speed_kbps")]
152
+ return {"total": len(rows), "https": sum(1 for r in rows if r.get("https")),
153
+ "stable": sum(1 for r in rows if (r.get("uptime_7d") or 0) >= 90),
154
+ # None when none of them was tried: the site check only runs on HTTPS-capable ones, and not at all
155
+ # when it couldn't resolve the site – "0 get through" would blame proxies for a check that didn't run
156
+ "google": (sum(1 for r in rows if (r.get("sites") or {}).get("google"))
157
+ if any("google" in (r.get("sites") or {}) for r in rows) else None),
158
+ "speed": round(statistics.median(speeds)) if speeds else None}
159
+
160
+
161
+ def _faq(path: str, facts: Dict[str, object], when: str) -> List[Tuple[str, str]]:
162
+ short, flags = _short(path), _pick_flags(path)
163
+ if facts["google"] is None:
164
+ got = "These proxies weren't part of the last site check – it only tries the ones that tunnel HTTPS."
165
+ elif facts["google"]:
166
+ got = f"In the last check, {facts['google']:,} of them got through to Google search without a captcha."
167
+ else:
168
+ got = "In the last check, none of them got through to Google search without a captcha."
169
+ return [
170
+ (f"How many free {short} work right now?",
171
+ f"{facts['total']:,} passed every check in the last run ({when}): a real handshake, a honeypot check on two "
172
+ f"sites and a content check. {facts['https']:,} of them tunnel HTTPS with verified TLS, and "
173
+ f"{facts['stable']:,} were on the list in 90 % or more of this week's hourly checks. The list is checked "
174
+ "again every hour."),
175
+ (f"Which free {short} get through to Google?",
176
+ f"{got} The site check also tries Reddit, Amazon, Instagram and TikTok – the full list "
177
+ "on the website can be filtered by it."),
178
+ (f"How do I get working {short} in code or the terminal?",
179
+ f"Without installing anything, download proxies.txt from this page. With the tool: pipx install "
180
+ f"proxy-scraper-cli, then proxy-scraper --pick 5 {flags} prints five from the hourly list. From Python, "
181
+ "live_proxies() takes the same filters."),
182
+ ]
183
+
184
+
112
185
  def _render(path: str, title: str, what: str, rows: List[dict], updated: datetime, nav: str) -> str:
113
186
  depth = path.count("/")
114
187
  root = "../" * depth
@@ -120,19 +193,49 @@ def _render(path: str, title: str, what: str, rows: List[dict], updated: datetim
120
193
  else:
121
194
  lede = (f"No {what} passed every check in the last run ({when}). The list is checked again every hour, "
122
195
  "so look again later or try the full list.")
123
- body_rows = "\n".join(
124
- f"<tr><td class=\"mono\"><a href=\"{root}proxy/{escape(r['proxy'].replace(':', '-'))}/\">"
125
- f"{escape(r['proxy'])}</a></td><td>{escape(r['ptype'].upper())}</td>"
126
- f"<td>{escape(r.get('country') or '–')}</td><td>{r['latency']:,} ms</td>"
127
- f"<td>{'yes' if r.get('https') else 'no'}</td><td>{escape(r.get('anonymity') or '–')}</td>"
128
- f"<td>{escape((r.get('org') or '–')[:40])}</td></tr>"
129
- for r in rows[:ROWS_PER_PAGE])
196
+ def cells(r: dict) -> str:
197
+ through = ", ".join(SITE_NAMES.get(n, n.title()) for n, ok in (r.get("sites") or {}).items() if ok) or "–"
198
+ return (f"<tr><td class=\"mono\"><a href=\"{root}proxy/{escape(r['proxy'].replace(':', '-'))}/\">"
199
+ f"{escape(r['proxy'])}</a></td><td>{escape(r['ptype'].upper())}</td>"
200
+ f"<td>{escape(r.get('country') or '–')}</td><td>{r['latency']:,} ms</td>"
201
+ f"<td>{(str(r['speed_kbps']) + ' KB/s') if r.get('speed_kbps') else '–'}</td>"
202
+ f"<td>{(str(r['uptime_7d']) + ' %') if r.get('uptime_7d') is not None else '–'}</td>"
203
+ f"<td>{'yes' if r.get('https') else 'no'}</td><td>{escape(through)}</td>"
204
+ f"<td>{escape((r.get('org') or '–')[:40])}</td></tr>")
205
+ body_rows = "\n".join(cells(r) for r in rows[:ROWS_PER_PAGE])
130
206
  table = (f"<div class=\"table\"><table><caption>The {min(total, ROWS_PER_PAGE):,} fastest, as ip:port. "
131
207
  f"The download has all {total:,} as type://ip:port.</caption>"
132
208
  "<thead><tr><th scope=\"col\">Proxy</th><th scope=\"col\">Type</th><th scope=\"col\">Country</th>"
133
- "<th scope=\"col\">Latency</th><th scope=\"col\">HTTPS</th><th scope=\"col\">Anonymity</th>"
209
+ "<th scope=\"col\">Latency</th><th scope=\"col\">Speed</th><th scope=\"col\">Uptime</th>"
210
+ "<th scope=\"col\">HTTPS</th><th scope=\"col\">Gets through</th>"
134
211
  f"<th scope=\"col\">Provider</th></tr></thead><tbody>{body_rows}</tbody></table></div>"
135
212
  if total else "")
213
+ facts = _facts(rows)
214
+ facts_html = "" if not total else (
215
+ "<dl class=\"facts\">"
216
+ f"<div><dt>{facts['total']:,}</dt><dd>working right now</dd></div>"
217
+ f"<div><dt>{facts['https']:,}</dt><dd>tunnel HTTPS</dd></div>"
218
+ f"<div><dt>{facts['stable']:,}</dt><dd>on the list 90 %+ of the week</dd></div>"
219
+ + (f"<div><dt>{facts['google']:,}</dt><dd>get through to Google</dd></div>"
220
+ if facts["google"] is not None else "")
221
+ + (f"<div><dt>{facts['speed']:,} KB/s</dt><dd>median download speed</dd></div>" if facts["speed"] else "")
222
+ + "</dl>")
223
+ flags = _pick_flags(path)
224
+ # curl goes to an https:// address, so only proxies that can tunnel it
225
+ curl_flags = flags if "--https-only" in flags else f"{flags} --https-only".strip()
226
+ commands = (
227
+ "<section class=\"use\"><h2>In the terminal or in code</h2>"
228
+ f"<pre class=\"mono\">pipx install proxy-scraper-cli\nproxy-scraper --pick 5 {escape(flags)}\n"
229
+ f"curl -x \"$(proxy-scraper --pick {escape(curl_flags)})\" https://api.ipify.org</pre>"
230
+ "<p class=\"note\">--pick takes proxies from the same hourly list in about half a second. "
231
+ f"From Python: <code>live_proxies()</code>, see the <a href=\"{REPO_URL}#from-python\">README</a>."
232
+ "</p></section>")
233
+ faq = _faq(path, facts, when)
234
+ faq_html = "<section class=\"faq\"><h2>Questions</h2>" + "".join(
235
+ f"<h3>{escape(q)}</h3><p>{escape(a)}</p>" for q, a in faq) + "</section>"
236
+ faq_ld = json.dumps({"@context": "https://schema.org", "@type": "FAQPage", "mainEntity": [
237
+ {"@type": "Question", "name": q, "acceptedAnswer": {"@type": "Answer", "text": a}} for q, a in faq]},
238
+ ensure_ascii=False).replace("</", "<\\/")
136
239
  description = (f"{total:,} free {what}, checked every hour: real handshake, honeypot and content check. "
137
240
  "Download as text or filter the full list.")
138
241
  return f"""<!doctype html>
@@ -154,7 +257,8 @@ def _render(path: str, title: str, what: str, rows: List[dict], updated: datetim
154
257
  <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
155
258
  <link href="https://fonts.googleapis.com/css2?family=Geist:wght@400..700&family=Geist+Mono:wght@400;500&display=swap"
156
259
  rel="stylesheet">
157
- <style>{CSS}</style>
260
+ <style>{CSS}{PAGE_CSS}</style>
261
+ <script type="application/ld+json">{faq_ld}</script>
158
262
  </head>
159
263
  <body>
160
264
  <div class="wrap">
@@ -171,7 +275,10 @@ def _render(path: str, title: str, what: str, rows: List[dict], updated: datetim
171
275
  <a class="btn" href="{REPO_URL}">Check them from your network</a>
172
276
  </div>
173
277
  </main>
278
+ {facts_html}
174
279
  {table}
280
+ {commands}
281
+ {faq_html}
175
282
  {nav}
176
283
  <footer>
177
284
  <span>Free proxies are run by strangers. Never send passwords or personal data through them.</span>
@@ -4,12 +4,16 @@ Selection per connection in three steps:
4
4
  1. filters from the request (Selection): country, type – via the user name, e.g. "country-de-type-socks5"
5
5
  2. sticky: the same session (or with --sticky the same target site) keeps the same proxy for a while
6
6
  3. strategy for everything else: weighted (default), random, round-robin, fastest
7
+
8
+ "Fast" means how quickly a page comes through, not just the first answer: when the proxies come from the live list,
9
+ which measures a download per proxy every hour, that download counts too (#186).
7
10
  """
8
11
 
9
12
  from __future__ import annotations
10
13
 
11
14
  import random
12
15
  import re
16
+ import statistics
13
17
  import time
14
18
  from dataclasses import dataclass
15
19
  from typing import Callable, Dict, List, Optional, Set, Tuple
@@ -20,6 +24,25 @@ DISABLE_AFTER = 3 # this many failures in a row -> out of the rotation
20
24
  STRATEGIES = ("weighted", "random", "round-robin", "fastest")
21
25
  SESSION_SECONDS = 600 # how long a session (session-…) keeps its proxy when --sticky isn't set
22
26
  _TOKEN_RE = re.compile(r"(country|type|session)[-_]([A-Za-z0-9]+)")
27
+ PAGE_KB = 100 # roughly one page – the download the speed step measures
28
+ UNMEASURED_KBPS = 20 # tried in the speed step, no download got through: counted as slow (it passed the checks)
29
+
30
+
31
+ def page_ms(result, typical_kbps: Optional[float]) -> float:
32
+ """Roughly how long a page takes through this proxy: the first answer plus the download.
33
+
34
+ typical_kbps is the median measured speed of the set, None when it has no speeds at all (an own scan) – then
35
+ latency decides alone. The speed step only tries HTTPS-capable proxies: one of those without a speed didn't
36
+ get the download through, the others were never tried and count as typical."""
37
+ if not typical_kbps:
38
+ return result.latency
39
+ kbps = result.speed_kbps or (UNMEASURED_KBPS if result.https else typical_kbps)
40
+ return result.latency + PAGE_KB * 1000 / kbps
41
+
42
+
43
+ def typical_speed(results) -> Optional[float]:
44
+ speeds = [r.speed_kbps for r in results if r.speed_kbps]
45
+ return statistics.median(speeds) if speeds else None
23
46
 
24
47
 
25
48
  @dataclass(frozen=True)
@@ -57,12 +80,17 @@ class PoolEntry:
57
80
  fail_streak: int = 0
58
81
  active: int = 0
59
82
  disabled: bool = False
83
+ typical_kbps: Optional[float] = None # set by the pool: median measured speed of its proxies
84
+
85
+ @property
86
+ def cost(self) -> float:
87
+ return page_ms(self.result, self.typical_kbps)
60
88
 
61
89
  @property
62
90
  def weight(self) -> float:
63
91
  # prefer fast and proven ones, but give everyone a chance
64
92
  reliability = (self.ok + 1) / (self.ok + self.fail + 2)
65
- return reliability / (self.result.latency + 300)
93
+ return reliability / (self.cost + 300)
66
94
 
67
95
 
68
96
  class ProxyPool:
@@ -71,7 +99,8 @@ class ProxyPool:
71
99
  clock: Callable[[], float] = time.monotonic, strict_tls: bool = False):
72
100
  if strategy not in STRATEGIES:
73
101
  raise ValueError(f"unknown strategy {strategy!r} (possible: {', '.join(STRATEGIES)})")
74
- self.entries = [PoolEntry(r) for r in sorted(results, key=lambda r: r.latency)]
102
+ typical = typical_speed(results)
103
+ self.entries = sorted((PoolEntry(r, typical_kbps=typical) for r in results), key=lambda e: e.cost)
75
104
  self.rng = rng or random.Random()
76
105
  self.strategy = strategy
77
106
  self.sticky_seconds = sticky_seconds
@@ -135,8 +164,8 @@ class ProxyPool:
135
164
  # the fastest free one; if all are busy, the least busy one (then the faster one)
136
165
  idle = [e for e in candidates if not e.active]
137
166
  if idle:
138
- return min(idle, key=lambda e: (e.result.latency, -e.weight))
139
- return min(candidates, key=lambda e: (e.active, e.result.latency))
167
+ return min(idle, key=lambda e: (e.cost, -e.weight))
168
+ return min(candidates, key=lambda e: (e.active, e.cost))
140
169
  if self.strategy == "round-robin":
141
170
  ordered = sorted(candidates, key=lambda e: e.result.key)
142
171
  entry = ordered[self._next % len(ordered)]
@@ -186,7 +215,10 @@ class ProxyPool:
186
215
  for r in fresh.values():
187
216
  kept.append(PoolEntry(r))
188
217
  added += 1
189
- self.entries = sorted(kept, key=lambda e: e.result.latency)
218
+ typical = typical_speed([e.result for e in kept])
219
+ for entry in kept:
220
+ entry.typical_kbps = typical
221
+ self.entries = sorted(kept, key=lambda e: e.cost)
190
222
  return added
191
223
 
192
224
  def revive(self, entry: PoolEntry) -> None:
@@ -11,7 +11,7 @@ def fake(n, **extra):
11
11
  for i in range(n)]
12
12
 
13
13
 
14
- def run(monkeypatch, tmp_path, env, found=3, rechecked=None):
14
+ def run(monkeypatch, tmp_path, env, found=3, rechecked=None, recheck=None):
15
15
  calls = {}
16
16
 
17
17
  def live(**kwargs):
@@ -20,7 +20,7 @@ def run(monkeypatch, tmp_path, env, found=3, rechecked=None):
20
20
 
21
21
  def check(urls, **kwargs):
22
22
  calls["check"] = list(urls)
23
- return fake(found)[:rechecked]
23
+ return recheck(found) if recheck else fake(found)[:rechecked]
24
24
  monkeypatch.setattr(action, "live_proxies", live)
25
25
  monkeypatch.setattr(action, "check_proxies", check)
26
26
  out = tmp_path / "out.txt"
@@ -36,10 +36,10 @@ def run(monkeypatch, tmp_path, env, found=3, rechecked=None):
36
36
  def test_filters_reach_live_proxies_and_outputs_are_set(monkeypatch, tmp_path):
37
37
  code, out, calls = run(monkeypatch, tmp_path, {
38
38
  "PS_TYPES": "socks5, http", "PS_COUNTRIES": "de,nl", "PS_HTTPS": "true", "PS_MIN_UPTIME": "90",
39
- "PS_WORKS_ON": "google", "PS_LIMIT": "2"})
39
+ "PS_MIN_SPEED": "100", "PS_WORKS_ON": "google", "PS_LIMIT": "2"})
40
40
  assert code == 0
41
41
  assert calls["live"] == {"types": ["socks5", "http"], "countries": "de,nl", "https": True, "min_uptime": 90,
42
- "works_on": ["google"], "limit": 2}
42
+ "min_speed": 100, "works_on": ["google"], "limit": 2}
43
43
  assert out["proxy"] == "socks5://1.1.1.0:1080" and out["count"] == "2" # limit
44
44
  file = out["file"]
45
45
  assert file.startswith(str(tmp_path)) and Path(file).read_text().splitlines()[0] == "socks5://1.1.1.0:1080"
@@ -67,3 +67,12 @@ def test_nothing_found_fails_the_step_unless_allowed(monkeypatch, tmp_path, caps
67
67
  def test_bad_inputs_are_explained(monkeypatch, tmp_path, capsys):
68
68
  code, _, _ = run(monkeypatch, tmp_path, {"PS_LIMIT": "lots"})
69
69
  assert code == 1 and "limit" in capsys.readouterr().out
70
+
71
+
72
+
73
+ def test_a_recheck_keeps_the_live_lists_order(monkeypatch, tmp_path):
74
+ # the live list comes ranked by page load; the recheck's latencies say nothing about downloads
75
+ def slower_first(n): # from the runner, the list's first proxy happens to answer slowest
76
+ return [CheckResult(r.key, r.ptype, r.proxy, 500 - r.latency, r.exit_ip, True) for r in fake(n)]
77
+ _, out, _ = run(monkeypatch, tmp_path, {"PS_RECHECK": "true"}, recheck=slower_first)
78
+ assert out["proxy"] == "socks5://1.1.1.0:1080"
@@ -1,5 +1,6 @@
1
1
  """Static pages per protocol and country for search engines, plus the sitemap (#121)."""
2
2
 
3
+ import html
3
4
  from datetime import datetime, timezone
4
5
 
5
6
  from proxyscraper import pages
@@ -58,3 +59,44 @@ def test_filter_button_opens_the_list_with_the_same_filter(tmp_path):
58
59
  for path, query in expected.items():
59
60
  html = (tmp_path / path / "index.html").read_text(encoding="utf-8")
60
61
  assert f'href="{"../" * (path.count("/") + 1)}{query}#list">Filter the full list' in html
62
+
63
+
64
+ def test_a_country_page_has_the_numbers_commands_and_answers(tmp_path):
65
+ import json
66
+ import re
67
+ rows = [row(1, "socks5", https=True, uptime_7d=95, speed_kbps=400, sites={"google": True}),
68
+ row(2, "http", https=False, uptime_7d=20),
69
+ row(3, "http", country="US")]
70
+ pages.write_pages(rows, tmp_path, NOW)
71
+ de = (tmp_path / "country" / "de" / "index.html").read_text(encoding="utf-8")
72
+ facts = re.sub(r"\s+", " ", re.sub(r"<[^>]+>", " ", de))
73
+ assert "2 working right now" in facts and "1 tunnel HTTPS" in facts and "1 on the list 90 %+ of the week" in facts
74
+ assert "1 get through to Google" in facts and "400 KB/s" in facts
75
+ assert "proxy-scraper --pick 5 --country DE" in de and 'curl -x "$(proxy-scraper --pick --country DE' in de
76
+ assert ">Speed<" in de and ">Uptime<" in de
77
+ ld = [json.loads(b) for b in re.findall(r'<script type="application/ld\+json">(.*?)</script>', de, re.S)]
78
+ faq = next(d for d in ld if d["@type"] == "FAQPage")
79
+ assert any("Germany" in q["name"] for q in faq["mainEntity"])
80
+
81
+
82
+ def test_protocol_pages_pick_by_protocol(tmp_path):
83
+ pages.write_pages([row(1, "socks5", https=True)], tmp_path, NOW)
84
+ assert "proxy-scraper --pick 5 --types socks5" in (tmp_path / "socks5" / "index.html").read_text()
85
+ assert "proxy-scraper --pick 5 --https-only" in (tmp_path / "https" / "index.html").read_text()
86
+ assert "proxy-scraper --pick 5 --anonymity elite" in (tmp_path / "elite" / "index.html").read_text()
87
+
88
+
89
+ def test_an_empty_page_still_renders(tmp_path):
90
+ pages.write_pages([], tmp_path, NOW)
91
+ assert "No proxies in Germany" in (tmp_path / "country" / "de" / "index.html").read_text()
92
+
93
+
94
+ def test_the_median_speed_is_the_real_median():
95
+ assert pages._facts([row(1, speed_kbps=100), row(2, speed_kbps=400)])["speed"] == 250
96
+
97
+
98
+ def test_pages_dont_claim_google_blocked_proxies_that_were_never_checked(tmp_path):
99
+ pages.write_pages([row(1, "socks4"), row(2, "socks4")], tmp_path, NOW)
100
+ page = html.unescape((tmp_path / "socks4" / "index.html").read_text(encoding="utf-8"))
101
+ assert "none of them got through" not in page and "<dd>get through to Google</dd>" not in page
102
+ assert "weren't part of the last site check" in page
@@ -42,3 +42,10 @@ def test_options_for_a_scan_are_an_error_not_ignored(extra, capsys):
42
42
  with pytest.raises(SystemExit):
43
43
  cli.run(["--pick", *extra])
44
44
  assert "--pick" in capsys.readouterr().err
45
+
46
+
47
+ def test_min_speed_keeps_only_proxies_that_downloaded_that_fast(monkeypatch, capsys):
48
+ rows = [dict(ROWS[0], speed_kbps=300), dict(ROWS[2], speed_kbps=40), ROWS[1]]
49
+ monkeypatch.setattr(api, "_live_fetch", fetch_from({"proxies.json": rows, "stats.json": STATS}))
50
+ assert cli.run(["--pick", "5", "--min-speed", "100"]) == 0
51
+ assert capsys.readouterr().out == "socks5://1.1.1.1:1080\n"
@@ -0,0 +1,58 @@
1
+ """Download speed in the choice of proxy (#186): a quick answer to one tiny request says little about loading a
2
+ page – in a test on the live list, the 25 proxies with the best latency loaded 8 of 50 pages, the 25 that downloaded
3
+ fastest in the last run 33 of 50."""
4
+
5
+ import random
6
+
7
+ from proxyscraper import api, live_proxies
8
+ from proxyscraper.agent import _as_result
9
+ from proxyscraper.checker import CheckResult
10
+ from proxyscraper.server import ProxyPool
11
+ from proxyscraper.server.pool import page_ms
12
+
13
+ from .test_live_api import ROWS, STATS, fetch_from
14
+
15
+
16
+ def result(n, latency, speed=None, https=True):
17
+ proxy = f"10.0.0.{n}:80"
18
+ return CheckResult(f"http {proxy}", "http", proxy, latency, "9.9.9.9", https=https, speed_kbps=speed)
19
+
20
+
21
+ def test_a_proxy_that_downloads_fast_beats_one_that_only_answers_fast():
22
+ quick_ping, fast_download = result(1, 100), result(2, 400, speed=500)
23
+ assert page_ms(fast_download, typical_kbps=100) < page_ms(quick_ping, typical_kbps=100)
24
+ pool = ProxyPool([quick_ping, fast_download], strategy="fastest")
25
+ assert pool.pick(set()).result is fast_download
26
+
27
+
28
+ def test_the_weighted_choice_leans_to_the_fast_downloader():
29
+ pool = ProxyPool([result(1, 100), result(2, 400, speed=500)], rng=random.Random(1))
30
+ picks = [pool.pick(set()).result.proxy for _ in range(1000)]
31
+ assert picks.count("10.0.0.2:80") > 700
32
+
33
+
34
+ def test_without_any_speed_data_latency_decides_as_before():
35
+ pool = ProxyPool([result(1, 400), result(2, 100)], strategy="fastest")
36
+ assert pool.pick(set()).result.latency == 100
37
+
38
+
39
+ def test_the_live_list_brings_its_speed_into_the_pool():
40
+ row = {"ptype": "http", "proxy": "1.2.3.4:80", "latency": 100, "https": True, "speed_kbps": 250}
41
+ assert _as_result(row).speed_kbps == 250
42
+ assert _as_result({**row, "speed_kbps": "fast"}).speed_kbps is None
43
+
44
+
45
+ def test_live_proxies_put_the_measured_fast_ones_first(monkeypatch):
46
+ rows = [dict(r) for r in ROWS]
47
+ rows[0]["speed_kbps"] = 400 # socks5, latency 300 – the slowest to answer, the only one that downloaded
48
+ monkeypatch.setattr(api, "_live_fetch", fetch_from({"proxies.json": rows, "stats.json": STATS}))
49
+ # the socks4 one answers faster but got no download through in the speed step
50
+ assert [p.url for p in live_proxies(https=True)] == ["socks5://1.1.1.1:1080", "socks4://3.3.3.3:4145"]
51
+
52
+
53
+ def test_proxies_the_speed_step_never_tried_count_as_typical_not_slow():
54
+ # it only measures HTTPS-capable ones – a plain-HTTP-only proxy wasn't slow, it just wasn't measured
55
+ plain_only, measured = result(1, 50, https=False), result(2, 900, speed=100)
56
+ pool = ProxyPool([plain_only, measured], strategy="fastest")
57
+ assert pool.pick(set()).result is plain_only
58
+ assert pool.pick(set(), tls=True).result is measured