diff --git a/scripts/directory-monitor.py b/scripts/directory-monitor.py new file mode 100644 index 0000000..da3f647 --- /dev/null +++ b/scripts/directory-monitor.py @@ -0,0 +1,185 @@ +#!/usr/bin/env python3 +""" +Bitcoin Apps Directory Monitor +============================== +Autonomous health check for bitcoinapps.info + +Reads src/data/apps.json and checks: +1. URL health — are all app URLs still online? +2. GitHub activity — are open-source repos still active? + +GitHub repos come from the "github" field in apps.json — single source of truth. +""" + +import json +import sys +import ssl +import re +import urllib.request +import urllib.error +from datetime import datetime, timezone +from concurrent.futures import ThreadPoolExecutor, as_completed + +APPS_JSON = "src/data/apps.json" + +ctx = ssl.create_default_context() +ctx.check_hostname = False +ctx.verify_mode = ssl.CERT_NONE + +GITHUB_RE = re.compile(r'github\.com/([^/]+/[^/]+)') + +# ─── Check 1: URL Health ──────────────────────────────────────────── + +def check_url(app): + """HTTP health check for an app URL.""" + title = app["title"] + url = app["url"].split("?")[0] + try: + req = urllib.request.Request(url, method="HEAD") + req.add_header("User-Agent", "Mozilla/5.0 (BitcoinAppsMonitor/1.0)") + with urllib.request.urlopen(req, timeout=15, context=ctx) as resp: + return {"title": title, "url": url, "status": resp.status, "error": None} + except urllib.error.HTTPError as e: + if e.code in (405, 403, 404): + try: + req = urllib.request.Request(url, method="GET") + req.add_header("User-Agent", "Mozilla/5.0 (BitcoinAppsMonitor/1.0)") + with urllib.request.urlopen(req, timeout=15, context=ctx) as resp: + return {"title": title, "url": url, "status": resp.status, "error": None} + except Exception: + pass + return {"title": title, "url": url, "status": e.code, "error": str(e)} + except urllib.error.URLError as e: + return {"title": title, "url": url, "status": 0, "error": str(e.reason)} + except Exception as e: + return {"title": title, "url": url, "status": 0, "error": str(e)} + +# ─── Check 2: GitHub Activity ─────────────────────────────────────── + +def check_github_repo(title, repo_slug): + """Check GitHub activity via gh CLI.""" + empty = { + "title": title, "repo": repo_slug, + "pushed_at": None, "days_since_push": None, + "stars": None, "open_issues": None, + "archived": None, "disabled": None, + "language": None, "error": None + } + try: + import subprocess + cmd = [ + "gh", "api", f"repos/{repo_slug}", + "--jq", '{pushed_at,stargazers_count,open_issues_count,archived,disabled,language,default_branch}' + ] + proc = subprocess.run(cmd, capture_output=True, text=True, timeout=15) + if proc.returncode == 0 and proc.stdout.strip(): + data = json.loads(proc.stdout) + pushed = datetime.fromisoformat(data["pushed_at"].replace("Z", "+00:00")) + empty["pushed_at"] = data["pushed_at"][:10] + empty["days_since_push"] = (datetime.now(timezone.utc) - pushed).days + empty["stars"] = data.get("stargazers_count") + empty["open_issues"] = data.get("open_issues_count") + empty["archived"] = bool(data.get("archived", False)) + empty["disabled"] = bool(data.get("disabled", False)) + empty["language"] = data.get("language", "") + empty["error"] = None + return empty + empty["error"] = f"gh returned {proc.returncode}: {proc.stderr.strip()[:200]}" + return empty + except Exception as e: + empty["error"] = str(e) + return empty + +# ─── Main ──────────────────────────────────────────────────────────── + +def main(): + print("⚡ Bitcoin Apps Directory Monitor") + print(f"📅 {datetime.now(timezone.utc).strftime('%Y-%m-%d %H:%M UTC')}") + print("=" * 60) + + with open(APPS_JSON) as f: + apps = json.load(f) + print(f"📊 {len(apps)} apps\n") + + # ── 1. URL Health ───────────────────────────────────────── + print("🔍 Checking URL health...") + url_results = [] + with ThreadPoolExecutor(max_workers=20) as executor: + futures = {executor.submit(check_url, a): a for a in apps} + for future in as_completed(futures): + url_results.append(future.result()) + + ok = [r for r in url_results if r["status"] and 100 <= r["status"] < 400] + errors = [r for r in url_results if not r["status"] or r["status"] >= 400] + print(f" ✅ {len(ok)} ❌ {len(errors)}") + + # ── 2. GitHub Activity ──────────────────────────────────── + # Read github field from apps.json — single source of truth + print("\n🐙 Checking GitHub activity...") + gh_apps = [] + for a in apps: + github = a.get("github", "") + m = GITHUB_RE.search(github) + if m: + gh_apps.append((a["title"], m.group(1))) + print(f" {len(gh_apps)} repos (from 'github' field in apps.json)") + + gh = [check_github_repo(t, r) for t, r in gh_apps] + + healthy = [r for r in gh if r["error"] is None and not r.get("archived") and (r.get("days_since_push") or 9999) <= 180] + stale = [r for r in gh if r["error"] is None and not r.get("archived") and 180 < (r.get("days_since_push") or 0) <= 365] + dead = [r for r in gh if r.get("archived") or (r.get("days_since_push") and r["days_since_push"] > 365)] + errored = [r for r in gh if r["error"]] + + print(f" 🟢 {len(healthy)} 🟡 {len(stale)} 🔴 {len(dead)} ⚠️ {len(errored)}") + + # ── Report ──────────────────────────────────────────────── + print(f"\n{'=' * 60}") + if errors: + print(f"\n❌ DEAD APPS ({len(errors)}):") + for r in sorted(errors, key=lambda x: x["title"]): + print(f" • {r['title']} {r['status']} {r['error']}\n {r['url']}") + else: + print("\n✅ All apps online") + + if stale: + print(f"\n🟡 STALE REPOS (>180 days):") + for r in sorted(stale, key=lambda x: x["days_since_push"]): + print(f" • {r['title']} {r['repo']} {r['pushed_at']} ({r['days_since_push']}d ago)") + if dead: + print(f"\n🔴 DEAD REPOS (>365 days / archived):") + for r in sorted(dead, key=lambda x: x.get("days_since_push") or 0): + status = "archived" if r.get("archived") else f"{r['days_since_push']}d ago" + print(f" • {r['title']} {r['repo']} {status}") + if errored: + print(f"\n⚠️ GH ERRORS ({len(errored)}):") + for r in errored: + print(f" • {r['title']} {r['repo']} {r['error']}") + + if not errors and not stale and not dead and not errored: + print("\n✅ All clear!") + + # Save report + report = { + "date": datetime.now(timezone.utc).isoformat(), + "total_apps": len(apps), + "urls_ok": len(ok), + "urls_errors": len(errors), + "github_total": len(gh_apps), + "github_healthy": len(healthy), + "github_stale": len(stale), + "github_dead": len(dead), + "github_errors": len(errored), + "url_error_details": errors, + "github_stale_details": stale, + "github_dead_details": dead, + } + with open("monitor_report.json", "w") as f: + json.dump(report, f, indent=2) + + if errors or dead: + sys.exit(1) + sys.exit(0) + +if __name__ == "__main__": + main() diff --git a/src/data/apps.json b/src/data/apps.json index 5ea3a38..0721a59 100644 --- a/src/data/apps.json +++ b/src/data/apps.json @@ -11,35 +11,38 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/getalby/mcp" }, { "categories": [ "ai" ], "title": "Alby Bitcoin Builder Skill", - "description": "Generate Lightning-powered apps, payment flows, and wallet logic in minutes — ready to test and ship.", + "description": "Generate Lightning-powered apps, payment flows, and wallet logic in minutes \u2014 ready to test and ship.", "url": "https://github.com/getAlby/alby-agent-skill?tab=readme-ov-file&utm_source=getalby", "platforms": [ "desktop" ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/getAlby/alby-agent-skill" }, { "categories": [ "ai" ], "title": "Alby Bitcoin Wallet Skill", - "description": "Give your agent its own wallet — and let it operate independently. Perfect for autonomous agents like OpenClaw.", + "description": "Give your agent its own wallet \u2014 and let it operate independently. Perfect for autonomous agents like OpenClaw.", "url": "https://github.com/getAlby/alby-cli-skill?tab=readme-ov-file&utm_source=getalby", "platforms": [ "desktop" ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/getAlby/alby-cli-skill" }, { "categories": [ @@ -53,7 +56,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/getAlby/paidmcp" }, { "categories": [ @@ -78,7 +82,7 @@ "ai" ], "title": "PayPerQ", - "description": "GPT4, DALL·E, and more AI chatbots using pay-per-usage model", + "description": "GPT4, DALL\u00b7E, and more AI chatbots using pay-per-usage model", "url": "https://ppq.ai/invite/6366bd9e?utm_source=getalby", "platforms": [ "web" @@ -178,7 +182,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/coracle-social/flotilla" }, { "categories": [ @@ -196,7 +201,8 @@ ], "products": [ "alby_go" - ] + ], + "github": "https://github.com/HiveTalk/hivetalksfu" }, { "categories": [ @@ -313,7 +319,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/stackernews/stacker.news" }, { "categories": [ @@ -423,7 +430,8 @@ "url": "https://shopstr.store/?ref=getalby.com&utm_source=getalby", "platforms": [ "web" - ] + ], + "github": "https://github.com/shopstr-eng/shopstr" }, { "categories": [ @@ -500,7 +508,7 @@ "rewards" ], "title": "sMiles", - "description": "Earn sats by playing games, watching ads, and even… walking", + "description": "Earn sats by playing games, watching ads, and even\u2026 walking", "url": "https://www.smilesbitcoin.com/?ref=getalby.com&utm_source=getalby", "platforms": [ "web", @@ -651,7 +659,8 @@ "url": "https://nostr.build/?ref=getalby.com&utm_source=getalby", "platforms": [ "web" - ] + ], + "github": "https://github.com/nostrbuild/nostr.build" }, { "categories": [ @@ -757,7 +766,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/vitorpamplona/amethyst" }, { "categories": [ @@ -771,7 +781,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/coracle-social/coracle" }, { "categories": [ @@ -787,7 +798,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/damus-io/damus" }, { "categories": [ @@ -816,7 +828,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/Freerse/Freerse" }, { "categories": [ @@ -844,7 +857,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/irislib/iris-messenger" }, { "categories": [ @@ -916,7 +930,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/quentintaranpino/nostrcheck-api-ts" }, { "categories": [ @@ -1218,7 +1233,8 @@ "url": "https://lightning-poker.com/?utm_source=getalby", "platforms": [ "web" - ] + ], + "github": "https://github.com/j-chimienti/lightning-poker" }, { "categories": [ @@ -1229,7 +1245,8 @@ "url": "https://lightning-roulette.com/?utm_source=getalby", "platforms": [ "web" - ] + ], + "github": "https://github.com/igreshev/lightning-roulette" }, { "categories": [ @@ -1291,7 +1308,8 @@ "image": "pages/discover/satoshi-s-place.png", "platforms": [ "web" - ] + ], + "github": "https://github.com/LightningK0ala/satoshis.place" }, { "categories": [ @@ -1350,7 +1368,8 @@ "url": "https://simln.dev/?ref=getalby.com&utm_source=getalby", "platforms": [ "web" - ] + ], + "github": "https://github.com/bitcoin-dev-project/sim-ln" }, { "categories": [ @@ -1397,7 +1416,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/btcpayserver/btcpayserver" }, { "categories": [ @@ -1519,7 +1539,8 @@ ], "walletSubcategory": [ "nwc_wallets" - ] + ], + "github": "https://github.com/lnbits/lnbits" }, { "categories": [ @@ -1592,7 +1613,8 @@ "walletSubcategory": [ "nwc_wallets", "wallet_interfaces" - ] + ], + "github": "https://github.com/ZeusLN/zeus" }, { "categories": [ @@ -1614,7 +1636,8 @@ "url": "https://satoshisend.xyz/", "platforms": [ "web" - ] + ], + "github": "https://github.com/alecsmrekar/satoshisend" }, { "categories": [ @@ -1625,7 +1648,8 @@ "url": "https://mullvad.net/?ref=getalby&utm_source=getalby", "platforms": [ "web" - ] + ], + "github": "https://github.com/mullvad/mullvadvpn-app" }, { "categories": [ @@ -1654,7 +1678,7 @@ "privacy" ], "title": "Obscura", - "description": "Privacy-first VPN that can’t log your activity", + "description": "Privacy-first VPN that can\u2019t log your activity", "url": "https://obscura.net/?ref=getalby&utm_source=getalby", "platforms": [ "web" @@ -1691,7 +1715,8 @@ "url": "https://tunnelsats.com/?ref=getalby&utm_source=getalby", "platforms": [ "web" - ] + ], + "github": "https://github.com/Tunnelsats/tunnelsats" }, { "categories": [ @@ -1756,7 +1781,8 @@ "url": "https://cal.com/?utm_source=getalby", "platforms": [ "web" - ] + ], + "github": "https://github.com/calcom/cal.com" }, { "categories": [ @@ -1925,7 +1951,8 @@ "url": "https://zap.cooking/?ref=getalby.com&utm_source=getalby", "platforms": [ "web" - ] + ], + "github": "https://github.com/zapcooking/frontend" }, { "categories": [ @@ -1947,7 +1974,8 @@ "url": "https://zapin.me/?ref=getalby.com&utm_source=getalby", "platforms": [ "web" - ] + ], + "github": "https://github.com/miguelmedeiros/zapin.me" }, { "categories": [ @@ -2007,7 +2035,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/bramkanstein/startwithbitcoin" }, { "categories": [ @@ -2150,7 +2179,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/zapstore/zapstore" }, { "categories": [ @@ -2206,7 +2236,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/jinglescode/nostr.kiwi" }, { "categories": [ @@ -2221,7 +2252,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/damus-io/notedeck" }, { "categories": [ @@ -2257,7 +2289,8 @@ ], "walletSubcategory": [ "nwc_wallets" - ] + ], + "github": "https://github.com/ClubOrangeBitcoin/ClubOrange-releases" }, { "categories": [ @@ -2272,7 +2305,8 @@ ], "protocols": [ "nwc" - ] + ], + "github": "https://github.com/frnandu/yana" }, { "categories": [ @@ -2382,7 +2416,8 @@ ], "walletSubcategory": [ "wallet_interfaces" - ] + ], + "github": "https://github.com/SparrowTek/avocadough" }, { "categories": [