proxy-scraper-cli 1.7.1__py3-none-any.whl
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.7.1.dist-info/METADATA +794 -0
- proxy_scraper_cli-1.7.1.dist-info/RECORD +57 -0
- proxy_scraper_cli-1.7.1.dist-info/WHEEL +5 -0
- proxy_scraper_cli-1.7.1.dist-info/entry_points.txt +4 -0
- proxy_scraper_cli-1.7.1.dist-info/licenses/LICENSE +21 -0
- proxy_scraper_cli-1.7.1.dist-info/top_level.txt +1 -0
- proxyscraper/__init__.py +12 -0
- proxyscraper/__main__.py +7 -0
- proxyscraper/agent.py +635 -0
- proxyscraper/api.py +139 -0
- proxyscraper/app.py +595 -0
- proxyscraper/asndb.py +181 -0
- proxyscraper/blocklist.py +92 -0
- proxyscraper/checker.py +514 -0
- proxyscraper/cli.py +269 -0
- proxyscraper/compat.py +83 -0
- proxyscraper/completion.py +196 -0
- proxyscraper/exporters.py +118 -0
- proxyscraper/fetchcache.py +103 -0
- proxyscraper/geo.py +150 -0
- proxyscraper/geodb.py +143 -0
- proxyscraper/handshake.py +153 -0
- proxyscraper/history.py +106 -0
- proxyscraper/judges.py +159 -0
- proxyscraper/mcp_entry.py +34 -0
- proxyscraper/mcp_server.py +220 -0
- proxyscraper/netio.py +167 -0
- proxyscraper/options.py +274 -0
- proxyscraper/output.py +181 -0
- proxyscraper/pages.py +212 -0
- proxyscraper/parsing.py +192 -0
- proxyscraper/paths.py +55 -0
- proxyscraper/pipeline.py +434 -0
- proxyscraper/preferences.py +24 -0
- proxyscraper/publish.py +236 -0
- proxyscraper/server/__init__.py +41 -0
- proxyscraper/server/core.py +518 -0
- proxyscraper/server/http.py +164 -0
- proxyscraper/server/pool.py +195 -0
- proxyscraper/server/socks.py +65 -0
- proxyscraper/server/status.py +108 -0
- proxyscraper/server/upstream.py +119 -0
- proxyscraper/site/apple-touch-icon.png +0 -0
- proxyscraper/site/googleaac1161b7853c5b5.html +1 -0
- proxyscraper/site/index.html +934 -0
- proxyscraper/site/logo.png +0 -0
- proxyscraper/site/og.png +0 -0
- proxyscraper/sources.json +395 -0
- proxyscraper/sources.py +491 -0
- proxyscraper/targets.py +76 -0
- proxyscraper/ui/__init__.py +54 -0
- proxyscraper/ui/dashboard.py +344 -0
- proxyscraper/ui/keys.py +82 -0
- proxyscraper/ui/report.py +209 -0
- proxyscraper/ui/serve.py +142 -0
- proxyscraper/ui/widgets.py +250 -0
- proxyscraper/ui/wizard.py +595 -0
proxyscraper/parsing.py
ADDED
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
"""Finds proxies in any list format and normalizes them.
|
|
2
|
+
|
|
3
|
+
Proxies are kept everywhere as the key "type ip:port" (one string) – with more than a million
|
|
4
|
+
entries that saves a lot of memory compared to tuples.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import ipaddress
|
|
10
|
+
import re
|
|
11
|
+
from typing import Iterable, List, Optional, Set, Tuple
|
|
12
|
+
from urllib.parse import unquote
|
|
13
|
+
|
|
14
|
+
from .handshake import format_auth
|
|
15
|
+
|
|
16
|
+
PROXY_TYPES = ("http", "socks4", "socks5")
|
|
17
|
+
TYPE_ALIASES = {
|
|
18
|
+
"http": "http", "https": "http",
|
|
19
|
+
"socks4": "socks4", "socks4a": "socks4",
|
|
20
|
+
"socks5": "socks5", "socks5h": "socks5", "socks5a": "socks5",
|
|
21
|
+
"auto": "auto", "mixed": "auto",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
_IP = rb"((?:\d{1,3}\.){3}\d{1,3})"
|
|
25
|
+
# Schneller Standardfall: ip:port
|
|
26
|
+
PLAIN_RE = re.compile(rb"(?<![\d.])" + _IP + rb"\s*:\s*(\d{2,5})(?![\d.])")
|
|
27
|
+
# complete: additionally "ip port" and HTML tables like <td>1.2.3.4</td><td>8080</td>
|
|
28
|
+
PROXY_RE = re.compile(
|
|
29
|
+
rb"(?<![\d.])" + _IP + rb"(?:\s*:\s*|[ \t]+|\s*(?:<[^<>]{0,100}>\s*){1,6})(\d{2,5})(?![\d.])"
|
|
30
|
+
)
|
|
31
|
+
# JSON APIs like {"ip": "1.2.3.4", ..., "port": "8080"} – in both orders
|
|
32
|
+
JSON_IP_PORT_RE = re.compile(rb'"ip"\s*:\s*"' + _IP + rb'"[^{}]{0,500}?"port"\s*:\s*"?(\d{2,5})')
|
|
33
|
+
JSON_PORT_IP_RE = re.compile(rb'"port"\s*:\s*"?(\d{2,5})"?[^{}]{0,500}?"ip"\s*:\s*"' + _IP + rb'"')
|
|
34
|
+
# type://[user:pass@]ip:port – here the type is in the line itself
|
|
35
|
+
SCHEME_RE = re.compile(
|
|
36
|
+
rb"(?i)(?<![a-z0-9])(https?|socks4a?|socks5h?)://(?:([^\s@/]{1,100})@)?" + _IP + rb":(\d{2,5})(?!\d)"
|
|
37
|
+
)
|
|
38
|
+
SCHEME_TYPES = {k.encode(): v for k, v in TYPE_ALIASES.items() if v != "auto"}
|
|
39
|
+
|
|
40
|
+
RawCandidate = Tuple[str, bytes, bytes, bytes] # type, ip, port, credentials (b"" without)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
_SPACE_SEPARATED_RE = re.compile(rb"\d\.\d{1,3}[ \t]+\d{2,5}(?![\d.])")
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _needs_full_regex(data: bytes) -> bool:
|
|
47
|
+
"""Only space/HTML lists need the slower regex (sample from the start of the file)."""
|
|
48
|
+
sample = data[:8192]
|
|
49
|
+
return b"<" in sample or _SPACE_SEPARATED_RE.search(sample) is not None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def extract_candidates(data: bytes, default_type: str) -> Set[RawCandidate]:
|
|
53
|
+
"""Finds proxies in text, HTML tables and JSON.
|
|
54
|
+
|
|
55
|
+
Lines with a type:// prefix keep their own type; everything else gets the type of the source.
|
|
56
|
+
For sources of type "auto" only lines with a prefix count.
|
|
57
|
+
"""
|
|
58
|
+
out: Set[RawCandidate] = set()
|
|
59
|
+
with_scheme = set()
|
|
60
|
+
if b"://" in data:
|
|
61
|
+
for scheme, auth, ip, port in SCHEME_RE.findall(data):
|
|
62
|
+
ptype = SCHEME_TYPES.get(scheme.lower())
|
|
63
|
+
if ptype:
|
|
64
|
+
out.add((ptype, ip, port, auth))
|
|
65
|
+
with_scheme.add((ip, port))
|
|
66
|
+
if default_type == "auto":
|
|
67
|
+
return out
|
|
68
|
+
regex = PROXY_RE if _needs_full_regex(data) else PLAIN_RE
|
|
69
|
+
pairs = set(regex.findall(data))
|
|
70
|
+
if b'"ip"' in data:
|
|
71
|
+
pairs.update(JSON_IP_PORT_RE.findall(data))
|
|
72
|
+
pairs.update((ip, port) for port, ip in JSON_PORT_IP_RE.findall(data))
|
|
73
|
+
out.update((default_type, ip, port, b"") for ip, port in pairs - with_scheme)
|
|
74
|
+
return out
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# networks that aren't publicly reachable (private, loopback, CGNAT, documentation, multicast, reserved …).
|
|
78
|
+
# ipaddress.is_private and friends are very slow per address, hence a table by first octet:
|
|
79
|
+
# True = the whole /8 is blocked, list = only check these subnets, empty = everything public.
|
|
80
|
+
_BLOCKED_BY_FIRST_OCTET: list = [[] for _ in range(256)]
|
|
81
|
+
for _net in map(ipaddress.IPv4Network, (
|
|
82
|
+
"0.0.0.0/8", "10.0.0.0/8", "100.64.0.0/10", "127.0.0.0/8", "169.254.0.0/16",
|
|
83
|
+
"172.16.0.0/12", "192.0.0.0/24", "192.0.2.0/24", "192.168.0.0/16", "198.18.0.0/15",
|
|
84
|
+
"198.51.100.0/24", "203.0.113.0/24", "224.0.0.0/4", "240.0.0.0/4",
|
|
85
|
+
)):
|
|
86
|
+
_first = int(_net.network_address) >> 24
|
|
87
|
+
if _net.prefixlen <= 8:
|
|
88
|
+
for _o in range(_first, _first + 2 ** (8 - _net.prefixlen)):
|
|
89
|
+
_BLOCKED_BY_FIRST_OCTET[_o] = True
|
|
90
|
+
else:
|
|
91
|
+
_BLOCKED_BY_FIRST_OCTET[_first].append((int(_net.network_address), int(_net.netmask)))
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def normalize_public_ip(ip: bytes) -> Optional[str]:
|
|
95
|
+
"""'001.2.3.4' -> '1.2.3.4'; None for invalid or non-public addresses."""
|
|
96
|
+
a, b, c, d = map(int, ip.split(b"."))
|
|
97
|
+
if a > 255 or b > 255 or c > 255 or d > 255:
|
|
98
|
+
return None
|
|
99
|
+
rules = _BLOCKED_BY_FIRST_OCTET[a]
|
|
100
|
+
if rules is True:
|
|
101
|
+
return None
|
|
102
|
+
if rules:
|
|
103
|
+
n = a << 24 | b << 16 | c << 8 | d
|
|
104
|
+
if any(n & mask == net for net, mask in rules):
|
|
105
|
+
return None
|
|
106
|
+
return f"{a}.{b}.{c}.{d}"
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def normalize_proxy(ip: bytes, port: bytes) -> Optional[str]:
|
|
110
|
+
"""'001.2.3.4', b'080' -> '1.2.3.4:80'; None for an invalid port or a non-public IP."""
|
|
111
|
+
p = int(port)
|
|
112
|
+
if not 0 < p < 65536:
|
|
113
|
+
return None
|
|
114
|
+
ip_s = normalize_public_ip(ip)
|
|
115
|
+
return f"{ip_s}:{p}" if ip_s else None
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def make_key(ptype: str, proxy: str) -> str:
|
|
119
|
+
return f"{ptype} {proxy}"
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def split_key(key: str) -> Tuple[str, str]:
|
|
123
|
+
ptype, _, proxy = key.partition(" ")
|
|
124
|
+
return ptype, proxy
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def normalize_auth(auth: str) -> str:
|
|
128
|
+
"""'user:p%40ss' or 'user:p@ss' -> encoded uniformly ('user:p%40ss'); '' without a user."""
|
|
129
|
+
user, _, password = auth.partition(":")
|
|
130
|
+
return format_auth(unquote(user), unquote(password))
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def validate_candidates(candidates: Iterable[RawCandidate]) -> Set[str]:
|
|
134
|
+
"""{(type, ip, port, auth)} -> {"type [auth@]ip:port"} only for valid, public addresses."""
|
|
135
|
+
out = set()
|
|
136
|
+
for ptype, ip, port, auth in candidates:
|
|
137
|
+
proxy = normalize_proxy(ip, port)
|
|
138
|
+
if not proxy:
|
|
139
|
+
continue
|
|
140
|
+
if auth:
|
|
141
|
+
creds = normalize_auth(auth.decode("utf-8", "replace"))
|
|
142
|
+
if creds:
|
|
143
|
+
proxy = f"{creds}@{proxy}"
|
|
144
|
+
out.add(make_key(ptype, proxy))
|
|
145
|
+
return out
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def parse_blob(data: bytes, default_type: str, wanted: Tuple[str, ...]) -> str:
|
|
149
|
+
"""Complete parse step of a source for the process pool.
|
|
150
|
+
|
|
151
|
+
Returns the keys as a single string separated by \\n – one object can be passed
|
|
152
|
+
between processes much faster than hundreds of thousands of small ones.
|
|
153
|
+
"""
|
|
154
|
+
keys = [k for k in validate_candidates(extract_candidates(data, default_type)) if k.split(" ", 1)[0] in wanted]
|
|
155
|
+
return "\n".join(keys)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def parse_proxy_line(line: str, default_type: Optional[str] = None) -> Optional[str]:
|
|
159
|
+
"""A line from a result file ('socks5://user:pass@1.2.3.4:1080' or '1.2.3.4:80') -> key."""
|
|
160
|
+
line = line.strip()
|
|
161
|
+
if not line or line.startswith("#"):
|
|
162
|
+
return None
|
|
163
|
+
ptype = default_type
|
|
164
|
+
if "://" in line:
|
|
165
|
+
scheme, _, line = line.partition("://")
|
|
166
|
+
ptype = TYPE_ALIASES.get(scheme.lower())
|
|
167
|
+
if ptype not in PROXY_TYPES:
|
|
168
|
+
return None
|
|
169
|
+
parts = line.split()
|
|
170
|
+
if not parts: # "http://" with nothing after it
|
|
171
|
+
return None
|
|
172
|
+
line = parts[0]
|
|
173
|
+
auth, _, line = line.rpartition("@")
|
|
174
|
+
ip, _, port = line.partition(":")
|
|
175
|
+
port = port.rstrip("/")
|
|
176
|
+
if not port.isdigit() or ip.count(".") != 3 or not all(p.isdigit() for p in ip.split(".")):
|
|
177
|
+
return None
|
|
178
|
+
proxy = normalize_proxy(ip.encode(), port.encode())
|
|
179
|
+
if not proxy:
|
|
180
|
+
return None
|
|
181
|
+
creds = normalize_auth(auth) if auth else ""
|
|
182
|
+
return make_key(ptype, f"{creds}@{proxy}" if creds else proxy)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def parse_keys(lines: Iterable[str], default_type: Optional[str] = None) -> List[str]:
|
|
186
|
+
seen, out = set(), []
|
|
187
|
+
for line in lines:
|
|
188
|
+
key = parse_proxy_line(line, default_type)
|
|
189
|
+
if key and key not in seen:
|
|
190
|
+
seen.add(key)
|
|
191
|
+
out.append(key)
|
|
192
|
+
return out
|
proxyscraper/paths.py
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Where things are stored.
|
|
2
|
+
|
|
3
|
+
- source list: in the package (proxyscraper/sources.json)
|
|
4
|
+
- from a cloned repo: learned state in data/, results in results/ – both inside the project
|
|
5
|
+
- installed (pip/pipx): learned state in the system's user data folder, results in the current folder
|
|
6
|
+
- PROXY_SCRAPER_HOME overrides the folder for the learned state
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
PACKAGE_DIR = Path(__file__).resolve().parent
|
|
17
|
+
PROJECT_DIR = PACKAGE_DIR.parent
|
|
18
|
+
APP_NAME = "proxy-scraper"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def is_checkout(project_dir: Path = PROJECT_DIR) -> bool:
|
|
22
|
+
"""Is the program running straight from the repo (instead of an installation)?"""
|
|
23
|
+
return (project_dir / "pyproject.toml").is_file() and (project_dir / "proxy_scraper.py").is_file()
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def user_data_dir(platform: str = sys.platform, env=os.environ, home: Optional[Path] = None) -> Path:
|
|
27
|
+
"""The usual folder for application data on this system."""
|
|
28
|
+
home = home or Path.home()
|
|
29
|
+
if platform == "darwin":
|
|
30
|
+
return home / "Library" / "Application Support" / APP_NAME
|
|
31
|
+
if platform == "win32":
|
|
32
|
+
return Path(env.get("LOCALAPPDATA") or home / "AppData" / "Local") / APP_NAME
|
|
33
|
+
return Path(env.get("XDG_DATA_HOME") or home / ".local" / "share") / APP_NAME
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def data_dir(env=os.environ) -> Path:
|
|
37
|
+
if env.get("PROXY_SCRAPER_HOME"):
|
|
38
|
+
return Path(env["PROXY_SCRAPER_HOME"]).expanduser()
|
|
39
|
+
return PROJECT_DIR / "data" if is_checkout() else user_data_dir(env=env)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def results_dir() -> Path:
|
|
43
|
+
return PROJECT_DIR / "results" if is_checkout() else Path.cwd() / "results"
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
DATA_DIR = data_dir()
|
|
47
|
+
RESULTS_DIR = results_dir()
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def atomic_write(path: Path, text: str) -> None:
|
|
51
|
+
# write to a temporary file first, then rename – an interruption leaves no half-written file
|
|
52
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
53
|
+
tmp = path.with_suffix(path.suffix + ".tmp")
|
|
54
|
+
tmp.write_text(text, encoding="utf-8")
|
|
55
|
+
os.replace(tmp, path)
|
proxyscraper/pipeline.py
ADDED
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
"""Flow: assemble sources, load & parse in parallel, prioritize, check."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import contextlib
|
|
7
|
+
import os
|
|
8
|
+
import random
|
|
9
|
+
from collections import Counter
|
|
10
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from typing import Callable, Dict, Iterable, List, Optional, Set, Tuple
|
|
13
|
+
|
|
14
|
+
from . import sources as srcs
|
|
15
|
+
from .asndb import ProviderLookup
|
|
16
|
+
from .blocklist import Blocklist
|
|
17
|
+
from .checker import Checker, CheckResult
|
|
18
|
+
from .compat import on_interrupt
|
|
19
|
+
from .fetchcache import FetchCache
|
|
20
|
+
from .geo import GeoResolver
|
|
21
|
+
from .history import ProxyHistory
|
|
22
|
+
from .judges import JudgeWatch
|
|
23
|
+
from .netio import http_get, http_request
|
|
24
|
+
from .options import RunOptions
|
|
25
|
+
from .output import ResultWriter
|
|
26
|
+
from .parsing import PROXY_TYPES, parse_blob, split_key
|
|
27
|
+
from .ui import ACCENT, CheckDashboard, CollectView, fmt, widgets
|
|
28
|
+
|
|
29
|
+
AUTO_DISCOVER_AFTER_DAYS = 1.0 # daily: found sources are kept, so every search adds to the pile
|
|
30
|
+
# parse small lists directly – the detour through another process costs more than it saves
|
|
31
|
+
INLINE_PARSE_BYTES = 256 * 1024
|
|
32
|
+
DOWNLOAD_CONCURRENCY = 64
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# --------------------------------------------------------------------------- #
|
|
36
|
+
# Sources
|
|
37
|
+
# --------------------------------------------------------------------------- #
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class SourcePlan:
|
|
41
|
+
sources: Dict[str, str]
|
|
42
|
+
skipped: Counter
|
|
43
|
+
n_curated: int
|
|
44
|
+
n_meta: int
|
|
45
|
+
meta_ok: int
|
|
46
|
+
meta_total: int
|
|
47
|
+
n_discovered: int
|
|
48
|
+
discovery_ran: bool = False
|
|
49
|
+
discovery_token: bool = False
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
async def collect_sources(opts: RunOptions, quality: srcs.SourceStats) -> SourcePlan:
|
|
53
|
+
"""Curated + meta + discovered sources, minus the ones learned to be bad."""
|
|
54
|
+
sources, meta = srcs.load_source_file()
|
|
55
|
+
n_curated = len(sources)
|
|
56
|
+
|
|
57
|
+
age = srcs.discovered_age_days()
|
|
58
|
+
due = not opts.no_discover and (age is None or age > AUTO_DISCOVER_AFTER_DAYS)
|
|
59
|
+
# asking gh for a token takes up to 5 s – only when discovery can actually run, and off the event loop
|
|
60
|
+
token = await asyncio.to_thread(srcs.github_token) if (opts.discover or due) else None
|
|
61
|
+
run_discovery = opts.discover or (due and token is not None)
|
|
62
|
+
if run_discovery:
|
|
63
|
+
max_repos = opts.discover_repos if token else min(opts.discover_repos, 40)
|
|
64
|
+
with widgets.console.status(f"[bold {ACCENT}]Searching GitHub for new proxy lists …", spinner="dots") as status:
|
|
65
|
+
found = await srcs.discover_github(
|
|
66
|
+
http_get, token, max_repos,
|
|
67
|
+
on_progress=lambda msg: status.update(f"[bold {ACCENT}]GitHub-Discovery:[/] {msg}"),
|
|
68
|
+
)
|
|
69
|
+
if found:
|
|
70
|
+
srcs.save_discovered(found)
|
|
71
|
+
discovered = srcs.load_discovered()
|
|
72
|
+
|
|
73
|
+
with widgets.console.status(f"[bold {ACCENT}]Loading meta sources …", spinner="dots"):
|
|
74
|
+
meta_found, meta_ok = await srcs.resolve_meta(meta, http_get)
|
|
75
|
+
for extra in (meta_found, discovered):
|
|
76
|
+
for url, ptype in extra.items():
|
|
77
|
+
sources.setdefault(url, ptype)
|
|
78
|
+
|
|
79
|
+
wanted = set(opts.types)
|
|
80
|
+
sources = {u: t for u, t in sources.items() if t == "auto" or t in wanted}
|
|
81
|
+
skipped: Counter = Counter()
|
|
82
|
+
if not opts.all_sources:
|
|
83
|
+
active = {}
|
|
84
|
+
for url, ptype in sources.items():
|
|
85
|
+
reason = quality.skip_now(url)
|
|
86
|
+
if reason:
|
|
87
|
+
skipped[reason] += 1
|
|
88
|
+
else:
|
|
89
|
+
active[url] = ptype
|
|
90
|
+
sources = active
|
|
91
|
+
return SourcePlan(
|
|
92
|
+
sources, skipped, n_curated, len(meta_found), meta_ok, len(meta), len(discovered),
|
|
93
|
+
discovery_ran=run_discovery, discovery_token=token is not None,
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
@dataclass
|
|
98
|
+
class ScrapeResult:
|
|
99
|
+
urls: List[str]
|
|
100
|
+
# proxy key -> indices of the sources that list it (for priority and statistics)
|
|
101
|
+
index: Dict[str, List[int]] = field(default_factory=dict)
|
|
102
|
+
ok_sources: int = 0
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
async def scrape(sources: Dict[str, str], types, quality: srcs.SourceStats, view: CollectView,
|
|
106
|
+
cache: Optional[FetchCache] = None) -> ScrapeResult:
|
|
107
|
+
"""Loads every source and parses large lists in parallel on all CPU cores.
|
|
108
|
+
|
|
109
|
+
That keeps the event loop free for more downloads instead of working through regexes for minutes.
|
|
110
|
+
With the cache, unchanged lists are skipped via ETag (see fetchcache.py).
|
|
111
|
+
"""
|
|
112
|
+
res = ScrapeResult(list(sources))
|
|
113
|
+
cache = cache or FetchCache(enabled=False)
|
|
114
|
+
# always parse every type, filtering only happens when sorting them in: the cache stores all types, and
|
|
115
|
+
# the statistics must be able to tell "no proxies at all" from "none of the requested types"
|
|
116
|
+
parse_types = PROXY_TYPES
|
|
117
|
+
prefixes = tuple(f"{t} " for t in types)
|
|
118
|
+
loop = asyncio.get_running_loop()
|
|
119
|
+
sem = asyncio.Semaphore(DOWNLOAD_CONCURRENCY)
|
|
120
|
+
index = res.index
|
|
121
|
+
|
|
122
|
+
with ProcessPoolExecutor(max_workers=max(1, (os.cpu_count() or 2) - 1)) as pool:
|
|
123
|
+
|
|
124
|
+
async def download(url: str):
|
|
125
|
+
"""-> (data, keys from the cache). Exactly one of the two is set."""
|
|
126
|
+
conditional = cache.conditional_headers(url, sources[url])
|
|
127
|
+
async with sem:
|
|
128
|
+
status, headers, body = await http_request(url, timeout=30, headers=conditional or None)
|
|
129
|
+
if status == 304 and conditional:
|
|
130
|
+
cached = cache.load(url)
|
|
131
|
+
if cached is not None:
|
|
132
|
+
return None, headers, cached
|
|
133
|
+
async with sem: # cache file broken -> just load it again
|
|
134
|
+
status, headers, body = await http_request(url, timeout=30)
|
|
135
|
+
if status != 200:
|
|
136
|
+
raise ConnectionError(f"HTTP {status}")
|
|
137
|
+
return body, headers, None
|
|
138
|
+
|
|
139
|
+
async def fetch(i: int, url: str) -> None:
|
|
140
|
+
data: Optional[bytes] = None
|
|
141
|
+
keys = ""
|
|
142
|
+
unchanged = False
|
|
143
|
+
try:
|
|
144
|
+
data, headers, cached = await download(url)
|
|
145
|
+
if cached is not None:
|
|
146
|
+
keys, unchanged = cached, True
|
|
147
|
+
view.cached += 1
|
|
148
|
+
else:
|
|
149
|
+
view.bytes += len(data)
|
|
150
|
+
if len(data) <= INLINE_PARSE_BYTES:
|
|
151
|
+
keys = parse_blob(data, sources[url], parse_types)
|
|
152
|
+
else:
|
|
153
|
+
keys = await loop.run_in_executor(pool, parse_blob, data, sources[url], parse_types)
|
|
154
|
+
cache.store(url, headers, keys, sources[url])
|
|
155
|
+
except Exception: # source unreachable/broken – counts as a failure in the statistics
|
|
156
|
+
pass
|
|
157
|
+
n = parsed = 0
|
|
158
|
+
if keys:
|
|
159
|
+
parsed = keys.count("\n") + 1 # every type, also the ones this run doesn't want
|
|
160
|
+
for key in keys.split("\n"):
|
|
161
|
+
if not key.startswith(prefixes):
|
|
162
|
+
continue
|
|
163
|
+
owners = index.get(key)
|
|
164
|
+
if owners is None:
|
|
165
|
+
index[key] = [i]
|
|
166
|
+
else:
|
|
167
|
+
owners.append(i)
|
|
168
|
+
n += 1
|
|
169
|
+
quality.record_fetch(url, data, n, unchanged=unchanged, parsed=parsed)
|
|
170
|
+
if n:
|
|
171
|
+
res.ok_sources += 1
|
|
172
|
+
view.ok += 1
|
|
173
|
+
else:
|
|
174
|
+
view.failed += 1
|
|
175
|
+
view.unique = len(index)
|
|
176
|
+
view.advance()
|
|
177
|
+
|
|
178
|
+
await asyncio.gather(*(fetch(i, u) for i, u in enumerate(res.urls)))
|
|
179
|
+
return res
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def prioritize(res: ScrapeResult, quality: srcs.SourceStats, history: ProxyHistory, types) -> List[str]:
|
|
183
|
+
"""Order of checking: known working proxies first, then by quality of the sources.
|
|
184
|
+
|
|
185
|
+
Within the same source quality, proxies that appear in more lists win.
|
|
186
|
+
"""
|
|
187
|
+
wanted = set(types)
|
|
188
|
+
known = [k for k in history.ranked_keys() if split_key(k)[0] in wanted]
|
|
189
|
+
known_set = set(known)
|
|
190
|
+
|
|
191
|
+
scores = [quality.score(u) for u in res.urls]
|
|
192
|
+
keys = [k for k in res.index if k not in known_set]
|
|
193
|
+
random.shuffle(keys) # shuffle real ties so every type makes progress
|
|
194
|
+
index = res.index
|
|
195
|
+
|
|
196
|
+
def priority(key: str) -> float:
|
|
197
|
+
owners = index[key]
|
|
198
|
+
if len(owners) == 1:
|
|
199
|
+
return scores[owners[0]]
|
|
200
|
+
return max(map(scores.__getitem__, owners)) + 0.002 * min(len(owners), 10)
|
|
201
|
+
|
|
202
|
+
prio = list(map(priority, keys))
|
|
203
|
+
order = sorted(range(len(keys)), key=prio.__getitem__, reverse=True)
|
|
204
|
+
return known + [keys[i] for i in order]
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
def attribute_results(res: ScrapeResult, checked: List[str], working: Set[str]) -> Dict[str, Tuple[int, int]]:
|
|
208
|
+
"""Attribute results to the sources -> url -> (checked, working)."""
|
|
209
|
+
n_checked = [0] * len(res.urls)
|
|
210
|
+
n_working = [0] * len(res.urls)
|
|
211
|
+
for key in checked:
|
|
212
|
+
ok = key in working
|
|
213
|
+
for i in res.index.get(key, ()):
|
|
214
|
+
n_checked[i] += 1
|
|
215
|
+
if ok:
|
|
216
|
+
n_working[i] += 1
|
|
217
|
+
return {u: (n_checked[i], n_working[i]) for i, u in enumerate(res.urls) if n_checked[i]}
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def best_sources(per_source: Dict[str, Tuple[int, int]], limit: int = 10, min_checked: int = 30):
|
|
221
|
+
return sorted(
|
|
222
|
+
((w / c, w, c, u) for u, (c, w) in per_source.items() if c >= min_checked and w),
|
|
223
|
+
reverse=True,
|
|
224
|
+
)[:limit]
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# --------------------------------------------------------------------------- #
|
|
228
|
+
# Checking
|
|
229
|
+
# --------------------------------------------------------------------------- #
|
|
230
|
+
|
|
231
|
+
@dataclass
|
|
232
|
+
class CheckRun:
|
|
233
|
+
results: List[CheckResult] = field(default_factory=list)
|
|
234
|
+
checked: List[str] = field(default_factory=list)
|
|
235
|
+
working: Set[str] = field(default_factory=set)
|
|
236
|
+
interrupted: bool = False
|
|
237
|
+
reached_goal: bool = False
|
|
238
|
+
judge_switches: List[str] = field(default_factory=list) # "old → new"
|
|
239
|
+
rechecked: int = 0 # checks that were repeated because a check target went down
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
class JobQueue:
|
|
243
|
+
"""Iterator over the jobs that still accepts stragglers after running empty.
|
|
244
|
+
|
|
245
|
+
A generator would be exhausted forever after the first StopIteration – a proxy that a still
|
|
246
|
+
running worker puts back after a check-target switch would then never be checked again."""
|
|
247
|
+
|
|
248
|
+
def __init__(self, jobs: Iterable[str]):
|
|
249
|
+
self.jobs = iter(jobs)
|
|
250
|
+
self.retry: List[str] = []
|
|
251
|
+
|
|
252
|
+
def __iter__(self):
|
|
253
|
+
return self
|
|
254
|
+
|
|
255
|
+
def __next__(self) -> str:
|
|
256
|
+
for key in self.jobs:
|
|
257
|
+
return key
|
|
258
|
+
if self.retry:
|
|
259
|
+
return self.retry.pop()
|
|
260
|
+
raise StopIteration
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
async def run_checks(
|
|
264
|
+
jobs: List[str],
|
|
265
|
+
checker: Checker,
|
|
266
|
+
opts: RunOptions,
|
|
267
|
+
dashboard: CheckDashboard,
|
|
268
|
+
writer: ResultWriter,
|
|
269
|
+
geo: GeoResolver,
|
|
270
|
+
live_factory: Callable,
|
|
271
|
+
watch: Optional[JudgeWatch] = None,
|
|
272
|
+
providers: Optional[ProviderLookup] = None,
|
|
273
|
+
blocklist: Optional[Blocklist] = None,
|
|
274
|
+
quiet: bool = False,
|
|
275
|
+
) -> CheckRun:
|
|
276
|
+
"""Checks `jobs` with `opts.concurrency` parallel workers until everything is done, the goal is
|
|
277
|
+
reached or Ctrl+C is pressed.
|
|
278
|
+
|
|
279
|
+
With `watch` the check target is monitored: if it goes down, the checks since the last successful
|
|
280
|
+
probe are repeated and don't count for the source statistics.
|
|
281
|
+
|
|
282
|
+
quiet: runs in the background next to another live view (--serve-refill) – no spinner, and Ctrl+C
|
|
283
|
+
belongs to whoever runs in the foreground."""
|
|
284
|
+
loop = asyncio.get_running_loop()
|
|
285
|
+
stats = dashboard.s
|
|
286
|
+
filters, details, want = opts.filters, opts.details, opts.want
|
|
287
|
+
run = CheckRun()
|
|
288
|
+
written: Set[str] = set()
|
|
289
|
+
enriched: Set[str] = set() # hits with a finished detail check (HTTPS may stay open)
|
|
290
|
+
by_exit_ip: Dict[str, List[CheckResult]] = {}
|
|
291
|
+
pending = JobQueue(jobs) # all workers pull from the same queue – safe in asyncio without a lock
|
|
292
|
+
|
|
293
|
+
# Check target outages: every check remembers which target ("generation") it started with and its number.
|
|
294
|
+
# On a switch the old generation becomes suspicious from the last good probe on – failures from it
|
|
295
|
+
# are repeated, including those that only finish after the switch. Hits are never suspicious
|
|
296
|
+
# (the proxy did work), so every check is counted exactly once.
|
|
297
|
+
generation = 0
|
|
298
|
+
# Order instead of clock time: every check gets a running number when it starts. The event loop clock
|
|
299
|
+
# is only accurate to ~15 ms on Windows – two events could easily get the same timestamp there.
|
|
300
|
+
started_count = 0
|
|
301
|
+
last_ok = 0 # this many checks had started at the last good probe (0 = start of the run)
|
|
302
|
+
suspect_since: Dict[int, int] = {} # replaced generation -> checks from this number on are suspicious
|
|
303
|
+
recent_failures: List[Tuple[int, int, str]] = [] # failures since the last good probe
|
|
304
|
+
|
|
305
|
+
def is_suspect(gen: int, started: int) -> bool:
|
|
306
|
+
return gen in suspect_since and started > suspect_since[gen]
|
|
307
|
+
|
|
308
|
+
def requeue(keys: List[str]) -> None:
|
|
309
|
+
pending.retry.extend(keys)
|
|
310
|
+
run.rechecked += len(keys)
|
|
311
|
+
dashboard.add_rechecks(keys)
|
|
312
|
+
|
|
313
|
+
def judge_ok() -> None:
|
|
314
|
+
nonlocal last_ok
|
|
315
|
+
last_ok = started_count
|
|
316
|
+
recent_failures.clear()
|
|
317
|
+
|
|
318
|
+
def judge_switched(old, new) -> None:
|
|
319
|
+
nonlocal generation, last_ok
|
|
320
|
+
suspect_since[generation] = last_ok
|
|
321
|
+
generation += 1
|
|
322
|
+
last_ok = started_count # the new target was just probed successfully – the baseline from here on
|
|
323
|
+
failed = {key for gen, started, key in recent_failures if is_suspect(gen, started)}
|
|
324
|
+
recent_failures.clear()
|
|
325
|
+
if failed:
|
|
326
|
+
run.checked[:] = [k for k in run.checked if k not in failed]
|
|
327
|
+
requeue(sorted(failed))
|
|
328
|
+
run.judge_switches.append(f"{old.judge.host} → {new.judge.host}")
|
|
329
|
+
dashboard.judge_changed(new.judge.host)
|
|
330
|
+
|
|
331
|
+
if watch:
|
|
332
|
+
watch.on_ok, watch.on_switch = judge_ok, judge_switched
|
|
333
|
+
all_workers: Optional[asyncio.Future] = None
|
|
334
|
+
|
|
335
|
+
def consider(r: CheckResult) -> None:
|
|
336
|
+
"""Live file & goal counter, as soon as all the info the filters need is there."""
|
|
337
|
+
if r.key in written or (details and r.key not in enriched):
|
|
338
|
+
return
|
|
339
|
+
if filters.countries and not r.country:
|
|
340
|
+
return # country still coming – on_country calls again
|
|
341
|
+
if filters.accepts(r):
|
|
342
|
+
written.add(r.key)
|
|
343
|
+
stats.passing += 1
|
|
344
|
+
writer.add_live(r)
|
|
345
|
+
if want and stats.passing >= want and all_workers and not all_workers.done():
|
|
346
|
+
run.reached_goal = True
|
|
347
|
+
all_workers.cancel()
|
|
348
|
+
|
|
349
|
+
def on_country(ip: str, cc: str) -> None:
|
|
350
|
+
for r in by_exit_ip.get(ip, ()):
|
|
351
|
+
if not r.country:
|
|
352
|
+
r.country = cc
|
|
353
|
+
stats.countries[cc] += 1
|
|
354
|
+
consider(r)
|
|
355
|
+
|
|
356
|
+
geo.on_resolved = on_country
|
|
357
|
+
geo_task = asyncio.ensure_future(geo.run())
|
|
358
|
+
watch_task = asyncio.ensure_future(watch.run()) if watch else None
|
|
359
|
+
|
|
360
|
+
async def worker() -> None:
|
|
361
|
+
nonlocal started_count
|
|
362
|
+
for key in pending:
|
|
363
|
+
started_count += 1
|
|
364
|
+
gen, started = generation, started_count
|
|
365
|
+
r = await checker.check(key)
|
|
366
|
+
stats.add_checked(key.split(" ", 1)[0])
|
|
367
|
+
dashboard.advance()
|
|
368
|
+
if r is None and is_suspect(gen, started):
|
|
369
|
+
requeue([key]) # only finished after the switch – still a victim of the outage
|
|
370
|
+
continue
|
|
371
|
+
if r is None:
|
|
372
|
+
run.checked.append(key)
|
|
373
|
+
if watch:
|
|
374
|
+
recent_failures.append((gen, started, key))
|
|
375
|
+
continue
|
|
376
|
+
# A hit only counts as checked once the verdict is in: a worker cancelled during the next requests
|
|
377
|
+
# (--want reached, Ctrl+C) must not record a proxy that just worked as a failure.
|
|
378
|
+
# second, independent request – honeypots often pass the first check by chance
|
|
379
|
+
if not await checker.confirm(r):
|
|
380
|
+
run.checked.append(key)
|
|
381
|
+
stats.fakes += 1
|
|
382
|
+
continue
|
|
383
|
+
# third request: does a known page arrive unchanged? Otherwise the proxy injects something
|
|
384
|
+
if await checker.tampers(r):
|
|
385
|
+
run.checked.append(key)
|
|
386
|
+
stats.tampered += 1
|
|
387
|
+
continue
|
|
388
|
+
if providers:
|
|
389
|
+
providers.annotate(r)
|
|
390
|
+
if blocklist:
|
|
391
|
+
r.blocklisted = await blocklist.lookup(r.exit_ip)
|
|
392
|
+
run.checked.append(key)
|
|
393
|
+
run.results.append(r)
|
|
394
|
+
run.working.add(key)
|
|
395
|
+
by_exit_ip.setdefault(r.exit_ip, []).append(r)
|
|
396
|
+
r.country = geo.request(r.exit_ip)
|
|
397
|
+
if r.country:
|
|
398
|
+
stats.countries[r.country] += 1
|
|
399
|
+
stats.add_working(r)
|
|
400
|
+
if details:
|
|
401
|
+
# HTTPS test only if the proxy can still pass the filters at all
|
|
402
|
+
if filters.may_pass(r):
|
|
403
|
+
await checker.enrich(r)
|
|
404
|
+
enriched.add(r.key)
|
|
405
|
+
stats.add_details(r)
|
|
406
|
+
else:
|
|
407
|
+
stats.details_saved += 1
|
|
408
|
+
consider(r)
|
|
409
|
+
|
|
410
|
+
with live_factory(dashboard):
|
|
411
|
+
workers = [asyncio.ensure_future(worker()) for _ in range(min(opts.concurrency, len(jobs)))]
|
|
412
|
+
all_workers = asyncio.gather(*workers)
|
|
413
|
+
# Ctrl+C stops cleanly so the results still get saved (on Windows too)
|
|
414
|
+
with contextlib.nullcontext() if quiet else on_interrupt(loop, all_workers.cancel):
|
|
415
|
+
try:
|
|
416
|
+
await all_workers
|
|
417
|
+
except asyncio.CancelledError:
|
|
418
|
+
run.interrupted = not run.reached_goal
|
|
419
|
+
|
|
420
|
+
if watch_task:
|
|
421
|
+
watch_task.cancel()
|
|
422
|
+
if quiet and run.interrupted:
|
|
423
|
+
# nobody pressed Ctrl+C here – the task running us was cancelled (the server stops), so pass it on
|
|
424
|
+
geo_task.cancel()
|
|
425
|
+
raise asyncio.CancelledError
|
|
426
|
+
# wait for open country lookups (briefly at most)
|
|
427
|
+
geo.stop()
|
|
428
|
+
if geo.pending and not geo.failed:
|
|
429
|
+
message = f"[bold {ACCENT}]Looking up countries for {fmt(len(geo.pending))} exit IPs …"
|
|
430
|
+
spinner = contextlib.nullcontext() if quiet else widgets.console.status(message, spinner="dots")
|
|
431
|
+
with spinner, contextlib.suppress(asyncio.TimeoutError):
|
|
432
|
+
await asyncio.wait_for(asyncio.shield(geo_task), 30)
|
|
433
|
+
geo_task.cancel()
|
|
434
|
+
return run
|