3
6

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[GAS] Qiita記事の統計(PV・いいね・ストック)を全自動で可視化するGASを全力で作ってみた!

3
Last updated at Posted at 2026-03-02

qiita_stats_eyecatch.png

はじめに

エンジニアにとって、Qiitaへのアウトプットは技術の棚卸しや自身のポートフォリオ構築として極めて有意義です。しかし、「記事を書く」ことと「記事を育てる(観測・分析する)」ことは別のスキルです。せっかく書いた記事がどれくらい読まれ(PV)、どんな反響(いいね・ストック)を得ているのか、毎日手動でQiitaを開いて確認していませんか? その手間、実はエンジニアとしての貴重な時間を奪っています。

執筆モチベーションを「感情」ではなく**「客観的な数字」**で支える。それが継続的なアウトプットの秘訣です。本記事では、Google Apps Script (GAS) を活用し、Qiita API から全自動でデータを取得、スプレッドシートで美しく、そして「観測可能」な形で可視化するシステムを全力で構築しました。

今回は、Google Apps Script (GAS) を使って、Qiita記事の統計を全自動で取得し、スプレッドシートで美しく可視化する「決定版」のスクリプトを紹介します。

本記事は、現在連載している「制御可能性中心設計(Controllability-Centered Design)」シリーズの実践編です。思想だけでなく、実際に自分のアウトプットをどう観測し、どう設計するかを、小さなスクリプトとして形にしました。


何ができるか

このスクリプトを導入することで、以下のような「観測」が可能になります。

1. 最新の全記事統計(自動ハイライト付き)

「最新統計」シートに、全記事のタイトル、PV、いいね数、ストック数が一覧表示されます。前回の集計時から数値が増えたセルは自動的に背景色が変わり、どの記事が伸びているのか一目で分かります。

2. 毎日の推移データを蓄積

「推移データ」シートに、全記事の合算値を毎日自動で記録します。過去の累計データが消えることなく、ストックされていきます。

3. 2軸グラフによるトレンド可視化

蓄積されたデータをもとに、PVと(いいね・ストック)を分けた「2軸折れ線グラフ」を自動生成します。記事の伸びと反応の相関を視覚的に把握できます。


こだわりの実装ポイント

長期にわたって継続的に活動することを想定し、単なる使い切りのツールではなく、実用性(負荷や耐故障など)を細部まで考慮した設計を取り入れています。具体的には、以下のような工夫を凝らしました。

🚄 並列処理 (UrlFetchApp.fetchAll) で爆速実行

通常、記事ごとに1回ずつAPIを叩くと、記事数に比例して処理時間が延びてしまいます。本スクリプトでは fetchAll を採用し、複数のリクエストを並列で実行。記事数が多くても一瞬でデータを取得完了します。

🛡️ チャンク処理による安全設計

GASには実行時間制限やAPI呼び出し回数の制約があります。一度に大量のリクエストを投げすぎないよう、**50件ずつの「チャンク」**に分けて処理することで、サーバーへの負荷を抑えつつ確実に実行を完遂させます。

補足: Qiita APIのレートリミット(認証時1000回/時)やGASのメモリ制限を考慮し、最も安定して動作した50件を採用しました。

🛡️ 堅牢性を支える追加設計(プロフェッショナル仕様)

さらに、現場での長期的な運用を可能にするために、以下の高度な工夫を加えています。

  • 排他制御 (LockService): トリガーの重複実行によるデータの競合や、API呼び出しの過多を構造的に防ぎます。
  • 指数バックオフ & 429 (Rate Limit) 対応: ネットワークの一時的な不安定さだけでなく、APIのレート制限に遭遇した場合も Retry-After ヘッダを解析して自律的に待機・再試行します。
  • 取得失敗ログの自動生成: 万が一取得に失敗した記事(パースエラー含む)があった場合、専用の「取得失敗ログ」シートにエラー内容を自動記録。後からのリカバリを容易にします。
  • 数値パース & JSONガード: APIレスポンスの欠損や不正なJSONに対しても、システム全体がクラッシュしないよう境界線でデータを無害化しています。
  • 列構成の変化に強い設計: シートの列を「名前」で動的に解決するため、途中に列を挿入してもスクリプトが壊れません。
  • 柔軟な設定: CHUNK_SIZE などの挙動をスクリプトプロパティからコード変更なしでチューニング可能です。

セットアップ手順

誰でも5分で導入できます。

  1. Qiitaアクセストークンの発行

    • Qiitaの「設定」>「アプリケーション」から、個人用アクセストークンを発行します(スコープは read_qiita でOK)。
  2. Googleスプレッドシートの準備

    • 新規スプレッドシートを作成し、上部メニューの「拡張機能」>「Apps Script」を開きます。
  3. スクリプトの貼り付けと設定

    • 下記のコードをエディタに貼り付け、保存します。
    • エディタ左側の「設定(歯車アイコン)」>「スクリプト プロパティ」に、プロパティ名 QIITA_TOKEN 、値に先ほど発行したトークンを保存します。
  4. トリガーの設定

    • 時計アイコンの「トリガー」から「updateQiitaStats」を「時間主導型」で毎日実行するように設定すれば、全自動化の完了です!

コード全文(V1.1)

const QIITA_TOKEN_PROP = 'QIITA_TOKEN';
const CHUNK_SIZE_PROP = 'QIITA_CHUNK_SIZE';
const DEFAULT_CHUNK_SIZE = 50;

/**
 * スプレッドシートを開いた時に実行される(カスタムメニュー)
 */
function onOpen() {
    const ui = SpreadsheetApp.getUi();
    ui.createMenu('Qiita集計')
        .addItem('今すぐ最新化する', 'updateQiitaStats')
        .addToUi();
}

/**
 * メイン実行関数
 */
function updateQiitaStats() {
    const lock = LockService.getScriptLock();
    // 30秒待機してロック取得を試みる
    if (!lock.tryLock(30000)) {
        throw new Error('別のプロセスが実行中です。しばらく待ってから再試行してください。');
    }

    try {
        const ss = SpreadsheetApp.getActiveSpreadsheet();
        const scriptProps = PropertiesService.getScriptProperties();
        const token = scriptProps.getProperty(QIITA_TOKEN_PROP);
        const chunkSize = Number(scriptProps.getProperty(CHUNK_SIZE_PROP)) || DEFAULT_CHUNK_SIZE;

        if (!token) {
            throw new Error('スクリプト プロパティ "QIITA_TOKEN" が設定されていません。');
        }

        const startTime = Date.now();

        // 1. Qiita APIから統計取得
        const stats = fetchStats(token, chunkSize, ss);

        // 2. 「最新統計」シートの更新
        updateMainSheet(ss, stats);

        // 3. 「推移データ」シートの蓄積
        updateHistorySheet(ss, stats);

        // 4. グラフの更新
        updateChart(ss);

        const duration = (Date.now() - startTime) / 1000;
        console.log(`実行完了: 処理時間 ${duration.toFixed(1)}秒`);
    } finally {
        lock.releaseLock();
    }
}

/**
 * Qiita API からデータを取得
 */
function fetchStats(token, chunkSize, ss) {
    const headers = {
        'Authorization': 'Bearer ' + token,
        'Accept': 'application/json'
    };

    // 1. ユーザーIDの取得(リトライ付き)
    let userId;
    let authError;
    for (let attempt = 0; attempt < 3; attempt++) {
        try {
            const userRes = UrlFetchApp.fetch('https://qiita.com/api/v2/authenticated_user', { headers: headers });
            userId = JSON.parse(userRes.getContentText()).id;
            break;
        } catch (e) {
            authError = e.message;
            if (attempt < 2) {
                console.warn(`認証ユーザー取得リトライ (${attempt + 1}/3): ${authError}`);
                Utilities.sleep(1000 * Math.pow(2, attempt));
            }
        }
    }

    if (!userId) {
        logFailure(ss, 'authenticated_user', 'AUTH_ERROR', 'ユーザー情報の取得に最終的に失敗: ' + authError);
        throw new Error('Qiita への認証に失敗しました。トークンを確認してください。');
    }

    // 2. 記事一覧の取得(ページネーション対応)
    let allItems = [];
    let page = 1;
    const perPage = 100;

    while (true) {
        const url = `https://qiita.com/api/v2/authenticated_user/items?page=${page}&per_page=${perPage}`;
        const res = UrlFetchApp.fetch(url, { headers: headers, muteHttpExceptions: true });
        if (res.getResponseCode() !== 200) {
            logFailure(ss, url, res.getResponseCode(), '記事一覧の取得に失敗');
            break;
        }

        let items;
        try {
            items = JSON.parse(res.getContentText());
        } catch (e) {
            logFailure(ss, url, 'JSON_PARSE_ERROR', '一覧のパースに失敗');
            break;
        }

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

        allItems = allItems.concat(items);
        if (items.length < perPage) break;
        page++;
    }

    const statsData = [["タイトル", "URL", "ビュー数", "いいね数", "ストック数", "タグ", "作成日", "更新日"]];

    // 3. 各記事の詳細を並列フェッチ (fetchAll + チャンク化)
    const requests = allItems.map(item => ({
        url: 'https://qiita.com/api/v2/items/' + item.id,
        method: 'get',
        headers: headers,
        muteHttpExceptions: true
    }));

    // 50件ずつのチャンクに分けて実行(負荷分散・制限対策)
    for (let i = 0; i < requests.length; i += chunkSize) {
        const chunk = requests.slice(i, i + chunkSize);
        const responses = fetchWithRetry(chunk);

        responses.forEach((res, index) => {
            const itemUrl = chunk[index].url;
            const code = res.getResponseCode();
            if (code === 200) {
                try {
                    const detail = JSON.parse(res.getContentText());
                    statsData.push([
                        detail.title,
                        detail.url,
                        Number(detail.page_views_count) || 0,
                        Number(detail.likes_count) || 0,
                        Number(detail.stocks_count) || 0,
                        detail.tags.map(t => t.name).join(', '),
                        detail.created_at.substring(0, 10),
                        detail.updated_at.substring(0, 10)
                    ]);
                } catch (e) {
                    logFailure(ss, itemUrl, 'JSON_PARSE_ERROR', '詳細のパースに失敗');
                    statsData.push(["(パース失敗) " + itemUrl, itemUrl, 0, 0, 0, "", "", ""]);
                }
            } else {
                logFailure(ss, itemUrl, code, '詳細の取得に失敗');
                statsData.push(["(取得失敗) " + itemUrl, itemUrl, 0, 0, 0, "", "", ""]);
            }
        });
    }

    return statsData;
}

/**
 * fetchAll に指数バックオフ付きリトライを追加
 */
function fetchWithRetry(chunk, maxRetries = 3) {
    let lastError;
    for (let attempt = 0; attempt <= maxRetries; attempt++) {
        let responses;
        try {
            responses = UrlFetchApp.fetchAll(chunk);
            const rateLimited = responses.find(res => res.getResponseCode() === 429);

            if (rateLimited) {
                const retryAfter = parseInt(rateLimited.getHeaders()['Retry-After'] || '60', 10);
                console.warn(`レート制限適用 (429): ${retryAfter}秒待機します。`);
                Utilities.sleep(retryAfter * 1000);
                continue; // 待機後に再試行
            }

            const hasError = responses.some(res => res.getResponseCode() !== 200);
            if (!hasError) return responses;
            lastError = `Status: ${responses.find(r => r.getResponseCode() !== 200).getResponseCode()}`;
        } catch (e) {
            lastError = e.message;
        }

        if (attempt < maxRetries) {
            const sleepTime = Math.pow(2, attempt) * 1000;
            console.warn(`リトライ試行 ${attempt + 1}/${maxRetries} (待機: ${sleepTime}ms): ${lastError}`);
            Utilities.sleep(sleepTime);
        }
    }
    // 最終的に失敗した場合は、詳細をログに残す
    if (lastError) {
        chunk.forEach(req => {
            logFailure(ss, req.url, 'FETCH_ALL_FAILED', `最終試行で失敗: ${lastError}`);
        });
    }

    // 最終試行として返し、個別エラー処理に委ねる
    return UrlFetchApp.fetchAll(chunk);
}

/**
 * 失敗ログをスプレッドシートに記録
 */
function logFailure(ss, url, code, message) {
    let sheet = ss.getSheetByName('取得失敗ログ') || ss.insertSheet('取得失敗ログ');
    if (sheet.getLastRow() === 0) {
        sheet.appendRow(["タイムスタンプ", "URL/Endpoint", "コード", "エラー内容"]);
        sheet.getRange(1, 1, 1, 4).setFontWeight('bold');
    }
    sheet.appendRow([
        Utilities.formatDate(new Date(), "JST", "yyyy-MM-dd HH:mm:ss"),
        url, code, message
    ]);
}

/**
 * 「最新統計」シートを更新(変動箇所をハイライト + 総計行を追加)
 */
function updateMainSheet(ss, newData) {
    let sheet = ss.getSheetByName('最新統計') || ss.insertSheet('最新統計');

    // 1. 前回データの読み取り(URLをキーにして数値を保持)
    const lastRow = sheet.getLastRow();
    const headers = newData[0];

    // 列位置の解決と整合性チェック
    const colIdx = {
        title: headers.indexOf("タイトル") + 1,
        url: headers.indexOf("URL") + 1,
        views: headers.indexOf("ビュー数") + 1,
        likes: headers.indexOf("いいね数") + 1,
        stocks: headers.indexOf("ストック数") + 1
    };

    // 必須列の存在確認
    const missingCols = Object.keys(colIdx).filter(key => colIdx[key] === 0);
    if (missingCols.length > 0) {
        const msg = `シートのヘッダに不足があります: ${missingCols.join(', ')}。列名が変更されていないか確認してください。`;
        logFailure(ss, 'updateMainSheet', 'HEADER_ERROR', msg);
        throw new Error(msg);
    }

    const oldDataMap = Object.create(null);

    if (lastRow > 1) {
        const oldValues = sheet.getRange(1, 1, lastRow, sheet.getLastColumn()).getValues();
        oldValues.forEach(row => {
            const url = row[colIdx.url - 1];
            if (url && url.startsWith('http')) {
                oldDataMap[url] = {
                    views: row[colIdx.views - 1],
                    likes: row[colIdx.likes - 1],
                    stocks: row[colIdx.stocks - 1]
                };
            }
        });
    }

    // 2. シートの初期化(コンテンツ消去と書式リセット)
    const maxRows = sheet.getMaxRows();
    const maxCols = sheet.getMaxColumns();
    if (maxRows > 0 && maxCols > 0) {
        sheet.getRange(1, 1, maxRows, maxCols).setFontWeight('normal').setBackground(null);
        sheet.clearContents();
    }

    // 3. 総計の計算 (Array.reduce)
    const total = newData.slice(1).reduce((acc, row) => ({
        views: acc.views + (Number(row[colIdx.views - 1]) || 0),
        likes: acc.likes + (Number(row[colIdx.likes - 1]) || 0),
        stocks: acc.stocks + (Number(row[colIdx.stocks - 1]) || 0)
    }), { views: 0, likes: 0, stocks: 0 });

    // 4. データに総計行を追加
    const finalData = [...newData];
    finalData.push(["総計", "", total.views, total.likes, total.stocks, "", "", ""]);

    // 5. 新しいデータの書き込み
    sheet.getRange(1, 1, finalData.length, finalData[0].length).setValues(finalData);
    sheet.getRange(1, 1, 1, finalData[0].length).setFontWeight('bold');

    // 6. 数値が増えたセルをハイライト(総計行は除く)    // 変動ハイライト
    const highlightColor = '#d9ead3'; // 薄い緑
    for (let i = 1; i < newData.length; i++) {
        const row = newData[i];
        const old = oldDataMap[row[colIdx.url - 1]];
        if (old) {
            if (Number(row[colIdx.views - 1]) > Number(old.views)) sheet.getRange(i + 1, colIdx.views).setBackground(highlightColor);
            if (Number(row[colIdx.likes - 1]) > Number(old.likes)) sheet.getRange(i + 1, colIdx.likes).setBackground(highlightColor);
            if (Number(row[colIdx.stocks - 1]) > Number(old.stocks)) sheet.getRange(i + 1, colIdx.stocks).setBackground(highlightColor);
        }
    }

    // 7. 総計行のスタイル設定
    const totalRowIndex = finalData.length;
    sheet.getRange(totalRowIndex, 1, 1, finalData[0].length)
        .setFontWeight('bold')
        .setBackground('#f3f3f3');

    // 8. 最終集計日時の表示
    const now = Utilities.formatDate(new Date(), "JST", "yyyy-MM-dd HH:mm:ss");
    const timeCell = sheet.getRange(1, finalData[0].length + 1);
    timeCell.setValue('最終集計: ' + now).setFontWeight('bold');
}

/**
 * 「推移データ」シートに蓄積
 */
function updateHistorySheet(ss, stats) {
    let sheet = ss.getSheetByName('推移データ') || ss.insertSheet('推移データ');
    const today = Utilities.formatDate(new Date(), "JST", "yyyy-MM-dd");

    // 1. 最新の集計値算出(4列分: 日付, ビュー, いいね, ストック)
    const total = stats.slice(1).reduce((acc, row) => ({
        views: acc.views + (Number(row[2]) || 0),
        likes: acc.likes + (Number(row[3]) || 0),
        stocks: acc.stocks + (Number(row[4]) || 0)
    }), { views: 0, likes: 0, stocks: 0 });

    const newRowData = [today, total.views, total.likes, total.stocks];

    // 2. 空行(日付が空)のクリーンアップ
    // A列を基準に、途中に挟まった空行を削除して上に詰める
    const lastRow = sheet.getLastRow();
    if (lastRow > 1) {
        const aValues = sheet.getRange(1, 1, lastRow, 1).getValues();
        for (let i = lastRow - 1; i >= 1; i--) {
            const cell = aValues[i][0];
            if (!cell || (typeof cell === 'string' && cell.trim() === "")) {
                sheet.deleteRow(i + 1);
            }
        }
    }

    // 3. 書き込み先の特定(クリーンアップ後)
    const data = sheet.getDataRange().getValues();
    // ヘッダーがない場合の初期化
    if (data.length === 0 || data[0][0] !== "日付") {
        const header = ["日付", "総ビュー数", "総いいね数", "総ストック数"];
        sheet.getRange(1, 1, 1, header.length).setValues([header]).setFontWeight('bold');
        sheet.getRange(2, 1, 1, newRowData.length).setValues([newRowData]);
    } else {
        let targetRowIndex = -1;
        for (let i = 1; i < data.length; i++) {
            const rowDate = data[i][0] instanceof Date ? Utilities.formatDate(data[i][0], "JST", "yyyy-MM-dd") : data[i][0];
            if (rowDate === today) {
                targetRowIndex = i + 1;
                break;
            }
        }

        if (targetRowIndex !== -1) {
            // 今日のデータがあれば、左側4列だけ更新(他の列は維持)
            sheet.getRange(targetRowIndex, 1, 1, newRowData.length).setValues([newRowData]);
        } else {
            // なければ最終行の次に追加
            sheet.getRange(sheet.getLastRow() + 1, 1, 1, newRowData.length).setValues([newRowData]);
        }
    }

    console.log(`履歴の更新と空行の清掃が完了しました。`);
}

/**
 * グラフの更新
 */
function updateChart(ss) {
    const sheet = ss.getSheetByName('推移データ');
    if (!sheet) return;
    const charts = sheet.getCharts();
    if (charts.length > 0) sheet.removeChart(charts[0]);

    const lastRow = sheet.getLastRow();
    if (lastRow < 2) return; // データがない場合はグラフ作成不可

    const range = sheet.getRange(1, 1, lastRow, 4);

    const chart = sheet.newChart()
        .setChartType(Charts.ChartType.LINE)
        .addRange(range)
        .setOption('useFirstRowAsHeaders', true)
        .setPosition(2, 6, 0, 0)
        .setOption('title', 'Qiita 総合推移 (合計値)')
        .setOption('legend', { position: 'right' })
        .setOption('series', {
            0: { targetAxisIndex: 0, labelInLegend: '総ビュー数' },
            1: { targetAxisIndex: 1, labelInLegend: '総いいね数' },
            2: { targetAxisIndex: 1, labelInLegend: '総ストック数' }
        })
        .setOption('vAxes', {
            0: { title: '総ビュー数' },
            1: { title: '総いいね・ストック数' }
        })
        .build();

    sheet.insertChart(chart);
}

テスト・運用チェックリスト

導入後、安心して使い続けるためのチェックポイントです。

  • 初回実行の確認: エディタから手動実行し、承認ダイアログ(「Googleスプレッドシートへの編集」「外部サービスへの接続」等)を許可したか。
  • 負荷の計測: 記事数が非常に多い場合、実行時間が6分を超えないか、メモリ不足エラーが出ないか確認。問題があればスクリプトプロパティで QIITA_CHUNK_SIZE を 20 程度に下げます。
  • 失敗ログの監視: 数日運用して「取得失敗ログ」シートをチェック。特定のリクエストが常に失敗していないか確認してください。
  • トークンの有効期限: 取得エラーが頻発し始めたら、Qiitaトークンの有効期限や権限をまず疑いましょう。

運用・トラブルシュート

長く使い続けるためのヒントです。

  • 承認時の注意: 初回実行時に「このアプリは確認されていません」と表示された場合は、「詳細」をクリックして「(プロジェクト名)に移動」を選択してください。自作スクリプトにおける標準的な挙動です。
  • 実行ログの確認: エラーが発生した場合は、GASエディタの「実行数」タブから詳細なエラーメッセージやリトライの履歴、処理時間(ログ出力)を確認できます。
  • レート制限(429): 本スクリプトは自動でリトライしますが、あまりに頻繁に発生する場合は実行頻度を下げるかチャンクサイズを小さくしてください。

おわりに

継続して記事を書き続けるなら、一時的な感情ではなく「観測できる状態」を作っておく方が健全です。自分自身の成長を客観的に捉え、次のステップへ進むための余白を作りましょう。

小さな実践ですが、本連載を支える裏側の設計の一例として参考になれば幸いです。

ぜひこのスクリプトを活用して、快適なQiitaライフを送ってください!


ライセンス

本記事で紹介したコードはご自由に改変・再配布してください。企業内ブログでの共有やチーム内ツールとしての導入も大歓迎です。

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?