proxy-scraper-cli 1.15.0__tar.gz → 1.17.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 (118) hide show
  1. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/PKG-INFO +13 -1
  2. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/README.md +12 -0
  3. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxy_scraper_cli.egg-info/PKG-INFO +13 -1
  4. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxy_scraper_cli.egg-info/SOURCES.txt +1 -0
  5. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/__init__.py +2 -2
  6. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/agent.py +5 -1
  7. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/api.py +165 -3
  8. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/pages.py +116 -9
  9. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_pages.py +42 -0
  10. proxy_scraper_cli-1.17.0/tests/test_rotator.py +129 -0
  11. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/LICENSE +0 -0
  12. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxy_scraper_cli.egg-info/dependency_links.txt +0 -0
  13. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxy_scraper_cli.egg-info/entry_points.txt +0 -0
  14. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxy_scraper_cli.egg-info/requires.txt +0 -0
  15. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxy_scraper_cli.egg-info/top_level.txt +0 -0
  16. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/__main__.py +0 -0
  17. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/action.py +0 -0
  18. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/app.py +0 -0
  19. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/asndb.py +0 -0
  20. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/blocklist.py +0 -0
  21. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/charts.py +0 -0
  22. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/checker.py +0 -0
  23. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/cli.py +0 -0
  24. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/compat.py +0 -0
  25. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/completion.py +0 -0
  26. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/exporters.py +0 -0
  27. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/fetchcache.py +0 -0
  28. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/geo.py +0 -0
  29. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/geodb.py +0 -0
  30. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/handshake.py +0 -0
  31. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/history.py +0 -0
  32. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/judges.py +0 -0
  33. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/mcp_entry.py +0 -0
  34. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/mcp_server.py +0 -0
  35. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/netio.py +0 -0
  36. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/options.py +0 -0
  37. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/output.py +0 -0
  38. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/parsing.py +0 -0
  39. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/paths.py +0 -0
  40. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/pipeline.py +0 -0
  41. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/preferences.py +0 -0
  42. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/proxypages.py +0 -0
  43. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/publish.py +0 -0
  44. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/report.py +0 -0
  45. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/runsteps.py +0 -0
  46. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/server/__init__.py +0 -0
  47. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/server/core.py +0 -0
  48. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/server/http.py +0 -0
  49. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/server/pool.py +0 -0
  50. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/server/socks.py +0 -0
  51. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/server/status.py +0 -0
  52. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/server/upstream.py +0 -0
  53. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/site/apple-touch-icon.png +0 -0
  54. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/site/googleaac1161b7853c5b5.html +0 -0
  55. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/site/index.html +0 -0
  56. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/site/logo.png +0 -0
  57. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/site/og.png +0 -0
  58. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/sites.py +0 -0
  59. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/sources.json +0 -0
  60. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/sources.py +0 -0
  61. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/speed.py +0 -0
  62. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/targets.py +0 -0
  63. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/ui/__init__.py +0 -0
  64. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/ui/dashboard.py +0 -0
  65. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/ui/keys.py +0 -0
  66. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/ui/report.py +0 -0
  67. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/ui/serve.py +0 -0
  68. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/ui/widgets.py +0 -0
  69. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/proxyscraper/ui/wizard.py +0 -0
  70. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/pyproject.toml +0 -0
  71. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/setup.cfg +0 -0
  72. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_action.py +0 -0
  73. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_agent.py +0 -0
  74. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_api.py +0 -0
  75. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_app.py +0 -0
  76. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_asndb.py +0 -0
  77. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_auth.py +0 -0
  78. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_blocklist.py +0 -0
  79. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_charts.py +0 -0
  80. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_checker.py +0 -0
  81. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_compat.py +0 -0
  82. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_completion.py +0 -0
  83. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_discovery_growth.py +0 -0
  84. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_exporters.py +0 -0
  85. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_fetchcache.py +0 -0
  86. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_geodb.py +0 -0
  87. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_judges.py +0 -0
  88. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_learning.py +0 -0
  89. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_live_api.py +0 -0
  90. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_mcp_server.py +0 -0
  91. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_mine_sources.py +0 -0
  92. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_options.py +0 -0
  93. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_output_stdout.py +0 -0
  94. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_packaging.py +0 -0
  95. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_parsing.py +0 -0
  96. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_parsing_fuzz.py +0 -0
  97. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_paths.py +0 -0
  98. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_pick.py +0 -0
  99. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_pipeline.py +0 -0
  100. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_proxy_pages.py +0 -0
  101. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_proxy_sources.py +0 -0
  102. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_publish.py +0 -0
  103. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_report.py +0 -0
  104. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_robustness.py +0 -0
  105. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_serve_password.py +0 -0
  106. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_serve_refill.py +0 -0
  107. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_serve_v2.py +0 -0
  108. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_server.py +0 -0
  109. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_server_closed_writer.py +0 -0
  110. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_server_fixes.py +0 -0
  111. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_sites.py +0 -0
  112. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_sources_json.py +0 -0
  113. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_speed.py +0 -0
  114. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_tamper.py +0 -0
  115. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_targets.py +0 -0
  116. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_ui.py +0 -0
  117. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.0}/tests/test_uptime.py +0 -0
  118. {proxy_scraper_cli-1.15.0 → proxy_scraper_cli-1.17.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.15.0
3
+ Version: 1.17.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
@@ -444,6 +444,18 @@ r = requests.get("https://api.ipify.org", proxies={"https": next(pool)}, timeout
444
444
 
445
445
  Same filters as `find_proxies`, plus `min_uptime`, `works_on` (`["google"]`, `"reddit"`, `"amazon"`, `"instagram"`, `"tiktok"`) and `limit`. Every result also has `uptime_24h`, `uptime_7d`, `first_seen`, `up_for_hours` and `sites`.
446
446
 
447
+ Or let the library do the retrying: `ProxyRotator` loads a URL through the list and moves on to the next proxy when one fails – HTTPS only through proxies with verified TLS.
448
+
449
+ ```python
450
+ from proxyscraper import ProxyRotator
451
+
452
+ with ProxyRotator(country="DE") as rotator:
453
+ r = rotator.get("https://httpbin.org/ip")
454
+ print(r.status, r.text, "via", r.via)
455
+ ```
456
+
457
+ Usually a few seconds; when many proxies in a row are down it can take a minute (`timeout=` is per attempt). There's `aget()` with `async with` for asyncio code.
458
+
447
459
  <a id="proxy-server"></a>
448
460
 
449
461
  ## Rotating proxy server
@@ -403,6 +403,18 @@ r = requests.get("https://api.ipify.org", proxies={"https": next(pool)}, timeout
403
403
 
404
404
  Same filters as `find_proxies`, plus `min_uptime`, `works_on` (`["google"]`, `"reddit"`, `"amazon"`, `"instagram"`, `"tiktok"`) and `limit`. Every result also has `uptime_24h`, `uptime_7d`, `first_seen`, `up_for_hours` and `sites`.
405
405
 
406
+ Or let the library do the retrying: `ProxyRotator` loads a URL through the list and moves on to the next proxy when one fails – HTTPS only through proxies with verified TLS.
407
+
408
+ ```python
409
+ from proxyscraper import ProxyRotator
410
+
411
+ with ProxyRotator(country="DE") as rotator:
412
+ r = rotator.get("https://httpbin.org/ip")
413
+ print(r.status, r.text, "via", r.via)
414
+ ```
415
+
416
+ Usually a few seconds; when many proxies in a row are down it can take a minute (`timeout=` is per attempt). There's `aget()` with `async with` for asyncio code.
417
+
406
418
  <a id="proxy-server"></a>
407
419
 
408
420
  ## Rotating proxy server
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: proxy-scraper-cli
3
- Version: 1.15.0
3
+ Version: 1.17.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
@@ -444,6 +444,18 @@ r = requests.get("https://api.ipify.org", proxies={"https": next(pool)}, timeout
444
444
 
445
445
  Same filters as `find_proxies`, plus `min_uptime`, `works_on` (`["google"]`, `"reddit"`, `"amazon"`, `"instagram"`, `"tiktok"`) and `limit`. Every result also has `uptime_24h`, `uptime_7d`, `first_seen`, `up_for_hours` and `sites`.
446
446
 
447
+ Or let the library do the retrying: `ProxyRotator` loads a URL through the list and moves on to the next proxy when one fails – HTTPS only through proxies with verified TLS.
448
+
449
+ ```python
450
+ from proxyscraper import ProxyRotator
451
+
452
+ with ProxyRotator(country="DE") as rotator:
453
+ r = rotator.get("https://httpbin.org/ip")
454
+ print(r.status, r.text, "via", r.via)
455
+ ```
456
+
457
+ Usually a few seconds; when many proxies in a row are down it can take a minute (`timeout=` is per attempt). There's `aget()` with `async with` for asyncio code.
458
+
447
459
  <a id="proxy-server"></a>
448
460
 
449
461
  ## Rotating proxy server
@@ -99,6 +99,7 @@ tests/test_proxy_sources.py
99
99
  tests/test_publish.py
100
100
  tests/test_report.py
101
101
  tests/test_robustness.py
102
+ tests/test_rotator.py
102
103
  tests/test_serve_password.py
103
104
  tests/test_serve_refill.py
104
105
  tests/test_serve_v2.py
@@ -1,13 +1,13 @@
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.15.0"
4
+ __version__ = "1.17.0"
5
5
 
6
6
 
7
7
  def __getattr__(name):
8
8
  # load the Python API only when needed – "import proxyscraper" should stay light (the CLI doesn't need it)
9
9
  if name in ("find_proxies", "find_proxies_async", "check_proxies", "check_proxies_async", "live_proxies",
10
- "live_proxies_async", "LiveProxy"):
10
+ "live_proxies_async", "LiveProxy", "ProxyRotator", "ProxyResponse"):
11
11
  from . import api
12
12
  return getattr(api, name)
13
13
  raise AttributeError(f"module 'proxyscraper' has no attribute {name!r}")
@@ -502,7 +502,8 @@ class PageFetcher:
502
502
  return live
503
503
 
504
504
  async def fetch(self, url: str, protocol: str = "any", country: str = "", max_chars: int = 20000,
505
- raw_html: bool = False, progress: Optional[Progress] = None, tick: float = 5.0) -> dict:
505
+ raw_html: bool = False, progress: Optional[Progress] = None, tick: float = 5.0,
506
+ with_body: bool = False) -> dict:
506
507
  url = await asyncio.to_thread(self.check, url) # may look the name up
507
508
  protocol = _check_protocol(protocol)
508
509
  countries = normalize_countries([country] if country else [])
@@ -566,6 +567,9 @@ class PageFetcher:
566
567
  "text": text[:max_chars] if is_text else "",
567
568
  "truncated": truncated,
568
569
  "note": None if is_text else f"Binary content ({content_type}) isn't returned as text.",
570
+ # for the Python API (ProxyRotator): the bytes and every header line as they came
571
+ **({"body": body[:MAX_PAGE_BYTES], "header_items": list(headers.items()) if headers else [],
572
+ "body_truncated": len(body) > MAX_PAGE_BYTES} if with_body else {}),
569
573
  }
570
574
 
571
575
  async def _download(self, url: str, user: str, port: int, progress: Optional[Progress], tick: float,
@@ -25,13 +25,14 @@ from __future__ import annotations
25
25
 
26
26
  import asyncio
27
27
  import contextlib
28
+ import email.message
28
29
  import io
29
30
  import os
30
31
  import re
31
32
  import tempfile
32
33
  import threading
33
34
  from dataclasses import dataclass, field
34
- from typing import Dict, Iterable, List, Optional
35
+ from typing import Dict, Iterable, List, Optional, Tuple
35
36
 
36
37
  from rich.console import Console
37
38
 
@@ -41,8 +42,18 @@ from .parsing import PROXY_TYPES
41
42
  from .targets import parse_target
42
43
  from .ui import widgets
43
44
 
44
- __all__ = ["CheckResult", "LiveProxy", "check_proxies", "check_proxies_async", "find_proxies", "find_proxies_async",
45
- "live_proxies", "live_proxies_async"]
45
+ __all__ = [
46
+ "CheckResult",
47
+ "LiveProxy",
48
+ "ProxyResponse",
49
+ "ProxyRotator",
50
+ "check_proxies",
51
+ "check_proxies_async",
52
+ "find_proxies",
53
+ "find_proxies_async",
54
+ "live_proxies",
55
+ "live_proxies_async",
56
+ ]
46
57
 
47
58
 
48
59
  def _types(types: Iterable[str]) -> List[str]:
@@ -237,3 +248,154 @@ async def live_proxies_async(*, types: Iterable[str] = PROXY_TYPES, countries: I
237
248
  def live_proxies(**kwargs) -> List[LiveProxy]:
238
249
  """Like live_proxies_async, just synchronous (starts its own event loop)."""
239
250
  return asyncio.run(live_proxies_async(**kwargs))
251
+
252
+
253
+ # --------------------------------------------------------------------------- fetching through the list
254
+
255
+ class _Headers(dict):
256
+ """Response headers: looked up without regard to case, the first value per name as a plain dict entry,
257
+ and every value – Set-Cookie comes more than once – through get_all()."""
258
+
259
+ def __init__(self, items: Iterable[Tuple[str, str]] = ()):
260
+ self._items = list(items)
261
+ super().__init__()
262
+ for name, value in self._items:
263
+ super().setdefault(name.lower(), value)
264
+
265
+ def __getitem__(self, name: str) -> str:
266
+ return super().__getitem__(name.lower())
267
+
268
+ def get(self, name: str, default=None):
269
+ return super().get(name.lower(), default)
270
+
271
+ def __contains__(self, name: object) -> bool:
272
+ return isinstance(name, str) and super().__contains__(name.lower())
273
+
274
+ def get_all(self, name: str) -> List[str]:
275
+ return [value for key, value in self._items if key.lower() == name.lower()]
276
+
277
+
278
+ @dataclass
279
+ class ProxyResponse:
280
+ """What ProxyRotator.get() returns."""
281
+ status: int
282
+ url: str
283
+ final_url: str # after redirects
284
+ headers: _Headers # case doesn't matter; headers.get_all("Set-Cookie") for repeated ones
285
+ content: bytes
286
+ via: Optional[str] # the proxy that delivered it, e.g. socks5://1.2.3.4:1080
287
+ proxy_country: Optional[str]
288
+ elapsed_ms: int
289
+ truncated: bool = False # the body was longer than 2 MB and is cut off
290
+
291
+ @property
292
+ def text(self) -> str:
293
+ message = email.message.Message()
294
+ message["Content-Type"] = self.headers.get("Content-Type", "")
295
+ charset = message.get_content_charset() or "utf-8" # handles charset="…" and any case, like a browser
296
+ try:
297
+ return self.content.decode(charset, errors="replace")
298
+ except LookupError: # a charset Python doesn't know
299
+ return self.content.decode("utf-8", errors="replace")
300
+
301
+ def raise_for_status(self) -> None:
302
+ if self.status >= 400:
303
+ raise ConnectionError(f"HTTP {self.status} from {self.final_url} (via {self.via})")
304
+ if self.truncated:
305
+ raise ConnectionError(f"the body from {self.final_url} was cut off at 2 MB")
306
+
307
+
308
+ class ProxyRotator:
309
+ """Loads URLs through proxies from the hourly list and switches to the next one when a proxy fails –
310
+ the loop every scraper writes by hand. HTTPS only through proxies with verified TLS.
311
+
312
+ with ProxyRotator(country="DE") as rotator:
313
+ print(rotator.get("https://api.ipify.org").text)
314
+
315
+ Behind it runs the same rotating server as `proxy-scraper --serve`, on 127.0.0.1 with a random password.
316
+ Use it either sync (`get()`, safe from several threads) or async (`aget()` with `async with`) – not both on
317
+ the same rotator. Private and local addresses are refused. Bodies are cut at 2 MB (`truncated`).
318
+ """
319
+
320
+ def __init__(self, *, protocol: str = "any", country: str = "", timeout: float = 20.0,
321
+ _source=None, _allow_private: bool = False):
322
+ self.protocol, self.country, self.timeout = protocol, country, timeout
323
+ self._source, self._allow_private = _source, _allow_private
324
+ self._fetcher = None
325
+ self._mode: Optional[str] = None # "sync" or "async" – their proxy servers live on different loops
326
+ self._loop: Optional[asyncio.AbstractEventLoop] = None # the sync API's own loop, in a thread
327
+ self._thread: Optional[threading.Thread] = None
328
+ self._start_lock = threading.Lock()
329
+
330
+ def _use(self, mode: str) -> None:
331
+ if self._mode is None:
332
+ self._mode = mode
333
+ elif self._mode != mode:
334
+ raise RuntimeError(f"this ProxyRotator is used {self._mode}hronously – make a second one for "
335
+ f"{mode} code (sync: get()/close(), async: aget()/async with)")
336
+
337
+ def _make_fetcher(self):
338
+ from .agent import LiveSource, PageFetcher
339
+
340
+ return PageFetcher(self._source or LiveSource(fetch=_live_fetch), allow_private=self._allow_private,
341
+ timeout=self.timeout)
342
+
343
+ async def aget(self, url: str) -> ProxyResponse:
344
+ self._use("async")
345
+ return await self._aget(url)
346
+
347
+ async def _aget(self, url: str) -> ProxyResponse:
348
+ from .agent import AgentError
349
+
350
+ if self._fetcher is None:
351
+ self._fetcher = self._make_fetcher()
352
+ try:
353
+ url = await asyncio.to_thread(self._fetcher.check, url)
354
+ except AgentError as e:
355
+ raise ValueError(str(e)) from None
356
+ try:
357
+ page = await self._fetcher.fetch(url, self.protocol, self.country, max_chars=0, raw_html=True,
358
+ with_body=True)
359
+ except AgentError as e:
360
+ raise ConnectionError(str(e)) from None
361
+ return ProxyResponse(status=page["status"], url=url, final_url=page["final_url"],
362
+ headers=_Headers(page["header_items"]), content=page["body"], via=page["via"],
363
+ proxy_country=page["proxy_country"], elapsed_ms=page["elapsed_ms"],
364
+ truncated=page["body_truncated"])
365
+
366
+ async def aclose(self) -> None:
367
+ if self._fetcher is not None:
368
+ await self._fetcher.close()
369
+ self._fetcher = None
370
+
371
+ async def __aenter__(self) -> "ProxyRotator":
372
+ return self
373
+
374
+ async def __aexit__(self, *exc) -> None:
375
+ await self.aclose()
376
+
377
+ # sync: one event loop in a background thread keeps the proxy server alive between calls
378
+ def get(self, url: str) -> ProxyResponse:
379
+ with self._start_lock: # several threads calling get() at once still share one loop
380
+ self._use("sync")
381
+ if self._loop is None:
382
+ self._loop = asyncio.new_event_loop()
383
+ self._thread = threading.Thread(target=self._loop.run_forever, name="proxy-rotator", daemon=True)
384
+ self._thread.start()
385
+ return asyncio.run_coroutine_threadsafe(self._aget(url), self._loop).result()
386
+
387
+ def close(self) -> None:
388
+ with self._start_lock:
389
+ if self._loop is None:
390
+ return
391
+ asyncio.run_coroutine_threadsafe(self.aclose(), self._loop).result()
392
+ self._loop.call_soon_threadsafe(self._loop.stop)
393
+ self._thread.join(timeout=5)
394
+ self._loop.close()
395
+ self._thread = None
396
+
397
+ def __enter__(self) -> "ProxyRotator":
398
+ return self
399
+
400
+ def __exit__(self, *exc) -> None:
401
+ self.close()
@@ -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>
@@ -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
@@ -0,0 +1,129 @@
1
+ """ProxyRotator: get a URL through the live list, switching proxies until one delivers."""
2
+
3
+ import asyncio
4
+
5
+ import pytest
6
+
7
+ from proxyscraper import ProxyRotator, agent
8
+ from tests.fakes import http_forward_proxy, serve, target_server
9
+ from tests.test_agent import STATS, row
10
+
11
+
12
+ def source_for(rows):
13
+ live = agent.LiveList(rows=rows, stats=STATS, loaded_at=0.0)
14
+
15
+ class Source:
16
+ async def get(self):
17
+ return live
18
+ return Source()
19
+
20
+
21
+ async def local_setup():
22
+ target_srv, target_port = await serve(target_server)
23
+ proxy_srv, proxy_port = await serve(http_forward_proxy)
24
+ rows = [row(1, proxy="127.0.0.1:1", url="http://127.0.0.1:1", latency=10), # nothing listens there
25
+ row(2, proxy=f"127.0.0.1:{proxy_port}", url=f"http://127.0.0.1:{proxy_port}", latency=500)]
26
+ return target_srv, proxy_srv, target_port, rows
27
+
28
+
29
+ def test_async_get_switches_past_a_dead_proxy():
30
+ async def go():
31
+ target_srv, proxy_srv, port, rows = await local_setup()
32
+ try:
33
+ async with ProxyRotator(_source=source_for(rows), _allow_private=True, timeout=5) as rotator:
34
+ return await rotator.aget(f"http://127.0.0.1:{port}/ok")
35
+ finally:
36
+ target_srv.close()
37
+ proxy_srv.close()
38
+ response = asyncio.run(go())
39
+ assert response.status == 200 and response.content == b"ok" and response.text == "ok"
40
+ assert response.via.endswith(":" + response.via.rsplit(":", 1)[1]) and "127.0.0.1:1" not in response.via
41
+
42
+
43
+ def test_sync_get_works_from_plain_code():
44
+ # the sync version keeps its own event loop in a thread: the proxy server lives across calls
45
+ loop = asyncio.new_event_loop()
46
+ target_srv, proxy_srv, port, rows = loop.run_until_complete(local_setup())
47
+ import threading
48
+ thread = threading.Thread(target=loop.run_forever, daemon=True)
49
+ thread.start()
50
+ try:
51
+ with ProxyRotator(_source=source_for(rows), _allow_private=True, timeout=5) as rotator:
52
+ first = rotator.get(f"http://127.0.0.1:{port}/ok")
53
+ second = rotator.get(f"http://127.0.0.1:{port}/ok")
54
+ assert first.status == second.status == 200
55
+ finally:
56
+ loop.call_soon_threadsafe(target_srv.close)
57
+ loop.call_soon_threadsafe(proxy_srv.close)
58
+ loop.call_soon_threadsafe(loop.stop)
59
+
60
+
61
+ def test_nothing_matching_is_a_clear_error():
62
+ async def go():
63
+ async with ProxyRotator(country="JP", _source=source_for([row(1)]), _allow_private=True) as rotator:
64
+ await rotator.aget("http://127.0.0.1:9/")
65
+ with pytest.raises(ConnectionError, match="JP"):
66
+ asyncio.run(go())
67
+
68
+
69
+ def test_private_addresses_are_refused_by_default():
70
+ async def go():
71
+ async with ProxyRotator(_source=source_for([row(1)])) as rotator:
72
+ await rotator.aget("http://127.0.0.1/")
73
+ with pytest.raises(ValueError):
74
+ asyncio.run(go())
75
+
76
+
77
+ def response(headers, content=b"x", truncated=False):
78
+ from proxyscraper.api import ProxyResponse, _Headers
79
+ return ProxyResponse(200, "u", "u", _Headers(headers), content, "http://p:1", None, 1, truncated=truncated)
80
+
81
+
82
+ def test_headers_ignore_case_and_keep_every_value():
83
+ r = response([("content-type", "text/html"), ("Set-Cookie", "a=1"), ("set-cookie", "b=2")])
84
+ assert r.headers["Content-Type"] == "text/html" and r.headers.get("CONTENT-TYPE") == "text/html"
85
+ assert r.headers.get_all("Set-Cookie") == ["a=1", "b=2"]
86
+
87
+
88
+ def test_the_charset_is_read_like_a_browser_would():
89
+ latin = "Grüße".encode("latin-1")
90
+ assert response([("Content-Type", 'text/html; charset="ISO-8859-1"')], latin).text == "Grüße"
91
+ assert response([("Content-Type", "text/html; Charset=latin-1")], latin).text == "Grüße"
92
+ assert response([("Content-Type", "text/html; charset=nonsense")], "ä".encode()).text == "ä"
93
+
94
+
95
+ def test_a_cut_off_body_says_so():
96
+ r = response([], b"x" * 10, truncated=True)
97
+ assert r.truncated
98
+ with pytest.raises(ConnectionError, match="cut off"):
99
+ r.raise_for_status()
100
+
101
+
102
+ def test_sync_get_from_many_threads_shares_one_loop(monkeypatch):
103
+ import concurrent.futures as cf
104
+
105
+ from proxyscraper import api
106
+
107
+ async def fake_aget(self, url):
108
+ return response([], url.encode())
109
+ monkeypatch.setattr(api.ProxyRotator, "_aget", fake_aget)
110
+ rotator = api.ProxyRotator()
111
+ with cf.ThreadPoolExecutor(8) as pool:
112
+ results = list(pool.map(rotator.get, [f"http://x/{i}" for i in range(32)]))
113
+ assert [r.content for r in results] == [f"http://x/{i}".encode() for i in range(32)]
114
+ loops = rotator._loop
115
+ rotator.close()
116
+ assert loops.is_closed()
117
+
118
+
119
+ def test_mixing_sync_and_async_is_refused(monkeypatch):
120
+ from proxyscraper import api
121
+
122
+ async def fake_aget(self, url):
123
+ return response([])
124
+ monkeypatch.setattr(api.ProxyRotator, "_aget", fake_aget)
125
+ rotator = api.ProxyRotator()
126
+ rotator.get("http://x/")
127
+ with pytest.raises(RuntimeError, match="async"):
128
+ asyncio.run(rotator.aget("http://x/"))
129
+ rotator.close()