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.
Files changed (57) hide show
  1. proxy_scraper_cli-1.7.1.dist-info/METADATA +794 -0
  2. proxy_scraper_cli-1.7.1.dist-info/RECORD +57 -0
  3. proxy_scraper_cli-1.7.1.dist-info/WHEEL +5 -0
  4. proxy_scraper_cli-1.7.1.dist-info/entry_points.txt +4 -0
  5. proxy_scraper_cli-1.7.1.dist-info/licenses/LICENSE +21 -0
  6. proxy_scraper_cli-1.7.1.dist-info/top_level.txt +1 -0
  7. proxyscraper/__init__.py +12 -0
  8. proxyscraper/__main__.py +7 -0
  9. proxyscraper/agent.py +635 -0
  10. proxyscraper/api.py +139 -0
  11. proxyscraper/app.py +595 -0
  12. proxyscraper/asndb.py +181 -0
  13. proxyscraper/blocklist.py +92 -0
  14. proxyscraper/checker.py +514 -0
  15. proxyscraper/cli.py +269 -0
  16. proxyscraper/compat.py +83 -0
  17. proxyscraper/completion.py +196 -0
  18. proxyscraper/exporters.py +118 -0
  19. proxyscraper/fetchcache.py +103 -0
  20. proxyscraper/geo.py +150 -0
  21. proxyscraper/geodb.py +143 -0
  22. proxyscraper/handshake.py +153 -0
  23. proxyscraper/history.py +106 -0
  24. proxyscraper/judges.py +159 -0
  25. proxyscraper/mcp_entry.py +34 -0
  26. proxyscraper/mcp_server.py +220 -0
  27. proxyscraper/netio.py +167 -0
  28. proxyscraper/options.py +274 -0
  29. proxyscraper/output.py +181 -0
  30. proxyscraper/pages.py +212 -0
  31. proxyscraper/parsing.py +192 -0
  32. proxyscraper/paths.py +55 -0
  33. proxyscraper/pipeline.py +434 -0
  34. proxyscraper/preferences.py +24 -0
  35. proxyscraper/publish.py +236 -0
  36. proxyscraper/server/__init__.py +41 -0
  37. proxyscraper/server/core.py +518 -0
  38. proxyscraper/server/http.py +164 -0
  39. proxyscraper/server/pool.py +195 -0
  40. proxyscraper/server/socks.py +65 -0
  41. proxyscraper/server/status.py +108 -0
  42. proxyscraper/server/upstream.py +119 -0
  43. proxyscraper/site/apple-touch-icon.png +0 -0
  44. proxyscraper/site/googleaac1161b7853c5b5.html +1 -0
  45. proxyscraper/site/index.html +934 -0
  46. proxyscraper/site/logo.png +0 -0
  47. proxyscraper/site/og.png +0 -0
  48. proxyscraper/sources.json +395 -0
  49. proxyscraper/sources.py +491 -0
  50. proxyscraper/targets.py +76 -0
  51. proxyscraper/ui/__init__.py +54 -0
  52. proxyscraper/ui/dashboard.py +344 -0
  53. proxyscraper/ui/keys.py +82 -0
  54. proxyscraper/ui/report.py +209 -0
  55. proxyscraper/ui/serve.py +142 -0
  56. proxyscraper/ui/widgets.py +250 -0
  57. proxyscraper/ui/wizard.py +595 -0
@@ -0,0 +1,24 @@
1
+ """Remembers the last choice from the setup wizard (as command line arguments)."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from typing import List, Optional
7
+
8
+ from .paths import DATA_DIR, atomic_write
9
+
10
+ PREFERENCES_FILE = DATA_DIR / "preferences.json"
11
+
12
+
13
+ def load_last_argv() -> Optional[List[str]]:
14
+ try:
15
+ argv = json.loads(PREFERENCES_FILE.read_text(encoding="utf-8")).get("last")
16
+ except (OSError, ValueError, AttributeError):
17
+ return None
18
+ if isinstance(argv, list) and all(isinstance(a, str) for a in argv):
19
+ return argv
20
+ return None
21
+
22
+
23
+ def save_last_argv(argv: List[str]) -> None:
24
+ atomic_write(PREFERENCES_FILE, json.dumps({"last": argv}, indent=1))
@@ -0,0 +1,236 @@
1
+ """Prepares the hits of a run for the `proxy-list` branch (runs in GitHub Actions).
2
+
3
+ python -m proxyscraper.publish results/<run> public/ --min 20
4
+
5
+ Writes lists per protocol, HTTPS and elite lists, JSON/CSV, badge files for shields.io,
6
+ a README and the website for GitHub Pages (site/index.html plus history.json for the trend).
7
+ If there are fewer than `--min` hits (e.g. because the runner was unlucky), it exits with
8
+ code 78 and writes nothing – the old list then stays online.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import argparse
14
+ import csv
15
+ import json
16
+ import os
17
+ import statistics
18
+ import sys
19
+ from collections import Counter
20
+ from datetime import datetime, timezone
21
+ from pathlib import Path
22
+ from typing import Dict, List, Optional
23
+
24
+ from .pages import write_pages
25
+ from .parsing import PROXY_TYPES
26
+
27
+ SKIP_EXIT_CODE = 78
28
+ SITE = Path(__file__).resolve().parent / "site" # index.html plus the images it links (logo, preview, touch icon)
29
+ RUN_HOURS = 1 # the proxy-list workflow runs every hour; clients read it from stats.json as run_hours
30
+ HISTORY_LIMIT = 30 * 24 // RUN_HOURS # 30 days of runs for the chart
31
+ RAW_BASE = "https://raw.githubusercontent.com/maximilianfeix/proxy-scraper/proxy-list"
32
+
33
+
34
+ def is_public(row: dict) -> bool:
35
+ """Proxies with credentials never belong in the public list – the login comes from some
36
+ third-party list, and once published it would be visible to everyone."""
37
+ return "@" not in row.get("proxy", "")
38
+
39
+
40
+ def load_rows(run_dir: Path) -> List[dict]:
41
+ rows = json.loads((run_dir / "proxies.json").read_text(encoding="utf-8"))
42
+ return sorted((r for r in rows if is_public(r)), key=lambda r: r["latency"])
43
+
44
+
45
+ def num(n: int) -> str:
46
+ """Thousands separators: 12345 -> 12,345"""
47
+ return f"{n:,}"
48
+
49
+
50
+ def badge(label: str, count: int, color: str) -> dict:
51
+ """Format for https://shields.io/badges/endpoint-badge"""
52
+ return {"schemaVersion": 1, "label": label, "message": num(count), "color": color}
53
+
54
+
55
+ def stats_for(rows: List[dict], now: datetime) -> dict:
56
+ return {
57
+ "updated": now.isoformat(timespec="seconds"),
58
+ "total": len(rows),
59
+ "by_type": {t: sum(1 for r in rows if r["ptype"] == t) for t in PROXY_TYPES},
60
+ "https": sum(1 for r in rows if r.get("https")),
61
+ "elite": sum(1 for r in rows if r.get("anonymity") == "elite"),
62
+ # only with provider data – otherwise "0" would wrongly mean "no datacenters"
63
+ "datacenter": sum(1 for r in rows if r.get("hosting")) if any(r.get("org") for r in rows) else None,
64
+ # only when the lookup ran – otherwise "0" would wrongly mean "none listed"
65
+ "blocklisted": sum(1 for r in rows if r.get("blocklisted"))
66
+ if any(r.get("blocklisted") is not None for r in rows) else None,
67
+ "stable": sum(1 for r in rows if r.get("streak", 0) >= STABLE_RUNS),
68
+ "run_hours": RUN_HOURS,
69
+ "countries": dict(Counter(r["country"] for r in rows if r.get("country")).most_common(15)),
70
+ "median_latency": round(statistics.median(r["latency"] for r in rows)) if rows else 0,
71
+ }
72
+
73
+
74
+ def write_lists(rows: List[dict], out: Path) -> Dict[str, int]:
75
+ files = {
76
+ "all.txt": [r["url"] for r in rows],
77
+ "https.txt": [r["url"] for r in rows if r.get("https")],
78
+ "elite.txt": [r["url"] for r in rows if r.get("anonymity") == "elite"],
79
+ }
80
+ for t in PROXY_TYPES:
81
+ files[f"{t}.txt"] = [r["proxy"] for r in rows if r["ptype"] == t]
82
+ for name, lines in files.items():
83
+ (out / name).write_text("".join(f"{line}\n" for line in lines), encoding="utf-8")
84
+ return {name: len(lines) for name, lines in files.items()}
85
+
86
+
87
+ def readme(stats: dict, counts: Dict[str, int]) -> str:
88
+ updated = datetime.fromisoformat(stats["updated"]).strftime("%Y-%m-%d %H:%M UTC")
89
+ rows = [
90
+ ("All (`type://ip:port`)", "all.txt"),
91
+ ("HTTP (`ip:port`)", "http.txt"),
92
+ ("SOCKS4 (`ip:port`)", "socks4.txt"),
93
+ ("SOCKS5 (`ip:port`)", "socks5.txt"),
94
+ ("HTTPS-capable (`type://ip:port`)", "https.txt"),
95
+ ("Elite (`type://ip:port`)", "elite.txt"),
96
+ ]
97
+ table = "\n".join(f"| {label} | {num(counts[name])} | [{name}]({RAW_BASE}/{name}) |" for label, name in rows)
98
+ details = f"[proxies.json]({RAW_BASE}/proxies.json) · [proxies.csv]({RAW_BASE}/proxies.csv)"
99
+ countries = " · ".join(f"{cc} {num(n)}" for cc, n in stats["countries"].items()) or "–"
100
+ return f"""# Live proxy list
101
+
102
+ Generated automatically by [proxy-scraper](https://github.com/maximilianfeix/proxy-scraper) with GitHub Actions.
103
+ Every proxy here really worked in the last run – sorted by latency, fastest first.
104
+ Browse and filter it on the [website](https://maximilianfeix.github.io/proxy-scraper/).
105
+
106
+ **Updated:** {updated} · **{num(stats["total"])} proxies** · median latency {num(stats["median_latency"])} ms
107
+
108
+ | List | Count | File |
109
+ |---|---:|---|
110
+ {table}
111
+ | Details (latency, country, HTTPS, anonymity) | {num(stats["total"])} | {details} |
112
+
113
+ **Top countries:** {countries}
114
+
115
+ > Public proxies are run by strangers. Never send passwords or personal data through them.
116
+ """
117
+
118
+
119
+ def step_summary(stats: dict) -> str:
120
+ by_type = " · ".join(f"{t}: {n}" for t, n in stats["by_type"].items())
121
+ return (f"### Proxy list updated\n\n**{stats['total']}** working proxies ({by_type}), "
122
+ f"{stats['https']} HTTPS-capable, {stats['elite']} elite, median latency {stats['median_latency']} ms\n")
123
+
124
+
125
+ def write_json(path: Path, data, indent: Optional[int] = None) -> None:
126
+ path.write_text(json.dumps(data, indent=indent, ensure_ascii=False), encoding="utf-8")
127
+
128
+
129
+ def load_history(path: Optional[Path]) -> List[dict]:
130
+ """History of the last runs (fetched from the branch) – broken or missing means: start over."""
131
+ if not path:
132
+ return []
133
+ try:
134
+ data = json.loads(path.read_text(encoding="utf-8"))
135
+ except (OSError, ValueError):
136
+ return []
137
+ if not isinstance(data, list):
138
+ return []
139
+ return [e for e in data if isinstance(e, dict) and isinstance(e.get("total"), int)]
140
+
141
+
142
+ def history_entry(stats: dict) -> dict:
143
+ return {"updated": stats["updated"], "total": stats["total"], "https": stats["https"],
144
+ "by_type": stats["by_type"], "median_latency": stats["median_latency"], "run_hours": RUN_HOURS}
145
+
146
+
147
+ def streak_factor(runs: List[dict]) -> int:
148
+ """Old streaks count runs of the previous interval: entries from before run_hours existed ran every 6 hours.
149
+ Multiplying keeps "up for 5 days" at 5 days when the interval gets shorter."""
150
+ if not runs:
151
+ return 1
152
+ before = runs[-1].get("run_hours", 6)
153
+ return max(1, before // RUN_HOURS) if type(before) is int and before > 0 else 1
154
+
155
+
156
+ STABLE_RUNS = 24 // RUN_HOURS # this many runs in a row (= 24 hours) means "stable"
157
+
158
+
159
+ def load_streaks(path: Optional[Path]) -> Dict[str, int]:
160
+ """url -> runs in a row it has been on the list (fetched from the branch); broken or missing = start over."""
161
+ if not path:
162
+ return {}
163
+ try:
164
+ data = json.loads(path.read_text(encoding="utf-8"))
165
+ except (OSError, ValueError):
166
+ return {}
167
+ if not isinstance(data, dict):
168
+ return {}
169
+ return {k: v for k, v in data.items() if isinstance(k, str) and type(v) is int and v > 0} # bool is an int too
170
+
171
+
172
+ def publish(run_dir: Path, out: Path, minimum: int = 20, now: Optional[datetime] = None,
173
+ history: Optional[Path] = None, streaks: Optional[Path] = None) -> int:
174
+ rows = load_rows(run_dir)
175
+ if len(rows) < minimum:
176
+ print(f"Only {len(rows)} hits (< {minimum}) – the old list stays online.")
177
+ return SKIP_EXIT_CODE
178
+ now = now or datetime.now(timezone.utc)
179
+ # whoever was there in the last run keeps counting – everyone else starts at 1, whoever is missing drops out
180
+ past_runs = load_history(history)
181
+ factor = streak_factor(past_runs)
182
+ previous = {url: n * factor for url, n in load_streaks(streaks).items()}
183
+ for row in rows:
184
+ row["streak"] = previous.get(row["url"], 0) + 1
185
+ out.mkdir(parents=True, exist_ok=True)
186
+ counts = write_lists(rows, out)
187
+ # don't just copy: rewrite the details so nothing with credentials ends up there either
188
+ (out / "proxies.json").write_text(json.dumps(rows, indent=1, ensure_ascii=False), encoding="utf-8")
189
+ with (run_dir / "proxies.csv").open(newline="", encoding="utf-8") as src, \
190
+ (out / "proxies.csv").open("w", newline="", encoding="utf-8") as dst:
191
+ reader = csv.DictReader(src)
192
+ writer = csv.DictWriter(dst, fieldnames=reader.fieldnames or ["proxy"])
193
+ writer.writeheader()
194
+ writer.writerows(r for r in reader if is_public(r))
195
+ stats = stats_for(rows, now)
196
+ write_json(out / "stats.json", stats, indent=1)
197
+ badges = out / "badges"
198
+ badges.mkdir(exist_ok=True)
199
+ colors = {"total": "brightgreen", "http": "blue", "socks4": "blueviolet", "socks5": "green"}
200
+ write_json(badges / "total.json", badge("working proxies", stats["total"], colors["total"]))
201
+ for t in PROXY_TYPES:
202
+ write_json(badges / f"{t}.json", badge(t, stats["by_type"][t], colors[t]))
203
+ write_json(badges / "updated.json", {"schemaVersion": 1, "label": "updated",
204
+ "message": now.strftime("%Y-%m-%d %H:%M UTC"), "color": "grey"})
205
+ (out / "README.md").write_text(readme(stats, counts), encoding="utf-8")
206
+ # website: a static page that loads proxies.json/stats.json/history.json from next door
207
+ for asset in SITE.iterdir():
208
+ if asset.suffix in (".html", ".png"):
209
+ (out / asset.name).write_bytes(asset.read_bytes())
210
+ (out / ".nojekyll").write_text("", encoding="utf-8") # Pages should serve the files unchanged
211
+ write_pages(rows, out, now) # static pages per protocol and country plus sitemap.xml, for search engines
212
+ runs = [*past_runs, history_entry(stats)][-HISTORY_LIMIT:]
213
+ write_json(out / "history.json", runs)
214
+ write_json(out / "streaks.json", {row["url"]: row["streak"] for row in rows})
215
+
216
+ summary_file = os.environ.get("GITHUB_STEP_SUMMARY")
217
+ if summary_file:
218
+ with open(summary_file, "a", encoding="utf-8") as fh:
219
+ fh.write(step_summary(stats))
220
+ print(f"{stats['total']} proxies written to {out}.")
221
+ return 0
222
+
223
+
224
+ def main(argv: Optional[List[str]] = None) -> int:
225
+ p = argparse.ArgumentParser(description="prepare the hits of a run for the proxy-list branch")
226
+ p.add_argument("run_dir", type=Path)
227
+ p.add_argument("out_dir", type=Path)
228
+ p.add_argument("--min", type=int, default=20, help="at least this many hits, otherwise write nothing")
229
+ p.add_argument("--history", type=Path, help="history.json from last time (for the chart on the website)")
230
+ p.add_argument("--streaks", type=Path, help="streaks.json from last time (how long each proxy has been listed)")
231
+ args = p.parse_args(argv)
232
+ return publish(args.run_dir, args.out_dir, args.min, history=args.history, streaks=args.streaks)
233
+
234
+
235
+ if __name__ == "__main__":
236
+ sys.exit(main())
@@ -0,0 +1,41 @@
1
+ """Local rotating proxy server (--serve).
2
+
3
+ pool.py which proxies there are and which one comes next
4
+ http.py take HTTP requests apart and rebuild them, check upstream responses
5
+ upstream.py connect through a proxy from the pool
6
+ core.py the server itself: accept clients, relay, switch on errors
7
+ """
8
+
9
+ from .core import FIRST_CHUNK_WAIT, MAX_ATTEMPTS, MAX_REPLAY_BODY, RequestLog, RotatingServer, ServerStats
10
+ from .http import (
11
+ PROXY_AUTH_REQUIRED,
12
+ SCREEN_LIMIT,
13
+ ResponseScreen,
14
+ forward_request,
15
+ origin_request,
16
+ parse_request_head,
17
+ plausible_answer,
18
+ )
19
+ from .pool import DISABLE_AFTER, PoolEntry, ProxyPool
20
+ from .upstream import UpstreamError, open_upstream
21
+
22
+ __all__ = [
23
+ "DISABLE_AFTER",
24
+ "FIRST_CHUNK_WAIT",
25
+ "MAX_ATTEMPTS",
26
+ "MAX_REPLAY_BODY",
27
+ "PROXY_AUTH_REQUIRED",
28
+ "SCREEN_LIMIT",
29
+ "PoolEntry",
30
+ "ProxyPool",
31
+ "RequestLog",
32
+ "ResponseScreen",
33
+ "RotatingServer",
34
+ "ServerStats",
35
+ "UpstreamError",
36
+ "forward_request",
37
+ "open_upstream",
38
+ "origin_request",
39
+ "parse_request_head",
40
+ "plausible_answer",
41
+ ]