#!/usr/bin/env bash # SPDX-License-Identifier: MIT # Copyright (c) 2026 Frost set -Eeuo pipefail WORKING_BRANCH="working" MAIN_BRANCH="main" REMOTE="origin" COMMIT_MSG="${*:-Update site}" # ---------- Colors ---------- if [[ -t 1 ]]; then RESET=$'\033[0m' BLUE=$'\033[1;34m' GREEN=$'\033[1;32m' YELLOW=$'\033[1;33m' RED=$'\033[1;31m' CYAN=$'\033[1;36m' else RESET='' BLUE='' GREEN='' YELLOW='' RED='' CYAN='' fi log_info() { printf '%b[INFO]%b %s\n' "$BLUE" "$RESET" "$*"; } log_ok() { printf '%b[ OK ]%b %s\n' "$GREEN" "$RESET" "$*"; } log_warn() { printf '%b[WARN]%b %s\n' "$YELLOW" "$RESET" "$*"; } log_error() { printf '%b[ERR ]%b %s\n' "$RED" "$RESET" "$*" >&2; } die() { log_error "$*" exit 1 } require_cmd() { command -v "$1" >/dev/null 2>&1 || die "Required command not found: $1" } confirm_step() { local prompt="$1" local answer while true; do printf '%b%s%b [Y/n]: ' "$CYAN" "$prompt" "$RESET" if ! IFS= read -r answer; then printf '\n' log_warn "Input closed; exiting safely." exit 0 fi case "$answer" in y|Y) return 0 ;; n|N) return 1 ;; *) printf '\n' log_warn "Invalid input; exiting safely." exit 0 ;; esac done } ensure_branch_exists() { git show-ref --verify --quiet "refs/heads/$1" || die "Branch does not exist: $1" } restore_branch() { if [[ -n "${START_BRANCH:-}" ]] && git show-ref --verify --quiet "refs/heads/$START_BRANCH"; then if [[ "$(git branch --show-current)" != "$START_BRANCH" ]]; then git switch --quiet "$START_BRANCH" >/dev/null 2>&1 || true fi fi } build_neocities_stats() { set -Eeuo pipefail log_info "Fetching Neocities stats + rebuilding analytics..." local project_root="$1" local STATS_URL="https://neocities.org/site/frostecho/stats" local INFO_URL="https://neocities.org/api/info?sitename=frostecho" local DAILY="$project_root/frostecho-stats.csv" local META="$project_root/frostecho-meta.csv" local STATS_MD="$project_root/content/stats.md" local OUT_FILE="$project_root/static/assets/svg/frostecho-traffic.svg" 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" > "$TMP_STATS" curl -fsSL "$INFO_URL" > "$TMP_INFO" python3 - "$TMP_STATS" "$TMP_INFO" "$DAILY" "$META" "$STATS_MD" "$OUT_FILE" <<'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]) md_path = Path(sys.argv[5]) svg_path = Path(sys.argv[6]) 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): 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, {}) new_hits = h.strip() new_visits = v.strip() merged.append({ "day": iso, "hits": new_hits if new_hits != "" else prev.get("hits", prev.get("views", "0")), "visits": new_visits if new_visits != "" else prev.get("visits", prev.get("views", "0")), }) seen.add(iso) for day, row in existing.items(): if day not in seen: merged.append({ "day": day, "hits": row.get("hits", row.get("views", "0")), "visits": row.get("visits", row.get("views", "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, last_updated_dt: datetime): views = int(info.get("views", 0)) total_hits = int(info.get("hits", 0)) last_updated_unix = int(last_updated_dt.timestamp()) days_since_creation = max(1, (last_updated_dt - 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}", last_updated_unix, ]) return views, total_hits, followers, days_since_creation, avg_views_per_day, avg_hits_per_day, last_updated_unix def update_markdown(path: Path, views: int, total_hits: int, followers: int, days_since_creation: int, avg_views_per_day: float, avg_hits_per_day: float, last_updated_dt: datetime): if not path.exists(): return text = path.read_text(encoding="utf-8") human_date = last_updated_dt.strftime("%Y-%m-%d %H:%M:%S UTC") replacements = [ ( r"(?m)^\s*-\s+\*\*Views\*\*:\s*.*$", f"- **Views**: {views}" ), ( r"(?m)^\s*-\s+\*\*Hits\*\*:\s*.*$", f"- **Hits**: {total_hits}" ), ( r"(?m)^\s*-\s+\*\*Followers\*\*:\s*.*$", f"- **Followers**: {followers}" ), ( r"(?m)^\s*-\s+\*\*Site Age\*\*:\s*.*$", f"- **Site Age**: {days_since_creation} days" ), ( r"(?m)^\s*-\s+\*\*Average Views / Day\*\*:\s*.*$", f"- **Average Views / Day**: {avg_views_per_day:.2f}" ), ( r"(?m)^\s*-\s+\*\*Average Hits / Day\*\*:\s*.*$", f"- **Average Hits / Day**: {avg_hits_per_day:.2f}" ), ( r"(?m)^\s*-\s+\*\*Last Updated\*\*:\s*.*$", f"- **Last Updated**: {human_date}" ), ] for pattern, replacement in replacements: text = re.sub(pattern, replacement, text, count=1) path.write_text(text, encoding="utf-8") 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 BG = "#04070b" SURFACE = "#0b1220" FG = "#e6f7ff" FG_SUBTLE = "#91abc1" BLUE = "#6ad6ff" GREEN = "#8dffbf" BORDER = "#20344f" GRID = "#172740" 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 left", 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) info = info_json.get("info", {}) last_updated_dt = datetime.now(timezone.utc) views, total_hits, followers, days_since_creation, avg_views_per_day, avg_hits_per_day, last_updated_unix = write_meta_csv( meta_path, info, followers, last_updated_dt ) update_markdown( md_path, views, total_hits, followers, days_since_creation, avg_views_per_day, avg_hits_per_day, last_updated_dt, ) 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 {md_path}\n {svg_path}") main() PY } sync_media_metadata() { local media_file="$1" local sync_script="$2" log_info "Syncing duplicate media metadata globally..." cat > "$sync_script" <<'PY' import re import sys from pathlib import Path RATING_RE = re.compile(r'(\{\{\<\s*rating\b[^>]*?\>\}\})') LINK_RE = re.compile(r'^\[(?P