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