孅いエンジニアブログ

記事の内容に沿って、AIが背景画像を生成してくれるブログ作ってみた

これです↓

Coffeeに特に意味はないです。コーヒー飲みながら記事書こうかなみたいな。

ソースコードここ置いてます。

技術スタック

  • OpenAI
    • Image models DALL·E 3
  • Astro
  • microCMS
  • Cloudflare
    • Cloudflare Pages
    • Cloudflare R2

SSGなJamstackな感じです。

普通にブログとしての機能の説明は省きます。

お金

AIはOpenAIのDALL·E 3モデルを使った。他にも画像生成AIあるかもしれんけど、探すのがめんどくさかったです。

DALL·E 3モデルで、1792x1024のサイズで生成してて、クオリティはStandardだっけ?たぶん。なので、一回画像を生成するごとにワイさんの懐から$0.080持っていかれます。

ということでできるだけお金かけたくないので、記事1つにつき1回だけ画像を生成するようにしました。

ページ閲覧ごとに生成したら金額えぐいし、ビルドごとに生成するようにしました。

そして、ビルドごとに全部記事まるごと画像を生成すると記事が増えるごとに金額が腫れ上がっていくので、一度生成したものは再度生成しないようにしました。

生成した画像はCloudflare R2に保管しました。

ただ、結局記事1つ書くごとに金取られるのは変わらないので、なんというか...収益化できれば嬉しいよね。

AI生成

Astroでビルドを行うので、Astroのビルド時とかに任意のスクリプトを実行できるインテグレーションというやつを使いました。

まずこんな感じでインテグレーションを読み込みます。

import { defineConfig } from "astro/config";
import buildAiImage from "./src/lib/build-ai-image.ts";

// https://astro.build/config
export default defineConfig({
	integrations: [buildAiImage()]
});

これでビルド時にインテグレーションが実行されます。

でインテグレーションはこれです。

import type { AstroIntegration } from "astro";
import { loadEnv } from "vite";
import { S3Client, ListObjectsV2Command, PutObjectCommand } from "@aws-sdk/client-s3";
import { getListContents, getContentsDetail } from "./microcms.ts";
import OpenAI from "openai";
import cheerio from "cheerio";
import type { Blog } from "./microcms.ts";

// .envファイルを読み込み
const env = loadEnv("", process.cwd());

// OpenAIのキーを設定
const openai = new OpenAI({
	apiKey: env.VITE_OPENAPI_KEY ?? ""
});

// Cloudflare R2のキーとか設定
const S3 = new S3Client({
	region: "auto",
	endpoint: env.VITE_R2_ENDPOINT ?? "",
	credentials: {
		accessKeyId: env.VITE_R2_ACCESS_KEY_ID ?? "",
		secretAccessKey: env.VITE_R2_SECRET_ACCESS_KEY ?? ""
	}
});

export default function (): AstroIntegration {
	return {
		name: "ai-image",
		hooks: {
			"astro:build:start": async ({ logger }) => {
				// 画像生成に失敗した場合に、記事の生成を止めるため、ビルド開始時に実行

				logger.info("Ai画像未生成の記事を検索中");
				try {
					// Cloudflare R2に保管している画像を取得
					const r2Data = await S3.send(new ListObjectsV2Command({ Bucket: "coffee" }));
					if (r2Data.Contents === undefined) {
						throw new Error();
					}

					// microCMSの記事一覧を取得
					const posts = await getListContents<Blog>("blogs");
					const ids: string[] = [];
					posts.contents.forEach((post) => {
						ids.push(post.id);
					});

					// 画像を生成してない記事のIDだけに絞る
					const ungeneratedIds: string[] = [];
					ids.forEach((id) => {
						let generated = false;

						r2Data.Contents?.forEach((content) => {
							if (`${id}.png` === content.Key) {
								generated = true;
							}
						});

						if (!generated) {
							ungeneratedIds.push(id);
						}
					});

					// 画像を生成してない記事があった場合に画像を生成
					if (ungeneratedIds.length !== 0) {
						logger.info("以下のIDについて画像生成を行います。");
						logger.info(ungeneratedIds.join(", "));

						logger.info("Ai画像生成中");

						// 記事分処理
						for (const id of ungeneratedIds) {
							// 記事本文を取得
							const post = await getContentsDetail<Blog>("blogs", id);
							const $ = cheerio.load(post.contents);

							// HTMLタグとかを消してテキストだけにする
							const blogContent = $.text();

							// AIに渡すプロンプト
							// DALL·E 3は日本語も入力できるけど、なんとなく英語でやってる
							const prompt = `Extract some keywords from the following text and generate images without including text according to the keywords. The atmosphere of the image should be in the style of a real-life photograph.If the image would be blocked by the safety system, generate it with something that will not be blocked.\n\n"${blogContent}"`;

							const imageResponse = await openai.images.generate({
								model: "dall-e-3",
								prompt,
								n: 1,
								size: "1792x1024"
							});

							if (imageResponse.data[0] === undefined) {
								throw new Error();
							}

							const imageUrl = imageResponse.data[0].url;

							if (imageUrl === undefined) {
								throw new Error();
							}

							const res = await fetch(imageUrl);
							const arrayBuffer = await res.arrayBuffer();
							const buffer = Buffer.from(arrayBuffer);

							// Cloudflare R2に保管
							await S3.send(
								new PutObjectCommand({
									Body: buffer,
									Bucket: "coffee",
									Key: `${id}.png`,
									ContentType: "image/png"
								})
							);
						}
					}
				} catch (e) {
					logger.error(String(e));
					logger.info("エラーが発生しました。");
					process.exit(1);
				}
			}
		}
	};
}

処理の内容はコメントを書いているのでそれ読んでください。

Cloudflare R2への接続は@aws-sdk/client-s3で簡単にできます。やり方は公式ドキュメント参照。(丸投げ)(気が向いたら書くかも)

記事本文がリッチエディタな関係でHTMLタグとかついてので、それを除去するのにcheerioを使用します。

画像生成はOpenAIのライブラリでやってます。これ公式...?かはちょっと分からないです。

感想

著作権?とかに違反してそうな画像とかが生成されると生成がストップされるので、ちょっと気をつけた方が良さそうです。

一応プロンプトに「ブロックされそうなら、ブロックされなさそうに生成して」とか言う効果あるのか分からん文言は入れてみました。

例えばキムチ鍋パーティしながらマリカした話だとこんな感じの画像になります↓

記事ページ見ると、背景画像がぼやけてるけど、ページ右下のページカールみたいなの押したら見れます。

暇なときにでも読んでみて。