diff options
Diffstat (limited to 'scripts/build-pool-index.py')
| -rw-r--r-- | scripts/build-pool-index.py | 174 |
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 | |||
| 6 | import argparse | ||
| 7 | import html | ||
| 8 | import json | ||
| 9 | import os | ||
| 10 | import time | ||
| 11 | import urllib.parse | ||
| 12 | import urllib.request | ||
| 13 | |||
| 14 | |||
| 15 | def 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 | |||
| 23 | def 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 | |||
| 52 | def 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 | |||
| 62 | def 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 | |||
| 141 | def 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 | |||
| 173 | if __name__ == "__main__": | ||
| 174 | main() | ||