Claude Codeに、記事へ自動で画像を生成させる仕組みを作ってもらった話
約11分で読めます

Claude Codeに、記事へ自動で画像を生成させる仕組みを作ってもらった話


このブログの記事には、これまで挿絵の類が一切ありませんでした。記事を書くたびに「ここに図があると分かりやすいのに」と思うことが増えてきたので、生成AIに画像を作らせて記事に自動で埋め込む仕組みを、Claude Codeと一緒に作ってみました。

今回は「動くものができました」で終わらせず、どういう設計にしたのか、実際のコードはどうなっているのか、そして実装中に実際に遭遇したバグまで含めて、仕組みの中身を深掘りします。

全体の仕組み

やりたいことはシンプルです。記事のMarkdownを書いているときに、「ここに画像がほしい」という場所に説明文を書いておくだけで、あとから自動でその説明に沿った画像が生成され、記事に埋め込まれる——という流れにしました。

具体的には次の4ステップです。

  1. 記事のMarkdown内に <!-- image-gen: 生成してほしい画像の説明 --> というコメントを書いておく
  2. npm run gen-images -- 記事のパス というコマンドを実行する
  3. スクリプトがそのコメントを見つけて、OpenAIの画像生成APIを呼び出し、画像を生成する
  4. 生成された画像を記事と同じフォルダに保存し、コメント部分を実際の画像タグに書き換える

ポイントは、記事執筆と画像生成を分離したことです。記事を書いている最中はプレースホルダーを置くだけでよく、画像生成はあとから一括で実行するだけです。人間がプレビューで確認してからコミットする、というレビューの余地も残せます。

a diagram-like illustration showing a document with a placeholder marker turning into a document with an embedded picture, representing an automated content pipeline

なぜNode.jsのシンプルなスクリプトにしたか

このブログ自体がAstro(Node.js製の静的サイトジェネレーター)で作られているため、追加の言語やランタイムを増やしたくありませんでした。また、この用途は「記事を書いたときにたまに手動で実行する」程度の頻度なので、大掛かりなCI連携や自動化基盤は過剰だと判断しました。

外部ライブラリも極力増やさず、Node.js標準のfetchだけでOpenAIのAPIを直接呼ぶ、素朴な1ファイルのスクリプトにしています。package.jsonnpm run gen-imagesというコマンドを1行追加しただけです。

TypeScriptではなくJavaScriptにした理由

このAstroプロジェクト自体は、コンテンツ設定などの一部でTypeScriptを使っています。それでも今回のスクリプトはあえてプレーンなJavaScript(.mjs)にしました。

  • .tsのままnodeで直接実行しようとすると、型ストリッピング用のフラグやtsxのような追加ツールが必要になり、実行方法が複雑になる
  • このスクリプトのためだけにts-nodetsxをdevDependenciesに追加するのは、90行程度の小さなツールには過剰
  • 正規表現でのプレースホルダー抽出とfetch呼び出し1つだけの単純な処理で、型定義で守るべきほどの構造的な複雑さがない
  • 記事執筆のたびに手動実行するだけの補助ツールであり、アプリ本体のコードほど型チェックの恩恵を重視する場面ではない

「タスクに対して過剰な仕組みを足さない」という考え方で、あえてシンプルな構成を選びました。

実際のコード

コアになる部分はこの2つの関数です。

プレースホルダーを見つける正規表現

const PLACEHOLDER_RE = /<!--\s*image-gen:\s*(.+?)\s*-->/g;

<!-- image-gen: ... --> という形式のコメントを、Markdown内からすべて拾い出します。

画像を生成する部分

async function generateImage(apiKey, prompt) {
	const res = await fetch('https://api.openai.com/v1/images/generations', {
		method: 'POST',
		headers: {
			Authorization: `Bearer ${apiKey}`,
			'Content-Type': 'application/json',
		},
		body: JSON.stringify({
			model: 'gpt-image-1',
			prompt: `${prompt}${STYLE_SUFFIX}`,
			size: '1024x1024',
			n: 1,
		}),
	});
	// ...
	const data = await res.json();
	return data.data[0].b64_json;
}

OpenAIのgpt-image-1モデルは、画像をURLではなくBase64形式で直接返してくるので、それをデコードしてファイルとして保存する仕組みになっています。

見た目の一貫性をどう保つか

生成AIに好きなように画像を作らせると、記事ごとにタッチや色使いがバラバラになってしまいます。そこで、どんなプロンプトが来ても共通のスタイル指定を必ず末尾に付け足すようにしました。

const STYLE_SUFFIX =
	', flat minimalist illustration, clean monotone color palette, no text or letters, subtle professional blog illustration style';

「フラットなミニマルイラスト」「モノトーン」「文字を入れない」という指定を固定で足すことで、記事ごとに説明文(プロンプト)は変わっても、サイト全体としての見た目のトーンは揃うようにしています。「文字を入れない」を入れているのは、画像生成AIは文字の描画が苦手で、変な文字列が混ざった画像になりやすいためです。

a set of small flat minimalist icons in a consistent monotone color palette, representing visual consistency across a design system

二重課金を防ぐ設計

画像生成には実際にAPI課金が発生するため、同じ記事に対してgen-imagesを何度も実行しても、二重に課金されない設計にしておきたいところです。

これは特別な仕組みを足さずに、素直な作りで解決しています。一度画像が生成されると、Markdown内の<!-- image-gen: ... -->というコメント自体が実際の画像タグに書き換えられて消えるため、次に同じファイルに対してスクリプトを実行しても、その画像はもう「未生成のプレースホルダー」として検出されません。新しく追加した部分だけが処理される、というシンプルな仕組みです。

実装中に実際に遭遇したバグ

動作確認のために、わざと無効なAPIキーでスクリプトを実行してエラーハンドリングを試したところ、Windows環境でNode.jsのプロセスが正常に終了せず、次のようなエラーで異常終了することがありました。

Assertion failed: !(handle->flags & UV_HANDLE_CLOSING), file src\win\async.c, line 94

原因は、fetchの呼び出しがエラーになった直後にprocess.exit(1)を呼んでいたことでした。fetchの内部実装(undici)はまだ後片付け中のハンドルを持っていることがあり、そのタイミングでprocess.exit()によってプロセスを強制終了させると、Windowsのイベントループ実装(libuv)が矛盾した状態を検知してクラッシュしてしまうようです。

修正はシンプルで、process.exit(1)を呼ぶ代わりにprocess.exitCode = 1をセットするだけです。

main().catch((err) => {
	console.error(err.message);
	process.exitCode = 1; // process.exit(1) だとクラッシュすることがあった
});

こうすることで、Node.jsは強制終了せず、進行中の後片付けを終えてから自然に終了するようになり、クラッシュしなくなりました。地味な違いですが、非同期処理のエラーハンドリングではprocess.exit()を安易に使うと危険、という良い教訓になりました。

自分の説明文を自分で誤検出するバグ

もう一つ、実際にこの記事自体を素材にして動作確認したときに見つかったバグがあります。

この記事では、プレースホルダーの書き方を説明するために、本文中に<!-- image-gen: 生成してほしい画像の説明 -->という例をそのまま書いています。ところが、スクリプトの正規表現はMarkdownの構造を理解しているわけではなく、単純にテキスト全体から<!-- image-gen: ... -->という形式の文字列を探すだけでした。そのため、説明用の例文や、正規表現の実装そのものを載せたコードブロックまで「本物のプレースホルダー」として誤検出し、5件の生成対象として拾ってしまいました(本来は2件のはずでした)。

原因は、コードブロック(```で囲んだ部分)やインラインコード(`で囲んだ部分)を区別せず、本文全体を検索対象にしていたことです。修正として、まずコードブロックとインラインコードの範囲を検出し、その範囲内に出現したimage-genはマッチ候補から除外するようにしました。

function findCodeRanges(text) {
	const ranges = [];
	for (const m of text.matchAll(/```[\s\S]*?```/g)) {
		ranges.push([m.index, m.index + m[0].length]);
	}
	for (const m of text.matchAll(/`[^`\n]+`/g)) {
		const start = m.index;
		const end = start + m[0].length;
		if (!ranges.some(([s, e]) => start >= s && end <= e)) ranges.push([start, end]);
	}
	return ranges;
}

このように、検出した候補のうちコードブロック内にあるものだけを除外するフィルターを挟むことで、正しく2件だけが処理対象になりました。この機能について解説する記事自体が、この機能のバグを見つけるテストケースになった、というのは我ながら面白い巡り合わせでした。

セキュリティ面の配慮

画像生成用のAPIキーは、他の用途と混ざらないよう、権限を「画像生成のみ」に制限したスコープ付きキーを発行し、.envファイル(Gitには含めない)で管理しています。この辺りの考え方は、以前の記事「AIコーディングアシスタントに.envを読まれると何が起きるか」で詳しく書いた内容がそのまま活きています。

まとめ

記事に画像を自動挿入する仕組みは、大掛かりな基盤を用意しなくても、素朴なNode.jsスクリプト1本と「プレースホルダーを書き換えて消す」というシンプルな設計で実現できました。ポイントは、スタイル指定を固定して見た目の一貫性を保つこと、書き換え済みのプレースホルダーが自然に「実行済みの印」になるようにして二重課金を防ぐこと、そしてAPIキーは権限を絞って管理することです。次はこの仕組みを使って、実際に過去の記事にも画像を追加していこうと思います。