🍏

【Claude Codeで個人開発】GitHub 活動をドラゴンボール風「戦闘力」で可視化!Next.js 製GitHubスカウター開発記

に公開4

はじめに - なぜ GitHub の戦闘力を測るのか?

「私の戦闘力は530000です」

こんなセリフを一度は言ってみたくありませんか?

それができます。

そう、GitHubスカウターなら!

というわけでGitHubアカウントの戦闘力を測れるサイトを作ってみました。

https://github-scouter.com/ja

このサイトで自分のGitHubアカウントのidを入力すれば、自分の戦闘力を測ることができます!

この記事では、GitHub の活動データを基に「戦闘力」を算出する Web アプリケーション「GitHubスカウター」の開発について、技術的な側面から詳しく解説します。

🎯 プロジェクト概要

GitHub スカウター は、GitHub ユーザーの活動データから戦闘力を算出する Web アプリケーションです。

入力欄にGitHubアカウントのidを入力し、「計算」ボタンをクリックすることで、自分の戦闘力が表示されます。

今回はただ戦闘力が表示されるだけでなく、rechartsを使用してチャート機能も作成しました!

https://recharts.org/en-US

ちなみに実装はほとんどClaude Codeを使用したため、リリースまでわずか2日間でした!すごい!

戦闘力の計算式

戦闘力の計算式は以下になります。

戦闘力 = (総スター数 × 10) + (総コントリビューション数 × 1) + (フォロワー数 × 5)

スターはすごいので、かなりの比重をおいています。

また、現在はprivate repositoryやorganizationの活動は取得できていないため、実際よりも少ない戦闘力になってしまう人もいるかと思います(こちらはアップデート中)

戦闘力レベル分類

せっかくなら戦闘力を6つに分類しています。

  • 初心者 (0-500): 開発の第一歩
  • 中級者 (501-2000): 基礎力が身についた状態
  • 上級者 (2001-5000): 実力者の証
  • エキスパート (5001-20000): 業界で認められるレベル
  • マスター (20001-50000): 開発界のエリート
  • レジェンド (50001+): 伝説級の開発者

ちなみに私は上級者でした。レジェンド級の人がいたら是非とも教えてください!!!

🛠️ 技術スタック

フロントエンド

  • Next.js 15: App Router によるモダンなルーティング
  • TypeScript: 型安全性の確保
  • Tailwind CSS: 効率的なスタイリング
  • shadcn/ui: 美しい UI コンポーネント
  • React Hook Form + Zod: フォーム管理とバリデーション
  • Vercel: 簡単にCDを構築できて便利!
  • Cloudflare: 今回はこちらでドメインを取得しました!

API・データ取得

GitHubからデータを取得するには、提供されているGitHub GraphQL APIを使用しました。

そのまま使用する場合は、リクエスト数の制限が大きいので、GitHubからclasic tokenを発行し環境変数においています。

これにより最大1時間あたり12,500リクエストが可能になっています。

  • GitHub REST API: リポジトリ情報の取得
  • GitHub GraphQL API: コントリビューション情報の取得

その他

  • Vitest: テストフレームワーク
  • next-intl: 国際化対応
  • Recharts: レーダーチャート表示

今回国際化対応したことと、動的OGPを設定したことがこだわりポイントとなっています。

単純に日本人口は減少の一途を辿るばかりですし、リーチできるユーザーが最大1億人なのか、80億人かということは、今後のサービスのスケールに大きな影響を与えると考えています。

  • リリースしてからのアクティブユーザー

1. データフロー設計

GitHub Power Meter のデータフローは、効率性とユーザー体験を重視した設計になっています。

全体的なデータフロー

User Input → Form Validation → API Client → GitHub API → Cache → Power Calculation → UI Display

実装は以下です。

// lib/github-api.ts - メインのデータ取得関数
export async function fetchGitHubStats(username: string): Promise<GitHubStats> {
  // 1. キャッシュから既存データを確認
  const cachedResult = githubCache.get(username);
  if (cachedResult) {
    return cachedResult;
  }

  // 2. トークンの有効性を検証
  const token = process.env.NEXT_PUBLIC_GITHUB_TOKEN;
  if (token) {
    const isValidToken = await validateToken(token);
    if (!isValidToken) {
      throw new GitHubAPIError(
        "GitHubトークンが無効です。新しいトークンを生成してください。",
        401,
        "INVALID_TOKEN"
      );
    }
  }

  // 3. 並列でAPI呼び出し実行
  const [repoStats, contributionsData, followers] = await Promise.all([
    fetchUserRepos(username),
    fetchUserContributions(username, token),
    fetchUserFollowers(username),
  ]);

  // 4. 結果をキャッシュに保存
  const result: GitHubStats = {
    totalStars: repoStats.totalStars,
    totalContributions: contributionsData.total,
    repoCount: repoStats.repoCount,
    followers,
    contributionDetails: contributionsData.details,
  };

  githubCache.set(username, result);
  return result;
}

リポジトリ情報の取得(ページネーション対応)

export async function fetchUserRepos(
  username: string
): Promise<{ totalStars: number; repoCount: number }> {
  let totalStars = 0;
  let repoCount = 0;
  let page = 1;

  // ページネーションを使用して全リポジトリを取得
  while (true) {
    const response = await fetchWithErrorHandling(
      `${GITHUB_API_BASE}/users/${username}/repos?per_page=${DEFAULT_PER_PAGE}&page=${page}&type=owner`,
      { headers: createGitHubHeaders(token) }
    );

    const repos = z.array(githubRepoSchema).parse(await response.json());

    if (repos.length === 0) break;

    // スター数とリポジトリ数を集計
    repos.forEach((repo) => {
      totalStars += repo.stargazers_count;
      repoCount++;
    });

    if (repos.length < DEFAULT_PER_PAGE) break;
    page++;
  }

  return { totalStars, repoCount };
}

2. 戦闘力計算ロジック

戦闘力の計算は、GitHub の活動指標を適切に重み付けして算出します:

// lib/calcPower.ts
export function calculatePower({
  totalStars,
  totalContributions,
  followers,
}: PowerCalculationInput): PowerCalculationResult {
  // 各指標に対する重み付け
  const starsPower = totalStars * POWER_CALCULATION.STAR_MULTIPLIER; // × 10
  const contributionsPower =
    totalContributions * POWER_CALCULATION.CONTRIBUTION_MULTIPLIER; // × 1
  const followersPower = followers * POWER_CALCULATION.FOLLOWER_MULTIPLIER; // × 5

  const totalPower = starsPower + contributionsPower + followersPower;

  // 戦闘力レベルの判定
  const level =
    POWER_LEVELS.find((l) => totalPower >= l.min && totalPower <= l.max) ||
    POWER_LEVELS[0];

  return {
    power: totalPower,
    formula: `(${totalStars} × ${POWER_CALCULATION.STAR_MULTIPLIER}) + (${totalContributions} × ${POWER_CALCULATION.CONTRIBUTION_MULTIPLIER}) + (${followers} × ${POWER_CALCULATION.FOLLOWER_MULTIPLIER})`,
    level,
    breakdown: { starsPower, contributionsPower, followersPower },
  };
}

🎨 UI/UX の工夫

1. 戦闘力レベル別カラーリング

視覚的なインパクトを最大化するため、戦闘力レベルごとに異なる色を設定:

// lib/constants/index.ts
export const POWER_LEVELS = [
  { min: 0, max: 500, label: "初心者", color: "text-gray-500" },
  { min: 501, max: 2000, label: "中級者", color: "text-blue-500" },
  { min: 2001, max: 5000, label: "上級者", color: "text-green-500" },
  { min: 5001, max: 20000, label: "エキスパート", color: "text-purple-500" },
  { min: 20001, max: 50000, label: "マスター", color: "text-orange-500" },
  { min: 50001, max: Infinity, label: "レジェンド", color: "text-red-500" },
] as const;

最初は個別に画像を生成したので、それを表示しようとしたのですが、なんとなく見た目が気に入らなかったので不採用になりました。

2. レーダーチャートによる詳細な可視化

戦闘力の内訳を直感的に理解できるレーダーチャートを実装しました。これはカッコよくて気に入っています。

// components/PowerRadarChart.tsx
export function PowerRadarChart({
  totalStars,
  followers,
  repoCount,
  totalContributions,
}: PowerRadarChartProps) {
  // 各指標を正規化(0-100の範囲)
  const data = useMemo(
    () => [
      {
        metric: locale === "ja" ? "スター獲得" : "Stars Earned",
        value: Math.min(
          CHART_CONFIG.DOMAIN_MAX,
          (totalStars / CHART_SCALING_FACTORS.STARS) * 100
        ),
        actualValue: totalStars,
      },
      {
        metric: locale === "ja" ? "フォロワー数" : "Followers",
        value: Math.min(
          CHART_CONFIG.DOMAIN_MAX,
          (followers / CHART_SCALING_FACTORS.FOLLOWERS) * 100
        ),
        actualValue: followers,
      },
      {
        metric: locale === "ja" ? "リポジトリ" : "Repositories",
        value: Math.min(
          CHART_CONFIG.DOMAIN_MAX,
          (repoCount / CHART_SCALING_FACTORS.REPOSITORIES) * 100
        ),
        actualValue: repoCount,
      },
      {
        metric: locale === "ja" ? "貢献度" : "Contributions",
        value: Math.min(
          CHART_CONFIG.DOMAIN_MAX,
          (totalContributions / CHART_SCALING_FACTORS.CONTRIBUTIONS) * 100
        ),
        actualValue: totalContributions,
      },
      // 複合指標も追加
      {
        metric: locale === "ja" ? "影響力" : "Impact",
        value: Math.min(
          CHART_CONFIG.DOMAIN_MAX,
          ((totalStars + followers) / CHART_SCALING_FACTORS.IMPACT) * 100
        ),
        actualValue: totalStars + followers,
      },
      {
        metric: locale === "ja" ? "活動量" : "Activity",
        value: Math.min(
          CHART_CONFIG.DOMAIN_MAX,
          ((totalContributions + repoCount) / CHART_SCALING_FACTORS.ACTIVITY) *
            100
        ),
        actualValue: totalContributions + repoCount,
      },
    ],
    [totalStars, followers, repoCount, totalContributions, locale]
  );

  return (
    <div className="relative">
      {/* グロー効果の背景 */}
      <div className="absolute inset-0 bg-gradient-to-r from-green-500/10 via-emerald-500/10 to-cyan-500/10 blur-2xl rounded-full"></div>

      <div className="relative bg-white dark:bg-gray-950 border border-green-500/50 rounded-2xl p-6 shadow-2xl">
        {/* タイトル部分 */}
        <div className="text-center mb-6">
          <h2 className="text-2xl font-bold bg-gradient-to-r from-green-400 via-emerald-400 to-cyan-400 bg-clip-text text-transparent mb-2">
            {locale === "ja" ? "戦闘力スキャナー" : "POWER SCANNER"}
          </h2>
          <p className="text-green-300/70 text-xs font-mono uppercase">
            {locale === "ja"
              ? "GitHub パフォーマンス解析"
              : "GitHub Performance Analysis"}
          </p>
        </div>

        {/* レーダーチャート */}
        <ResponsiveContainer width="100%" height={400}>
          <RadarChart data={data}>
            <defs>
              <linearGradient
                id="powerGradient"
                x1="0%"
                y1="0%"
                x2="100%"
                y2="100%"
              >
                <stop
                  offset="0%"
                  stopColor={CHART_COLORS.PRIMARY}
                  stopOpacity={1}
                />
                <stop
                  offset="50%"
                  stopColor={CHART_COLORS.SECONDARY}
                  stopOpacity={0.8}
                />
                <stop
                  offset="100%"
                  stopColor={CHART_COLORS.TERTIARY}
                  stopOpacity={0.6}
                />
              </linearGradient>
            </defs>

            <PolarGrid stroke={CHART_COLORS.GRID} strokeOpacity={0.3} />
            <PolarAngleAxis
              dataKey="metric"
              className="fill-green-300 text-xs font-mono"
            />
            <PolarRadiusAxis domain={[0, CHART_CONFIG.DOMAIN_MAX]} />

            <Tooltip
              content={({ active, payload }) => {
                if (active && payload && payload.length) {
                  const data = payload[0];
                  return (
                    <div className="bg-black/90 backdrop-blur-sm border border-green-500/50 rounded-lg p-4 shadow-xl">
                      <p className="text-green-300 font-mono text-sm font-bold mb-2">
                        {data.payload.metric}
                      </p>
                      <p className="text-green-400 font-mono text-xl font-bold">
                        {data.payload.actualValue.toLocaleString()}
                      </p>
                    </div>
                  );
                }
                return null;
              }}
            />

            <Radar
              dataKey="value"
              stroke={CHART_COLORS.PRIMARY}
              fill="url(#powerGradient)"
              strokeWidth={3}
              animationDuration={2000}
            />
          </RadarChart>
        </ResponsiveContainer>

        {/* ステータス表示 */}
        <div className="flex justify-between mt-6 text-xs font-mono">
          <div className="text-green-400">
            <span className="inline-block w-2 h-2 bg-green-400 rounded-full mr-2 animate-pulse"></span>
            {locale === "ja" ? "システム稼働中" : "SYSTEM ONLINE"}
          </div>
          <div className="text-cyan-400">
            <span className="inline-block w-2 h-2 bg-cyan-400 rounded-full mr-2 animate-pulse"></span>
            {locale === "ja" ? "データ同期: 100%" : "DATA SYNC: 100%"}
          </div>
        </div>
      </div>
    </div>
  );
}

3. 動的 OGP 画像生成

SNS 共有時のインパクトを最大化するため、動的に OGP 画像を生成:

// app/api/ogp/route.tsx
export async function GET(request: NextRequest) {
  const { searchParams } = request.nextUrl;
  const username = searchParams.get("username");
  const power = searchParams.get("power");

  const powerNum = parseInt(power, 10);
  const level =
    POWER_LEVELS.find((l) => powerNum >= l.min && powerNum <= l.max)?.label ||
    "不明";

  return new ImageResponse(
    (
      <div
        style={{
          width: "1200px",
          height: "630px",
          display: "flex",
          flexDirection: "column",
          alignItems: "center",
          justifyContent: "center",
          backgroundColor: "#0a0a0a",
          background:
            "linear-gradient(135deg, #0a0a0a 0%, #1a1a2e 50%, #0a0a0a 100%)",
        }}
      >
        <div style={{ fontSize: "40px", opacity: 0.9 }}>
          https://github.com/{username}</div>
        <div style={{ fontSize: "40px", opacity: 0.9 }}>GitHub戦闘力は</div>
        <div
          style={{
            fontSize: "80px",
            color: "#FFD700",
            textShadow: "0 0 20px rgba(255, 215, 0, 0.5)",
          }}
        >
          {powerNum.toLocaleString()}
        </div>
        <div style={{ fontSize: "48px", color: "#FFD700" }}>({level})</div>
        <div style={{ fontSize: "40px", opacity: 0.9 }}>でした!</div>
      </div>
    ),
    { width: 1200, height: 630 }
  );
}

Next.js v15を使用したため、動的OGPはドキュメント通りに実装すると動かすことができました。

正直このv15の機能は革命的だと感じます。

https://nextjs.org/docs/app/getting-started/metadata-and-og-images#generated-open-graph-images

📱 レスポンシブデザイン

Tailwind CSS を使用したモバイルファーストのレスポンシブデザイン:

1. グリッドレイアウトの最適化

// components/PowerResult.tsx
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
  <StatsCard
    icon={<Star className="h-4 w-4" />}
    label={t("stars")}
    value={stats.totalStars}
    power={result.breakdown.starsPower}
  />
  <StatsCard
    icon={<GitBranch className="h-4 w-4" />}
    label={t("contributions")}
    value={stats.totalContributions}
    power={result.breakdown.contributionsPower}
  />
  <StatsCard
    icon={<Users className="h-4 w-4" />}
    label={t("followers")}
    value={stats.followers}
    power={result.breakdown.followersPower}
  />
  <StatsCard
    icon={<Package className="h-4 w-4" />}
    label={t("repositories")}
    value={stats.repoCount}
  />
</div>

2. 画面サイズ別のレイアウト調整

// app/page-component.tsx
export default function Home() {
  return (
    <div className="container mx-auto px-4 py-8 max-w-2xl">
      <div className="text-center mb-8">
        <div className="flex justify-center items-center gap-2 mb-4">
          <GitBranch className="h-8 w-8" />
          <h1 className="text-3xl font-bold">{t("title")}</h1>
        </div>
        <p className="text-muted-foreground">{t("description")}</p>
      </div>

      <Card className="mb-8">
        <CardHeader>
          <CardTitle>{t("measure")}</CardTitle>
          <CardDescription>{t("measureDescription")}</CardDescription>
        </CardHeader>
        <CardContent>
          <GithubPowerForm
            onSubmit={handleSubmit}
            isLoading={isLoading}
            error={error}
          />
        </CardContent>
      </Card>
    </div>
  );
}

スマートフォンからアクセスするユーザーも多いと想定し、スマホサイズでも見た目が崩れないことを意識私しました。

🧪 テスト戦略

Claude Codeなどの自立型AIを用いてVibe Coding実装をするとなると、テスト戦略は非常に重要です。

私は必ずロジックには単体テストを書かせるようにして、CIでテストが通らないコードは弾くようにしています。

1. Vitest 設定

// vitest.config.ts
import { defineConfig } from "vitest/config";
import react from "@vitejs/plugin-react";
import path from "path";

export default defineConfig({
  plugins: [react()],
  test: {
    environment: "jsdom",
    globals: true,
    setupFiles: "./vitest.setup.ts",
  },
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./"),
    },
  },
});

2. 戦闘力計算ロジックのテスト

// lib/calcPower.test.ts
import { describe, it, expect } from "vitest";
import { calculatePower } from "./calcPower";

describe("calculatePower", () => {
  it("should calculate power correctly with standard values", () => {
    const result = calculatePower({
      totalStars: 100,
      totalContributions: 500,
      followers: 50,
    });

    expect(result.power).toBe(1750); // (100×10) + (500×1) + (50×5)
    expect(result.level.label).toBe("中級者");
    expect(result.breakdown.starsPower).toBe(1000);
    expect(result.breakdown.contributionsPower).toBe(500);
    expect(result.breakdown.followersPower).toBe(250);
  });

  it("should handle edge cases correctly", () => {
    const result = calculatePower({
      totalStars: 0,
      totalContributions: 0,
      followers: 0,
    });

    expect(result.power).toBe(0);
    expect(result.level.label).toBe("初心者");
  });

  it("should categorize legendary level correctly", () => {
    const result = calculatePower({
      totalStars: 5000,
      totalContributions: 10000,
      followers: 1000,
    });

    expect(result.power).toBe(65000); // (5000×10) + (10000×1) + (1000×5)
    expect(result.level.label).toBe("レジェンド");
  });

  it("should generate correct formula string", () => {
    const result = calculatePower({
      totalStars: 100,
      totalContributions: 500,
      followers: 50,
    });

    expect(result.formula).toBe("(100 × 10) + (500 × 1) + (50 × 5)");
  });
});

🌐 国際化対応

Next.js 15 の App Router とnext-intlを使用した包括的な国際化対応を実現しました。

ユーザーがどこからアクセスしているかを判断し、日本からのアクセスの場合には日本語を表示するようにしています。

1. 設定ファイル

// middleware.ts
import createMiddleware from "next-intl/middleware";

export default createMiddleware({
  locales: ["ja", "en"],
  defaultLocale: "ja",
  localePrefix: "always",
});

export const config = {
  matcher: ["/((?!api|_next|_vercel|.*\\..*).*)"],
};

2. 言語別メッセージファイル

// messages/ja/home.json
{
  "title": "GitHub戦闘力測定器",
  "description": "あなたのGitHub活動から戦闘力を算出します",
  "measure": "戦闘力を測定",
  "measureDescription": "GitHubユーザー名を入力して戦闘力を計算しましょう",
  "username": "GitHubユーザー名",
  "usernamePlaceholder": "例: octocat",
  "calculate": "戦闘力を計算",
  "calculating": "計算中...",
  "battlePower": "戦闘力",
  "breakdown": "内訳",
  "powerAnalysis": "戦闘力分析"
}

マネタイズについて

今後どうやってマネタイズしていくかはまだ未定です。

現状でもドメインに年間$10ドルを払っているので、稼働時間含めて回収したい所存です。

一応レバテックの紹介キャンペーンのリンクを貼っており、もしここから僕を紹介者にして登録してくれたら少しお金が入ってきます。

紹介された人には8万円支給されます。

https://github-scouter.com/ja/about

他にもマネタイズの良い方法があれば是非とも教えてください!

🎯 今後の展望

機能拡張案

  1. 詳細分析機能: 言語別分析、時系列データ
  2. ランキング機能: 戦闘力ランキング表示
  3. チーム戦闘力: Organization 単位での集計
  4. バッジシステム: 特定条件達成でのバッジ付与

まとめ

GitHub スカウター は、開発者のモチベーションを高めることを目的として作りました。
毎日仕事終わりにコードを書いている人が報われ、正当に評価されてほしい。そんな思いから作ったwebアプリになります。

あなたの戦闘力は何万でしょうか? ぜひ実際に測定してみてください!


参考リンク

技術記事で使用した主要なライブラリ

  • Next.js 15
  • TypeScript
  • Tailwind CSS
  • shadcn/ui
  • React Hook Form
  • Zod
  • SWR
  • Recharts
  • Vitest

#GitHub #Next.js #TypeScript #WebDevelopment #API #戦闘力 #開発者向け

Discussion

橋田至橋田至

素晴らしい!

みんなも是非ともコメントで測定結果を教えてね!

owayoowayo

面白いツールの開発ありがとうございます。プライベートリポジトリを含めたデータも見れると嬉しいです
画面から個々にトークン入れられるようにすればできますかね

橋田至橋田至

ありがとうございます!

個々にトークン入れるのは怖そうです…