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/sources.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Source management for the proxy scraper:
|
|
3
|
+
|
|
4
|
+
- loads the curated source list (sources.json) and sources found with --discover,
|
|
5
|
+
- resolves meta sources (lists of source URLs maintained by others),
|
|
6
|
+
- finds new proxy lists on GitHub,
|
|
7
|
+
- remembers per source how many of its proxies really work, and
|
|
8
|
+
skips dead, outdated or unreachable sources.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import asyncio
|
|
14
|
+
import hashlib
|
|
15
|
+
import json
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import subprocess
|
|
19
|
+
import time
|
|
20
|
+
import warnings
|
|
21
|
+
from collections import Counter
|
|
22
|
+
from dataclasses import asdict, dataclass
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
from typing import Awaitable, Callable, Dict, Iterable, List, Optional, Tuple
|
|
26
|
+
from urllib.parse import quote, urlencode
|
|
27
|
+
|
|
28
|
+
from .parsing import PROXY_TYPES, TYPE_ALIASES
|
|
29
|
+
from .paths import DATA_DIR, PACKAGE_DIR, atomic_write
|
|
30
|
+
|
|
31
|
+
SOURCES_FILE = PACKAGE_DIR / "sources.json"
|
|
32
|
+
DISCOVERED_FILE = DATA_DIR / "sources_discovered.json"
|
|
33
|
+
STATS_FILE = DATA_DIR / "source_stats.json"
|
|
34
|
+
|
|
35
|
+
GH_RAW = "https://raw.githubusercontent.com"
|
|
36
|
+
# "auto" = the list contains type://ip:port lines, the type is given per line
|
|
37
|
+
SOURCE_TYPES = (*PROXY_TYPES, "auto")
|
|
38
|
+
|
|
39
|
+
# url -> Typ
|
|
40
|
+
SourceMap = Dict[str, str]
|
|
41
|
+
Getter = Callable[..., Awaitable[bytes]]
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
# --------------------------------------------------------------------------- #
|
|
45
|
+
# URLs & source lists
|
|
46
|
+
# --------------------------------------------------------------------------- #
|
|
47
|
+
|
|
48
|
+
_GH_BLOB_RE = re.compile(r"^https://github\.com/([^/]+)/([^/]+)/(?:raw|blob)/(?:refs/heads/)?(.+)$")
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def normalize_url(url: str) -> Optional[str]:
|
|
52
|
+
"""Normalizes source URLs so the same file isn't loaded several times.
|
|
53
|
+
|
|
54
|
+
github.com/…/raw/… -> raw.githubusercontent.com/…, '/refs/heads/' is dropped,
|
|
55
|
+
format suffixes like ',,ColonURL' are cut off. Templates with {…} -> None.
|
|
56
|
+
"""
|
|
57
|
+
url = url.strip().split(",", 1)[0].strip()
|
|
58
|
+
if not url.startswith(("http://", "https://")) or "{" in url:
|
|
59
|
+
return None
|
|
60
|
+
m = _GH_BLOB_RE.match(url)
|
|
61
|
+
if m:
|
|
62
|
+
url = f"{GH_RAW}/{m[1]}/{m[2]}/{m[3]}"
|
|
63
|
+
if url.startswith(GH_RAW):
|
|
64
|
+
url = url.replace("/refs/heads/", "/", 1)
|
|
65
|
+
return url
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _add(target: SourceMap, url: str, ptype: str) -> None:
|
|
69
|
+
ptype = TYPE_ALIASES.get(ptype.lower(), "")
|
|
70
|
+
url = normalize_url(url) if url else None
|
|
71
|
+
if url and ptype:
|
|
72
|
+
target.setdefault(url, ptype)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def load_source_file(path: Path = SOURCES_FILE) -> Tuple[SourceMap, List[dict]]:
|
|
76
|
+
"""Reads sources.json -> (sources, meta sources)."""
|
|
77
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
78
|
+
sources: SourceMap = {}
|
|
79
|
+
for ptype, urls in data.get("sources", {}).items():
|
|
80
|
+
for url in urls:
|
|
81
|
+
_add(sources, url, ptype)
|
|
82
|
+
return sources, list(data.get("meta", []))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def load_discovered(path: Path = DISCOVERED_FILE, now: Optional[datetime] = None) -> SourceMap:
|
|
86
|
+
"""Found sources the search saw in the last DISCOVERED_KEEP_DAYS – also when no search ran since (no token,
|
|
87
|
+
GitHub down), so old finds expire either way. VPN-config repos from before the filter existed are dropped."""
|
|
88
|
+
if not path.exists():
|
|
89
|
+
return {}
|
|
90
|
+
try:
|
|
91
|
+
data = json.loads(path.read_text(encoding="utf-8"))
|
|
92
|
+
except (OSError, ValueError):
|
|
93
|
+
return {}
|
|
94
|
+
now = now or datetime.now(timezone.utc)
|
|
95
|
+
seen = data.get("last_seen", {}) if isinstance(data.get("last_seen"), dict) else {}
|
|
96
|
+
out: SourceMap = {}
|
|
97
|
+
for url, ptype in data.get("sources", {}).items():
|
|
98
|
+
if _age_days(seen.get(url) or data.get("generated"), now) > DISCOVERED_KEEP_DAYS or _is_vpn_repo(url):
|
|
99
|
+
continue
|
|
100
|
+
_add(out, url, ptype)
|
|
101
|
+
return out
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _age_days(stamp, now: datetime) -> float:
|
|
105
|
+
try:
|
|
106
|
+
return (now - datetime.fromisoformat(stamp)).total_seconds() / DAY
|
|
107
|
+
except (TypeError, ValueError):
|
|
108
|
+
return float("inf")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _is_vpn_repo(url: str) -> bool:
|
|
112
|
+
parts = url.split("/")
|
|
113
|
+
return url.startswith(GH_RAW) and len(parts) > 4 and bool(_REPO_REJECT_RE.search("/".join(parts[3:5])))
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def discovered_age_days(path: Path = DISCOVERED_FILE) -> Optional[float]:
|
|
117
|
+
"""Age of the last discovery in days, None if there hasn't been one yet."""
|
|
118
|
+
try:
|
|
119
|
+
generated = json.loads(path.read_text(encoding="utf-8"))["generated"]
|
|
120
|
+
return (datetime.now(timezone.utc) - datetime.fromisoformat(generated)).total_seconds() / DAY
|
|
121
|
+
except (OSError, ValueError, KeyError, TypeError):
|
|
122
|
+
return None
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
DISCOVERED_KEEP_DAYS = 21 # a found source stays this long after the search last saw it
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def save_discovered(sources: SourceMap, path: Path = DISCOVERED_FILE, now: Optional[datetime] = None) -> SourceMap:
|
|
129
|
+
"""Adds this run's finds to what earlier runs found – the search only sees repos pushed in the last few days,
|
|
130
|
+
so a list that was quiet for a day would otherwise drop out. Whatever the search hasn't seen for
|
|
131
|
+
DISCOVERED_KEEP_DAYS goes; dead sources are skipped by the learning long before that. -> everything kept."""
|
|
132
|
+
now = now or datetime.now(timezone.utc)
|
|
133
|
+
stamp = now.isoformat(timespec="seconds")
|
|
134
|
+
try:
|
|
135
|
+
old = json.loads(path.read_text(encoding="utf-8"))
|
|
136
|
+
except (OSError, ValueError):
|
|
137
|
+
old = {}
|
|
138
|
+
old_sources = old.get("sources", {}) if isinstance(old, dict) else {}
|
|
139
|
+
seen = old.get("last_seen", {}) if isinstance(old, dict) else {}
|
|
140
|
+
kept: SourceMap = {}
|
|
141
|
+
last_seen: Dict[str, str] = {}
|
|
142
|
+
for url, ptype in old_sources.items():
|
|
143
|
+
when = seen.get(url) or old.get("generated") # files from before last_seen: count from their date
|
|
144
|
+
if _age_days(when, now) <= DISCOVERED_KEEP_DAYS and isinstance(ptype, str) and not _is_vpn_repo(url):
|
|
145
|
+
kept[url], last_seen[url] = ptype, when
|
|
146
|
+
for url, ptype in sources.items():
|
|
147
|
+
kept[url], last_seen[url] = ptype, stamp
|
|
148
|
+
payload = {"generated": stamp, "sources": kept, "last_seen": last_seen}
|
|
149
|
+
atomic_write(path, json.dumps(payload, indent=1, sort_keys=True))
|
|
150
|
+
return kept
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
# --------------------------------------------------------------------------- #
|
|
154
|
+
# meta sources: lists of source URLs maintained by others
|
|
155
|
+
# --------------------------------------------------------------------------- #
|
|
156
|
+
|
|
157
|
+
def parse_url_list(data: bytes, ptype: str) -> SourceMap:
|
|
158
|
+
"""One source URL per line (e.g. gfpcom/free-proxy-list/sources/http.txt)."""
|
|
159
|
+
out: SourceMap = {}
|
|
160
|
+
for line in data.decode("utf-8", "ignore").splitlines():
|
|
161
|
+
line = line.strip()
|
|
162
|
+
if line and not line.startswith("#"):
|
|
163
|
+
_add(out, line, ptype)
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
_TOML_SECTION_RE = re.compile(r"^\s*\[scraping\.(\w+)\]")
|
|
168
|
+
_TOML_URL_RE = re.compile(r'"(https?://[^"]+)"')
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def parse_monosans_toml(data: bytes) -> SourceMap:
|
|
172
|
+
"""URLs from the [scraping.<type>] sections of monosans/proxy-scraper-checker's config.toml.
|
|
173
|
+
|
|
174
|
+
Python 3.9 has no tomllib – a line parser is enough for this flat structure.
|
|
175
|
+
"""
|
|
176
|
+
out: SourceMap = {}
|
|
177
|
+
section = None
|
|
178
|
+
for line in data.decode("utf-8", "ignore").splitlines():
|
|
179
|
+
m = _TOML_SECTION_RE.match(line)
|
|
180
|
+
if m:
|
|
181
|
+
section = m[1]
|
|
182
|
+
continue
|
|
183
|
+
if line.lstrip().startswith("["):
|
|
184
|
+
section = None
|
|
185
|
+
elif section in PROXY_TYPES and not line.lstrip().startswith("#"):
|
|
186
|
+
for url in _TOML_URL_RE.findall(line):
|
|
187
|
+
_add(out, url, section)
|
|
188
|
+
return out
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
META_PARSERS: Dict[str, Callable[[bytes, dict], SourceMap]] = {
|
|
192
|
+
"url-list": lambda data, meta: parse_url_list(data, meta.get("type", "auto")),
|
|
193
|
+
"monosans-toml": lambda data, meta: parse_monosans_toml(data),
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
async def resolve_meta(meta: List[dict], get: Getter) -> Tuple[SourceMap, int]:
|
|
198
|
+
"""Loads every meta source -> (sources found, number of meta sources loaded successfully)."""
|
|
199
|
+
|
|
200
|
+
async def one(entry: dict) -> Optional[SourceMap]:
|
|
201
|
+
parser = META_PARSERS.get(entry.get("format", "url-list"))
|
|
202
|
+
if not parser:
|
|
203
|
+
return None
|
|
204
|
+
try:
|
|
205
|
+
return parser(await get(entry["url"], timeout=20), entry)
|
|
206
|
+
except Exception: # meta source unreachable -> just fewer sources, no abort
|
|
207
|
+
return None
|
|
208
|
+
|
|
209
|
+
found: SourceMap = {}
|
|
210
|
+
ok = 0
|
|
211
|
+
for res in await asyncio.gather(*(one(m) for m in meta)):
|
|
212
|
+
if res is not None:
|
|
213
|
+
ok += 1
|
|
214
|
+
for url, ptype in res.items():
|
|
215
|
+
found.setdefault(url, ptype)
|
|
216
|
+
return found, ok
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
# --------------------------------------------------------------------------- #
|
|
220
|
+
# GitHub-Discovery
|
|
221
|
+
# --------------------------------------------------------------------------- #
|
|
222
|
+
|
|
223
|
+
DISCOVERY_QUERIES = (
|
|
224
|
+
"topic:proxy-list",
|
|
225
|
+
"topic:free-proxy-list",
|
|
226
|
+
"topic:proxy-lists",
|
|
227
|
+
"topic:free-proxy",
|
|
228
|
+
"topic:free-proxies",
|
|
229
|
+
"topic:proxylist",
|
|
230
|
+
"topic:proxies-list",
|
|
231
|
+
"topic:socks5-proxy",
|
|
232
|
+
"topic:socks5-proxy-list",
|
|
233
|
+
"topic:socks4-proxy",
|
|
234
|
+
"topic:http-proxy-list",
|
|
235
|
+
"topic:https-proxy",
|
|
236
|
+
"topic:proxy-scraper",
|
|
237
|
+
"topic:proxy-checker",
|
|
238
|
+
"topic:proxy-pool",
|
|
239
|
+
"proxy list in:name",
|
|
240
|
+
"proxy-list in:name",
|
|
241
|
+
"free proxy in:name,description",
|
|
242
|
+
"proxies in:name",
|
|
243
|
+
"socks5 in:name",
|
|
244
|
+
"socks4 in:name",
|
|
245
|
+
"fresh proxy in:name,description",
|
|
246
|
+
"proxy updated every in:description",
|
|
247
|
+
)
|
|
248
|
+
SEARCH_PAGES = 3 # GitHub returns 100 per page; most queries end earlier
|
|
249
|
+
RATE_LIMIT_WAIT = 61.0 # the search API allows 30 requests a minute with a token (10 without)
|
|
250
|
+
RATE_LIMIT_RETRIES = 3 # waits per discovery at most
|
|
251
|
+
RATE_LIMIT_ERRORS = {"HTTP 403", "HTTP 429"} # how netio.http_get reports GitHub's rate limit
|
|
252
|
+
# paths that end in .txt but aren't (complete) proxy lists:
|
|
253
|
+
# VPN configs, splits by country/ASN (subsets only), archives, blocklists …
|
|
254
|
+
_PATH_REJECT_RE = re.compile(
|
|
255
|
+
r"(v2ray|vmess|vless|trojan|shadowsocks|(^|[/_\-])ssr?([/_\-.]|$)|clash|mtproto|wireguard|hysteria|tuic"
|
|
256
|
+
r"|readme|license|requirements|block|countr|geo|/asn/|archive|history|backup|old/|test|bench|corpus"
|
|
257
|
+
r"|subscri|(^|/)subs/|config"
|
|
258
|
+
r"|(^|/)[a-z]{2}\.txt$)"
|
|
259
|
+
)
|
|
260
|
+
_PATH_TYPE_RE = re.compile(r"(socks5|socks4|https?)")
|
|
261
|
+
_FILE_GENERIC_RE = re.compile(r"(^|[/_\-.])(proxy|proxies|all)[^/]*\.txt$")
|
|
262
|
+
# repos that collect VPN configs: their addresses aren't HTTP/SOCKS proxies, only dead candidates
|
|
263
|
+
_REPO_REJECT_RE = re.compile(r"v2ray|vpn|xray|clash|sing-?box|nekobox|vless|vmess|subscri|config", re.I)
|
|
264
|
+
MAX_FILES_PER_REPO = 12
|
|
265
|
+
MAX_REPOS_PER_OWNER = 3 # against spam accounts with dozens of identical clone repos
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def classify_path(path: str) -> Optional[str]:
|
|
269
|
+
"""Proxy type of a file from its path, or None if it doesn't look like a proxy list."""
|
|
270
|
+
p = path.lower()
|
|
271
|
+
if not p.endswith(".txt") or p.count("/") > 3 or _PATH_REJECT_RE.search(p):
|
|
272
|
+
return None
|
|
273
|
+
m = _PATH_TYPE_RE.search(p)
|
|
274
|
+
if m:
|
|
275
|
+
return TYPE_ALIASES[m[1]]
|
|
276
|
+
return "auto" if _FILE_GENERIC_RE.search(p) else None
|
|
277
|
+
|
|
278
|
+
|
|
279
|
+
def github_token() -> Optional[str]:
|
|
280
|
+
"""Token from GITHUB_TOKEN/GH_TOKEN or the gh CLI – raises the API limit from 60 to 5000 requests/h."""
|
|
281
|
+
for key in ("GITHUB_TOKEN", "GH_TOKEN"):
|
|
282
|
+
if os.environ.get(key):
|
|
283
|
+
return os.environ[key].strip()
|
|
284
|
+
try:
|
|
285
|
+
res = subprocess.run(["gh", "auth", "token"], capture_output=True, text=True, timeout=5)
|
|
286
|
+
except (OSError, subprocess.SubprocessError):
|
|
287
|
+
return None
|
|
288
|
+
if res.returncode != 0:
|
|
289
|
+
return None
|
|
290
|
+
return res.stdout.strip() or None
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
async def discover_github(
|
|
294
|
+
get: Getter,
|
|
295
|
+
token: Optional[str],
|
|
296
|
+
max_repos: int,
|
|
297
|
+
days: int = 3,
|
|
298
|
+
on_progress: Optional[Callable[[str], None]] = None,
|
|
299
|
+
) -> SourceMap:
|
|
300
|
+
"""Looks for actively maintained proxy list repos and their list files."""
|
|
301
|
+
headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"}
|
|
302
|
+
if token:
|
|
303
|
+
headers["Authorization"] = f"Bearer {token}"
|
|
304
|
+
since = (datetime.now(timezone.utc) - timedelta(days=days)).strftime("%Y-%m-%d")
|
|
305
|
+
|
|
306
|
+
repos: Dict[str, Tuple[str, int]] = {}
|
|
307
|
+
waits_left = RATE_LIMIT_RETRIES
|
|
308
|
+
|
|
309
|
+
async def search(query: str):
|
|
310
|
+
nonlocal waits_left
|
|
311
|
+
while True:
|
|
312
|
+
try:
|
|
313
|
+
return json.loads(await get(f"https://api.github.com/search/repositories?{query}", headers=headers))
|
|
314
|
+
except Exception as e:
|
|
315
|
+
# only GitHub's rate limit (403/429) is worth waiting for – a bad token or no network won't get better
|
|
316
|
+
if waits_left <= 0 or str(e) not in RATE_LIMIT_ERRORS:
|
|
317
|
+
return None
|
|
318
|
+
waits_left -= 1
|
|
319
|
+
if on_progress:
|
|
320
|
+
on_progress("rate limit – waiting a minute")
|
|
321
|
+
await asyncio.sleep(RATE_LIMIT_WAIT)
|
|
322
|
+
|
|
323
|
+
for q in DISCOVERY_QUERIES:
|
|
324
|
+
for page in range(1, SEARCH_PAGES + 1):
|
|
325
|
+
query = urlencode({"q": f"{q} pushed:>{since}", "sort": "stars", "per_page": 100, "page": page})
|
|
326
|
+
data = await search(query)
|
|
327
|
+
if data is None:
|
|
328
|
+
break
|
|
329
|
+
items = data.get("items", [])
|
|
330
|
+
for it in items:
|
|
331
|
+
repos.setdefault(it["full_name"], (it["default_branch"], it["stargazers_count"]))
|
|
332
|
+
if on_progress:
|
|
333
|
+
on_progress(f"{len(repos)} repos found")
|
|
334
|
+
if len(items) < 100:
|
|
335
|
+
break
|
|
336
|
+
|
|
337
|
+
per_owner: Counter = Counter()
|
|
338
|
+
chosen: List[Tuple[str, str]] = []
|
|
339
|
+
for name, (branch, _stars) in sorted(repos.items(), key=lambda kv: -kv[1][1]):
|
|
340
|
+
if _REPO_REJECT_RE.search(name):
|
|
341
|
+
continue
|
|
342
|
+
owner = name.split("/", 1)[0].lower()
|
|
343
|
+
if per_owner[owner] < MAX_REPOS_PER_OWNER:
|
|
344
|
+
per_owner[owner] += 1
|
|
345
|
+
chosen.append((name, branch))
|
|
346
|
+
chosen = chosen[:max_repos]
|
|
347
|
+
|
|
348
|
+
sem = asyncio.Semaphore(8)
|
|
349
|
+
done = 0
|
|
350
|
+
|
|
351
|
+
async def scan(name: str, branch: str) -> SourceMap:
|
|
352
|
+
nonlocal done
|
|
353
|
+
url = f"https://api.github.com/repos/{name}/git/trees/{quote(branch, safe='')}?recursive=1"
|
|
354
|
+
async with sem:
|
|
355
|
+
try:
|
|
356
|
+
tree = json.loads(await get(url, headers=headers, timeout=30)).get("tree", [])
|
|
357
|
+
except Exception:
|
|
358
|
+
tree = []
|
|
359
|
+
done += 1
|
|
360
|
+
if on_progress:
|
|
361
|
+
on_progress(f"{done}/{len(chosen)} repos searched")
|
|
362
|
+
files = []
|
|
363
|
+
for entry in tree:
|
|
364
|
+
if entry.get("type") == "blob" and entry.get("size", 0) >= 200:
|
|
365
|
+
ptype = classify_path(entry["path"])
|
|
366
|
+
if ptype:
|
|
367
|
+
files.append((entry["path"].count("/"), entry["path"], ptype))
|
|
368
|
+
files.sort() # shallow paths first – those are usually the complete lists
|
|
369
|
+
return {
|
|
370
|
+
f"{GH_RAW}/{name}/{branch}/{quote(path)}": ptype
|
|
371
|
+
for _depth, path, ptype in files[:MAX_FILES_PER_REPO]
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
found: SourceMap = {}
|
|
375
|
+
for res in await asyncio.gather(*(scan(n, b) for n, b in chosen)):
|
|
376
|
+
found.update(res)
|
|
377
|
+
return found
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
# --------------------------------------------------------------------------- #
|
|
381
|
+
# learning the quality of each source
|
|
382
|
+
# --------------------------------------------------------------------------- #
|
|
383
|
+
|
|
384
|
+
DAY = 86400.0
|
|
385
|
+
DECAY = 0.5 # older runs count half as much with every run
|
|
386
|
+
PRIOR_HITS, PRIOR_MISSES = 1.0, 30.0 # new sources start at a ~3 % hit rate
|
|
387
|
+
STALE_AFTER = 7 * DAY # content unchanged for a week -> the list is no longer maintained
|
|
388
|
+
DEAD_MIN_CHECKED = 300 # this many checks without a hit -> the source counts as dead
|
|
389
|
+
UNREACHABLE_STREAK = 3 # failed to load this many times in a row -> pause
|
|
390
|
+
UNREACHABLE_PAUSE = 2 * DAY
|
|
391
|
+
STALE_RECHECK = 3 * DAY # an outdated list is still looked at this often – it may be maintained again
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
@dataclass
|
|
395
|
+
class SourceRecord:
|
|
396
|
+
first_seen: float = 0.0
|
|
397
|
+
last_fetch: float = 0.0
|
|
398
|
+
fail_streak: int = 0
|
|
399
|
+
count: int = 0
|
|
400
|
+
content_hash: str = ""
|
|
401
|
+
last_change: float = 0.0
|
|
402
|
+
checked: float = 0.0 # decaying sums over the runs
|
|
403
|
+
working: float = 0.0
|
|
404
|
+
runs: int = 0
|
|
405
|
+
|
|
406
|
+
@property
|
|
407
|
+
def score(self) -> float:
|
|
408
|
+
"""Estimated hit rate (Bayesian smoothing, so 1 out of 1 doesn't mean 100 %)."""
|
|
409
|
+
return (self.working + PRIOR_HITS) / (self.checked + PRIOR_HITS + PRIOR_MISSES)
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
class SourceStats:
|
|
413
|
+
def __init__(self, path: Path = STATS_FILE):
|
|
414
|
+
self.path = path
|
|
415
|
+
self.records: Dict[str, SourceRecord] = {}
|
|
416
|
+
if path.exists():
|
|
417
|
+
try:
|
|
418
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
419
|
+
self.records = {url: SourceRecord(**rec) for url, rec in raw.items()}
|
|
420
|
+
except (OSError, ValueError, TypeError) as e:
|
|
421
|
+
# broken statistics are no reason to abort – we just learn again
|
|
422
|
+
warnings.warn(f"{path.name} unreadable ({e}), starting without source statistics", stacklevel=2)
|
|
423
|
+
|
|
424
|
+
def get(self, url: str) -> SourceRecord:
|
|
425
|
+
return self.records.get(url) or SourceRecord()
|
|
426
|
+
|
|
427
|
+
def score(self, url: str) -> float:
|
|
428
|
+
return self.get(url).score
|
|
429
|
+
|
|
430
|
+
def skip_reason(self, url: str, now: Optional[float] = None) -> Optional[str]:
|
|
431
|
+
rec = self.records.get(url)
|
|
432
|
+
if rec is None:
|
|
433
|
+
return None
|
|
434
|
+
now = time.time() if now is None else now
|
|
435
|
+
if rec.fail_streak >= UNREACHABLE_STREAK and now - rec.last_fetch < UNREACHABLE_PAUSE:
|
|
436
|
+
return "unreachable"
|
|
437
|
+
if rec.last_change and now - rec.last_change > STALE_AFTER and now - rec.first_seen > STALE_AFTER:
|
|
438
|
+
return "outdated"
|
|
439
|
+
if rec.runs >= 2 and rec.checked >= DEAD_MIN_CHECKED and rec.working < 0.5:
|
|
440
|
+
return "dead"
|
|
441
|
+
return None
|
|
442
|
+
|
|
443
|
+
def skip_now(self, url: str, now: Optional[float] = None) -> Optional[str]:
|
|
444
|
+
"""Like skip_reason, but an outdated list still gets fetched every STALE_RECHECK – it may be
|
|
445
|
+
maintained again. skip_reason stays the status for display, so it doesn't flip back and forth."""
|
|
446
|
+
now = time.time() if now is None else now
|
|
447
|
+
reason = self.skip_reason(url, now)
|
|
448
|
+
if reason == "outdated" and now - self.records[url].last_fetch >= STALE_RECHECK:
|
|
449
|
+
return None
|
|
450
|
+
return reason
|
|
451
|
+
|
|
452
|
+
def record_fetch(self, url: str, data: Optional[bytes], count: int, now: Optional[float] = None,
|
|
453
|
+
unchanged: bool = False, parsed: Optional[int] = None) -> None:
|
|
454
|
+
"""unchanged=True: the server answered 304 – reachable, same content as last time.
|
|
455
|
+
Hash and last_change stay as they are, so the outdated detection keeps working normally.
|
|
456
|
+
|
|
457
|
+
count: proxies of the types this run wants; parsed: proxies of any type. A list that has proxies,
|
|
458
|
+
just none of the requested types (e.g. --types http on a socks5-only list), did answer fine."""
|
|
459
|
+
now = time.time() if now is None else now
|
|
460
|
+
rec = self.records.setdefault(url, SourceRecord(first_seen=now))
|
|
461
|
+
rec.last_fetch = now
|
|
462
|
+
if unchanged: # also with 0 matching proxies (e.g. other --types): the source did answer
|
|
463
|
+
rec.fail_streak = 0
|
|
464
|
+
rec.count = count
|
|
465
|
+
return
|
|
466
|
+
if data is None or (count if parsed is None else parsed) == 0:
|
|
467
|
+
rec.fail_streak += 1
|
|
468
|
+
return
|
|
469
|
+
rec.fail_streak = 0
|
|
470
|
+
rec.count = count
|
|
471
|
+
digest = hashlib.sha1(data).hexdigest()
|
|
472
|
+
if digest != rec.content_hash:
|
|
473
|
+
rec.content_hash = digest
|
|
474
|
+
rec.last_change = now
|
|
475
|
+
|
|
476
|
+
def record_checks(self, results: Dict[str, Tuple[int, int]]) -> None:
|
|
477
|
+
"""results: url -> (checked, working) in this run."""
|
|
478
|
+
for url, (checked, working) in results.items():
|
|
479
|
+
if not checked:
|
|
480
|
+
continue
|
|
481
|
+
rec = self.records.setdefault(url, SourceRecord(first_seen=time.time()))
|
|
482
|
+
rec.checked = rec.checked * DECAY + checked
|
|
483
|
+
rec.working = rec.working * DECAY + working
|
|
484
|
+
rec.runs += 1
|
|
485
|
+
|
|
486
|
+
def ranking(self, urls: Iterable[str]) -> List[Tuple[str, SourceRecord]]:
|
|
487
|
+
return sorted(((u, self.get(u)) for u in urls), key=lambda x: -x[1].score)
|
|
488
|
+
|
|
489
|
+
def save(self) -> None:
|
|
490
|
+
payload = {url: asdict(rec) for url, rec in sorted(self.records.items())}
|
|
491
|
+
atomic_write(self.path, json.dumps(payload, indent=1))
|
proxyscraper/targets.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Target sites for --target: a proxy only counts if it really reaches these sites."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from urllib.parse import urlsplit
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
@dataclass(frozen=True)
|
|
10
|
+
class Target:
|
|
11
|
+
url: str
|
|
12
|
+
host: str
|
|
13
|
+
port: int
|
|
14
|
+
path: str
|
|
15
|
+
tls: bool
|
|
16
|
+
|
|
17
|
+
@property
|
|
18
|
+
def host_header(self) -> str:
|
|
19
|
+
"""Host header: with the port if it differs from the default – otherwise you may end up in the wrong vhost."""
|
|
20
|
+
return self.host if self.port == (443 if self.tls else 80) else f"{self.host}:{self.port}"
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def label(self) -> str:
|
|
24
|
+
"""Short name for display: https://www.google.com/ -> google.com"""
|
|
25
|
+
host = self.host[4:] if self.host.startswith("www.") else self.host
|
|
26
|
+
default = 443 if self.tls else 80
|
|
27
|
+
return host if self.port == default else f"{host}:{self.port}"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_target(text: str) -> Target:
|
|
31
|
+
"""'google.com' -> https://google.com/ ; raises ValueError for anything unusable."""
|
|
32
|
+
text = text.strip()
|
|
33
|
+
if "://" not in text:
|
|
34
|
+
text = "https://" + text
|
|
35
|
+
u = urlsplit(text)
|
|
36
|
+
if u.scheme not in ("http", "https"):
|
|
37
|
+
raise ValueError(f"only http:// and https:// are possible, not {u.scheme}://")
|
|
38
|
+
if not u.hostname:
|
|
39
|
+
raise ValueError(f"no host in {text!r}")
|
|
40
|
+
tls = u.scheme == "https"
|
|
41
|
+
try:
|
|
42
|
+
port = u.port
|
|
43
|
+
except ValueError as e: # port outside 0–65535 or similar
|
|
44
|
+
raise ValueError(f"invalid port in {text!r}") from e
|
|
45
|
+
if port is None:
|
|
46
|
+
port = 443 if tls else 80
|
|
47
|
+
elif port <= 0: # don't silently replace ":0" with the default port
|
|
48
|
+
raise ValueError(f"invalid port in {text!r}")
|
|
49
|
+
path = (u.path or "/") + (f"?{u.query}" if u.query else "")
|
|
50
|
+
default = 443 if tls else 80
|
|
51
|
+
netloc = u.hostname if port == default else f"{u.hostname}:{port}"
|
|
52
|
+
return Target(url=f"{u.scheme}://{netloc}{path}", host=u.hostname, port=port, path=path, tls=tls)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def target_label(url: str, others=()) -> str:
|
|
56
|
+
"""Short name; with the scheme when it could be confused (http:// and https:// of the same site)."""
|
|
57
|
+
try:
|
|
58
|
+
label = parse_target(url).label
|
|
59
|
+
except ValueError:
|
|
60
|
+
return url
|
|
61
|
+
if any(o != url and target_label(o) == label for o in others):
|
|
62
|
+
return url.split("/", 3)[0] + "//" + label
|
|
63
|
+
return label
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
# suggestions for the setup wizard
|
|
67
|
+
SUGGESTIONS = (
|
|
68
|
+
("Google", "https://www.google.com/"),
|
|
69
|
+
("YouTube", "https://www.youtube.com/"),
|
|
70
|
+
("Discord", "https://discord.com/"),
|
|
71
|
+
("Instagram", "https://www.instagram.com/"),
|
|
72
|
+
("X / Twitter", "https://x.com/"),
|
|
73
|
+
("Reddit", "https://www.reddit.com/"),
|
|
74
|
+
("TikTok", "https://www.tiktok.com/"),
|
|
75
|
+
("Amazon", "https://www.amazon.de/"),
|
|
76
|
+
)
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
"""Terminal UI: building blocks (widgets), live views (dashboard) and the final report (report).
|
|
2
|
+
|
|
3
|
+
The console is deliberately not re-exported: whatever prints uses `widgets.console` at runtime,
|
|
4
|
+
so it can be swapped (e.g. for recording screenshots or in tests).
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from . import widgets
|
|
8
|
+
from .dashboard import CheckDashboard, CollectView, LiveStats
|
|
9
|
+
from .report import render_source_ranking, render_summary
|
|
10
|
+
from .widgets import (
|
|
11
|
+
ACCENT,
|
|
12
|
+
BAD,
|
|
13
|
+
BLOCKED_HIT_RATE,
|
|
14
|
+
BORDER,
|
|
15
|
+
GOOD,
|
|
16
|
+
MUTED,
|
|
17
|
+
WARN,
|
|
18
|
+
banner,
|
|
19
|
+
bar,
|
|
20
|
+
fmt,
|
|
21
|
+
fmt_duration,
|
|
22
|
+
info,
|
|
23
|
+
note,
|
|
24
|
+
pct,
|
|
25
|
+
section,
|
|
26
|
+
section_end,
|
|
27
|
+
short_url,
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
__all__ = [
|
|
31
|
+
"ACCENT",
|
|
32
|
+
"BAD",
|
|
33
|
+
"BLOCKED_HIT_RATE",
|
|
34
|
+
"BORDER",
|
|
35
|
+
"GOOD",
|
|
36
|
+
"MUTED",
|
|
37
|
+
"WARN",
|
|
38
|
+
"CheckDashboard",
|
|
39
|
+
"CollectView",
|
|
40
|
+
"LiveStats",
|
|
41
|
+
"banner",
|
|
42
|
+
"bar",
|
|
43
|
+
"fmt",
|
|
44
|
+
"fmt_duration",
|
|
45
|
+
"info",
|
|
46
|
+
"note",
|
|
47
|
+
"pct",
|
|
48
|
+
"render_source_ranking",
|
|
49
|
+
"render_summary",
|
|
50
|
+
"section",
|
|
51
|
+
"section_end",
|
|
52
|
+
"short_url",
|
|
53
|
+
"widgets",
|
|
54
|
+
]
|