""" Lexistats API — receives samples from GitHub Actions, serves stats dashboard + API. Endpoints: POST /api/v1/samples — ingest a sample (requires API key) GET /api/v1/stats — full stats blob (same shape as old stats.json) GET /api/v1/rankings — lexicon rankings (events/sec, unique users) GET /api/v1/history/:nsid — time series for a specific lexicon GET /api/v1/lexicons — list all known lexicons with latest stats GET /health — health check GET / — dashboard page """ import os from datetime import datetime from contextlib import contextmanager from typing import Optional from decimal import Decimal import psycopg2 import psycopg2.extras from fastapi import FastAPI, Header, HTTPException, Query from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse from pydantic import BaseModel app = FastAPI(title="Lexistats API", version="0.1.0") app.add_middleware( CORSMiddleware, allow_origins=["*"], allow_methods=["GET", "POST"], allow_headers=["*"], ) DB_DSN = os.environ.get("DATABASE_URL", "") DID_CAP_PER_NSID = 10_000 DID_EXPIRE_DAYS = 30 @contextmanager def get_db(): conn = psycopg2.connect(DB_DSN) try: yield conn conn.commit() except Exception: conn.rollback() raise finally: conn.close() def verify_api_key(api_key: str): with get_db() as conn: cur = conn.cursor() cur.execute("SELECT 1 FROM api_keys WHERE key = %s", (api_key,)) if not cur.fetchone(): raise HTTPException(status_code=401, detail="Invalid API key") def decimal_to_float(obj): """Convert Decimal values for JSON serialization.""" if isinstance(obj, Decimal): return float(obj) if isinstance(obj, dict): return {k: decimal_to_float(v) for k, v in obj.items()} if isinstance(obj, list): return [decimal_to_float(i) for i in obj] return obj # --- Models --- class SamplePayload(BaseModel): ts: str duration_sec: int = 60 total: int counts: dict[str, int] unique_dids: Optional[dict[str, list[str]]] = None # --- Ingest --- @app.post("/api/v1/samples") def ingest_sample(payload: SamplePayload, x_api_key: str = Header(...)): verify_api_key(x_api_key) ts = datetime.fromisoformat(payload.ts) eps = round(payload.total / max(payload.duration_sec, 1), 2) with get_db() as conn: cur = conn.cursor() cur.execute( "INSERT INTO lexicon_samples (ts, duration_sec, total_events, events_per_sec) " "VALUES (%s, %s, %s, %s) RETURNING id", (ts, payload.duration_sec, payload.total, eps) ) sample_id = cur.fetchone()[0] rows = [] for nsid, count in payload.counts.items(): nsid_eps = round(count / max(payload.duration_sec, 1), 2) rows.append((sample_id, nsid, count, nsid_eps)) psycopg2.extras.execute_values( cur, "INSERT INTO lexicon_counts (sample_id, nsid, event_count, events_per_sec) VALUES %s", rows ) if payload.unique_dids: for nsid, dids in payload.unique_dids.items(): cur.execute( "SELECT COUNT(*) FROM lexicon_unique_dids WHERE nsid = %s", (nsid,) ) current_count = cur.fetchone()[0] for did in dids: if current_count >= DID_CAP_PER_NSID: cur.execute( "UPDATE lexicon_unique_dids SET last_seen = %s " "WHERE nsid = %s AND did = %s", (ts, nsid, did) ) else: cur.execute( "INSERT INTO lexicon_unique_dids (nsid, did, first_seen, last_seen) " "VALUES (%s, %s, %s, %s) " "ON CONFLICT (nsid, did) DO UPDATE SET last_seen = %s", (nsid, did, ts, ts, ts) ) if cur.rowcount == 1: current_count += 1 cur.execute( "DELETE FROM lexicon_unique_dids WHERE last_seen < now() - interval '%s days'", (DID_EXPIRE_DAYS,) ) return {"ok": True, "sample_id": sample_id, "lexicons": len(payload.counts)} # --- Stats (compatible with old stats.json shape) --- @app.get("/api/v1/stats") def get_stats(): """Full stats blob — same shape as the old stats.json for dashboard compatibility.""" with get_db() as conn: cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) # Totals cur.execute(""" SELECT COUNT(*) as total_samples, COALESCE(SUM(total_events), 0) as total_events, MAX(ts) as last_updated FROM lexicon_samples """) totals = cur.fetchone() # Per-collection aggregates cur.execute(""" SELECT c.nsid, SUM(c.event_count) as count, ROUND(SUM(c.event_count)::numeric / NULLIF( (SELECT SUM(total_events) FROM lexicon_samples), 0 ) * 100, 1) as pct, MIN(s.ts) as first_seen, MAX(s.ts) as last_seen FROM lexicon_counts c JOIN lexicon_samples s ON s.id = c.sample_id GROUP BY c.nsid ORDER BY count DESC """) collections_rows = cur.fetchall() collections = {} for r in collections_rows: collections[r["nsid"]] = { "count": int(r["count"]), "pct": float(r["pct"] or 0), "first_seen": r["first_seen"].isoformat(), "last_seen": r["last_seen"].isoformat(), } # History (all samples with per-collection breakdown) cur.execute(""" SELECT s.id, s.ts, s.duration_sec, s.total_events, s.events_per_sec FROM lexicon_samples s ORDER BY s.ts ASC """) samples = cur.fetchall() # Get counts for all samples in one query cur.execute(""" SELECT sample_id, nsid, event_count, events_per_sec FROM lexicon_counts ORDER BY sample_id """) all_counts = cur.fetchall() # Group counts by sample_id counts_by_sample = {} for row in all_counts: sid = row["sample_id"] if sid not in counts_by_sample: counts_by_sample[sid] = {} counts_by_sample[sid][row["nsid"]] = { "count": int(row["event_count"]), "eps": float(row["events_per_sec"] or 0) } history = [] for s in samples: sid = s["id"] sample_counts = counts_by_sample.get(sid, {}) history.append({ "ts": s["ts"].isoformat(), "duration_sec": s["duration_sec"], "total": int(s["total_events"]), "eps": float(s["events_per_sec"] or 0), "counts": {nsid: d["count"] for nsid, d in sample_counts.items()}, "counts_per_sec": {nsid: d["eps"] for nsid, d in sample_counts.items()}, }) # Unique users per NSID (trailing 7 days) cur.execute(""" SELECT nsid, COUNT(*) as unique_users FROM lexicon_unique_dids WHERE last_seen > now() - interval '7 days' GROUP BY nsid """) unique_users = {r["nsid"]: int(r["unique_users"]) for r in cur.fetchall()} return { "last_updated": totals["last_updated"].isoformat() if totals["last_updated"] else None, "total_samples": int(totals["total_samples"]), "total_events": int(totals["total_events"]), "collections": collections, "history": history, "unique_users_7d": unique_users, } # --- Query --- @app.get("/api/v1/rankings") def get_rankings( period_hours: int = Query(default=168), limit: int = Query(default=50) ): with get_db() as conn: cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(""" SELECT c.nsid, ROUND(AVG(c.events_per_sec), 2) as avg_eps, SUM(c.event_count) as total_events, COUNT(DISTINCT s.id) as sample_count, MIN(s.ts) as first_sample, MAX(s.ts) as last_sample FROM lexicon_counts c JOIN lexicon_samples s ON s.id = c.sample_id WHERE s.ts > now() - make_interval(hours => %s) GROUP BY c.nsid ORDER BY avg_eps DESC LIMIT %s """, (period_hours, limit)) rankings = cur.fetchall() nsids = [r["nsid"] for r in rankings] uniques = {} if nsids: cur.execute(""" SELECT nsid, COUNT(*) as unique_users FROM lexicon_unique_dids WHERE nsid = ANY(%s) AND last_seen > now() - make_interval(hours => %s) GROUP BY nsid """, (nsids, period_hours)) for row in cur.fetchall(): uniques[row["nsid"]] = int(row["unique_users"]) result = [] for r in rankings: result.append({ "nsid": r["nsid"], "avg_eps": float(r["avg_eps"]), "total_events": int(r["total_events"]), "sample_count": int(r["sample_count"]), "first_sample": r["first_sample"].isoformat(), "last_sample": r["last_sample"].isoformat(), "unique_users_trailing": uniques.get(r["nsid"], 0), }) return {"period_hours": period_hours, "rankings": result} @app.get("/api/v1/history/{nsid:path}") def get_history(nsid: str, period_hours: int = Query(default=168)): with get_db() as conn: cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(""" SELECT s.ts, c.event_count, c.events_per_sec FROM lexicon_counts c JOIN lexicon_samples s ON s.id = c.sample_id WHERE c.nsid = %s AND s.ts > now() - make_interval(hours => %s) ORDER BY s.ts ASC """, (nsid, period_hours)) rows = [{"ts": r["ts"].isoformat(), "event_count": int(r["event_count"]), "events_per_sec": float(r["events_per_sec"])} for r in cur.fetchall()] return {"nsid": nsid, "period_hours": period_hours, "history": rows} @app.get("/api/v1/lexicons") def list_lexicons(): with get_db() as conn: cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(""" WITH latest AS ( SELECT id FROM lexicon_samples ORDER BY ts DESC LIMIT 1 ), trailing_7d AS ( SELECT c.nsid, ROUND(AVG(c.events_per_sec), 2) as avg_eps_7d, SUM(c.event_count) as total_events_7d FROM lexicon_counts c JOIN lexicon_samples s ON s.id = c.sample_id WHERE s.ts > now() - interval '7 days' GROUP BY c.nsid ) SELECT t.nsid, t.avg_eps_7d, t.total_events_7d, c.event_count as latest_count, c.events_per_sec as latest_eps, m.description, m.category, m.domain, m.unique_users_7d FROM trailing_7d t LEFT JOIN latest l ON true LEFT JOIN lexicon_counts c ON c.sample_id = l.id AND c.nsid = t.nsid LEFT JOIN lexicon_meta m ON m.nsid = t.nsid ORDER BY t.avg_eps_7d DESC """) rows = [decimal_to_float(dict(r)) for r in cur.fetchall()] return {"lexicons": rows} @app.get("/api/v1/lexicon-meta") def get_lexicon_meta(): """All lexicons with schema metadata, descriptions, categories, and links.""" with get_db() as conn: cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(""" SELECT m.nsid, m.authority, m.domain, m.description, m.category, m.tags, m.schema_type, m.lexicon_url, m.total_events, m.unique_users_7d, m.first_seen, m.last_seen, m.spidered_at, m.schema_json IS NOT NULL as has_schema, m.record_example IS NOT NULL as has_example FROM lexicon_meta m ORDER BY m.unique_users_7d DESC NULLS LAST, m.total_events DESC NULLS LAST """) rows = cur.fetchall() result = [] for r in rows: result.append({ "nsid": r["nsid"], "authority": r["authority"], "domain": r["domain"], "description": r["description"], "category": r["category"], "tags": r["tags"] or [], "schema_type": r["schema_type"], "lexicon_url": r["lexicon_url"], "total_events": int(r["total_events"] or 0), "unique_users_7d": int(r["unique_users_7d"] or 0), "first_seen": r["first_seen"].isoformat() if r["first_seen"] else None, "last_seen": r["last_seen"].isoformat() if r["last_seen"] else None, "has_schema": r["has_schema"], "has_example": r["has_example"], "spidered": r["spidered_at"] is not None, }) return {"lexicons": result} @app.get("/api/v1/lexicon-meta/{nsid:path}") def get_lexicon_detail(nsid: str): """Full detail for a single lexicon including schema and example record.""" with get_db() as conn: cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(""" SELECT * FROM lexicon_meta WHERE nsid = %s """, (nsid,)) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Lexicon not found") return { "nsid": row["nsid"], "authority": row["authority"], "domain": row["domain"], "description": row["description"], "category": row["category"], "tags": row["tags"] or [], "schema_type": row["schema_type"], "lexicon_url": row["lexicon_url"], "schema": row["schema_json"], "example_record": row["record_example"], "total_events": int(row["total_events"] or 0), "unique_users_7d": int(row["unique_users_7d"] or 0), "first_seen": row["first_seen"].isoformat() if row["first_seen"] else None, "last_seen": row["last_seen"].isoformat() if row["last_seen"] else None, "spidered_at": row["spidered_at"].isoformat() if row["spidered_at"] else None, } @app.get("/api/v1/feed/collections") def feed_collections(): """Return non-bsky authority wildcards for Jetstream wantedCollections.""" official = {'app.bsky', 'chat.bsky', 'com.atproto', 'tools.ozone'} with get_db() as conn: cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor) cur.execute(""" SELECT authority, SUM(total_events) as events FROM lexicon_meta WHERE authority != ALL(%s) GROUP BY authority ORDER BY SUM(total_events) DESC NULLS LAST """, (list(official),)) rows = cur.fetchall() collections = [r["authority"] + ".*" for r in rows] return {"collections": collections, "count": len(collections)} @app.get("/health") def health(): with get_db() as conn: cur = conn.cursor() cur.execute("SELECT COUNT(*) FROM lexicon_samples") count = cur.fetchone()[0] return {"status": "ok", "samples": count} # --- Dashboard --- @app.get("/", response_class=HTMLResponse) def dashboard(): return DASHBOARD_HTML @app.get("/chooser", response_class=HTMLResponse) def chooser(): return CHOOSER_HTML @app.get("/chooser/embed.js") def chooser_embed_js(): from fastapi.responses import Response return Response(content=CHOOSER_EMBED_JS, media_type="application/javascript") @app.get("/feed", response_class=HTMLResponse) def feed(): return FEED_HTML @app.get("/feed/view", response_class=HTMLResponse) def feed_view(uri: str = Query(..., description="AT URI to view")): """Single-record viewer: fetches the record from PDS, shows nice preview + raw JSON.""" return RECORD_VIEW_HTML.replace("__AT_URI__", uri) DASHBOARD_HTML = """\ LexiStats — ATProto Lexicon Usage

LexiStats

Stats · Live Feed · Chooser · Nick's Garden ↗
by LinkedTrust.us

ATProto Lexicon Usage from Jetstream

Loading stats...

API & Data Sources

Our data comes from Jetstream sampling, enriched with schema data from Lexicon Garden and direct PDS record fetches.

Our API

GET /api/v1/lexicon-metaAll lexicons with descriptions, categories, links, usage stats
GET /api/v1/lexicon-meta/{nsid}Full detail for one lexicon (schema, example record)
GET /api/v1/statsFull stats blob (history, per-collection counts)
GET /api/v1/rankingsLexicon rankings by events/sec
GET /api/v1/history/{nsid}Time series for a specific lexicon

Lexicon Garden XRPC

Resolve any lexicon schema definition (the source our spider uses):

GET https://lexicon.garden/xrpc/com.atproto.lexicon.resolveLexicon?nsid={nsid}

Example: app.bsky.feed.post · com.linkedclaims.claim

""" CHOOSER_HTML = """\ Lexicon Chooser — Find ATProto Lexicons to Reuse

LexiStats

Stats · Live Feed · Chooser · Nick's Garden ↗
by LinkedTrust.us

Search and discover ATProto lexicons to reuse in your app. Sorted by real usage data from the network.

Loading lexicons...

Embed this chooser

Add the lexicon chooser to your site:

<div id="lexicon-chooser"></div>
<script src="https://lexistats.linkedtrust.us/chooser/embed.js"></script>
""" CHOOSER_EMBED_JS = """\ (function() { const container = document.getElementById('lexicon-chooser'); if (!container) return; const iframe = document.createElement('iframe'); iframe.src = 'https://lexistats.linkedtrust.us/chooser'; iframe.style.cssText = 'width:100%;min-height:700px;border:1px solid #30363d;border-radius:6px;'; iframe.setAttribute('frameborder', '0'); container.appendChild(iframe); })(); """ RECORD_VIEW_HTML = """\ ATProto Record Viewer

LexiStats Feed / Record Viewer

Viewing an ATProto record with full details.

Loading record...
""" FEED_HTML = """\ ATProto Feed — Beyond Bluesky

LexiStats

Stats · Live Feed · Chooser · Nick's Garden ↗
by LinkedTrust.us

ATProto records from beyond Bluesky. Comment, like, or claim about any record.

Not connected
Chooser
|

ATProto feed from beyond Bluesky.

Records buffer silently — load them when you're ready.

"""