From 1e6ecb989b1a99e70412fbcd63dfa7a2ea4b62cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B0=E7=95=91=E3=81=B2=E3=81=A8=E3=81=97?= Date: Sun, 23 Aug 2026 10:35:04 +0900 Subject: [PATCH] Metrics: keep the download numbers GitHub throws away after 14 days MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The project had no measurable download at all. `git clone` is not counted, and the Source code zip/tar.gz GitHub attaches to every release is excluded from download_count — so all six releases report 0 assets and 0 downloads, which is not a low number but the absence of one. The only real signal, traffic clones, is served from a rolling 14-day window: a day not fetched is gone for good. Two workflows fix both halves. Publish image builds amd64 and arm64 on a `v*` tag and pushes to Docker Hub, whose pull count is a public unauthenticated API — the first honest download figure the project has had. It also builds (without pushing) on PRs that touch the Dockerfile or requirements, so a broken image is found before the tag is cut, not on release day. Metrics runs daily at 12:00 JST and appends traffic, release assets and pull count to a private Gist, so the window stops being the horizon. scripts/metrics.py does the collecting and doubles as the reader: GIST_ID=... python3 scripts/metrics.py report Same day's snapshot overwrites rather than appends, because the current day is still climbing when the cron fires and the next run picks up the rest. Verified end to end against the live repo: the first snapshot captured all 14 days still in the window. Needs four repo secrets — DOCKERHUB_USERNAME, DOCKERHUB_TOKEN, METRICS_TOKEN (classic PAT: gist + public_repo, the latter because traffic requires push), and GIST_ID. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01UJoGRWhksAqQttFdTZYRSm --- .github/workflows/docker-publish.yml | 83 ++++++++++ .github/workflows/metrics.yml | 40 +++++ scripts/metrics.py | 239 +++++++++++++++++++++++++++ 3 files changed, 362 insertions(+) create mode 100644 .github/workflows/docker-publish.yml create mode 100644 .github/workflows/metrics.yml create mode 100755 scripts/metrics.py diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml new file mode 100644 index 0000000..6afb211 --- /dev/null +++ b/.github/workflows/docker-publish.yml @@ -0,0 +1,83 @@ +# Docker Hub へイメージを公開する。 +# +# ここまで配布経路は `git clone` だけで、GitHub が数える「ダウンロード」は +# 構造的にゼロだった(自動生成の Source code zip は集計対象外)。Docker Hub は +# pull 数を認証不要の公開 API で返すので、これが最初の本物の DL 指標になる。 +# +# 必要な準備(GitHub → Settings → Secrets and variables → Actions): +# DOCKERHUB_USERNAME Docker Hub のユーザー名(イメージ名にも使う) +# DOCKERHUB_TOKEN Docker Hub の Access Token(パスワードではなく) +# +# 動くタイミング: `v*` タグを push した時と、手動実行。main への push では +# 動かさない(誰も使っていない中間ビルドで pull 数を汚さないため)。 +name: Publish image + +on: + push: + tags: ['v*'] + # Dockerfile まわりを触る PR では、push せずビルドだけ通るか見る。 + # タグを打った当日に初めて壊れているのが分かる、を避けるため。 + pull_request: + paths: + - 'Dockerfile' + - 'requirements.txt' + - '.github/workflows/docker-publish.yml' + workflow_dispatch: + inputs: + tag: + description: '手動公開するときのタグ名(例 v1.3.0)' + required: true + +permissions: + contents: read + +jobs: + # PR ではビルドが通ることだけ確認する(認証情報も要らない)。 + check: + name: image builds + if: github.event_name == 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: docker/setup-buildx-action@v3 + - uses: docker/build-push-action@v6 + with: + context: . + push: false + cache-from: type=gha + cache-to: type=gha,mode=max + + publish: + name: build and push + if: github.event_name != 'pull_request' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + # Apple Silicon と x86 の両方で動くように2アーキテクチャ出す。 + - uses: docker/setup-qemu-action@v3 + - uses: docker/setup-buildx-action@v3 + + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + - name: Work out the tags + id: meta + run: | + version="${{ github.event.inputs.tag || github.ref_name }}" + image="${{ secrets.DOCKERHUB_USERNAME }}/cli2ui" + echo "tags=${image}:${version#v},${image}:latest" >> "$GITHUB_OUTPUT" + echo "publishing ${image}:${version#v}" + + - name: Build and push + uses: docker/build-push-action@v6 + with: + context: . + platforms: linux/amd64,linux/arm64 + push: true + tags: ${{ steps.meta.outputs.tags }} + cache-from: type=gha + cache-to: type=gha,mode=max diff --git a/.github/workflows/metrics.yml b/.github/workflows/metrics.yml new file mode 100644 index 0000000..344e773 --- /dev/null +++ b/.github/workflows/metrics.yml @@ -0,0 +1,40 @@ +# 配布まわりの数字を毎日1回、非公開 Gist に貯める。 +# +# GitHub の traffic API は **直近14日しか返さない**。取り逃がした日は永久に +# 戻らないので、毎日拾って積む。Docker Hub の pull 数とリリースアセットの +# download_count も同時に記録する(こちらは累計値なので日次のスナップショット)。 +# +# 必要な準備: +# METRICS_TOKEN classic PAT。scope は `gist`(Gist 書き込み)と +# `public_repo`(traffic API は push 権限を要求する)。 +# GIST_ID 非公開 Gist の ID(URL 末尾の英数字)。中に +# `cli2ui-metrics.json` を1つ置いておけばよい(空でも可)。 +# +# 数字を見るときは手元で: GIST_ID=... python3 scripts/metrics.py report +name: Metrics + +on: + schedule: + # 12:00 JST(= 03:00 UTC)。traffic は日単位なので時刻はいつでもよいが、 + # 当日の値は日中に増えるため、同じ日を翌日また上書きして取り切る。 + - cron: '0 3 * * *' + workflow_dispatch: + +permissions: + contents: read + +jobs: + snapshot: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.13' + + - name: Collect and store + env: + METRICS_TOKEN: ${{ secrets.METRICS_TOKEN }} + GIST_ID: ${{ secrets.GIST_ID }} + DOCKERHUB_NAMESPACE: ${{ secrets.DOCKERHUB_USERNAME }} + run: python3 scripts/metrics.py snapshot diff --git a/scripts/metrics.py b/scripts/metrics.py new file mode 100755 index 0000000..4bfeba3 --- /dev/null +++ b/scripts/metrics.py @@ -0,0 +1,239 @@ +#!/usr/bin/env python3 +"""配布まわりの数字を1コマンドで出す。 + + python3 scripts/metrics.py report # 今の数字を表示する + python3 scripts/metrics.py snapshot # 非公開 Gist に今日の分を追記(CI 用) + +数えているもの: + + * **Docker Hub の pull 数** — 公開 API・認証不要。`git clone` しか配布経路が + 無かった間、GitHub が数える「ダウンロード」は構造的にゼロだった。イメージを + 出して初めて本物の DL 数が立ち上がる。 + * **リリースアセットの download_count** — アセットを添付したリリースのみ。 + 自動生成の Source code (zip/tar.gz) は GitHub が数えない。 + * **traffic の clone / view** — GitHub は**直近14日しか持たない**。毎日 + Gist に落とし込むのはそのため。取り逃がした日は永久に戻らない。 + +環境変数: + METRICS_TOKEN / GITHUB_TOKEN GitHub API 用(未設定なら `gh auth token`) + GIST_ID snapshot の保存先(非公開 Gist の ID) + DOCKERHUB_NAMESPACE Docker Hub のユーザー名(既定 'jiniie') +""" + +import argparse +import json +import os +import subprocess +import sys +import urllib.error +import urllib.request +from datetime import date, datetime, timezone + +REPO = os.getenv('METRICS_REPO', 'MR-TABATA/cli2ui') +DOCKER_NAMESPACE = os.getenv('DOCKERHUB_NAMESPACE', 'jiniie') +DOCKER_IMAGE = os.getenv('DOCKERHUB_IMAGE', 'cli2ui') +GIST_FILENAME = 'cli2ui-metrics.json' + + +# --------------------------------------------------------------------------- +# HTTP +# --------------------------------------------------------------------------- + +def _token() -> str: + for var in ('METRICS_TOKEN', 'GITHUB_TOKEN', 'GH_TOKEN'): + if os.getenv(var): + return os.environ[var] + try: + return subprocess.check_output(['gh', 'auth', 'token'], text=True).strip() + except (OSError, subprocess.CalledProcessError): + sys.exit('GitHub のトークンが無い。METRICS_TOKEN を設定するか `gh auth login` を実行する。') + + +def _get(url: str, token: str | None = None, method: str = 'GET', body=None): + """JSON を返す。404 は None(まだ公開していないイメージ等)。""" + data = json.dumps(body).encode() if body is not None else None + req = urllib.request.Request(url, data=data, method=method) + req.add_header('Accept', 'application/vnd.github+json') + if token: + req.add_header('Authorization', f'Bearer {token}') + if data: + req.add_header('Content-Type', 'application/json') + try: + with urllib.request.urlopen(req, timeout=30) as resp: + return json.loads(resp.read() or b'null') + except urllib.error.HTTPError as e: + if e.code == 404: + return None + raise + + +# --------------------------------------------------------------------------- +# 収集 +# --------------------------------------------------------------------------- + +def docker_pulls() -> dict | None: + """Docker Hub の pull 数。未公開/名前未設定なら None。""" + if not DOCKER_NAMESPACE: + return None + url = f'https://hub.docker.com/v2/repositories/{DOCKER_NAMESPACE}/{DOCKER_IMAGE}/' + info = _get(url) + if not info: + return None + return {'pulls': info.get('pull_count', 0), + 'stars': info.get('star_count', 0), + 'last_updated': (info.get('last_updated') or '')[:10]} + + +def release_downloads(token: str) -> list: + releases = _get(f'https://api.github.com/repos/{REPO}/releases', token) or [] + out = [] + for rel in releases: + assets = [{'name': a['name'], 'downloads': a['download_count']} + for a in rel.get('assets', [])] + out.append({'tag': rel['tag_name'], + 'published': (rel.get('published_at') or '')[:10], + 'assets': assets, + 'downloads': sum(a['downloads'] for a in assets)}) + return out + + +def traffic(token: str) -> dict: + """直近14日の clone / view。push 権限が要る。""" + clones = _get(f'https://api.github.com/repos/{REPO}/traffic/clones', token) or {} + views = _get(f'https://api.github.com/repos/{REPO}/traffic/views', token) or {} + days: dict[str, dict] = {} + for row in clones.get('clones', []): + days.setdefault(row['timestamp'][:10], {}).update( + clones=row['count'], clone_uniques=row['uniques']) + for row in views.get('views', []): + days.setdefault(row['timestamp'][:10], {}).update( + views=row['count'], view_uniques=row['uniques']) + return {'days': days, + 'clones_14d': clones.get('count', 0), + 'clone_uniques_14d': clones.get('uniques', 0), + 'views_14d': views.get('count', 0), + 'view_uniques_14d': views.get('uniques', 0)} + + +# --------------------------------------------------------------------------- +# Gist(履歴の置き場) +# --------------------------------------------------------------------------- + +def _gist_load(gist_id: str, token: str) -> dict: + gist = _get(f'https://api.github.com/gists/{gist_id}', token) + if not gist: + sys.exit(f'Gist {gist_id} が見つからない。GIST_ID とトークンの scope を確認する。') + files = gist.get('files') or {} + blob = files.get(GIST_FILENAME) + if not blob: + return {'repo': REPO, 'days': {}, 'totals': {}} + if blob.get('truncated'): + content = urllib.request.urlopen(blob['raw_url'], timeout=30).read().decode() + else: + content = blob['content'] + return json.loads(content or '{}') or {'repo': REPO, 'days': {}, 'totals': {}} + + +def _gist_save(gist_id: str, token: str, payload: dict) -> None: + body = {'files': {GIST_FILENAME: {'content': json.dumps(payload, indent=2, + ensure_ascii=False)}}} + _get(f'https://api.github.com/gists/{gist_id}', token, method='PATCH', body=body) + + +def snapshot() -> None: + """今日ぶんを取り込んで Gist を更新する。同じ日は新しい値で上書き。""" + gist_id = os.getenv('GIST_ID') + if not gist_id: + sys.exit('GIST_ID が未設定。') + token = _token() + + store = _gist_load(gist_id, token) + store.setdefault('repo', REPO) + store.setdefault('days', {}) + store.setdefault('totals', {}) + + t = traffic(token) + # 当日の値は日中に増える。取り直したら常に新しい方で置き換える。 + for day, row in t['days'].items(): + store['days'][day] = {**store['days'].get(day, {}), **row} + + today = date.today().isoformat() + pulls = docker_pulls() + releases = release_downloads(token) + store['totals'][today] = { + 'docker_pulls': (pulls or {}).get('pulls'), + 'release_downloads': sum(r['downloads'] for r in releases), + 'releases': {r['tag']: r['downloads'] for r in releases}, + } + store['updated_at'] = datetime.now(timezone.utc).isoformat(timespec='seconds') + _gist_save(gist_id, token, store) + + print(f"snapshot ok — {len(store['days'])} 日分, 最新 {today}") + print(f" clone(14d): {t['clones_14d']} / unique {t['clone_uniques_14d']}") + print(f" docker pulls: {(pulls or {}).get('pulls', '未公開')}") + + +# --------------------------------------------------------------------------- +# 表示 +# --------------------------------------------------------------------------- + +def report() -> None: + token = _token() + print(f'== {REPO} ==\n') + + pulls = docker_pulls() + print('-- Docker Hub --') + if pulls: + print(f" pull 数 : {pulls['pulls']}") + print(f" star : {pulls['stars']}") + print(f" 最終更新 : {pulls['last_updated']}") + elif DOCKER_NAMESPACE: + print(f' {DOCKER_NAMESPACE}/{DOCKER_IMAGE} はまだ Docker Hub に無い' + '(v* タグを push すると公開される)') + else: + print(' DOCKERHUB_NAMESPACE 未設定 — Docker Hub のユーザー名を入れると pull 数を出す') + + print('\n-- リリースアセット --') + releases = release_downloads(token) + if not releases: + print(' リリースなし') + for rel in releases: + if rel['assets']: + print(f" {rel['tag']} ({rel['published']}): {rel['downloads']} DL") + for a in rel['assets']: + print(f" {a['name']}: {a['downloads']}") + else: + print(f" {rel['tag']} ({rel['published']}): アセット無し " + f"— 自動生成の Source code は GitHub が数えない") + + print('\n-- GitHub traffic(直近14日・ここだけ消える)--') + t = traffic(token) + print(f" clone : {t['clones_14d']} (unique {t['clone_uniques_14d']})") + print(f" view : {t['views_14d']} (unique {t['view_uniques_14d']})") + + gist_id = os.getenv('GIST_ID') + if gist_id: + store = _gist_load(gist_id, token) + days = store.get('days', {}) + if days: + first, last = min(days), max(days) + tot_c = sum(d.get('clones', 0) for d in days.values()) + tot_u = sum(d.get('clone_uniques', 0) for d in days.values()) + print(f"\n-- 蓄積した履歴(Gist)--") + print(f" 期間 : {first} 〜 {last}({len(days)} 日)") + print(f" clone累計: {tot_c}(日ごと unique の合計 {tot_u})") + print(" ※ unique は日単位。別の日に来た同じ人は二重に数える。") + else: + print('\n(GIST_ID を設定すると、蓄積した履歴も併せて表示する)') + + +def main() -> None: + ap = argparse.ArgumentParser(description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter) + ap.add_argument('command', choices=('report', 'snapshot')) + args = ap.parse_args() + (report if args.command == 'report' else snapshot)() + + +if __name__ == '__main__': + main()