みなさんQiita記事はQiitaのWebサイト上で書いていますか?実はQiita記事はローカルで環境構築をすればオフラインでも執筆できます!Qiita記事を作成する際に便利なツールであるQiitaCLIについて紹介します.また,gitでの管理方法や,GithubActionを用いた投稿や更新の自動化方法も記載します.
このようにQiita記事をマークダウンで書いた際のPreview機能が使えたり,ローカルで記事を管理できるので便利です.また,IDEにGitHubCopilotを導入していると記事の執筆がよりスムーズになります.他にもIDEで執筆するので執筆中の記事の文字列検索や自分の他の記事からの各種検索も可能です.
さらにgit管理をすれば記事のバックアップや履歴管理ができるので安心です.
この記事もローカルで執筆してGitHubActionsを用いて自動投稿しています
今回の完成したディレクトリは以下のGithubを参照してください.
QiitaCLIの導入
初めにQiita記事をローカルで執筆するためのQiitaCLIの導入方法を記載する.
QiitaCLIとは
QiitaCLIはQiita記事をローカルで執筆するためのツールである.実はgit管理やActionsは不要でQiitaCLIのみで記事の投稿更新や取得が可能である
ここからはnode.jsはインストール済みであること前提で話します.
Qiita記事をインストールするためのディレクトリの作成をする.
mkdir qiita
cd qiita
次にQiitaCLIをインストールする.
npm install @qiita/qiita-cli --save-dev
Qiita記事を執筆するためのディレクトリ構成を作成する.
npx qiita init
現状のQiita記事の取得やPreview機能を使用するためQiitaのアクセストークンを入力する
npx qiita login
現状のQiitaの記事を取得してディレクトリに反映させるには以下のコマンドを実行する.
npx qiita pull
最初に述べたこのPreview機能を使用するには以下のコマンドを実行する
npx qiita preview
以下のようにpackage.json
を修正するとQiitaCLIを任意のコマンドで使用できる.
{
"devDependencies": {
"@qiita/qiita-cli": "^1.6.1"
},
"scripts": {
"push": "qiita publish",
"preview": "qiita preview",
"pull": "qiita pull."
}
}
例えば記事の取得なら以下のコマンドを実行する.
npm run pull
Preview機能を使用するなら以下のコマンドを実行する.
npm run preview
ちなみにPreview機能はブラウザで
http://localhost:8888 にアクセスすれば使用できる.
私の場合はIDEにブラウザを表示するプラグインを導入しています.
QiitaのCredentialをこのディレクトリで使用したい場合は以下のコマンドでログインする
npx qiita login --credential .
そしてpackage.json
を以下のように書き換えればoK.(scripts名は好きにしていいですよ)
{
"devDependencies": {
"@qiita/qiita-cli": "^1.6.1"
},
"scripts": {
"push": "qiita publish --credential .",
"preview": "qiita preview --credential .",
"pull": "qiita pull --credential ."
}
}
これでQiita記事を書く準備はできました.
Qiita記事を書くには以下のコマンドで記事を作成できます.
npx qiita new [記事のファイルのベース名]
記事のファイルはpublic/
のディレクトリで作成される.
記事が完成したら以下のコマンドまたはPreview上で記事の公開
を押すことで記事を公開できる.
npx qiita publish [記事のファイルのベース名]
gitでの管理
ここからはQiita記事をgitで管理する方法を記載する.gitで管理すると記事のバックアップや履歴管理ができるので安心です.また投稿も自動でできたり,スペルチェックも自動化できたりと執筆環境レベルが格段に上がります.
gitコマンドやghコマンドが使えることを前提とします.
まず現在のディレクトリをgitリポジトリにする.
git init
GitHub上にリポジトリを作成する.プライベートリポジトリにしたいのならば--private
にする.(もちろんGithub.com上で操作してもいいですよ)
gh repo create --public
リモートリポジトリをローカルリポジトリに紐づける.
git remote add origin [Github上に作成したリポジトリのURL(http or ssh)]
現在のディレクトリのファイルをステージングする.
git add .
ステージングしたファイルをコミットする.
git commit -m "initial commit"
masterブランチをpushする
git push origin master
これでgit管理ができるようになった.
GithubActionを用いた投稿や更新の自動化
ここからはGithubActionを用いてQiita記事の投稿や更新を自動化する方法を記載する.git管理のみしても投稿の自動化はできないのでActionsを書く必要がある.
今回は以下の要件でActionsを書く
- featureブランチからmasterブランチにプルリクエストを作成したら差分のファイルのみ記事の公開をする
- 記事の公開が成功したらプルリクエストをマージする
- プルリクエストがOpenの時はfeatureブランチにpushすれ差分のファイルのみ記事の更新をする
- ローカルで作成した記事のidとupdated_atとcreated_atを更新する
公式さんが作成したActionsありましたが,カスタマイズしたかったのでQiita APIを叩くスクリプトとActionsを自前で用意しました.
QiitaAPIを叩くスクリプトの作成
mkdir scripts
cd scripts
touch qiita_sync.js
スクリプトの中身(qiita_sync.js)
const fs = require('fs');
const path = require('path');
const matter = require('gray-matter');
const axios = require('axios');
const { execSync } = require('child_process');
const QIITA_TOKEN = process.env.QIITA_TOKEN;
if (!QIITA_TOKEN) {
console.error("Error: QIITA_TOKEN が設定されていません。");
process.exit(1);
}
/**
* ファイルを Qiita API 経由で更新または新規投稿する
*/
async function syncFile(filePath) {
try {
const fileContent = fs.readFileSync(filePath, 'utf8');
const parsed = matter(fileContent);
const data = parsed.data || {};
const content = parsed.content || "";
// Qiita API では、タグ情報は { name: "...", versions: [] } の形式で送る必要があるので変換する
const formattedTags = (data.tags || []).map(tag => {
return (typeof tag === 'string') ? { name: tag, versions: [] } : tag;
});
// API リクエスト用のペイロード作成
const payload = {
body: content,
private: false,
tags: formattedTags,
title: data.title || "Untitled"
};
let response;
if (!data.id || data.id === null) {
console.log(`新規投稿: ${filePath}`);
// POST リクエスト → 新規記事作成
response = await axios.post("https://qiita.com/api/v2/items", payload, {
headers: {
"Authorization": `Bearer ${QIITA_TOKEN}`,
"Content-Type": "application/json"
}
});
// 返却された記事情報でフロントマターを更新(id, created_at, updated_at)
data.id = response.data.id;
data.created_at = response.data.created_at;
data.updated_at = response.data.updated_at;
} else {
console.log(`更新 (PATCH): ${filePath} (id: ${data.id})`);
// PATCH リクエスト → 既存記事の更新
const url = `https://qiita.com/api/v2/items/${data.id}`;
response = await axios.patch(url, payload, {
headers: {
"Authorization": `Bearer ${QIITA_TOKEN}`,
"Content-Type": "application/json"
}
});
// 更新された情報 (updated_at) を反映
data.updated_at = response.data.updated_at;
}
// 更新後のフロントマターと本文でファイルを書き換え
const newContent = matter.stringify(content, data);
fs.writeFileSync(filePath, newContent, 'utf8');
console.log(`更新完了: ${filePath}`);
} catch (error) {
if (error.response && error.response.data) {
console.error(`Error processing ${filePath}:`, error.response.data);
} else {
console.error(`Error processing ${filePath}:`, error.message);
}
// エラーが発生したら強制終了して workflow を失敗させる
process.exit(1);
}
}
/**
* master ブランチとの差分で変更があった ./public 配下の Markdown ファイルのみ対象にする
*/
async function processChangedFiles() {
let diffOutput = "";
try {
// origin/master との比較で変更があったファイル一覧を取得
diffOutput = execSync("git diff --name-only origin/master -- ./public").toString();
} catch (error) {
console.error("Error fetching changed files:", error.message);
process.exit(1);
}
const changedFiles = diffOutput
.split('\n')
.map(line => line.trim())
.filter(line => line !== "" && line.endsWith('.md'));
if (changedFiles.length === 0) {
console.log("更新対象の Markdown ファイルはありません。");
return;
}
for (const file of changedFiles) {
// パスが相対パスの場合、スクリプトの実行ディレクトリを考慮して調整
const fullPath = path.resolve(file);
await syncFile(fullPath);
}
}
// 未捕捉例外および Promise の未処理拒否も検知してプロセスを終了する
process.on('uncaughtException', error => {
console.error("Uncaught Exception:", error);
process.exit(1);
});
process.on('unhandledRejection', error => {
console.error("Unhandled Rejection:", error);
process.exit(1);
});
(async () => {
try {
await processChangedFiles();
console.log("Qiita 同期処理が完了しました。");
} catch (ex) {
console.error("処理中にエラーが発生しました:", ex);
process.exit(1);
}
})();
このスクリプトは以下の処理を行う.
- ファイルの差分を取得して変更があったファイルのみQiita記事を更新または新規投稿する
- フロントマターのid, created_at, updated_atを更新する
上記のスクリプトを実行するActionsを作成する.Qiitaディレクトリの位置に移動してから以下のコマンドを実行してください.
mkdir .github
cd .github
mkdir workflows
cd workflows
touch publish.yml
name: Publish and Sync Qiita Articles
on:
pull_request:
branches:
- master
permissions:
contents: write
jobs:
qiita-sync:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v3
with:
fetch-depth: 0
ref: ${{ github.head_ref }}
- name: Setup Node.js
uses: actions/setup-node@v3
with:
node-version: '18.16.0'
- name: Install dependencies
run: npm install
- name: Run Qiita sync script
env:
QIITA_TOKEN: ${{ secrets.QIITA_TOKEN }}
run: node scripts/qiita_sync.js
- name: Commit and push diff
env:
COMMIT_MESSAGE: update by qiita_sync[bot]
run: |
git add ./public/*
if ! git diff --staged --exit-code; then
git config --global user.name 'github-actions[bot]'
git config --global user.email '41898282+github-actions[bot]@users.noreply.github.com'
echo "### 現在の差分 (git diff) ###"
git diff --color || true
echo "############################"
echo "以下の変更内容をコミットします:"
git diff --color || true
git commit -m "${COMMIT_MESSAGE}"
echo "コミット完了.push を実行します..."
git push origin HEAD:refs/heads/${{ github.head_ref }}
else
echo "更新対象のファイルはありません"
fi
shell: bash
このワークフローの内容は以下の通りである.
- プルリクエストがmasterブランチに作成されたときに実行される
- プルリクエストの差分のファイルのみチェックアウトする
- Node.jsの環境をセットアップする
- 依存関係をインストールする
- Qiita記事の同期スクリプト(node_sync.js)を実行する
- 差分のファイルをコミットしてプッシュする
フロントマターは以下のようになる.ちょうどこの記事のフロントマターはこの通り.tagsは1つ以上5つまで,titleの入力忘れに注意してください.
tagsはQiitaAPIの仕様でタグの最適化が行われます.
qiita → Qiita
qiitacli → QiitaCLI
githubactions → GitHubActions
となります.(ローカルでは変わりませんが)
title: QiitaCLIとGithubActionsを用いてローカル執筆環境とプルリクエストによる投稿自動化をしてみよう
tags:
- qiita
- qiitacli
- githubactions
- CI
private: false
updated_at: ''
id:
organization_url_name: null
slide: false
ignorePublish: false
created_at: ''
これでQiita記事の執筆環境と記事のgit管理と記事の自動更新機能の作成が完了しました.オフラインでできる環境でQiita記事の執筆の効率化に使ってください.
実行方法
- ローカルでブランチを作成する
- public/ 以下にmdファイルを追加する
- 記事を保存(git管理)したければ投稿したい記事をaddし,commit・pushをする
- 記事が完成したら作成したブランチでPRを作成する(PRを作成すれば投稿されます)
- PRを作成したブランチに対してpushすれば記事の更新がされます.
(PR作成後のブランチに一度でもpushしてCIが回った場合は次回の作業前に必ずpullをしてください.Actions[bot]がcommitをしているからです) - 完全にOKならmasterにマージします.(masterにマージしてもしなくても何か自動で行われるものはありません)
今回の作成したディレクトリは以下のGithubを参照してください.