From 3b914d4eaaa6d06a8b0f3c69b1e8d89ea68ef7bf Mon Sep 17 00:00:00 2001 From: malkuthro <166633396+malkuthro@users.noreply.github.com> Date: Sun, 10 May 2026 22:18:54 +0200 Subject: [PATCH] Add Comfy Registry download tracker (#127) --- .github/workflows/registry-metrics.yml | 52 +++++++ tools/collect_registry_metrics.py | 205 +++++++++++++++++++++++++ 2 files changed, 257 insertions(+) create mode 100644 .github/workflows/registry-metrics.yml create mode 100644 tools/collect_registry_metrics.py diff --git a/.github/workflows/registry-metrics.yml b/.github/workflows/registry-metrics.yml new file mode 100644 index 0000000..3f09a7f --- /dev/null +++ b/.github/workflows/registry-metrics.yml @@ -0,0 +1,52 @@ +name: Track Comfy Registry metrics + +on: + workflow_dispatch: + schedule: + - cron: "17 7 * * *" + +permissions: + contents: write + +jobs: + collect: + name: Collect download trend + runs-on: ubuntu-latest + steps: + - name: Check out source + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Preserve collector + run: cp tools/collect_registry_metrics.py /tmp/collect_registry_metrics.py + + - name: Check out metrics branch + run: | + set -euo pipefail + git fetch origin registry-metrics || true + if git show-ref --verify --quiet refs/remotes/origin/registry-metrics; then + git switch --track origin/registry-metrics + else + git switch --orphan registry-metrics + git rm -rf . || true + fi + + - name: Collect registry metrics + run: python /tmp/collect_registry_metrics.py --node koolook --output-dir metrics + + - name: Commit metrics + run: | + set -euo pipefail + git config user.name "github-actions[bot]" + git config user.email "41898282+github-actions[bot]@users.noreply.github.com" + git add metrics/comfy-registry-downloads.csv metrics/README.md + if git diff --cached --quiet; then + echo "No metrics changes to commit." + exit 0 + fi + git commit -m "Update Comfy Registry metrics" + git push origin registry-metrics diff --git a/tools/collect_registry_metrics.py b/tools/collect_registry_metrics.py new file mode 100644 index 0000000..76d9e0f --- /dev/null +++ b/tools/collect_registry_metrics.py @@ -0,0 +1,205 @@ +#!/usr/bin/env python3 +"""Collect Comfy Registry download trend snapshots. + +The Comfy Registry currently exposes downloads at the node/package level, +not per version. This script records that package-level total over time and +adds version/status context so we can see when a new release becomes active. +""" +from __future__ import annotations + +import argparse +import csv +import json +import re +import sys +import urllib.request +from collections.abc import Iterable +from dataclasses import dataclass +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + + +API_ROOT = "https://api.comfy.org" +NODE_ID_RE = re.compile(r"^[A-Za-z0-9._-]+$") +CSV_FIELDS = [ + "date", + "timestamp_utc", + "downloads_total", + "daily_delta", + "rolling_7d_avg", + "latest_version", + "latest_status", + "pending_versions", + "active_versions", +] + + +@dataclass(frozen=True) +class Snapshot: + date: str + timestamp_utc: str + downloads_total: int + daily_delta: int + rolling_7d_avg: str + latest_version: str + latest_status: str + pending_versions: str + active_versions: str + + def as_row(self) -> dict[str, str]: + return { + "date": self.date, + "timestamp_utc": self.timestamp_utc, + "downloads_total": str(self.downloads_total), + "daily_delta": str(self.daily_delta), + "rolling_7d_avg": self.rolling_7d_avg, + "latest_version": self.latest_version, + "latest_status": self.latest_status, + "pending_versions": self.pending_versions, + "active_versions": self.active_versions, + } + + +def _fetch_json(url: str) -> Any: + if not url.startswith(f"{API_ROOT}/"): + raise ValueError(f"refusing non-registry URL: {url}") + req = urllib.request.Request(url, headers={"User-Agent": "koolook-metrics/1.0"}) + with urllib.request.urlopen(req, timeout=30) as response: # nosec B310 + return json.loads(response.read().decode("utf-8")) + + +def _read_rows(path: Path) -> list[dict[str, str]]: + if not path.exists(): + return [] + with path.open(newline="", encoding="utf-8") as fh: + return list(csv.DictReader(fh)) + + +def _write_rows(path: Path, rows: Iterable[dict[str, str]]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with path.open("w", newline="", encoding="utf-8") as fh: + writer = csv.DictWriter(fh, fieldnames=CSV_FIELDS) + writer.writeheader() + writer.writerows(rows) + + +def _version_status(version: dict[str, Any]) -> str: + return str(version.get("status", "")).replace("NodeVersionStatus", "") + + +def _rolling_average(rows: list[dict[str, str]]) -> str: + deltas = [int(row.get("daily_delta") or 0) for row in rows[-7:]] + if not deltas: + return "0.0" + return f"{sum(deltas) / len(deltas):.1f}" + + +def _make_snapshot(node: dict[str, Any], versions: list[dict[str, Any]], rows: list[dict[str, str]]) -> Snapshot: + now = datetime.now(timezone.utc) + downloads = int(node.get("downloads") or 0) + previous_downloads = int(rows[-1]["downloads_total"]) if rows else downloads + daily_delta = downloads - previous_downloads + + latest = node.get("latest_version") or {} + pending = [ + str(item.get("version", "")) + for item in versions + if item.get("status") == "NodeVersionStatusPending" + ] + active = [ + str(item.get("version", "")) + for item in versions + if item.get("status") == "NodeVersionStatusActive" + ] + + candidate = Snapshot( + date=now.date().isoformat(), + timestamp_utc=now.isoformat(timespec="seconds").replace("+00:00", "Z"), + downloads_total=downloads, + daily_delta=daily_delta, + rolling_7d_avg="0.0", + latest_version=str(latest.get("version", "")), + latest_status=_version_status(latest), + pending_versions=", ".join(pending), + active_versions=", ".join(active), + ) + average_rows = [*rows, candidate.as_row()] + return Snapshot( + **{ + **candidate.as_row(), + "downloads_total": candidate.downloads_total, + "daily_delta": candidate.daily_delta, + "rolling_7d_avg": _rolling_average(average_rows), + } + ) + + +def _upsert_today(rows: list[dict[str, str]], snapshot: Snapshot) -> list[dict[str, str]]: + today = snapshot.date + kept = [row for row in rows if row.get("date") != today] + return [*kept, snapshot.as_row()] + + +def _render_overview(node_id: str, rows: list[dict[str, str]]) -> str: + if not rows: + return f"# Comfy Registry Metrics: {node_id}\n\nNo snapshots recorded yet.\n" + + latest = rows[-1] + recent = rows[-14:] + table = "\n".join( + "| {date} | {downloads_total} | {daily_delta} | {rolling_7d_avg} | {latest_version} | {latest_status} | {pending_versions} |".format(**row) + for row in reversed(recent) + ) + return f"""# Comfy Registry Metrics: {node_id} + +Last updated: `{latest["timestamp_utc"]}` + +## Current + +- Total downloads: **{latest["downloads_total"]}** +- Latest active version: **{latest["latest_version"]}** ({latest["latest_status"]}) +- Pending versions: **{latest["pending_versions"] or "none"}** +- Last recorded daily change: **{latest["daily_delta"]}** +- Rolling 7-day average: **{latest["rolling_7d_avg"]}/day** + +## Recent Trend + +| Date | Total | Daily change | 7-day avg | Latest | Status | Pending | +|---|---:|---:|---:|---|---|---| +{table} + +Note: Comfy Registry exposes downloads at package level, not per version. +""" + + +def main() -> int: + parser = argparse.ArgumentParser() + parser.add_argument("--node", default="koolook") + parser.add_argument("--output-dir", default="metrics") + args = parser.parse_args() + if not NODE_ID_RE.fullmatch(args.node): + parser.error("--node must be a Comfy Registry node id") + + output_dir = Path(args.output_dir) + csv_path = output_dir / "comfy-registry-downloads.csv" + overview_path = output_dir / "README.md" + + node = _fetch_json(f"{API_ROOT}/nodes/{args.node}") + versions = _fetch_json(f"{API_ROOT}/nodes/{args.node}/versions") + rows = _read_rows(csv_path) + snapshot = _make_snapshot(node, versions, rows) + updated_rows = _upsert_today(rows, snapshot) + + _write_rows(csv_path, updated_rows) + overview_path.write_text(_render_overview(args.node, updated_rows), encoding="utf-8") + + print( + f"{snapshot.date}: downloads={snapshot.downloads_total} " + f"delta={snapshot.daily_delta} pending={snapshot.pending_versions or 'none'}" + ) + return 0 + + +if __name__ == "__main__": + sys.exit(main())