music on atproto
plyr.fm
1#!/usr/bin/env python3
2# /// script
3# requires-python = ">=3.11"
4# dependencies = ["httpx", "rich", "pydantic-settings", "plotext", "typer"]
5# ///
6"""cloudflare analytics dashboard - vanity metrics at your fingertips
7
8usage:
9 uv run scripts/cf_analytics.py # last 7 days (default)
10 uv run scripts/cf_analytics.py --days 30 # last 30 days
11 uv run scripts/cf_analytics.py -d 14 # last 14 days
12 uv run scripts/cf_analytics.py --no-cache # force refresh
13"""
14
15import hashlib
16import json
17from datetime import datetime, timedelta
18from pathlib import Path
19from typing import Any
20
21import httpx
22import plotext as plt
23import typer
24from pydantic_settings import BaseSettings, SettingsConfigDict
25from rich.console import Console
26from rich.panel import Panel
27from rich.table import Table
28
29
30class Settings(BaseSettings):
31 model_config = SettingsConfigDict(env_file=".env", extra="ignore")
32
33 cf_api_token: str
34 cf_zone_id: str | None = None
35 cf_account_id: str | None = None
36
37
38settings = Settings()
39
40GRAPHQL_ENDPOINT = "https://api.cloudflare.com/client/v4/graphql"
41CACHE_DIR = Path.home() / ".cache" / "plyr-analytics"
42
43console = Console()
44app = typer.Typer(add_completion=False)
45
46
47def get_cache_path(query_type: str, days: int) -> Path:
48 """get cache file path for a query"""
49 today = datetime.now().strftime("%Y-%m-%d")
50 key = f"{query_type}-{days}-{today}"
51 return CACHE_DIR / f"{hashlib.md5(key.encode()).hexdigest()[:12]}.json"
52
53
54def load_cache(query_type: str, days: int) -> dict[str, Any] | None:
55 """load cached data if valid (same calendar day)"""
56 cache_path = get_cache_path(query_type, days)
57 if cache_path.exists():
58 try:
59 data = json.loads(cache_path.read_text())
60 data.pop("_cached_date", None) # strip metadata before returning
61 console.print(f"[dim]using cached {query_type} data[/]")
62 return data
63 except (json.JSONDecodeError, KeyError):
64 cache_path.unlink(missing_ok=True)
65 return None
66
67
68def save_cache(query_type: str, days: int, data: dict[str, Any]) -> None:
69 """save data to cache"""
70 CACHE_DIR.mkdir(parents=True, exist_ok=True)
71 cache_path = get_cache_path(query_type, days)
72 cached = {"_cached_date": datetime.now().strftime("%Y-%m-%d"), **data}
73 cache_path.write_text(json.dumps(cached))
74
75
76def clear_old_cache() -> None:
77 """remove cache files from previous days"""
78 if not CACHE_DIR.exists():
79 return
80 today = datetime.now().strftime("%Y-%m-%d")
81 for f in CACHE_DIR.glob("*.json"):
82 try:
83 data = json.loads(f.read_text())
84 if data.get("_cached_date") != today:
85 f.unlink()
86 except (json.JSONDecodeError, KeyError):
87 f.unlink()
88
89
90def query_cf(query: str, variables: dict[str, Any] | None = None) -> dict[str, Any]:
91 """hit the cloudflare graphql api"""
92 headers = {
93 "Authorization": f"Bearer {settings.cf_api_token}",
94 "Content-Type": "application/json",
95 }
96 payload: dict[str, Any] = {"query": query}
97 if variables:
98 payload["variables"] = variables
99
100 resp = httpx.post(GRAPHQL_ENDPOINT, json=payload, headers=headers, timeout=30)
101 resp.raise_for_status()
102 data = resp.json()
103
104 if data.get("errors"):
105 raise Exception(f"GraphQL errors: {data['errors']}")
106
107 return data["data"]
108
109
110def get_zone_analytics(days: int = 7, use_cache: bool = True) -> dict[str, Any]:
111 """get HTTP request analytics for the zone"""
112 if use_cache and (cached := load_cache("zone", days)):
113 return cached
114
115 start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
116
117 query = """
118 query ZoneAnalytics($zoneTag: String!, $since: Date!) {
119 viewer {
120 zones(filter: {zoneTag: $zoneTag}) {
121 httpRequests1dGroups(
122 filter: {date_gt: $since}
123 orderBy: [date_ASC]
124 limit: 100
125 ) {
126 dimensions {
127 date
128 }
129 sum {
130 requests
131 pageViews
132 bytes
133 cachedBytes
134 threats
135 }
136 uniq {
137 uniques
138 }
139 }
140 }
141 }
142 }
143 """
144
145 result = query_cf(query, {"zoneTag": settings.cf_zone_id, "since": start_date})
146 save_cache("zone", days, result)
147 return result
148
149
150def get_web_analytics(days: int = 7, use_cache: bool = True) -> dict[str, Any]:
151 """get RUM/web analytics (actual browser visits)"""
152 if use_cache and (cached := load_cache("rum", days)):
153 return cached
154
155 since = (datetime.now() - timedelta(days=days)).isoformat() + "Z"
156
157 query = """
158 query WebAnalytics($accountTag: String!, $since: Time!) {
159 viewer {
160 accounts(filter: {accountTag: $accountTag}) {
161 rumPageloadEventsAdaptiveGroups(
162 filter: {datetime_gt: $since}
163 limit: 5000
164 ) {
165 count
166 sum {
167 visits
168 }
169 dimensions {
170 date: date
171 }
172 }
173 }
174 }
175 }
176 """
177
178 result = query_cf(query, {"accountTag": settings.cf_account_id, "since": since})
179 save_cache("rum", days, result)
180 return result
181
182
183def get_top_paths(days: int = 7) -> dict[str, Any]:
184 """get top pages by requests"""
185 start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
186
187 query = """
188 query TopPaths($zoneTag: String!, $since: Date!) {
189 viewer {
190 zones(filter: {zoneTag: $zoneTag}) {
191 httpRequests1dGroups(
192 filter: {date_gt: $since}
193 orderBy: [sum_requests_DESC]
194 limit: 10
195 ) {
196 sum {
197 requests
198 }
199 dimensions {
200 clientRequestPath
201 }
202 }
203 }
204 }
205 }
206 """
207
208 return query_cf(query, {"zoneTag": settings.cf_zone_id, "since": start_date})
209
210
211def get_countries(days: int = 7) -> dict[str, Any]:
212 """get requests by country"""
213 start_date = (datetime.now() - timedelta(days=days)).strftime("%Y-%m-%d")
214
215 query = """
216 query Countries($zoneTag: String!, $since: Date!) {
217 viewer {
218 zones(filter: {zoneTag: $zoneTag}) {
219 httpRequests1dGroups(
220 filter: {date_gt: $since}
221 orderBy: [sum_requests_DESC]
222 limit: 10
223 ) {
224 sum {
225 requests
226 }
227 dimensions {
228 clientCountryName
229 }
230 }
231 }
232 }
233 }
234 """
235
236 return query_cf(query, {"zoneTag": settings.cf_zone_id, "since": start_date})
237
238
239def format_bytes(b: int) -> str:
240 """human readable bytes"""
241 for unit in ["B", "KB", "MB", "GB", "TB"]:
242 if b < 1024:
243 return f"{b:.1f} {unit}"
244 b /= 1024
245 return f"{b:.1f} PB"
246
247
248def display_dashboard(days: int = 7, use_cache: bool = True) -> None:
249 """render the vanity dashboard"""
250 clear_old_cache()
251 console.print(f"\n[bold cyan]plyr.fm analytics[/] - last {days} days\n")
252
253 # zone analytics
254 if settings.cf_zone_id:
255 try:
256 data = get_zone_analytics(days, use_cache=use_cache)
257 groups = data["viewer"]["zones"][0]["httpRequests1dGroups"]
258
259 total_requests = sum(g["sum"]["requests"] for g in groups)
260 total_pageviews = sum(g["sum"]["pageViews"] for g in groups)
261 total_bytes = sum(g["sum"]["bytes"] for g in groups)
262 total_cached = sum(g["sum"]["cachedBytes"] for g in groups)
263 total_uniques = sum(g["uniq"]["uniques"] for g in groups)
264 total_threats = sum(g["sum"]["threats"] for g in groups)
265
266 cache_ratio = (total_cached / total_bytes * 100) if total_bytes > 0 else 0
267
268 stats_table = Table(show_header=False, box=None, padding=(0, 2))
269 stats_table.add_column(style="dim")
270 stats_table.add_column(style="bold green", justify="right")
271
272 stats_table.add_row("total requests", f"{total_requests:,}")
273 stats_table.add_row("page views", f"{total_pageviews:,}")
274 stats_table.add_row("unique visitors", f"{total_uniques:,}")
275 stats_table.add_row("bandwidth", format_bytes(total_bytes))
276 stats_table.add_row("cache hit ratio", f"{cache_ratio:.1f}%")
277 stats_table.add_row("threats blocked", f"{total_threats:,}")
278
279 console.print(
280 Panel(stats_table, title="[bold]zone stats[/]", border_style="blue")
281 )
282
283 # extract data for charts
284 dates = [g["dimensions"]["date"][-5:] for g in groups] # MM-DD
285 requests = [g["sum"]["requests"] for g in groups]
286 pageviews = [g["sum"]["pageViews"] for g in groups]
287 uniques = [g["uniq"]["uniques"] for g in groups]
288
289 # requests bar chart
290 plt.clear_figure()
291 plt.theme("dark")
292 plt.title("daily requests")
293 plt.bar(dates, requests, color="cyan")
294 plt.plotsize(80, 15)
295 plt.show()
296 print()
297
298 # pageviews vs uniques line chart
299 plt.clear_figure()
300 plt.theme("dark")
301 plt.title("pageviews vs unique visitors")
302 x = list(range(len(dates)))
303 plt.plot(x, pageviews, label="pageviews", color="green", marker="braille")
304 plt.plot(x, uniques, label="uniques", color="magenta", marker="braille")
305 plt.xticks(x, dates)
306 plt.plotsize(80, 15)
307 plt.show()
308 print()
309
310 except Exception as e:
311 console.print(f"[red]zone analytics error:[/] {e}")
312
313 # web analytics (RUM)
314 if settings.cf_account_id:
315 try:
316 data = get_web_analytics(days, use_cache=use_cache)
317 groups = data["viewer"]["accounts"][0]["rumPageloadEventsAdaptiveGroups"]
318
319 total_visits = sum(g["sum"]["visits"] for g in groups)
320 total_pageloads = sum(g["count"] for g in groups)
321
322 rum_table = Table(show_header=False, box=None, padding=(0, 2))
323 rum_table.add_column(style="dim")
324 rum_table.add_column(style="bold magenta", justify="right")
325
326 rum_table.add_row("visits (RUM)", f"{total_visits:,}")
327 rum_table.add_row("page loads", f"{total_pageloads:,}")
328
329 console.print(
330 Panel(rum_table, title="[bold]web analytics[/]", border_style="magenta")
331 )
332
333 except Exception as e:
334 console.print(f"[yellow]web analytics:[/] {e}")
335
336 console.print()
337
338
339@app.command()
340def main(
341 days: int = typer.Option(7, "-d", "--days", help="number of days to look back"),
342 no_cache: bool = typer.Option(False, "--no-cache", help="force refresh from API"),
343) -> None:
344 """cloudflare analytics dashboard for plyr.fm"""
345 if not settings.cf_zone_id and not settings.cf_account_id:
346 console.print("[red]error:[/] set CF_ZONE_ID and/or CF_ACCOUNT_ID in .env")
347 raise typer.Exit(1)
348
349 display_dashboard(days, use_cache=not no_cache)
350
351
352if __name__ == "__main__":
353 app()