はじめに
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推奨)が必要です。
完成イメージ
毎朝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(記事の更新)
発行されたトークンは後で使うので控えておきます。
トークンは一度しか表示されません。必ずメモしておきましょう。
ステップ3:インデックス用の記事をQiitaに作成する
後で自動更新するための空の記事を公開状態で投稿しておきます。
タイトルは好きな名前でOKです。
投稿後、URLの末尾にある記事IDをメモしておきます。
https://qiita.com/your_name/items/【ここが記事ID】
ステップ4:claspをセットアップする
Apps Script APIを有効化する
以下のURLを開き、「Google Apps Script API」をオンにします(1回だけ)。
claspをインストールしてログイン
npm install -g @google/clasp@latest
clasp login
clasp login を実行するとブラウザが開くので、Googleアカウントで認証します。
認証完了後にターミナルへ戻り、Authorization successful. と表示されれば成功です。
初回実行時の警告について
GASを初めて実行すると以下の警告が表示されますが、自分で作成したスクリプトなので安全に進められます。
- 「このアプリは Google で確認されていません」 の画面で左下の 「詳細」 をクリック
- 「(安全ではないページ)に移動」 をクリック
- 権限の確認画面で 「許可」 をクリック
ステップ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 にタグ名と表示名のペアを追加することで、セクション見出しの表記を自由に変更できます。デフォルトでは Axmol → Axmol Engine に変換されます。
ステップ9:動作確認する
GASエディタで updateQiitaIndex を選択して手動実行し、Qiitaのインデックス記事が更新されていることを確認します。
ログは 「実行数」 から確認できます。
対象タグ: Axmol, C++
下書き除外: true
取得記事数: 25
下書き除外後: 20 件
ID除外後: 19 件
タグフィルタ後: 12 件
✅ インデックス記事を更新しました
ステップ10:自動トリガーを設定する
GASエディタで setDailyTrigger を1回だけ手動実行します。
これで毎日午前7時ごろに updateQiitaIndex が自動で実行されます。
毎週にしたい場合は Code.js の .everyDays(1) を .everyWeeks(1) に変更して clasp push し直してください。
GASの開発フロー
ローカルでコードを編集
↓
# コードを編集した後
clasp push
# GASの最新状態をローカルに取得
clasp pull
# 実行ログを確認
clasp logs
トラブルシューティング
clasp login で Premature 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/clasp → clasp login
|
| GASプロジェクト作成 | clasp create |
| コードを書いてpush | clasp push |
| スクリプトプロパティ設定 | トークン・記事ID・タグ・除外設定など |
| トリガー設定 |
setDailyTrigger を1回手動実行 |
clasp を使うことでローカルのVSCodeで快適に開発でき、Gitでバージョン管理もできます。Node.js v20 が必要な点とインデックス記事は公開状態が必要な点だけ注意すれば、あとはスムーズに動きます。
実際の更新対象インデックスページ




