7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

【AWS×Qiita】サーバーレスで自分だけのQiita記事ダッシュボードを作ってみる

7
Posted at

はじめに

スクリーンショット 2026-03-17 21.46.13.png

「自分のQiita記事、全部でどれくらい読まれてるんだろう?」

こんばんは、mirukyです。
Qiitaの管理画面でも記事ごとの閲覧数は見れますが、全記事を横断的に比較したり、いいね数やストック数をまとめて可視化するのは意外と面倒ですよね。

そこで今回は、AWS Lambda + S3 + EventBridgeでQiita APIを定期的に叩き、自分の全記事の統計情報をきれいなダッシュボードで表示するサーバーレスシステムを構築してみます。

目次

  1. Qiita APIアクセストークンを発行する
  2. Secrets Managerにトークンを保存する
  3. Lambda関数を作成する
  4. S3バケットを作成する
  5. テスト実行
  6. EventBridgeで定期実行を設定する
  7. 【オプション】CloudFrontでHTTPS配信する
  8. 【オプション】Parameter Store版(コスト削減)
  9. Qiita APIの注意点
  10. トラブルシューティング
  11. 応用アイデア
  12. おわりに

完成イメージ

最終的に、以下のようなダッシュボードが出来上がります。
スクリーンショット 2026-03-17 21.46.36.png

  • ユーザーアイコン・プロフィール表示
  • 全記事の合計ビュー数・いいね数・ストック数のサマリーカード
  • 記事別のビュー数・いいね数・ストック数ランキング(棒グラフ)
  • 全記事のデータテーブル(ソート可能)

アーキテクチャ

image.png

使用サービス一覧

サービス 役割
Lambda Qiita APIからデータ取得 → HTMLダッシュボード生成 → S3にアップロード
S3 静的ウェブサイトホスティング(ダッシュボードHTML)
EventBridge Lambda を毎日定時実行(スケジュール)
Secrets Manager Qiita APIアクセストークンの安全な保管
CloudFront HTTPS配信(オプション)

料金の目安

このシステムは非常に軽量なので、ほぼ無料枠内で運用できます。

サービス 費用
Lambda 1日1回の実行は無料枠内
S3 数KBのHTML保存は無料枠内
EventBridge スケジュールルールは無料
Secrets Manager $0.40/月(唯一の実費)

Secrets Managerの代替: コストを抑えたい場合は、Systems Manager Parameter Store(SecureString)を使うと無料になります。本記事ではセキュリティのベストプラクティスとしてSecrets Managerを使いますが、Parameter Storeでの手順も後述します。

1. Qiita APIアクセストークンを発行する

まず、Qiita APIにアクセスするためのトークンを発行します。
スクリーンショット 2026-03-17 21.07.38.png

  1. Qiita設定ページ にアクセス
  2. 「個人用アクセストークン」の「新しくトークンを発行する」をクリック
  3. 以下のように設定
項目 設定値
アクセストークンの説明 qiita-dashboard など任意の名前
スコープ read_qiita にチェック

4.「発行する」をクリック
5.表示されたトークンをメモしておく(この画面を閉じると二度と表示されません

page_views_count(閲覧数)は、認証済みユーザーの自分の記事を取得するエンドポイント(GET /api/v2/authenticated_user/items)でのみ返されます。スコープは read_qiita が必須です。

2. Secrets Managerにトークンを保存する

Lambdaのコードにトークンをハードコードするのは危険です。Secrets Managerに安全に保管しましょう。

スクリーンショット 2026-03-17 21.13.10.png

AWSマネジメントコンソールでの手順

  1. AWSマネジメントコンソールで「Secrets Manager」を開く
  2. 「新しいシークレットを保存する」をクリック
  3. 以下のように設定
項目 設定値
シークレットのタイプ その他のシークレットのタイプ
キー/値のペア キー: QIITA_API_TOKEN / 値: 発行したトークン
シークレットの名前 qiita-dashboard/api-token

4.「保存」をクリック

AWS CLIでの手順(代替)

aws secretsmanager create-secret \
  --name "qiita-dashboard/api-token" \
  --secret-string '{"QIITA_API_TOKEN":"ここにトークンを貼り付け"}'

Parameter Storeで代替する場合

aws ssm put-parameter \
  --name "/qiita-dashboard/api-token" \
  --value "ここにトークンを貼り付け" \
  --type SecureString

3. Lambda関数を作成する

3-1. Lambda関数の作成

スクリーンショット 2026-03-17 21.15.40.png

  1. AWSマネジメントコンソールで「Lambda」を開く
  2. 「関数の作成」をクリック
  3. 以下のように設定
項目 設定値
関数名 qiita-dashboard-generator
ランタイム Python 3.14
アーキテクチャ arm64(Graviton2 - コスト効率が良い)
実行ロール デフォルトロールを作成

4.「関数の作成」をクリック

3-2. タイムアウトとメモリの設定

スクリーンショット 2026-03-17 21.16.42.png

「設定」タブ → 「一般設定」→「編集」

項目 設定値
メモリ 256 MB
タイムアウト 1分

Qiita APIへのHTTPリクエストが複数回発生するため、デフォルトの3秒では足りません。記事数が多い場合を考慮して1分に設定します。

3-3. IAMロールにポリシーを追加

Lambdaの実行ロールに以下の権限を追加します。

  1. 「設定」タブ → 「アクセス権限」→ ロール名をクリック
  2. IAMコンソールで許可ポリシーセクションの「許可を追加」→「インラインポリシーを作成」
  3. JSON で以下を入力
{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Effect": "Allow",
      "Action": "secretsmanager:GetSecretValue",
      "Resource": "arn:aws:secretsmanager:ap-northeast-1:*:secret:qiita-dashboard/api-token-*"
    },
    {
      "Effect": "Allow",
      "Action": [
        "s3:PutObject",
        "s3:PutObjectAcl"
      ],
      "Resource": "arn:aws:s3:::your-qiita-dashboard-bucket/*"
    }
  ]
}

your-qiita-dashboard-bucket は、次のステップで作成するS3バケット名に置き換えてください。Secrets ManagerのARN末尾の -* は、自動付与されるランダムサフィックスに対応するためワイルドカードにしています。

3-4. Lambdaコード

「コード」タブに以下のPythonコードを貼り付けます。これが本システムのメインロジックです。

import json
import os
import urllib.request
import urllib.error
import boto3
from datetime import datetime, timezone, timedelta

# ========== 設定 ==========
SECRET_NAME = "qiita-dashboard/api-token"
S3_BUCKET = os.environ.get("S3_BUCKET", "your-qiita-dashboard-bucket")
S3_KEY = "index.html"
REGION = os.environ.get("AWS_REGION", "ap-northeast-1")

# ========== Qiita API ==========

def get_qiita_token():
    """Secrets ManagerからQiita APIトークンを取得"""
    client = boto3.client("secretsmanager", region_name=REGION)
    response = client.get_secret_value(SecretId=SECRET_NAME)
    secret = json.loads(response["SecretString"])
    return secret["QIITA_API_TOKEN"]


def qiita_api_get(endpoint, token, params=None):
    """Qiita API v2にGETリクエストを送信"""
    base_url = "https://qiita.com/api/v2"
    url = f"{base_url}{endpoint}"
    
    if params:
        query = "&".join(f"{k}={v}" for k, v in params.items())
        url = f"{url}?{query}"
    
    req = urllib.request.Request(url)
    req.add_header("Authorization", f"Bearer {token}")
    req.add_header("Content-Type", "application/json")
    
    with urllib.request.urlopen(req) as res:
        return json.loads(res.read().decode("utf-8"))


def fetch_all_items(token):
    """認証ユーザーの全記事を取得(ページネーション対応)"""
    all_items = []
    page = 1
    per_page = 100  # 最大100件/ページ
    
    while True:
        items = qiita_api_get(
            "/authenticated_user/items",
            token,
            {"page": str(page), "per_page": str(per_page)}
        )
        
        if not items:
            break
        
        all_items.extend(items)
        
        # 取得件数がper_page未満なら最後のページ
        if len(items) < per_page:
            break
        
        page += 1
        
        # 安全弁: 100ページ(10,000記事)まで
        if page > 100:
            break
    
    return all_items


def fetch_user_info(token):
    """認証ユーザーのプロフィール情報を取得"""
    return qiita_api_get("/authenticated_user", token)


# ========== HTML生成 ==========

def generate_dashboard_html(user_info, items):
    """ダッシュボードHTMLを生成"""
    
    # 統計データの集計
    total_views = sum(item.get("page_views_count") or 0 for item in items)
    total_likes = sum(item.get("likes_count") or 0 for item in items)
    total_stocks = sum(item.get("stocks_count") or 0 for item in items)
    total_articles = len(items)
    
    # 記事データをビュー数の降順でソート
    sorted_items = sorted(
        items,
        key=lambda x: x.get("page_views_count") or 0,
        reverse=True
    )
    
    # タグ集計
    tag_counts = {}
    for item in items:
        for tag in item.get("tags", []):
            name = tag["name"]
            tag_counts[name] = tag_counts.get(name, 0) + 1
    top_tags = sorted(tag_counts.items(), key=lambda x: x[1], reverse=True)[:10]
    
    # グラフ用データ(上位15記事)
    chart_items = sorted_items[:15]
    chart_labels = [item["title"][:30] + "..." if len(item["title"]) > 30 else item["title"] for item in chart_items]
    chart_views = [item.get("page_views_count") or 0 for item in chart_items]
    chart_likes = [item.get("likes_count") or 0 for item in chart_items]
    chart_stocks = [item.get("stocks_count") or 0 for item in chart_items]
    
    # ユーザー情報
    icon_url = user_info.get("profile_image_url", "")
    user_name = user_info.get("name") or user_info.get("id", "Unknown")
    user_id = user_info.get("id", "")
    followers = user_info.get("followers_count", 0)
    
    # 更新日時(JST)
    jst = timezone(timedelta(hours=9))
    updated_at = datetime.now(jst).strftime("%Y/%m/%d %H:%M JST")
    
    # テーブル行の生成
    table_rows = ""
    for i, item in enumerate(sorted_items, 1):
        views = item.get("page_views_count") or 0
        likes = item.get("likes_count") or 0
        stocks = item.get("stocks_count") or 0
        tags_str = ", ".join(tag["name"] for tag in item.get("tags", [])[:3])
        created = item.get("created_at", "")[:10]
        url = item.get("url", "")
        title = item.get("title", "")
        
        table_rows += f"""
        <tr>
          <td>{i}</td>
          <td><a href="{url}" target="_blank" rel="noopener">{title}</a></td>
          <td class="num">{views:,}</td>
          <td class="num">{likes:,}</td>
          <td class="num">{stocks:,}</td>
          <td>{tags_str}</td>
          <td>{created}</td>
        </tr>"""
    
    html = f"""<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
  <title>Qiita Dashboard - @{user_id}</title>
  <script src="https://cdn.jsdelivr.net/npm/chart.js@4.5.1/dist/chart.umd.min.js"></script>
  <style>
    * {{ margin: 0; padding: 0; box-sizing: border-box; }}
    body {{
      font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
      background: linear-gradient(135deg, #0f0c29, #302b63, #24243e);
      color: #e0e0e0;
      min-height: 100vh;
      padding: 24px;
    }}
    .container {{ max-width: 1200px; margin: 0 auto; }}
    
    /* ===== ヘッダー ===== */
    .header {{
      display: flex;
      align-items: center;
      gap: 20px;
      margin-bottom: 32px;
      padding: 24px;
      background: rgba(255,255,255,0.06);
      border-radius: 16px;
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255,255,255,0.1);
    }}
    .header img {{
      width: 80px; height: 80px;
      border-radius: 50%;
      border: 3px solid #55efc4;
    }}
    .header-info h1 {{ font-size: 1.6rem; color: #fff; }}
    .header-info p {{ color: #aaa; margin-top: 4px; }}
    .header-info .badge {{
      display: inline-block;
      background: #55efc4;
      color: #222;
      padding: 2px 10px;
      border-radius: 12px;
      font-size: 0.8rem;
      font-weight: bold;
      margin-top: 6px;
    }}
    
    /* ===== サマリーカード ===== */
    .summary {{
      display: grid;
      grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
      gap: 16px;
      margin-bottom: 32px;
    }}
    .card {{
      background: rgba(255,255,255,0.06);
      border-radius: 16px;
      padding: 24px;
      text-align: center;
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255,255,255,0.1);
      transition: transform 0.2s;
    }}
    .card:hover {{ transform: translateY(-4px); }}
    .card .label {{ font-size: 0.85rem; color: #aaa; margin-bottom: 8px; }}
    .card .value {{ font-size: 2.2rem; font-weight: bold; }}
    .card:nth-child(1) .value {{ color: #74b9ff; }}
    .card:nth-child(2) .value {{ color: #fd79a8; }}
    .card:nth-child(3) .value {{ color: #fdcb6e; }}
    .card:nth-child(4) .value {{ color: #55efc4; }}
    
    /* ===== チャート ===== */
    .chart-container {{
      background: rgba(255,255,255,0.06);
      border-radius: 16px;
      padding: 24px;
      margin-bottom: 32px;
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255,255,255,0.1);
    }}
    .chart-container h2 {{
      margin-bottom: 16px;
      font-size: 1.2rem;
      color: #fff;
    }}
    canvas {{ max-height: 400px; }}
    
    /* ===== タグ ===== */
    .tags-section {{
      background: rgba(255,255,255,0.06);
      border-radius: 16px;
      padding: 24px;
      margin-bottom: 32px;
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255,255,255,0.1);
    }}
    .tags-section h2 {{ margin-bottom: 16px; font-size: 1.2rem; color: #fff; }}
    .tag-list {{ display: flex; flex-wrap: wrap; gap: 8px; }}
    .tag {{
      background: rgba(116, 185, 255, 0.2);
      color: #74b9ff;
      padding: 6px 14px;
      border-radius: 20px;
      font-size: 0.85rem;
      border: 1px solid rgba(116, 185, 255, 0.3);
    }}
    .tag .count {{ margin-left: 6px; color: #aaa; }}
    
    /* ===== テーブル ===== */
    .table-container {{
      background: rgba(255,255,255,0.06);
      border-radius: 16px;
      padding: 24px;
      backdrop-filter: blur(10px);
      border: 1px solid rgba(255,255,255,0.1);
      overflow-x: auto;
    }}
    .table-container h2 {{ margin-bottom: 16px; font-size: 1.2rem; color: #fff; }}
    table {{ width: 100%; border-collapse: collapse; }}
    th {{
      background: rgba(255,255,255,0.08);
      padding: 12px 16px;
      text-align: left;
      font-size: 0.85rem;
      color: #aaa;
      text-transform: uppercase;
      letter-spacing: 0.5px;
      cursor: pointer;
      user-select: none;
      white-space: nowrap;
    }}
    th:hover {{ color: #fff; }}
    td {{
      padding: 12px 16px;
      border-bottom: 1px solid rgba(255,255,255,0.05);
      font-size: 0.9rem;
    }}
    td a {{ color: #74b9ff; text-decoration: none; }}
    td a:hover {{ text-decoration: underline; }}
    td.num {{ text-align: right; font-variant-numeric: tabular-nums; }}
    tr:hover {{ background: rgba(255,255,255,0.03); }}
    
    /* ===== フッター ===== */
    .footer {{
      text-align: center;
      margin-top: 32px;
      padding: 16px;
      color: #666;
      font-size: 0.8rem;
    }}
    
    @media (max-width: 768px) {{
      body {{ padding: 12px; }}
      .header {{ flex-direction: column; text-align: center; }}
      .card .value {{ font-size: 1.6rem; }}
    }}
  </style>
</head>
<body>
  <div class="container">
    <!-- ヘッダー -->
    <div class="header">
      <img src="{icon_url}" alt="@{user_id}" />
      <div class="header-info">
        <h1>{user_name}</h1>
        <p>@{user_id}</p>
        <span class="badge">{followers} followers</span>
      </div>
    </div>
    
    <!-- サマリーカード -->
    <div class="summary">
      <div class="card">
        <div class="label">総記事数</div>
        <div class="value">{total_articles}</div>
      </div>
      <div class="card">
        <div class="label">総閲覧数</div>
        <div class="value">{total_views:,}</div>
      </div>
      <div class="card">
        <div class="label">総いいね数</div>
        <div class="value">{total_likes:,}</div>
      </div>
      <div class="card">
        <div class="label">総ストック数</div>
        <div class="value">{total_stocks:,}</div>
      </div>
    </div>
    
    <!-- ビュー数チャート -->
    <div class="chart-container">
      <h2>記事別ビュー数 TOP 15</h2>
      <canvas id="viewsChart"></canvas>
    </div>
    
    <!-- いいね・ストック数チャート -->
    <div class="chart-container">
      <h2>記事別いいね数・ストック数 TOP 15</h2>
      <canvas id="likesChart"></canvas>
    </div>
    
    <!-- よく使うタグ -->
    <div class="tags-section">
      <h2>よく使うタグ TOP 10</h2>
      <div class="tag-list">
        {"".join(f'<span class="tag">{tag}<span class="count">({count})</span></span>' for tag, count in top_tags)}
      </div>
    </div>
    
    <!-- 記事一覧テーブル -->
    <div class="table-container">
      <h2>全記事一覧({total_articles}件)</h2>
      <table id="articlesTable">
        <thead>
          <tr>
            <th onclick="sortTable(0, 'num')">#</th>
            <th onclick="sortTable(1, 'str')">タイトル</th>
            <th onclick="sortTable(2, 'num')">Views ▼</th>
            <th onclick="sortTable(3, 'num')">Likes</th>
            <th onclick="sortTable(4, 'num')">Stocks</th>
            <th>タグ</th>
            <th onclick="sortTable(6, 'str')">投稿日</th>
          </tr>
        </thead>
        <tbody>
          {table_rows}
        </tbody>
      </table>
    </div>
    
    <!-- フッター -->
    <div class="footer">
      Last updated: {updated_at} | Powered by AWS Lambda + Qiita API v2
    </div>
  </div>
  
  <script>
    // ===== ビュー数チャート =====
    const viewsCtx = document.getElementById('viewsChart').getContext('2d');
    new Chart(viewsCtx, {{
      type: 'bar',
      data: {{
        labels: {json.dumps(chart_labels, ensure_ascii=False)},
        datasets: [{{
          label: 'Views',
          data: {json.dumps(chart_views)},
          backgroundColor: 'rgba(116, 185, 255, 0.6)',
          borderColor: 'rgba(116, 185, 255, 1)',
          borderWidth: 1,
          borderRadius: 6
        }}]
      }},
      options: {{
        indexAxis: 'y',
        responsive: true,
        plugins: {{
          legend: {{ display: false }},
          tooltip: {{
            callbacks: {{
              label: (ctx) => ctx.parsed.x.toLocaleString() + ' views'
            }}
          }}
        }},
        scales: {{
          x: {{
            ticks: {{ color: '#aaa', callback: (v) => v.toLocaleString() }},
            grid: {{ color: 'rgba(255,255,255,0.05)' }}
          }},
          y: {{
            ticks: {{ color: '#ccc', font: {{ size: 11 }} }},
            grid: {{ display: false }}
          }}
        }}
      }}
    }});
    
    // ===== いいね・ストック数チャート =====
    const likesCtx = document.getElementById('likesChart').getContext('2d');
    new Chart(likesCtx, {{
      type: 'bar',
      data: {{
        labels: {json.dumps(chart_labels, ensure_ascii=False)},
        datasets: [
          {{
            label: 'Likes',
            data: {json.dumps(chart_likes)},
            backgroundColor: 'rgba(253, 121, 168, 0.6)',
            borderColor: 'rgba(253, 121, 168, 1)',
            borderWidth: 1,
            borderRadius: 6
          }},
          {{
            label: 'Stocks',
            data: {json.dumps(chart_stocks)},
            backgroundColor: 'rgba(253, 203, 110, 0.6)',
            borderColor: 'rgba(253, 203, 110, 1)',
            borderWidth: 1,
            borderRadius: 6
          }}
        ]
      }},
      options: {{
        indexAxis: 'y',
        responsive: true,
        plugins: {{
          legend: {{ labels: {{ color: '#ccc' }} }},
          tooltip: {{
            callbacks: {{
              label: (ctx) => ctx.dataset.label + ': ' + ctx.parsed.x.toLocaleString()
            }}
          }}
        }},
        scales: {{
          x: {{
            stacked: true,
            ticks: {{ color: '#aaa' }},
            grid: {{ color: 'rgba(255,255,255,0.05)' }}
          }},
          y: {{
            stacked: true,
            ticks: {{ color: '#ccc', font: {{ size: 11 }} }},
            grid: {{ display: false }}
          }}
        }}
      }}
    }});
    
    // ===== テーブルソート =====
    function sortTable(colIdx, type) {{
      const table = document.getElementById('articlesTable');
      const tbody = table.querySelector('tbody');
      const rows = Array.from(tbody.querySelectorAll('tr'));
      
      const dir = table.dataset.sortCol == colIdx && table.dataset.sortDir === 'asc' ? 'desc' : 'asc';
      table.dataset.sortCol = colIdx;
      table.dataset.sortDir = dir;
      
      rows.sort((a, b) => {{
        let aVal = a.cells[colIdx].textContent.trim();
        let bVal = b.cells[colIdx].textContent.trim();
        
        if (type === 'num') {{
          aVal = parseInt(aVal.replace(/,/g, '')) || 0;
          bVal = parseInt(bVal.replace(/,/g, '')) || 0;
        }}
        
        if (aVal < bVal) return dir === 'asc' ? -1 : 1;
        if (aVal > bVal) return dir === 'asc' ? 1 : -1;
        return 0;
      }});
      
      rows.forEach(row => tbody.appendChild(row));
    }}
  </script>
</body>
</html>"""
    
    return html


# ========== メインハンドラー ==========

def lambda_handler(event, context):
    """Lambda メインハンドラー"""
    print("Qiita Dashboard Generator 開始")
    
    # 1. Qiita APIトークン取得
    token = get_qiita_token()
    print("APIトークン取得完了")
    
    # 2. ユーザー情報取得
    user_info = fetch_user_info(token)
    print(f"ユーザー情報取得完了: @{user_info.get('id')}")
    
    # 3. 全記事データ取得
    items = fetch_all_items(token)
    print(f"記事データ取得完了: {len(items)}")
    
    # 4. HTMLダッシュボード生成
    html = generate_dashboard_html(user_info, items)
    print(f"HTML生成完了: {len(html):,} bytes")
    
    # 5. S3にアップロード
    s3 = boto3.client("s3")
    s3.put_object(
        Bucket=S3_BUCKET,
        Key=S3_KEY,
        Body=html.encode("utf-8"),
        ContentType="text/html; charset=utf-8"
    )
    print(f"S3アップロード完了: s3://{S3_BUCKET}/{S3_KEY}")
    
    return {
        "statusCode": 200,
        "body": json.dumps({
            "message": "Dashboard updated successfully",
            "articles_count": len(items),
            "total_views": sum(item.get("page_views_count") or 0 for item in items)
        })
    }

3-5. 環境変数の設定

スクリーンショット 2026-03-17 21.22.27.png

「設定」タブ → 「環境変数」→「編集」

キー
S3_BUCKET your-qiita-dashboard-bucket(次のステップで作成するバケット名)

4. S3バケットを作成する

ダッシュボードHTMLを配置する静的ウェブサイト用のバケットを作成します。

4-1. バケットの作成

  1. AWSマネジメントコンソールで「S3」を開く
  2. 「バケットを作成」をクリック
  3. 以下のように設定
項目 設定値
バケット名 your-qiita-dashboard-bucket(グローバルで一意にする)
リージョン アジアパシフィック (東京)
パブリックアクセスをすべてブロック チェックを外す

4.「バケットを作成」をクリック

静的ウェブサイトホスティングではパブリックアクセスが必要です。ダッシュボードを自分だけに公開したい場合は、CloudFront + OACを使う方法(後述)を推奨します。

4-2. 静的ウェブサイトホスティングの有効化

スクリーンショット 2026-03-17 21.24.11.png

  1. 作成したバケットを開く → 「プロパティ」タブ
  2. 「静的ウェブサイトホスティング」→「編集」
  3. 以下のように設定
項目 設定値
静的ウェブサイトホスティング 有効にする
ホスティングタイプ 静的ウェブサイトをホストする
インデックスドキュメント index.html

4.「変更の保存」をクリック

4-3. バケットポリシーの設定

スクリーンショット 2026-03-17 21.24.59.png

「アクセス許可」タブ → 「バケットポリシー」→「編集」

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "PublicReadGetObject",
      "Effect": "Allow",
      "Principal": "*",
      "Action": "s3:GetObject",
      "Resource": "arn:aws:s3:::your-qiita-dashboard-bucket/*"
    }
  ]
}

your-qiita-dashboard-bucket は実際のバケット名に置き換えてください。

5. テスト実行

5-1. Lambda のテスト

スクリーンショット 2026-03-17 21.25.51.png

  1. Lambda関数の「テスト」タブを開く
  2. イベント名: test
  3. イベントJSON:
{}

4.「テスト」をクリック

成功すると以下のようなログが出力されます。

Qiita Dashboard Generator 開始
APIトークン取得完了
ユーザー情報取得完了: @miruky
記事データ取得完了: 29件
HTML生成完了: 48,230 bytes
S3アップロード完了: s3://your-qiita-dashboard-bucket/index.html

5-2. ダッシュボードの確認

S3の静的ウェブサイトエンドポイントにブラウザでアクセスします。

http://your-qiita-dashboard-bucket.s3-website-ap-northeast-1.amazonaws.com

S3バケットの「プロパティ」タブ → 「静的ウェブサイトホスティング」セクションにエンドポイントURLが表示されています。

スクリーンショット 2026-03-17 21.47.22.png
こう表示されれば成功です。

6. EventBridge スケジューラで定期実行を設定する

毎日自動でダッシュボードを更新するように、EventBridge スケジューラでスケジュールを作成します。

スケジュールの作成手順

スクリーンショット 2026-03-17 21.58.27.png

  1. AWSマネジメントコンソールで「Amazon EventBridge」を開く
  2. 左メニューの「Scheduler」→「スケジュール」を選択
  3. スケジュールを作成」をクリック

スケジュールの詳細を指定

項目 設定値
スケジュール名 qiita-dashboard-daily
スケジュールグループ default

スケジュールのパターン

項目 設定値
頻度 定期的なスケジュール
タイムゾーン (UTC+9:00) Asia/Tokyo
スケジュールの種類 Cron ベースのスケジュール
cron式 cron(0 0 * * ? *)
フレックスタイムウィンドウ オフ

cron(0 0 * * ? *) は UTC 00:00(JST 09:00)に毎日実行されます。好みの時間に変更してください。

4.「次へ」をクリック

ターゲットを選択

項目 設定値
ターゲットAPI AWS Lambda - Invoke
Lambda 関数 qiita-dashboard-generator

5.「次へ」をクリック

設定

項目 設定値
スケジュールを有効化 有効
アクセス許可 このスケジュールの新しいロールを作成

6.「次へ」→ 内容を確認して「スケジュールを作成」をクリック

これで毎日自動でダッシュボードが更新されます。

7.【オプション】CloudFrontでHTTPS配信する

S3の静的ウェブサイトホスティングはHTTPのみです。セキュアにアクセスしたい場合は、CloudFrontを前段に置きます。

7-1. CloudFrontディストリビューションの作成

  1. AWSマネジメントコンソールで「CloudFront」を開く
  2. ディストリビューションを作成」をクリック

Step 1: ディストリビューション名

スクリーンショット 2026-03-17 22.02.06.png

項目 設定値
ディストリビューション名 qiita-dashboard(任意)

Step 2: オリジンの指定

S3の静的ウェブサイトホスティングを使っている場合、オリジンタイプで「Amazon S3」ではなく「Other」を選ぶ必要があります。S3ウェブサイトエンドポイントはREST APIエンドポイントと異なるため、OAC(オリジンアクセスコントロール)は使用できません。

項目 設定値
オリジンタイプ Other
オリジンドメイン your-bucket.s3-website-ap-northeast-1.amazonaws.com
オリジン設定 Use recommended origin settings
Cache settings Use recommended cache settings tailored to serving Custom origin content

Step 3: セキュリティ

AWS WAF のセキュリティ保護は、今回は「セキュリティ保護を有効にしない」でOKです(個人ダッシュボード用途のため)。

Step 4: 確認と作成

スクリーンショット 2026-03-17 22.11.15.png
設定内容を確認し、「Create distribution」をクリック

デプロイ完了後、ディストリビューション一覧に表示されるDomain name(Standard)を使用して、https://[Domain name] でHTTPSアクセスできるようになります。

8.【オプション】Parameter Store版(コスト削減)

Secrets Managerの$0.40/月を削減したい場合、Parameter Store(SecureString)を使えます。Lambdaコードを以下のように変更するだけです。

def get_qiita_token():
    """Parameter StoreからQiita APIトークンを取得"""
    client = boto3.client("ssm", region_name=REGION)
    response = client.get_parameter(
        Name="/qiita-dashboard/api-token",
        WithDecryption=True
    )
    return response["Parameter"]["Value"]

IAMポリシーも変更します。

{
  "Effect": "Allow",
  "Action": "ssm:GetParameter",
  "Resource": "arn:aws:ssm:ap-northeast-1:*:parameter/qiita-dashboard/api-token"
}

Qiita APIの注意点

項目 詳細
レート制限 認証済み: 1,000リクエスト/時、未認証: 60リクエスト/時
page_views_count GET /api/v2/authenticated_user/items でのみ取得可能(自分の記事のみ)
ページネーション 1リクエストで最大100件。pageper_page パラメータで制御
トークンスコープ 閲覧のみなら read_qiita で十分

おわりに

今回は、AWS Lambda + S3 + EventBridge + Qiita APIを使って、自分の全記事の統計をきれいなダッシュボードで可視化するシステムを構築しました。

完全サーバーレスで、ランニングコストはほぼゼロ(Secrets Manager使用時でも月$0.40)です。一度構築すれば毎日自動更新されるので、定期的に自分のアウトプットを振り返るのに最適です。
何より、記事を執筆するモチベーション向上になりますね。
見える化って結構大事だと思いました。

Qiita APIは認証済みエンドポイントを使うことで page_views_count が取得できるようになる点がポイントです。
このシステムをベースに、DynamoDBで時系列データを蓄積して推移グラフを追加するなど、どんどん拡張してみてください。

参考リンク

7
7
0

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
7
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?