2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GitHubプロフィールを“動かす”!アニメーション×自動更新で魅せるREADMEの作り方

2
Last updated at Posted at 2025-10-09

GitHubの「About(私について)」は第一印象を左右すると思います。

とはいったもののGithubのProfileをカスタマイズできることを私も最近知ったのでメモベースで共有します。

この記事ではモダンな見た目アニメーションを保ちつつ、最近のプロジェクト/活動履歴/今月の活動グラフGitHub公式APIのみで自動更新する実装を解説します。

前提・要件

  • プロフィール用リポジトリ <your_id>/<your_id> を用意(READMEがプロフィールに表示される仕組み)
    https://github.com/kskmasa/kskmasa
    こんなかんじの
  • GitHub Actions が使えること

完成イメージ(簡潔)

  • ヒーロー部:名前・肩書・簡潔なタグライン(中央寄せ)
  • 自動更新3部:
    • 🔄 現在の開発中プロジェクト(直近Push順に3件、言語・スター・相対時刻)
    • 🏃 最近の活動(過去14日のコミット/PR/Issueから5件)
    • 🕓 今月のアクティビティ(過去30日の貢献数をアニメーションSVG棒グラフ)
  • 任意:GitHub Readme StatsやTyping SVGなどの装飾を加えてもよい(SaaS依存に注意)

実装手順

1. READMEに自動差し込み用マーカーを置く

後述のスクリプトが次のマーカー間を置換する。配置場所は自由。

README.md
## 🔄 現在の開発中プロジェクト
<!-- PROJECTS:START -->
<!-- PROJECTS:END -->

## 🏃 最近の活動
<!-- ACTIVITY:START -->
<!-- ACTIVITY:END -->

## 🕓 今月のアクティビティ
<!-- MONTHLY_GRAPH:START -->
<!-- MONTHLY_GRAPH:END -->

2. ワークフローを作成(毎日自動更新)

  • 実行はUTC基準。日本時間(JST=UTC+9)で09:00頃にしたい場合、0:00 UTC を指定。GitHubのキュー状況で数分ずれることがある。
.github/workflows/update-readme.yml
name: Update Profile README

on:
  workflow_dispatch:
  schedule:
    - cron: "0 0 * * *"  # 毎日 09:00 JST 相当(UTC 00:00)

permissions:
  contents: write

jobs:
  update:
    runs-on: ubuntu-latest
    steps:
      - name: Checkout profile repo
        uses: actions/checkout@v4

      - name: Setup Python
        uses: actions/setup-python@v5
        with:
          python-version: "3.11"

      - name: Install deps
        run: |
          pip install requests python-dateutil

      - name: Generate sections & SVG
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          GH_USER: ${{ github.repository_owner }}
        run: |
          python scripts/update_readme.py

      - name: Commit changes
        run: |
          git config user.name "github-actions[bot]"
          git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
          git add README.md assets/monthly_activity.svg
          git diff --staged --quiet || git commit -m "chore: auto-update README sections"
          git push

3. 更新スクリプト(GraphQL→Markdown差し替え→SVG生成)

  • 必要権限:デフォルトのGITHUB_TOKENでGraphQL読み取りOK(公開活動のみ)。
  • 取得対象:
    • repositories(orderBy: PUSHED_AT, isFork:false)で最近更新TOP3
    • contributionsCollectionから直近14日のコミット/PR/Issue
    • 過去30日の貢献数を配列化しアニメーションSVGで描画
scripts/update_readme.py(全文)
scripts/update_readme.py
import os, json, re, sys, math
from datetime import datetime, timedelta, timezone
import requests
from dateutil import parser

TOKEN = os.getenv("GITHUB_TOKEN")
USER = os.getenv("GH_USER")
API_GQL = "https://api.github.com/graphql"
HEADERS = {"Authorization": f"Bearer {TOKEN}"}

ROOT = os.getcwd()
ASSETS_DIR = os.path.join(ROOT, "assets")
SVG_PATH = os.path.join(ASSETS_DIR, "monthly_activity.svg")
README_PATH = os.path.join(ROOT, "README.md")

# ---------- helpers ----------
def gql(query, variables=None):
    r = requests.post(API_GQL, json={"query": query, "variables": variables or {}}, headers=HEADERS, timeout=30)
    r.raise_for_status()
    j = r.json()
    if "errors" in j:
        raise RuntimeError(j["errors"])
    return j["data"]

def humanize(dt_iso):
    dt = parser.isoparse(dt_iso).replace(tzinfo=timezone.utc)
    now = datetime.now(timezone.utc)
    diff = now - dt
    s = diff.total_seconds()
    if s < 60:  return f"{int(s)}s ago"
    if s < 3600: return f"{int(s//60)}m ago"
    if s < 86400: return f"{int(s//3600)}h ago"
    return f"{int(s//86400)}d ago"

def replace_between(text, start_marker, end_marker, new_body):
    pattern = re.compile(
        r"(?P<start><!--\s*"+re.escape(start_marker)+r"\s*-->)(?P<body>.*?)(?P<end><!--\s*"+re.escape(end_marker)+r"\s*-->)",
        re.DOTALL
    )
    return pattern.sub(lambda m: f"{m.group('start')}\n{new_body}\n{m.group('end')}", text)

# ---------- queries ----------
QUERY_RECENT_REPOS = """
query($login:String!) {
  user(login:$login){
    repositories(privacy:PUBLIC, first: 20, orderBy:{field: PUSHED_AT, direction: DESC}, isFork:false) {
      nodes {
        name description stargazerCount
        primaryLanguage { name color }
        pushedAt url
      }
    }
  }
}
"""

QUERY_EVENTS = """
query($login:String!, $from:DateTime!, $to:DateTime!) {
  user(login:$login) {
    contributionsCollection(from:$from, to:$to) {
      commitContributionsByRepository(maxRepositories: 10) {
        repository { name url }
        contributions(first: 5) {
          edges { node { occurredAt commitCount } }
        }
      }
      pullRequestContributions(first:10){
        edges{ node{ occurredAt pullRequest { title url } } }
      }
      issueContributions(first:10){
        edges{ node{ occurredAt issue { title url } } }
      }
    }
  }
}
"""

QUERY_MONTH = """
query($login:String!, $from:DateTime!, $to:DateTime!) {
  user(login:$login){
    contributionsCollection(from:$from, to:$to){
      contributionCalendar{
        weeks{ contributionDays{ date contributionCount } }
      }
    }
  }
}
"""

# ---------- builders ----------
def build_projects_section():
    data = gql(QUERY_RECENT_REPOS, {"login": USER})
    nodes = data["user"]["repositories"]["nodes"]
    top = nodes[:3]
    lines = ["### 🔄 Currently Building"]
    for n in top:
        lang = n["primaryLanguage"]["name"] if n["primaryLanguage"] else "-"
        color = n["primaryLanguage"]["color"] if n["primaryLanguage"] else "#cccccc"
        desc = (n["description"] or "No description").strip()
        pushed = humanize(n["pushedAt"])
        lines.append(
            f"- **[{n['name']}]({n['url']})** — {desc}\n"
            f"  <sub><span style='display:inline-block;width:10px;height:10px;background:{color};"
            f"border-radius:50%;vertical-align:middle;margin-right:6px'></span>{lang} • ⭐ {n['stargazerCount']} • updated {pushed}</sub>"
        )
    return "\n".join(lines)

def build_activity_section():
    to = datetime.now(timezone.utc)
    frm = to - timedelta(days=14)
    data = gql(QUERY_EVENTS, {"login": USER, "from": frm.isoformat(), "to": to.isoformat()})
    cc = data["user"]["contributionsCollection"]
    events = []
    for repoBlock in cc.get("commitContributionsByRepository", []):
        repo = repoBlock["repository"]
        for e in repoBlock["contributions"]["edges"]:
            node = e["node"]
            events.append({
                "type": "commit",
                "text": f"pushed **{node['commitCount']}** commit(s) to [{repo['name']}]({repo['url']})",
                "when": node["occurredAt"]
            })
    for e in cc.get("pullRequestContributions", {}).get("edges", []):
        node = e["node"]
        pr = node["pullRequest"]
        events.append({ "type": "pr", "text": f"opened PR: [{pr['title']}]({pr['url']})", "when": node["occurredAt"] })
    for e in cc.get("issueContributions", {}).get("edges", []):
        node = e["node"]
        isu = node["issue"]
        events.append({ "type": "issue", "text": f"opened Issue: [{isu['title']}]({isu['url']})", "when": node["occurredAt"] })
    events.sort(key=lambda x: x["when"], reverse=True)
    events = events[:5]
    if not events:
        return "_No recent public activity_"
    out = ["### 🏃 Recent Activity"]
    for ev in events:
        out.append(f"- ⏺️ {ev['text']} <sub>{humanize(ev['when'])}</sub>")
    return "\n".join(out)

def generate_month_svg():
    end = datetime.now(timezone.utc).date()
    start = end - timedelta(days=29)
    data = gql(QUERY_MONTH, {"login": USER, "from": start.isoformat()+"T00:00:00Z", "to": end.isoformat()+"T23:59:59Z"})
    weeks = data["user"]["contributionsCollection"]["contributionCalendar"]["weeks"]
    counts = {}
    for w in weeks:
        for d in w["contributionDays"]:
            date = d["date"]
            if start.isoformat() <= date <= end.isoformat():
                counts[date] = d["contributionCount"]
    days = [(start + timedelta(days=i)).isoformat() for i in range(30)]
    vals = [counts.get(day, 0) for day in days]
    maxv = max(vals) if any(vals) else 1

    width, height = 800, 240
    padding = 30
    chart_w = width - padding*2
    chart_h = height - padding*2
    bar_w = chart_w / len(vals)

    bars = []
    for i, v in enumerate(vals):
        x = padding + i * bar_w
        h = 0 if maxv == 0 else (v / maxv) * chart_h
        y = height - padding - h
        delay = i * 0.03
        bars.append(f"""
  <g class="bar" transform="translate({x:.2f}, {y:.2f})">
    <title>{days[i]}: {v}</title>
    <rect x="0" y="0" width="{bar_w*0.8:.2f}" height="{h:.2f}" rx="6" ry="6"
          style="animation: grow 0.8s {delay:.2f}s ease-out forwards; transform-origin: bottom;">
    </rect>
  </g>
""")

    svg = f"""<svg width="{width}" height="{height}" viewBox="0 0 {width} {height}" xmlns="http://www.w3.org/2000/svg">
  <style>
    .title {{ font: 700 16px 'Segoe UI', Roboto, Ubuntu, 'Helvetica Neue', Arial; }}
    .axis  {{ font: 12px 'Segoe UI', Roboto, Ubuntu, Arial; fill: #888; }}
    rect   {{ fill: #00bcd4; opacity: 0.85; }}
    rect:hover {{ opacity: 1.0; }}
    @keyframes grow {{ from {{ transform: scaleY(0); }} to {{ transform: scaleY(1); }} }}
  </style>
  <rect x="0" y="0" width="{width}" height="{height}" fill="#0b1220" rx="16" />
  <text x="{padding}" y="{padding-8}" class="title" fill="#ffffff">This Month Activity (last 30 days)</text>
  <g>
    {"".join(bars)}
  </g>
  <g class="axis">
    <text x="{padding}" y="{height - 8}">{days[0][5:]}</text>
    <text x="{padding + chart_w/2:.2f}" y="{height - 8}">{days[15][5:]}</text>
    <text x="{padding + chart_w - 36:.2f}" y="{height - 8}">{days[-1][5:]}</text>
  </g>
</svg>"""

    os.makedirs(ASSETS_DIR, exist_ok=True)
    with open(SVG_PATH, "w", encoding="utf-8") as f:
        f.write(svg)

def build_monthly_graph_section():
    return "### 🕓 This Month\n\n![Monthly Activity](assets/monthly_activity.svg)"

def main():
    if not TOKEN or not USER:
        print("Missing env GITHUB_TOKEN or GH_USER", file=sys.stderr); sys.exit(1)
    projects_md = build_projects_section()
    activity_md = build_activity_section()
    generate_month_svg()
    graph_md = build_monthly_graph_section()
    with open(README_PATH, "r", encoding="utf-8") as f:
        readme = f.read()
    readme = replace_between(readme, "PROJECTS:START", "PROJECTS:END", projects_md)
    readme = replace_between(readme, "ACTIVITY:START", "ACTIVITY:END", activity_md)
    readme = replace_between(readme, "MONTHLY_GRAPH:START", "MONTHLY_GRAPH:END", graph_md)
    with open(README_PATH, "w", encoding="utf-8") as f:
        f.write(readme)

if __name__ == "__main__":
    main()

4. 見栄えの整ったREADMEテンプレート(貼るだけ)

1.をそのまま使ってもよいし、こちらに差し替えてもよいです。
YOUR_GITHUB_ID とSNSリンクだけ置換すれば使える。
完成イメージ
https://github.com/kskmasa

README.md(自動更新分+他テンプレ)
README.md
<!-- =========================================================
GitHub Profile README — Modern / Minimal / Animated
TODO:
  1) YOUR_GITHUB_ID を自分のIDに置換
  2) 各SNSリンクを差し替え
  3) assets/monthly_activity.svg はワークフローが自動生成
========================================================= -->

<div align="center">

# 👋 Hi, I'm **Your Name**
*Designing systems that make life simpler and smarter.*

✨ Full-stack Engineer / Product Designer  
💡 AI Automation・Productivity Apps・Creative Tools

<img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png" width="100%" alt="divider" />

<p>
  <img src="https://github-readme-stats.vercel.app/api?username=YOUR_GITHUB_ID&show_icons=true&theme=tokyonight&hide_border=true" height="165" alt="stats"/>
  <img src="https://github-readme-stats.vercel.app/api/top-langs/?username=YOUR_GITHUB_ID&layout=compact&theme=tokyonight&hide_border=true" height="165" alt="top langs"/>
</p>

<a href="https://git.io/typing-svg">
  <img src="https://readme-typing-svg.demolab.com?font=Fira+Code&size=22&pause=1000&color=00BFFF&center=true&vCenter=true&width=700&lines=AI+Automation+Enthusiast;Building+apps+that+empower+people;Always+learning%2C+always+creating." alt="typing animation"/>
</a>

</div>

---

### 🧠 Tech Stack
![Python](https://img.shields.io/badge/Python-3776AB.svg?style=for-the-badge&logo=python&logoColor=white)
![FastAPI](https://img.shields.io/badge/FastAPI-009688.svg?style=for-the-badge&logo=fastapi&logoColor=white)
![TypeScript](https://img.shields.io/badge/TypeScript-3178C6.svg?style=for-the-badge&logo=typescript&logoColor=white)
![React](https://img.shields.io/badge/React-61DAFB.svg?style=for-the-badge&logo=react&logoColor=black)
![AWS](https://img.shields.io/badge/AWS-232F3E.svg?style=for-the-badge&logo=amazonaws&logoColor=white)
![Figma](https://img.shields.io/badge/Figma-F24E1E.svg?style=for-the-badge&logo=figma&logoColor=white)

> “Design isn’t what it looks like — it’s how it works.”

---

## 🔄 現在の開発中プロジェクト
<!-- PROJECTS:START -->
<!-- PROJECTS:END -->

---

## 🏃 最近の活動
<!-- ACTIVITY:START -->
<!-- ACTIVITY:END -->

---

## 🕓 今月のアクティビティ
<!-- MONTHLY_GRAPH:START -->
<!-- MONTHLY_GRAPH:END -->

---

### 🌐 Connect
[![Website](https://img.shields.io/badge/%F0%9F%8C%8D%20Website-Visit-blue?style=flat)](https://yourdomain.com)
[![LinkedIn](https://img.shields.io/badge/LinkedIn-Follow-0077B5?style=flat&logo=linkedin)](https://linkedin.com/in/yourname)
[![X](https://img.shields.io/badge/X-Follow-000000?style=flat&logo=x)](https://x.com/yourhandle)
[![Instagram](https://img.shields.io/badge/Instagram-@yourhandle-E4405F?style=flat&logo=instagram)](https://instagram.com/yourhandle)

<p align="center">
  <img src="https://raw.githubusercontent.com/andreasbm/readme/master/assets/lines/colored.png" width="100%" alt="divider" />
</p>

5. セットアップ(最短)

  1. プロフィール用リポジトリ <your_id>/<your_id> を作成し、上記ファイルを配置:
    • README.md(マーカー入り)
    • .github/workflows/update-readme.yml
    • scripts/update_readme.py
  2. Actions タブを有効化し、初回は Run workflow で手動実行
  3. 以後は毎日自動更新(UTC基準のcron)。

検証とトラブルシュート

  • 実行時刻が数分ずれる:GitHubは予定時刻の実行を保証しない。許容範囲で考えるか、頻度を上げる。
  • GITHUB_TOKEN の権限不足:permissions.contents: write を付与してから再実行。
  • 活動が表示されない:private活動はGraphQLのcontributionsCollectionに含まれない(公開活動のみ)。
  • SVGが生成されない:assets/ ディレクトリの書き込みとコミット処理を確認。

運用・拡張(制約・代替・セキュリティ)

  • 制約:private活動の集計は不可(GitHub仕様)。代替は自前ログやセルフホストRunnerでの集計。
  • テーマ変更:SVGの色・背景・サイズはスクリプト内CSSを編集。
  • 代替オプション:
    • GitHub Readme Stats(多テーマ・言語カード)
    • readme-typing-svg(打鍵アニメ)
    • Shields.io(技術スタックやリンク用バッジ)
  • セキュリティ:トークンはリポジトリシークレット(デフォルトのGITHUB_TOKEN)を使用。外部SaaSのURLはレート制限や停止のリスクを理解して利用。

まとめ

ここまでで、見た目も動きも整ったGitHubプロフィールREADMEが完成しました。
コードを書かなくても、毎日少しずつ“自分の今”が更新されるのってちょっと楽しいですよね。

最初はシンプルに始めて、
あとから「今日の活動グラフ」や「開発中のアプリ一覧」を足していくのもおすすめです。
プロフィールを“作品”として育てていく感じで、気軽に試してみてください。

参考リンク


Tweet Copy

プロフィールREADMEを“綺麗に自動更新”。
GraphQL × Actionsで、プロジェクト/活動/今月グラフを毎日生成。
外部SaaS不要で最小構成。
#GitHub #GitHubActions #GraphQL

2
1
1

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?