旅の写真からAIで下書きを作る旅ブログを、Cloudflare Workers 1つで作った
家族が旅ブログを始めたいというので、旅ブログ「ひとみごろ」を作りました。コンセプトは「旅の写真をまとめて選ぶと、AIが記事の下書きを作ってくれて、あとは手直しするだけ」。書く人はスマホしか使わない前提です。
作ってみて、技術的におもしろかったところ・ハマったところを絞って書きます。
構成:Worker 1つに全部まとめる
公開サイトも管理画面もAPIも、Cloudflare Workers 1つで動かしています。フレームワークは使わず、TypeScriptでHTMLを文字列で組み立てるだけのシンプルな作りです。
| 役割 | 使ったもの |
|---|---|
| 公開サイト・管理画面・API | Workers |
| 写真 | R2 |
| 旅・写真・記事のデータ | D1(SQLite) |
| AIの下書き生成 | Queues(バックグラウンド処理) |
| 管理画面のログイン | Cloudflare Zero Trust Access |
| 文章生成 | Gemini API |
| 店・スポット名 | Google Places API (New)/なければ OpenStreetMap (Nominatim) |
依存パッケージは JWT 検証用の jose だけ。デプロイは wrangler deploy 一発です。小さなサービスなら、Pages と Functions を分けるより Worker 1つにまとめたほうが見通しがよかったです。
写真の処理はブラウザ側でやる
スマホの写真は1枚数MB、iPhoneならHEICです。これをそのままWorkerに送って処理するのはつらいので、重い処理はブラウザに寄せました。
- EXIF の読み取り:
exifrで撮影時刻(タイムゾーンのオフセット込み)とGPSだけを抜き出して、メタデータとして送る - HEIC 変換:まず
createImageBitmapで読めるか試し、ダメなときだけheic2anyを動的に読み込んで JPEG にする - 縮小:canvas で縮小してから
toBlobで JPEG にしてアップロード
1つの旅で写真が数百枚になることもあるので、1枚ずつ縮小して2枚ずつ送るようにしています。全部を一度にメモリに載せないので、iPhoneでもタブが落ちにくくなりました。通信エラーは自動で数回リトライし、それでもダメな写真だけあとで送り直せます。送信中は Screen Wake Lock API で画面がスリープしないようにしています。
ハマったのは iPhone の写真選択画面です。「オプション」で位置情報をオンにしないと、GPS情報が削られた状態で届きます。コードではどうにもならないので、画面に注意書きを出しました。
写真を「日」と「スポット」に分けるのはプログラムで
最初は写真をまとめてGeminiに渡して、構成から全部考えてもらおうとしました。ところが写真が多いと、応答が途中で切れたり、写真が抜け落ちたりします。
そこで役割を分けました。
- 構成はプログラムで決める:撮影時刻で並べ、日付で「日」を分け、「前の写真から60分以内かつ400m以内」なら同じスポットにまとめる(距離はハーバサイン公式で計算)
- Geminiには文章だけ頼む:スポットを6か所ずつ、写真つきで渡して見出しと本文を書いてもらう
Gemini には responseMimeType: "application/json" と responseSchema を指定して、決まった形のJSONで返してもらっています。429 や 5xx は少し間を空けて自動でリトライし、それでも書けなかったスポットは編集画面に注意書きを出して、あとで「✨ AIで書く」ボタンから書き直せるようにしました。
LLM に任せる範囲を「文章を書く」だけに絞ったことで、写真が多い旅でも安定して下書きが作れるようになりました。決められることはプログラムで決める、が今回いちばんの学びです。
店の名前は「候補を渡して選ばせる」
写真の座標から地名を出すだけだと「〇〇町」くらいしかわからず、旅ブログとしては物足りません。そこで、スポットの座標から半径60m以内の店やスポットを Google Places API の Nearby Search で探し、候補リストと写真を Gemini に渡して「どの店か」を選ばせるようにしました。
AIに店名を自由に答えさせると、もっともらしい架空の店名を作ってしまうことがあります。候補の中から選ばせることで、それを防げます。候補は編集画面にも出すので、AIが外したらタップで差し替えられます。
Places API は従量課金なので、結果は座標を丸めて(約11m単位)D1 にキャッシュしています。下書きを何度作り直しても、同じ場所なら再課金されません。APIキーを設定しない場合は、無料の Nominatim で地名だけを取る作りにしています(こちらは1秒1リクエストの制限があるので、間隔を空けて呼んでいます)。
重い処理は Queues に逃がす
写真の振り分け → 地名・店の検索 → Gemini で文章作成、までやると数分かかります。リクエストの中で待つのは無理なので、ボタンを押したら Queue にメッセージを積んで、Consumer 側で処理しています。
一つ困ったのが、処理の途中で Worker が止まると、ステータスが「AIが作成中」のまま残ってしまうことでした。作り直しも削除もできなくなります。対策として、「作成中」が20分以上続いている旅は、画面を開いたときに「失敗」に戻すようにしました。cron を足さなくても、次に誰かが画面を開けば直るので、この規模ならこれで十分です。
管理画面の守り方
管理画面は Cloudflare Access でログインした人だけが見られるようにしています。ただ、Access の設定ミスや workers.dev のURL経由でのすり抜けが怖いので、Worker 側でも Cf-Access-Jwt-Assertion ヘッダーのJWTを 署名まで検証 しています。
- 公開鍵(JWKS)は Access のチームドメインから取得して、1時間キャッシュ
- 検証に失敗したら、鍵のローテーション直後かもしれないので 一度だけ鍵を取り直して再検証
- 失敗理由(aud が違う、など)はログに出して、
wrangler tailで原因を追えるようにする
さらに、ログインの Cookie は他サイトからのフォーム送信にも付いてしまうことがあるので、書き込み系のリクエストは Origin と Sec-Fetch-Site ヘッダーを見て、別サイトからの送信を拒否しています(CSRF 対策)。
ローカル開発ではログインを省略する設定も用意しましたが、localhost へのアクセスでしか効かないようにしてあります。本番で間違って有効にしても、Access の検証は必ず通ります。
2人で編集しても上書きしないように
書く人が2人いるので、同時に編集すると片方の文章が消える心配があります。そこで楽観ロックを入れました。編集フォームに記事の updated_at を hidden で持たせておき、保存時に
UPDATE posts SET ... WHERE slug = ? AND updated_at = ?
とします。更新件数が0なら、誰かが先に保存したということなので、409 で「保存していません」と画面に出して止めます。AIで作り直したり写真を消したりしたあとに、古い画面から保存して別のスポットの文章を上書きしてしまう事故も、これで防げました。
OGP 画像はブラウザで描く
SNS でシェアしたときのカード画像は、ポラロイド風の枠に表紙写真と記事タイトルを入れたものにしています。
Worker で画像を合成しようとすると、日本語フォントの用意や画像処理ライブラリが必要で、けっこう重くなります。そこで発想を変えて、管理画面の編集ページを開いたときに、ブラウザの canvas で1200×630の画像を描いて R2 に保存するようにしました。ブラウザなら日本語フォントもそのまま使えます。
タイトルや表紙写真などから署名(ハッシュ)を作っておき、それが変わったときだけ描き直します。まだ画像がない記事は、表紙写真をそのまま og:image に使います。
ドメイン変更は Worker で 301
途中で独自ドメインを変えました。すでにシェアしたリンクが切れないよう、旧ドメインも Worker のカスタムドメインとして残しておき、SITE_URL 以外のホストで来たアクセスは新ドメインへ 301 で転送しています。管理画面のパスも一緒に転送されるので、Access で守るのは新ドメインだけで済みました。
見た目のこだわり
技術というよりおまけですが、公開サイトは和紙・手書き風のデザインにして、季節によって桜・紅葉・雪が舞い、夏は風鈴が揺れるようにしました。prefers-reduced-motion が有効な人には動きを止めています。
まとめ
- Worker 1つ+D1・R2・Queues で、公開サイトと管理画面をまとめて作れる
- 写真の重い処理(EXIF・HEIC・縮小)はブラウザに寄せると、サーバーが軽くなる
- LLM には「文章を書く」だけを頼み、構成はプログラムで決めると安定する
- 店名などの事実は、候補を渡して「選ばせる」とハルシネーションを防げる
- Access を使っていても、Worker 側で JWT 署名の検証と CSRF 対策はしておく
書く人が使いながら「ここが使いにくい」と言ってくれるので、毎日のように直しています。英語版の記事ページやRSSも、そのうち足したいと思っています。
コメント
読み込み中…