#!/usr/bin/env bash # SPDX-License-Identifier: MIT # Copyright (c) 2026 Frost SITE_NAME="frostecho" # Replace with your name BG="#04070b" SURFACE="#0b1220" FG="#e6f7ff" FG_SUBTLE="#91abc1" BLUE="#6ad6ff" GREEN="#8dffbf" BORDER="#20344f" GRID="#172740" build_neocities_stats() { set -Eeuo pipefail echo "[INFO] Fetching Neocities stats + rebuilding analytics..." local project_root="$1" local STATS_URL="https://neocities.org/site/${SITE_NAME}/stats" local INFO_URL="https://neocities.org/api/info?sitename=${SITE_NAME}" local DAILY="$project_root/${SITE_NAME}-stats.csv" local META="$project_root/${SITE_NAME}-meta.csv" local OUT_FILE="$project_root/${SITE_NAME}-traffic.svg" mkdir -p "$(dirname "$DAILY")" mkdir -p "$(dirname "$META")" mkdir -p "$(dirname "$OUT_FILE")" touch "$DAILY" touch "$META" local TMP_STATS TMP_INFO TMP_STATS="$(mktemp)" TMP_INFO="$(mktemp)" cleanup() { rm -f "$TMP_STATS" "$TMP_INFO" } trap cleanup RETURN curl -fsSL "$STATS_URL" -o "$TMP_STATS" curl -fsSL "$INFO_URL" -o "$TMP_INFO" if [[ ! -s "$TMP_STATS" ]]; then echo "Failed to fetch stats page" return 1 fi if [[ ! -s "$TMP_INFO" ]]; then echo "Failed to fetch API info" return 1 fi python3 - \ "$TMP_STATS" \ "$TMP_INFO" \ "$DAILY" \ "$META" \ "$OUT_FILE" \ "$BG" \ "$SURFACE" \ "$FG" \ "$FG_SUBTLE" \ "$BLUE" \ "$GREEN" \ "$BORDER" \ "$GRID" <<'PY' import csv import json import re import sys from datetime import datetime, timezone from pathlib import Path import matplotlib as mpl import matplotlib.dates as mdates import matplotlib.pyplot as plt stats_path = Path(sys.argv[1]) info_path = Path(sys.argv[2]) daily_path = Path(sys.argv[3]) meta_path = Path(sys.argv[4]) svg_path = Path(sys.argv[5]) BG = sys.argv[6] SURFACE = sys.argv[7] FG = sys.argv[8] FG_SUBTLE = sys.argv[9] BLUE = sys.argv[10] GREEN = sys.argv[11] BORDER = sys.argv[12] GRID = sys.argv[13] # Optional: change this if you want long-term averages. CREATED_AT = datetime(2026, 4, 7, tzinfo=timezone.utc) def parse_stats_page(html: str): labels_match = re.search(r"labels:\s*\[(.*?)\]", html, re.S) hits_match = re.search(r"label:\s*['\"]Hits['\"].*?data:\s*\[(.*?)\]", html, re.S) visits_match = re.search(r"label:\s*['\"]Visits['\"].*?data:\s*\[(.*?)\]", html, re.S) followers_match = re.search(r"([\d,]+)\s*followers", html, re.I) if not (labels_match and hits_match and visits_match): raise SystemExit("Could not parse stats page") dates = re.findall(r'"([^"]+)"', labels_match.group(1)) hits = [x.strip() for x in hits_match.group(1).split(",")] visits = [x.strip() for x in visits_match.group(1).split(",")] followers = int(followers_match.group(1).replace(",", "")) if followers_match else 0 return dates, hits, visits, followers def load_existing_daily(path: Path): if path.exists() and path.stat().st_size == 0: return {} existing = {} if path.exists(): with path.open(newline="", encoding="utf-8") as f: reader = csv.DictReader(f) for row in reader: day = (row.get("day") or "").strip() if day: existing[day] = row return existing def merge_daily_rows(dates, hits, visits, existing): merged = [] seen = set() for d, h, v in zip(dates, hits, visits): iso = datetime.strptime(d, "%b %d, %Y").strftime("%Y-%m-%d") prev = existing.get(iso, {}) merged.append({ "day": iso, "hits": h.strip() if h.strip() else prev.get("hits", "0"), "visits": v.strip() if v.strip() else prev.get("visits", "0"), }) seen.add(iso) for day, row in existing.items(): if day not in seen: merged.append({ "day": day, "hits": row.get("hits", "0"), "visits": row.get("visits", "0"), }) merged.sort(key=lambda r: r["day"]) return merged def write_daily_csv(path: Path, merged): with path.open("w", newline="", encoding="utf-8") as f: writer = csv.DictWriter(f, fieldnames=["day", "hits", "visits"]) writer.writeheader() writer.writerows(merged) def write_meta_csv(path: Path, info: dict, followers: int, now: datetime): views = int(info.get("views", 0)) total_hits = int(info.get("hits", 0)) days_since_creation = max(1, (now - CREATED_AT).days) avg_views_per_day = views / days_since_creation avg_hits_per_day = total_hits / days_since_creation with path.open("w", newline="", encoding="utf-8") as f: writer = csv.writer(f) writer.writerow([ "views", "hits", "followers", "days_since_creation", "avg_views_per_day", "avg_hits_per_day", "last_updated_unix", ]) writer.writerow([ views, total_hits, followers, days_since_creation, f"{avg_views_per_day:.2f}", f"{avg_hits_per_day:.2f}", int(now.timestamp()), ]) def to_series(merged): dates_dt = [] hits_vals = [] visits_vals = [] for row in merged: try: dates_dt.append(datetime.strptime(row["day"], "%Y-%m-%d")) hits_vals.append(int(row.get("hits", 0))) visits_vals.append(int(row.get("visits", 0))) except Exception: continue if not dates_dt: raise SystemExit("No valid data to plot") return dates_dt, hits_vals, visits_vals def ema(values, alpha=0.35): out = [values[0]] for v in values[1:]: out.append(alpha * v + (1 - alpha) * out[-1]) return out def plot_chart(svg_path: Path, dates_dt, hits_vals, visits_vals): mpl.rcParams["svg.fonttype"] = "none" mpl.rcParams["path.simplify"] = True mpl.rcParams["path.simplify_threshold"] = 1.0 mpl.rcParams["axes.unicode_minus"] = False hits_ma = ema(hits_vals, alpha=0.35) visits_ma = ema(visits_vals, alpha=0.35) fig, ax = plt.subplots(figsize=(12, 5.5)) fig.patch.set_facecolor(BG) ax.set_facecolor(SURFACE) ax.bar( dates_dt, hits_vals, width=0.8, color=BLUE, alpha=0.10, label="_nolegend_", ) ax.bar( dates_dt, visits_vals, width=0.8, color=GREEN, alpha=0.10, label="_nolegend_", ) ax.plot( dates_dt, hits_ma, color=BLUE, linewidth=2.8, label="Hits EMA", ) ax.plot( dates_dt, visits_ma, color=GREEN, linewidth=2.8, label="Visits EMA", ) ax.set_yscale("log") ax.tick_params(axis="x", colors=FG_SUBTLE, labelsize=10) ax.tick_params(axis="y", colors=FG_SUBTLE, labelsize=10) for spine in ax.spines.values(): spine.set_color(BORDER) ax.grid(axis="y", color=GRID, linewidth=0.8, alpha=0.9) ax.legend( ncols=2, facecolor=SURFACE, edgecolor=BORDER, labelcolor=FG, fontsize=10, loc="upper right", framealpha=0.95, ) locator = mdates.AutoDateLocator() formatter = mdates.ConciseDateFormatter(locator) ax.xaxis.set_major_locator(locator) ax.xaxis.set_major_formatter(formatter) ax.margins(x=0.02) fig.autofmt_xdate() fig.savefig( svg_path, format="svg", bbox_inches="tight", facecolor=fig.get_facecolor(), ) plt.close(fig) def main(): stats_html = stats_path.read_text(encoding="utf-8") info_json = json.loads(info_path.read_text(encoding="utf-8")) dates, hits, visits, followers = parse_stats_page(stats_html) existing = load_existing_daily(daily_path) merged = merge_daily_rows( dates, hits, visits, existing, ) write_daily_csv(daily_path, merged) write_meta_csv( meta_path, info_json.get("info", {}), followers, datetime.now(timezone.utc), ) dates_dt, hits_vals, visits_vals = to_series(merged) plot_chart( svg_path, dates_dt, hits_vals, visits_vals, ) print(f"Updated:\n {daily_path}\n {meta_path}\n {svg_path}") main() PY } build_neocities_stats .