star-history.com で
facebook/reactの star 成長グラフを見たことがあるなら、あれがどうやって描かれているのか気になったことがあるかもしれません。結論から言うと「完全に正確には描けていない」です。GitHub の
/stargazersエンドポイントは 先頭から 40,000 人までしか取得できない という隠れた上限があります。それより多い repo の曲線は、どのツールも「サンプリング + 右端アンカー」で描いています。ブラウザだけで完結する版を作って、その仕組みをコードで確認しました。
🔗 Demo: https://sen.ltd/portfolio/github-star-history/
📦 GitHub: https://github.com/sen-ltd/github-star-history
サーバーもプロキシも経由しません。ブラウザが直接 api.github.com を叩きます。PAT を入れた場合もその端末の localStorage にだけ保存されて、送信先は api.github.com だけ。vanilla JS + 自前 SVG でおよそ 500 行。
隠れた Accept ヘッダで starred_at を取る
GET /repos/{owner}/{repo}/stargazers は普通に叩くと「user の一覧」が返ります。ただしカスタム media type を指定すると レスポンスの形が変わる のが鍵です:
GET /repos/{owner}/{repo}/stargazers
Accept: application/vnd.github.star+json
すると [{login, id, ...}] ではなく [{user, starred_at}] が返ってくる。star 履歴に必要なのはこの starred_at だけです。
REST API リファレンスには書いてある のですが、意識して読まないと見落としやすい。star-history 系ツールはすべてこれに依存しています。
ページングは Link ヘッダの rel="last" で一発で数えられる
GET /repos/facebook/react/stargazers?per_page=100&page=1
Accept: application/vnd.github.star+json
このレスポンスに付く Link ヘッダがこんな形:
<https://api.github.com/...&page=2>; rel="next",
<https://api.github.com/...&page=2320>; rel="last"
rel="last" の page 番号を 1 リクエスト目で取ってしまえば、全ページ数がわかります:
export function parseLastPage(linkHeader) {
if (!linkHeader) return 1;
const m = linkHeader.match(/<[^>]*[?&]page=(\d+)[^>]*>;\s*rel="last"/);
return m ? Number(m[1]) : 1;
}
facebook/react は約 23 万 stars ≒ 2320 ページ。ここからが面白いところ。
40,000 stargazer の壁
試しに ?page=500 を叩いてみると、空配列が返ります。?page=1000 も空。?page=401 も空。
GitHub の stargazers エンドポイントは page 400 で打ち止め なのです。公式ドキュメントには書かれていませんが、再現性があって star-history 系ツールの界隈では周知 の事実。つまり 40,000 人を超える stargazer は時系列的に辿れません。
facebook/react の場合:
-
GET /repos/{owner}/{repo}のstargazers_countで今の総数はわかる(約 232,000) - stargazers の page 1〜400 で時系列順の先頭 40,000 人の
starred_atが取れる - それ以降の 192,000 人分は この API からは辿れない
対応策は 3 つ:
- 無視する。 40k までの曲線を描いて打ち切る。→ React の線が 2015 年で止まる。明らかに嘘。
- GH Archive / BigQuery を使う。 正確だが有料、バッチ処理、ブラウザ不可。
-
サンプリング + 右端アンカー。
[1, 400]から等間隔に N ページだけ取り、starred_atを繋ぐ。最後に(now, stargazers_count)でグラフの右端を釘付けする。
star-history.com も、この app も、3 番を採用。実用上の嘘ですが無害な嘘です。数十万点の累積分布は非常に滑らかなので、24 サンプルでも目で見分けがつかない曲線になります。
サンプリング戦略
const HARD_PAGE_LIMIT = 400;
// page 1 の Link ヘッダから全ページ数を読む。
const first = await fetchStargazerPage(owner, repo, 1, token);
const reachableLastPage = Math.min(first.lastPage, HARD_PAGE_LIMIT);
let pages;
if (reachableLastPage <= maxSamplePages) {
// 小さい repo: 全ページ取る(完全忠実)。
pages = range(1, reachableLastPage);
} else {
// 大きい repo: 等間隔でサンプリング。
pages = evenSample(1, reachableLastPage, maxSamplePages);
}
evenSample(1, 400, 24) は [1, 18, 35, 52, ..., 383, 400] を返します。repo サイズが 40k でも 400k でも 24 API call で済む。未認証なら 60 req/h の制限があるので、これは効きます。
累積曲線の組み立て方。page P の i 番目は「stargazer #(P-1)*100 + i + 1」に相当:
pg.items.forEach((iso, i) => {
const starIndex = (pg.page - 1) * 100 + i + 1;
points.push({ t: new Date(iso).getTime(), n: starIndex });
});
最後にアンカー:
const last = points[points.length - 1];
if (meta.stargazersCount > last.n) {
points.push({ t: Date.now(), n: meta.stargazersCount });
}
この Date.now() 付加が曲線の形を決める重要ポイント。これがないと facebook/react は 2016 年あたりの 40k で打ち切られる。あるとちゃんと今日の 232k まで登る。サンプリング最終点(例えば 2016)と現在の間は直線補間されるので技術的には嘘ですが、凡例の (sampled) バッジで開示しています。
レート制限: もう 1 つの壁
未認証: 60 req / 時。24 req/repo なので 2 repo でロック。
PAT 認証: 5000 req / 時。Classic も Fine-grained も OK(public read なので特殊 scope 不要)。PAT は localStorage にだけ保存し、送信先は api.github.com のみ:
function authHeaders(token) {
const h = { Accept: "application/vnd.github.star+json" };
if (token) h.Authorization = `Bearer ${token}`;
return h;
}
上限に達すると 403 と共に 2 つの便利なヘッダが返ります:
x-ratelimit-remaining: 0-
x-ratelimit-reset: 1744812000(Unix 秒)
両方を UI に出す:
if (res.status === 403 && Number(res.headers.get("x-ratelimit-remaining")) === 0) {
const resetSec = Number(res.headers.get("x-ratelimit-reset"));
throw new RateLimitError(new Date(resetSec * 1000));
}
これで「レート制限に達しました。リセット時刻: 2026-04-24 14:40。PAT を入力すると上限が上がります」とユーザーに伝えられる。ただの「失敗」で終わらないのがポイント。
チャートライブラリを使わずに SVG で描く
グラフは自前 SVG。理由: 4 系列 × 各 100 点くらいなら、D3 の 90 KB gzip はアプリ残り全部より大きい。問題自体は小さいので手書きで十分:
- データの extent を出す(
minX, maxX, maxY)。 -
maxYを nice な数(1, 2, 2.5, 5, 10 × 10^k)に切り上げ。 - データ座標 → 画面座標の線形スケール。
- 系列ごとに
<path d="M.. L.. L..">を 1 本。グリッドに<line>、ラベルに<text>。
面白いのは nice number の部分:
export function niceMaxY(max) {
if (max <= 1) return 1;
const exp = Math.floor(Math.log10(max));
const pow = 10 ** exp;
const norm = max / pow;
let nice;
if (norm <= 1) nice = 1;
else if (norm <= 2) nice = 2;
else if (norm <= 2.5) nice = 2.5;
else if (norm <= 5) nice = 5;
else nice = 10;
return nice * pow;
}
max = 232000 → norm = 2.32 → nice = 2.5 → 目盛は 0, 50k, 100k, 150k, 200k, 250k。全部きれいな数字になります。2.5 の段がないと 232k → 500k に飛んで上半分が空白になる。
24 時間の localStorage キャッシュ
stargazer はそう速く変わらないので TTL 24 時間のキャッシュで十分です。再訪問時は API 0 回で表示:
const PREFIX = "gsh:v1:";
export function saveCached(owner, repo, data, store = localStorage) {
store.setItem(
`${PREFIX}${owner.toLowerCase()}/${repo.toLowerCase()}`,
JSON.stringify({ savedAt: Date.now(), data }),
);
}
export function loadCached(owner, repo, ttlMs = 24 * 3600 * 1000, store = localStorage) {
const raw = store.getItem(`${PREFIX}${owner.toLowerCase()}/${repo.toLowerCase()}`);
if (!raw) return null;
const { savedAt, data } = JSON.parse(raw);
if (Date.now() - savedAt > ttlMs) return null;
return data;
}
キー先頭に v1: のようなスキーマバージョンを入れておくと、後で構造を変えたいときに v2: にするだけで古いエントリは無効化される。「キャッシュ削除」ボタンで掃除もできる。
やってないこと
-
PNG 出力もクライアント側で完結。
canvas.toBlob+XMLSerializerで SVG をImageに描画して PNG 化。サーバー不要。 -
アナリティクス、ビーコン送信は一切なし。 DevTools の Network タブを見ても、外向き通信は
api.github.com宛のみ。 - 高度なキャッシュ無効化なし。 「ブログ用にざっくり曲線が欲しい」なら TTL 24 時間で十分。正確さが必要なら PAT を入れて「キャッシュ削除」を押す。
JS は minify 後で 9 KB 程度。ES modules + no build。ローカル実行は:
python3 -m http.server 8080
これだけ。
まとめ
star-history を作るのに必要な知識は 3 つ:
-
application/vnd.github.star+jsonの Accept ヘッダでstarred_atが返る - stargazers は page 400 で打ち止め(40k 人まで)、それ以上はサンプリング
-
stargazers_countを右端アンカーにして曲線を今日に固定
あと Link ヘッダパーサと PAT 対応のレート制限エラーハンドリング。サンプリングは小さな嘘ですが、23 万点の timestamps を 24 ページ等間隔で拾った曲線は、目には真実と区別がつきません。
ソース: https://github.com/sen-ltd/github-star-history
デモ: https://sen.ltd/portfolio/github-star-history/
この記事は SEN 合同会社(sen.ltd)が運営する「1 日 1 個、小さな OSS を公開する」ポートフォリオプログラムの一環です。これまでに公開した全案件はこちら。
