5
5

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 + clasp で自分のQiita記事を自動でインデックス化する(タグ指定)

5
Last updated at Posted at 2026-06-29

はじめに

Qiitaに記事が増えてくると「あの記事どこだっけ?」となりがちです。
この記事では Google Apps Script(GAS)clasp(CLIツール) を使って、

  • 自分の記事をタグ指定で取得
  • Markdown形式のインデックス記事を自動生成
  • 既存のQiita記事に毎日自動で上書き更新

する仕組みを作ります。

ローカルでコードを書いて clasp push するだけでGASに反映できるので、VSCodeで開発してGitで管理できるのも嬉しいポイントです。

clasp

clasp(Command Line Apps Script Projects)は、Google Apps Script(GAS)をローカル環境で開発するためのCLIツールです。

主な特徴

  • ローカル開発: GASのコードをローカルのエディタ(VS Codeなど)で書ける
  • バージョン管理: Gitと組み合わせてソース管理が可能
  • TypeScript対応: TypeScriptでGASを書いてトランスパイルできる
  • push/pull: ローカルとGASのオンラインエディタ間でコードを同期

インストール

npm install -g @google/clasp

Node.js(v20推奨)が必要です。

完成イメージ

スクリーンショット 2026-06-28 14.10.54.png

毎朝7時に自動実行され、こんな感じのインデックス記事が更新されます。

> 対象タグ: `Axmol` 最終更新: 2025/06/01 07:03

記事数: **12** 件

## Axmol Engine

https://qiita.com/your_name/items/aaa

https://qiita.com/your_name/items/bbb

https://qiita.com/your_name/items/ccc

必要なもの

  • Google アカウント
  • Node.js v20(重要:後述)
  • Qiita のアカウント

下書き記事・特定記事の扱いについて

Qiita APIの GET /api/v2/authenticated_user/items下書き記事も含めて取得します。
下書き記事には private: true フィールドが付いています。

ケース 動作
下書き記事をインデックスに含めたい デフォルトで含まれる ✅
下書き記事をインデックスから除外したい EXCLUDE_PRIVATE プロパティで制御 ✅
特定の記事IDを除外したい EXCLUDE_ARTICLE_IDS プロパティで制御 ✅

インデックス記事自体は公開記事である必要があります。
下書き記事に対してPATCHリクエストを送ると404エラーになります。

ステップ1:Node.js v20 を用意する

clasp は Node.js v21 以上では clasp login が失敗します。
oauth2.googleapis.com/token との通信時に Premature close エラーが発生するためです。
必ず Node.js v20 を使ってください。

現在のバージョンを確認します。

node --version
# v20.x.x であればOK

v21以上が表示された場合はv20に切り替えます。

nvm の場合

nvm install 20
nvm use 20
node --version  # v20.x.x

nodebrew の場合

nodebrew install 20.19.0
nodebrew use 20.19.0
node --version  # v20.19.0

ステップ2:Qiita APIトークンを取得する

Qiita右上のアイコン → 設定アプリケーション個人用アクセストークン から発行します。

スコープは以下の2つにチェックを入れてください。

  • read_qiita(記事の読み取り)
  • write_qiita(記事の更新)

スクリーンショット 2026-06-28 10.35.43.png

発行されたトークンは後で使うので控えておきます。

トークンは一度しか表示されません。必ずメモしておきましょう。

ステップ3:インデックス用の記事をQiitaに作成する

後で自動更新するための空の記事を公開状態で投稿しておきます。
タイトルは好きな名前でOKです。

投稿後、URLの末尾にある記事IDをメモしておきます。

https://qiita.com/your_name/items/【ここが記事ID】

ステップ4:claspをセットアップする

Apps Script APIを有効化する

以下のURLを開き、「Google Apps Script API」をオンにします(1回だけ)。

スクリーンショット 2026-06-28 10.40.13.png

claspをインストールしてログイン

npm install -g @google/clasp@latest
clasp login

clasp login を実行するとブラウザが開くので、Googleアカウントで認証します。
認証完了後にターミナルへ戻り、Authorization successful. と表示されれば成功です。

初回実行時の警告について

GASを初めて実行すると以下の警告が表示されますが、自分で作成したスクリプトなので安全に進められます。

  1. 「このアプリは Google で確認されていません」 の画面で左下の 「詳細」 をクリック
  2. 「(安全ではないページ)に移動」 をクリック
  3. 権限の確認画面で 「許可」 をクリック

ステップ5:プロジェクトを作成する

作業ディレクトリを作ってGASプロジェクトを新規作成します。

mkdir qiita-index-updater
cd qiita-index-updater
clasp create --title "Qiita Index Updater" --type standalone --rootDir ./src

実行すると src/appsscript.json.clasp.json が生成されます。

プロジェクト構成はこのようになります。

qiita-index-updater/
├── .clasp.json          # プロジェクトID(Git管理してOK)
├── .claspignore         # pushしないファイルを指定
├── .gitignore           # .env などを除外
└── src/
    ├── appsscript.json  # GASのマニフェスト
    └── Code.js          # メインスクリプト

.gitignore を作成する

.env
node_modules/

QIITA_TOKEN はコードに直接書かずに、後述のスクリプトプロパティで管理します。

.claspignore を作成する

**/.git/**
**/node_modules/**
.env
README.md

appsscript.json を編集する

タイムゾーンを日本時間に設定します。

{
  "timeZone": "Asia/Tokyo",
  "dependencies": {},
  "exceptionLogging": "STACKDRIVER",
  "runtimeVersion": "V8"
}

ステップ6:GASのコードを書く

src/Code.js を作成して以下のコードを貼り付けます。

// ============================================================
// Qiita 自動インデックス記事更新スクリプト
// ============================================================

const PROPS = PropertiesService.getScriptProperties();

// -----------------------------------------------
// メイン関数:自動トリガーから呼び出される
// -----------------------------------------------
function updateQiitaIndex() {
  const token          = PROPS.getProperty('QIITA_TOKEN');
  const tagsStr        = PROPS.getProperty('TARGET_TAGS') || '';
  const indexId        = PROPS.getProperty('INDEX_ARTICLE_ID');
  const indexTitle     = PROPS.getProperty('INDEX_TITLE') || '記事インデックス';
  const excludePrivate = PROPS.getProperty('EXCLUDE_PRIVATE') === 'true';

  if (!token || !indexId) {
    Logger.log('❌ QIITA_TOKEN または INDEX_ARTICLE_ID が未設定です');
    return;
  }

  const targetTags = tagsStr.split(',').map(t => t.trim()).filter(Boolean);
  Logger.log(`対象タグ: ${targetTags.join(', ') || '全記事'}`);
  Logger.log(`下書き除外: ${excludePrivate}`);

  // 1. 自分の記事を全件取得
  let articles = fetchMyArticles(token);
  Logger.log(`取得記事数: ${articles.length}`);

  // 2. 下書きを除外する(EXCLUDE_PRIVATE=true のとき)
  if (excludePrivate) {
    articles = articles.filter(a => !a.private);
    Logger.log(`下書き除外後: ${articles.length} 件`);
  }

  // 3. 特定記事IDを除外
  const excludeIds = (PROPS.getProperty('EXCLUDE_ARTICLE_IDS') || '')
    .split(',').map(id => id.trim()).filter(Boolean);
  if (excludeIds.length > 0) {
    articles = articles.filter(a => !excludeIds.includes(a.id));
    Logger.log(`ID除外後: ${articles.length} 件`);
  }

  // 4. タグでフィルタ(タグ未指定なら全記事)
  const filtered = targetTags.length > 0
    ? articles.filter(a => a.tags.some(t => targetTags.includes(t.name)))
    : articles;
  Logger.log(`タグフィルタ後: ${filtered.length} 件`);

  // 5. Markdown 生成
  const body = buildIndexMarkdown(filtered, targetTags, excludePrivate);

  // 6. Qiita 記事を上書き更新(PATCH)
  patchQiitaArticle(token, indexId, indexTitle, body);
  Logger.log('✅ インデックス記事を更新しました');
}

// -----------------------------------------------
// 自分の記事を全件取得(ページネーション対応)
// -----------------------------------------------
function fetchMyArticles(token) {
  const articles = [];
  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: { 'Authorization': `Bearer ${token}` },
      muteHttpExceptions: true
    });

    if (res.getResponseCode() !== 200) {
      Logger.log(`API エラー: ${res.getResponseCode()} ${res.getContentText()}`);
      break;
    }

    const data = JSON.parse(res.getContentText());
    if (data.length === 0) break;

    articles.push(...data);
    if (data.length < perPage) break;
    page++;
    Utilities.sleep(500); // レートリミット対策
  }

  return articles;
}

// -----------------------------------------------
// タグ名の表示名を変換する
// -----------------------------------------------
function getDisplayTagName(tag) {
  const tagMap = {
    'Axmol': 'Axmol Engine'
  };
  return tagMap[tag] || tag;
}

// -----------------------------------------------
// インデックス用 Markdown 生成
// -----------------------------------------------
function buildIndexMarkdown(articles, targetTags, excludePrivate) {
  const now = Utilities.formatDate(new Date(), 'Asia/Tokyo', 'yyyy/MM/dd HH:mm');
  const tagLabel = targetTags.length > 0 ? targetTags.join(', ') : '全記事';

  const grouped = groupByFirstTag(articles, targetTags);

  let md = '';
  md += `> 対象タグ: \`${tagLabel}\``;
  if (excludePrivate) md += ` 下書き: 除外`;
  md += ` 最終更新: ${now}\n\n`;
  md += `記事数: **${articles.length}** 件\n\n`;

  for (const [tag, items] of Object.entries(grouped)) {
    const displayTag = getDisplayTagName(tag);
    md += `## ${displayTag}\n\n`;

    // 更新日降順でソート
    items.sort((a, b) => new Date(b.updated_at) - new Date(a.updated_at));

    for (const item of items) {
      md += '\n';
      md += `${item.url}\n`;
      md += '\n';
    }
  }

  return md;
}

// -----------------------------------------------
// 記事をタグでグループ化
// -----------------------------------------------
function groupByFirstTag(articles, targetTags) {
  const grouped = {};

  for (const article of articles) {
    const articleTags = article.tags.map(t => t.name);

    let matchedTags;
    if (targetTags.length > 0) {
      matchedTags = targetTags.filter(t => articleTags.includes(t));
    } else {
      matchedTags = articleTags.length > 0 ? [articleTags[0]] : ['未分類'];
    }

    const groupTag = matchedTags[0] || '未分類';
    if (!grouped[groupTag]) grouped[groupTag] = [];
    grouped[groupTag].push(article);
  }

  return grouped;
}

// -----------------------------------------------
// Qiita 記事を PATCH で上書き更新
// -----------------------------------------------
function patchQiitaArticle(token, articleId, title, body) {
  const url = `https://qiita.com/api/v2/items/${articleId}`;
  const payload = JSON.stringify({ title, body });

  const res = UrlFetchApp.fetch(url, {
    method: 'PATCH',
    headers: {
      'Authorization': `Bearer ${token}`,
      'Content-Type': 'application/json'
    },
    payload,
    muteHttpExceptions: true
  });

  if (res.getResponseCode() !== 200) {
    throw new Error(`PATCH 失敗: ${res.getResponseCode()} ${res.getContentText()}`);
  }
}

// -----------------------------------------------
// 毎日トリガーを設定(1回だけ手動実行する)
// -----------------------------------------------
function setDailyTrigger() {
  // 既存トリガーを削除してから再設定
  ScriptApp.getProjectTriggers().forEach(t => {
    if (t.getHandlerFunction() === 'updateQiitaIndex') {
      ScriptApp.deleteTrigger(t);
    }
  });

  ScriptApp.newTrigger('updateQiitaIndex')
    .timeBased()
    .everyDays(1)   // 毎週にする場合は .everyWeeks(1)
    .atHour(7)      // 午前7時ごろ(JST)
    .create();

  Logger.log('✅ 毎日トリガーを設定しました');
}

ステップ7:GASにpushする

clasp push

正常にpushできていれば以下のように表示されます。

└─ src/Code.js
└─ src/appsscript.json
Pushed 2 files.

GASエディタで確認する場合は .clasp.json に記載されている scriptId を使って以下のURLをブラウザで開きます。

https://script.google.com/d/【scriptId】/edit

ステップ8:スクリプトプロパティを設定する

GASエディタで 「プロジェクトの設定」→「スクリプトプロパティ」 を開き、以下を追加します。

プロパティ名 設定する値 必須
QIITA_TOKEN ステップ2で取得したトークン
INDEX_ARTICLE_ID ステップ3でメモした記事ID
INDEX_TITLE インデックス記事のタイトル
TARGET_TAGS Axmol,C++(カンマ区切りで複数指定可、空なら全記事)
EXCLUDE_PRIVATE true(下書きを除外したい場合のみ設定)
EXCLUDE_ARTICLE_IDS 除外したい記事IDをカンマ区切りで指定

EXCLUDE_ARTICLE_IDS に複数指定する場合は id1,id2,id3 のようにカンマ区切りにします。

getDisplayTagName 関数内の tagMap にタグ名と表示名のペアを追加することで、セクション見出しの表記を自由に変更できます。デフォルトでは AxmolAxmol Engine に変換されます。

スクリーンショット 2026-06-28 14.09.34.png

ステップ9:動作確認する

GASエディタで updateQiitaIndex を選択して手動実行し、Qiitaのインデックス記事が更新されていることを確認します。

ログは 「実行数」 から確認できます。

対象タグ: Axmol, C++
下書き除外: true
取得記事数: 25
下書き除外後: 20 件
ID除外後: 19 件
タグフィルタ後: 12 件
✅ インデックス記事を更新しました

ステップ10:自動トリガーを設定する

GASエディタで setDailyTrigger を1回だけ手動実行します。
これで毎日午前7時ごろに updateQiitaIndex が自動で実行されます。

スクリーンショット 2026-06-29 8.28.15.png

毎週にしたい場合は Code.js.everyDays(1).everyWeeks(1) に変更して clasp push し直してください。

GASの開発フロー

ローカルでコードを編集

# コードを編集した後
clasp push

# GASの最新状態をローカルに取得
clasp pull

# 実行ログを確認
clasp logs

トラブルシューティング

clasp loginPremature close が出る

Node.js のバージョンが v21 以上の場合に発生します。v20 に切り替えてください。
※Node20オンリーかどうかの検証はしていませんが、少なくともNode24では筆者環境で発生しました

node --version   # v20.x.x であることを確認

PATCH で 404 エラーが出る

インデックス記事が下書き状態になっています。Qiitaで対象記事を公開状態に変更してください。下書き記事はPATCH APIで更新できません。

No credentials found が出る

clasp login が完了していない状態です。再度実行してください。

clasp login
cat ~/.clasprc.json  # トークンが書かれていれば認証済み

Apps Script API が無効エラーが出る

以下のURLでAPIがオンになっているか確認します。

まとめ

手順 内容
Node.js v20 を用意 v21以上では clasp login が失敗するため必須
Qiita APIトークン取得 read_qiita + write_qiita スコープ
インデックス記事を作成 公開状態の記事を投稿して記事IDをメモ
clasp セットアップ npm install -g @google/claspclasp login
GASプロジェクト作成 clasp create
コードを書いてpush clasp push
スクリプトプロパティ設定 トークン・記事ID・タグ・除外設定など
トリガー設定 setDailyTrigger を1回手動実行

clasp を使うことでローカルのVSCodeで快適に開発でき、Gitでバージョン管理もできます。Node.js v20 が必要な点とインデックス記事は公開状態が必要な点だけ注意すれば、あとはスムーズに動きます。

実際の更新対象インデックスページ

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

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?