upleb.uk

Public git repos — served from a NIP-34 GRASP relay at git.upleb.uk

summaryrefslogtreecommitdiff
path: root/scripts/build-pool-index.py
diff options
context:
space:
mode:
Diffstat (limited to 'scripts/build-pool-index.py')
-rw-r--r--scripts/build-pool-index.py174
1 files changed, 174 insertions, 0 deletions
diff --git a/scripts/build-pool-index.py b/scripts/build-pool-index.py
new file mode 100644
index 0000000..d3eb386
--- /dev/null
+++ b/scripts/build-pool-index.py
@@ -0,0 +1,174 @@
1#!/usr/bin/env python3
2#
3# Build a static public pool directory from one or more Bitflash pool_status.json
4# files. Inputs may be local paths or http(s) URLs.
5
6import argparse
7import html
8import json
9import os
10import time
11import urllib.parse
12import urllib.request
13
14
15def load_json(source):
16 if source.startswith("http://") or source.startswith("https://"):
17 with urllib.request.urlopen(source, timeout=10) as r:
18 return json.loads(r.read().decode("utf-8"))
19 with open(source, "r", encoding="utf-8-sig") as f:
20 return json.load(f)
21
22
23def normalize_pool(source, obj, now):
24 operator = obj.get("operator", {})
25 pool = obj.get("pool", {})
26 node = obj.get("node", {})
27 rounds_file = operator.get("roundsFile") or "pool_rounds.json"
28 rounds_url = ""
29 if source.startswith("http://") or source.startswith("https://"):
30 rounds_url = urllib.parse.urljoin(source, rounds_file)
31 updated = int(obj.get("updatedAt") or 0)
32 age = max(0, now - updated) if updated else None
33 online = bool(node.get("poolRunning")) and age is not None and age <= 120
34 return {
35 "source": source,
36 "name": operator.get("name") or "Bitflash Pool",
37 "btfAddress": operator.get("btfAddress") or "",
38 "feePercent": float(operator.get("feePercent") or 0.0),
39 "dashboardUrl": operator.get("dashboardUrl") or "",
40 "height": int(node.get("height") or 0),
41 "online": online,
42 "ageSeconds": age,
43 "authorizedMiners": int(pool.get("authorizedMiners") or 0),
44 "blocksFoundSession": int(pool.get("blocksFoundSession") or 0),
45 "roundShares": int(pool.get("roundShares") or 0),
46 "hashRate": float(pool.get("hashRate") or 0.0),
47 "roundsUrl": rounds_url,
48 "recentRounds": obj.get("recentRounds", []),
49 }
50
51
52def rank_key(pool):
53 return (
54 1 if pool["online"] else 0,
55 pool["hashRate"],
56 pool["authorizedMiners"],
57 -pool["feePercent"],
58 pool["name"].lower(),
59 )
60
61
62def render_html(pools, generated_at):
63 rows = []
64 for p in pools:
65 dash = p["dashboardUrl"]
66 rounds = p.get("roundsUrl", "")
67 # Only linkify http(s). The dashboard URL comes from an operator's
68 # self-published status file, so a "javascript:" or "data:" scheme
69 # would become a clickable script-injection on this public page.
70 dash_lower = dash.lower()
71 if dash_lower.startswith("http://") or dash_lower.startswith("https://"):
72 dash_html = '<a href="{0}" rel="noopener noreferrer">dashboard</a>'.format(
73 html.escape(dash, quote=True))
74 else:
75 dash_html = "-"
76 rounds_lower = rounds.lower()
77 if rounds_lower.startswith("http://") or rounds_lower.startswith("https://"):
78 rounds_html = '<a href="{0}" rel="noopener noreferrer">proof</a>'.format(
79 html.escape(rounds, quote=True))
80 else:
81 rounds_html = "-"
82 rows.append(
83 "<tr>"
84 "<td>{status}</td>"
85 "<td><strong>{name}</strong><br><code>{addr}</code></td>"
86 "<td>{fee:.2f}%</td>"
87 "<td>{miners}</td>"
88 "<td>{hashrate:.2f}</td>"
89 "<td>{blocks}</td>"
90 "<td>{height}</td>"
91 "<td>{age}</td>"
92 "<td>{dash}</td>"
93 "<td>{rounds}</td>"
94 "</tr>".format(
95 status="online" if p["online"] else "stale",
96 name=html.escape(p["name"]),
97 addr=html.escape(p["btfAddress"]),
98 fee=p["feePercent"],
99 miners=p["authorizedMiners"],
100 hashrate=p["hashRate"],
101 blocks=p["blocksFoundSession"],
102 height=p["height"],
103 age="{}s".format(p["ageSeconds"]) if p["ageSeconds"] is not None else "-",
104 dash=dash_html,
105 rounds=rounds_html,
106 )
107 )
108
109 return """<!doctype html>
110<html lang="en">
111<head>
112 <meta charset="utf-8">
113 <meta name="viewport" content="width=device-width, initial-scale=1">
114 <title>Bitflash Pools</title>
115 <style>
116 body {{ font: 15px/1.45 system-ui, sans-serif; margin: 32px; color: #111; }}
117 table {{ border-collapse: collapse; width: 100%; }}
118 th, td {{ border-bottom: 1px solid #ddd; padding: 10px; text-align: left; vertical-align: top; }}
119 th {{ font-size: 12px; text-transform: uppercase; letter-spacing: .08em; }}
120 code {{ font-size: 12px; word-break: break-all; }}
121 .note {{ color: #555; max-width: 780px; }}
122 </style>
123</head>
124<body>
125 <h1>Bitflash Pools</h1>
126 <p class="note">This directory is informational. Bitflash nodes discover pools through Nostr and .btf; this page only helps humans compare public operators.</p>
127 <table>
128 <thead>
129 <tr><th>Status</th><th>Pool</th><th>Fee</th><th>Miners</th><th>H/s</th><th>Blocks</th><th>Height</th><th>Age</th><th>Link</th><th>Proof</th></tr>
130 </thead>
131 <tbody>
132 {rows}
133 </tbody>
134 </table>
135 <p class="note">Generated at {generated_at}. JSON: <a href="pools.json">pools.json</a>.</p>
136</body>
137</html>
138""".format(rows="\n ".join(rows), generated_at=generated_at)
139
140
141def main():
142 ap = argparse.ArgumentParser()
143 ap.add_argument("--out-dir", default="pool-site")
144 ap.add_argument("sources", nargs="+", help="pool_status.json paths or URLs")
145 args = ap.parse_args()
146
147 now = int(time.time())
148 pools = []
149 errors = []
150 for source in args.sources:
151 try:
152 pools.append(normalize_pool(source, load_json(source), now))
153 except Exception as e:
154 errors.append({"source": source, "error": str(e)})
155
156 pools.sort(key=rank_key, reverse=True)
157 os.makedirs(args.out_dir, exist_ok=True)
158
159 generated_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(now))
160 with open(os.path.join(args.out_dir, "pools.json"), "w", encoding="utf-8") as f:
161 json.dump({
162 "schema": "bitflash-pool-directory-1",
163 "generatedAt": generated_at,
164 "pools": pools,
165 "errors": errors,
166 }, f, indent=2, sort_keys=True)
167 f.write("\n")
168
169 with open(os.path.join(args.out_dir, "index.html"), "w", encoding="utf-8") as f:
170 f.write(render_html(pools, generated_at))
171
172
173if __name__ == "__main__":
174 main()