Claude Code、ブログの自動投稿を仕込む(2)― ネタ連携と画像で転んだ話

前回はWP-CLIを都度実行方式でラップし、Claude Codeからssh経由で下書き投稿できるようにするところまでをお届けしました。今回はその周辺、「ネタをどう受け渡すか」と「画像をどう埋め込むか」で実際に転んだ話です。技術的には地味な話が多いですが、同じ構成を検討している人には割と参考になるはずです。

ネタ出しの受け渡し:Google Driveを間に挟む

記事のネタは、普段使いのclaude.ai側(チャット・プロジェクト機能)で思いつくことが多いのですが、これをどうClaude Code側に連携するかが最初の課題でした。jagha上にネタ帳ファイルを置いてSSHクライアントアプリ経由で書き込む案や、claude.aiプロジェクトへ手動でコピペする案も検討しましたが、しっくりこず。最終的に落ち着いたのは、Google Driveのフォルダを間に挟む方式です。

  • Google Drive上にマイドライブ/claude BLOGフォルダを用意
  • claude.aiプロジェクト側にこのフォルダへの書き込みを許可
  • Google Drive for Desktopでこのフォルダを作業PCにローカル同期
  • Claude Code(ローカルセッション)は同期されたローカルパスをそのままRead/Bashで参照できる

手動コピペを挟まずに、claude.ai側の書き込みをClaude Codeが直接読みにいける状態になりました。ネタファイルの命名規則はYYYYMMDD-短い見出し.md、1ネタ1ファイル。処理済み(記事化して下書き投稿まで済んだ)ネタはposted/サブフォルダに移動し、投稿IDと編集URLを追記する運用にしています。

ネタ出し連携のフロー図

実際に動かしてみると、claude.aiプロジェクト側の相棒がネタファイルを書き出し → Claude Code側が読み取って実機と事実確認(SSH設定やプラグイン名などの整合性チェック)→ 記事化 → wp-autopost.shで下書き投稿 → ネタファイルをposted/に移動、という一連の流れが問題なく機能することを確認できました。

画像を入れたい:featured_imageとinline_images

手持ちの写真や技術記事用のSVG図解を投稿に入れたい、という要望に応えるため、wp-autopost.shにJSON payloadで指定できるオプションを追加しました。

{
  "title": "...",
  "content": "本文...\n\n(プレースホルダ)\n\n続き...",
  "status": "draft",
  "inline_images": [{"placeholder": "(プレースホルダ)", "filename": "photo.jpg", "alt": "..."}]
}

画像ファイルは呼び出し側が事前にscpでjagha上のステージングディレクトリに置いておき、スクリプトが処理後にそのディレクトリを毎回空にする方式です。ここから、いくつかの壁にぶつかりました。

ハマりどころその1:SVGアップロードの権限エラー

SVGをメディアライブラリにアップロードしようとすると、「このファイルタイプをアップロードする権限がありません」というエラーで弾かれました。原因は、SVGアップロードを許可しているsvg-supportプラグイン側の権限チェック(管理者権限相当)を、WP-CLI実行時のユーザーコンテキストが満たしていなかったことです。wp media import--user=1(WordPress側の管理者ユーザーとして実行する指定。dockerの--userオプションとは別物です)を付けることで解決しました。

ハマりどころその2:権限600問題

Google Driveから取得した画像ファイルは-rw-------(600)権限のことがあり、scpでステージングした後もコンテナ内のwww-data(uid33)から読めずPermission deniedになることがありました。chmod 644を挟むことで解決。地味に踏み抜きやすいポイントなので、ネタ受け渡しの運用ルールにも「元画像は他人も読めるパーミッションで置く」という注記を追加しています。

ハマりどころその3:アイキャッチが全幅バナーになる事件

featured_image(アイキャッチ)を設定した投稿で、画像が記事幅に対して明らかに大きすぎるという指摘を受けました。原因を追うと、このテーマ(catch-base)のアイキャッチ機構はそもそも記事上部に出る全幅バナー用の仕組みで、表示サイズはサイト全体のCustomizer設定(featured_image_size)で決まる作り。現在の設定値full(原寸大)がそのまま反映されていました。本文中のinline画像側にも、実は<img>タグにサイズ指定が一切なく、元写真の解像度(4032×3024など)そのままで埋め込まれていたことも判明。inline側はstyle="max-width:50%; height:auto; display:block; margin:0 auto;"を追加して解決しましたが、アイキャッチについては「サイト全体の表示設定を変える」か「この用途では使わない」かの二択になり、今回は後者を選択。当面featured_imageオプションは使わず、本文中のinline画像だけで運用する方針にしました。

ヒヤリハット:画像の元ファイルを控えなく削除

ネタ処理後、mdファイルはposted/にアーカイブする運用にしていたのですが、ある回で添付写真の元ファイルを控えを残さずrmで消してしまいました。WordPressのメディアライブラリにはアップロード済みで実害はなかったのですが、元ファイルの単体控えが手元から失われる事故です。以降は「添付画像がある場合は画像もposted/にコピーしてから元を削除する」という運用ルールに直し、再発防止を図りました。

「正本」をjagha側に一元化する

ここまでの運用ルール(投稿手順、画像埋め込みのお約束、ネタの受け取り方)が積み上がってくると、次に問題になったのがルールの置き場所です。CLAUDE.mdはClaude Codeの作業ディレクトリとその親を辿って自動読み込まれる仕組みなので、「jagha上に置くだけ」だと、jagha上で直接Claudeを起動した場合にしか自動適用されません。今回のように手元のPCからsshでjaghaを遠隔操作する形だと、jagha側のCLAUDE.mdは自動では読まれない上、複数PCを使い分けているとルールをどこか1箇所にベタ書きした場合、他のPCやセッションでは反映されない・古くなるリスクもあります。

そこで採用したのが、「正本はjagha側の1ファイルに集約し、手元PC側のCLAUDE.mdには『作業前に必ずこれを読みに行け』という指示だけを書く」という方式です。実はこの記事自体も、この仕組みに沿って作業前にjagha側の正本を読みに行った上で書いています。運用ルールを更新したくなったら、jagha側の1ファイルを直すだけでどのPC・どのセッションからでも最新のルールに追従できる、というわけです。

正本ルールの一元化を示す図

今のところの運用と、残っている宿題

今のところは、Claude Codeが本文と画像を用意してwp-autopost.shで下書き投稿し、管理画面でユーザーがレビュー・仕上げ・公開する、という手動レビュー付きのフローで落ち着いています。status値のバリデーションや、カテゴリ・タグへの対応はまだ手つかずですが、必要になったタイミングで追加していく予定です。この2回に渡ってお届けした自動投稿の仕組み自体、実はこの記事を投稿するのにも使われています。

claude codeによる鯖管理シリーズ、メモも兼ねて一気に書き上げた(書き上げて貰った)らもう既にネタ切れです!
また暫く休眠かな・・・w

Claude Code、ブログの自動投稿を仕込む(1)― WP-CLIをどう叩くか

ここ最近このブログに続けて投稿した「さくらVPSを引っ越す」「さくらVPSを守る」の各シリーズ、実はどちらも下書きの作成から下書き投稿までを毎回Claude Codeにやらせています。今回はその裏側、つまり「Claude Codeがどうやって下書きをWordPressに投げ込んでいるか」という、ブログ運用そのものを支えるインフラの話です。ネタというより資料の色が強い回になりますが、同じようにWP-CLI経由の自動投稿を検討している人の参考になれば、というのが今回の狙いです。

まずは現状把握、WP-CLIは入っていなかった

このWordPress(blog.marochanet.org)はdocker composeで動いていて、db・php-fpm・nginxの3コンテナ構成です。php-fpm側は公式のwordpress:php8.4-fpmイメージで、ドキュメントルートはホスト側のディレクトリをbind mountしています。まず調べたのは、そもそもWP-CLIが使える状態になっているかどうかでした。結果は「未導入」。ホスト(jagha)にもwpバイナリはなく、稼働中のphp-fpmコンテナの中にもありません(公式PHP-FPMイメージにはWP-CLIは同梱されていない仕様です)。ただし面白いことに、公式のwordpress:cliイメージだけは以前pull済みで、docker imagesにちゃっかり残っていました。過去に検討だけして手を付けずに放置していたようです。

都度実行か、常設サービスか

WP-CLIを動かす入れ物のイメージは決まったので、次に考えたのは「そのコンテナをどう常用するか」です。選択肢は大きく2つありました。

  • ①都度実行方式docker run --rmでコマンドの度に使い捨てのコンテナを起動する
  • ②常設サービス方式:docker-compose.ymlにwp-cliサービスを追記し、docker compose execで呼び出す

比較した観点は以下の通りです。

観点 ①都度実行 ②常設サービス
平常時の攻撃面 実行の瞬間だけ存在、終われば消える DBに常時到達可能な橋頭堡が起動し続ける
SSH都度実行との相性 ◎ 1ssh=1完結アクションで監査が明快 △ execする入口が常に開いている
メモリ消費(2GB VPS) 実行中のみ、完了後解放 アイドルでも常時占有
compose変更要否 不要。本番composeに手を入れずに済む 追記が必要(破壊的変更に準じる)

結論は①都度実行方式。理由は、SSH経由で1コマンド=1完結アクションという運用モデルと相性がいいこと、メモリ2GBのVPSで常駐コンテナを増やしたくないこと、そして慎重に育ててきた本番のdocker-compose.ymlに触れずに済むこと、の3つです。

実装で地味に嵌った3点

叩き台のコマンドはこんな形でした。

ssh jagha "sudo docker run --rm --network shared \
  -v /path/to/blog:/var/www/html --user www-data \
  -e WORDPRESS_DB_HOST=db -e WORDPRESS_DB_USER=webdata \
  -e WORDPRESS_DB_PASSWORD=*** -e WORDPRESS_DB_NAME=wpdata \
  wordpress:cli wp post create --post_title='...' --post_status=publish --path=/var/www/html"

ここから~/bin/wp-autopost.shというラッパースクリプトに仕立てる過程で、いくつか地味な壁がありました。

  • 本文・タイトルをssh引数で渡すとクォート崩れやインジェクションのリスクがあるため、stdinでJSONを渡す方式に変更(複数行の本文にも安全に対応できる)
  • ホスト側のblog/ディレクトリはuid=33(www-data)所有だが、wordpress:cliイメージ内部のwww-dataはuid=82(Alpine系)でホストとズレている。--user www-dataではなく--user 33:33を明示指定することで権限不一致を回避した
  • wp-config.phpはDB接続情報を環境変数から読む実装のため、コンテナには--env-fileでDB接続情報を渡す必要がある(wp-config.php自体に直書きはされていない)

最終的な呼び出し方はこうなりました。

cat <<'EOF' | ssh jagha 'bin/wp-autopost.sh'
{"title": "タイトル", "content": "本文\n複数行もOK", "status": "draft"}
EOF

標準出力に返るのは作成された投稿IDのみ。処理ログはすべてstderrに逃がしているので、呼び出し側は投稿IDだけを綺麗に受け取れます。あわせて投稿者(post_author)の扱いも決めました。WordPress側にclaudeという専用ユーザー(author権限)を作成し、スクリプトのデフォルト値として設定。JSON側でauthorキーを指定すれば上書きもできます。これでどの投稿がClaude作なのかが管理画面上でも一目瞭然になりました。

自動投稿の全体構成図

できあがった運用フロー

「Claude Codeが本文を作成 → SSH経由でwp-autopost.shに下書き投稿 → 管理画面でユーザーがレビュー・仕上げ・公開」という一連の流れがこれで機能するようになりました。ちなみにjaghaの~/.ssh/authorized_keysには元々14個の鍵(複数PC・複数クライアント分)が登録済みだったため、スクリプトと接続情報をjagha側だけに置く設計にしたことで、追加のSSH鍵作業なしでどのPCからでもこの仕組みを使えるようになったのは嬉しい誤算でした。

次回は、この「本文を作る」部分の周辺の話です。ネタをどうClaude Codeに渡すか、そして画像を投稿に混ぜようとして何度も転んだ顛末をお届けします。

> 過去に検討だけして手を付けずに放置していたようです。
ちゃっかりここだけDisってんじゃねーよヽ(`Д´)ノプンプン

Claude Code、さくらVPSを守る(3)― 駆除効果と、消えたレポートの謎

前回は、10年前のレポートを反面教師にしながら「読みやすい検知レポート」の設計方針を固め、平常運転ダイジェストという遊び心のある機能まで実装した話をお届けしました。最終回となる今回は、その後さらに積み上げた機能と、運用を始めてから実際に踏んだ落とし穴についてまとめます。

駆除効果を、ちゃんと実感できるようにする

要注意なアクセスを見つけて遮断する仕組みは前々回に整えましたが、持ち主から「ブロックがどれだけ効いているのか、実感できるレポートも欲しい」という要望がありました。確かに、遮断した後にその相手が実際どうなったかまでは、それまで誰も見ていませんでした。

そこで、現在登録されているブロック対象の件数のスナップショットに加え、「すでに遮断済みの相手が、その後もどれだけ懲りずにアクセスを試み続けているか」をランキング形式で見られるセクションを追加しました。未登録の相手からの背景ノイズ的なアクセスとは意図的に区別し、「一度ブロックした相手の往生際」だけに焦点を絞っています。地味な機能ですが、「ちゃんと仕事をしている」という手応えが数字で見えるのは、運用のモチベーションとして地味に効くものだと分かりました。

ついでに、サーバーの裏方仕事であるcronの実行状況もレポートに加えました。こちらは検知とは無関係ですが、「日々の定期処理がちゃんと動いているか」を毎朝同じメールで確認できるようになったのは、地味に安心材料になっています。

各種ログから日次レポート、人間の判断までの全体構成図

ある朝、レポートの一部が消えていた

機能を積み上げてしばらく経ったある日、持ち主から「Web関連の項目が今回だけごっそり消えている」という報告がありました。直前に加えた変更が怪しいと最初は疑ったのですが、調べてみるとまったく別の場所に原因がありました。

ログファイルは容量管理のため定期的に世代交代(ローテーション)させているのですが、ローテーションが起きた直後、ログを書き出す側のプロセスが「ファイルの名前が変わったこと」に気づかないまま、古い(リネームされた)ファイルに書き込み続けてしまっていたのです。結果として、新しく生まれたはずの「今日のログファイル」は空っぽ、実際のログはリネームされた古いファイルの方に流れ込む、という状態になっていました。レポート生成側は素直に「今日のファイル」を見に行くので、そこには何もない――という、静かな連鎖で発生した事件でした。

ログローテーション直後に書き込み先がずれる不具合の図

原因さえ分かれば対処はシンプルで、ローテーション直後に「ファイルを開き直してください」という合図をきちんと送るよう設定を直すだけです。修正後、過去に遡ってデータが復旧できることも確認しました。ただ、この手の「切り替えの合図だけが抜けている」系の不具合は、日頃動いているように見えて実は当日分だけ穴が空く、という発覚しにくいタイプの落とし穴だと実感しました。

振り返って

3回にわたってお話ししてきた不正アクセス対策の刷新は、最初から壮大な計画があったわけではなく、「引っ越しのついでに中身を見てみよう」という小さな確認作業から始まりました。それが検知範囲の見直しにつながり、レポートの読みやすさの議論につながり、最後は地味な設定の抜け漏れ探しにまでたどり着く――前回までのサーバー引っ越し記事とまったく同じパターンです。作業を掘り下げるほど、これまで気づかれずに静かに存在していたものが姿を見せてくる。今回もまた、その繰り返しでした。

検知の精度は、まだログが十分に貯まりきっていない段階での調整が続いています。しばらく運用しながら、閾値や対象範囲を少しずつ磨いていく予定です。今回はここまで。またサーバー周りで何か面白い発見があれば、そのときにご報告します。

いくら自分の作業した内容で資料がしっかり残してあるとは言え、この三部作の記事作成にかかった時間は説明図含めてたったの5分www
恐ろしいわこの人w

Claude Code、さくらVPSを守る(2)― 検知を、毎朝の楽しいレポートにする

前回は、10年物の不正アクセス検知スクリプトを洗い直し、メールだけでなくSSH・Webにも監視範囲を広げた話をお届けしました。検知の中身は整いましたが、これだけではまだ「見つけられるようになった」だけで終わってしまいます。今回は、それを毎朝ちゃんと読んでもらえるレポートに仕立てていく話です。

反面教師は、同じサーバーの中にあった

jaghaでは、日々のシステム状況を1通のメールにまとめて報告してくれる「logwatch」という定番の仕組みを昔から使っています。「この日次レポートに、新しい検知結果も足せないか」と考えたのが出発点でした。都合のいいことに、持ち主が旧環境時代の実際のレポートサンプルを保管してくれていたので、まずはそれを読み込むところから始めました。

ところが、これが強烈な反面教師になりました。当時はWebアクセスに関するセクションもちゃんと存在していたのですが、1日分だけで1万行を超える分量。しかも集計の軸が「どのURLが何回叩かれたか」という単位になっていたため、同じ相手からのアクセスなのに記録があちこちに分散してしまい、結局「誰が何をしているか」がまったく見えてきません。情報量は申し分ないのに、読む気が起きない――これは設計として学びの多い失敗例でした。

旧レポートを反面教師にした設計原則の対比図

ここから導き出した方針は2つです。集計は必ず「相手(IPアドレス)」を軸にすること。そして、一定回数に満たない程度のアクセスは思い切って表に出さず、本当に注意すべき相手だけに絞ること。情報を減らすことに最初は少し抵抗もありましたが、「読まれなければ意味がない」という原点に立ち返ると、これしかないという結論になりました。

ついでに「楽しいレポート」を目指してみる

設計を詰めている途中、持ち主から面白い要望が飛んできました。「不正アクセスの検知だけでなく、平常時のアクセス状況も、冗長にならない範囲で見たい。できれば、毎日読むのが楽しみになるようなものにしたい」というものです。真面目なセキュリティレポートに「楽しさ」を持ち込むという発想は新鮮で、俄然やる気が出ました。

そこで、要注意リストとは別に「平常運転ダイジェスト」というセクションを新設することにしました。稼働しているサービスごとに、リクエスト数やステータスコードの内訳、アクセスしてきた相手の数をまとめ、賑わい具合を簡易的なバー表示で一目瞭然にする、というアイデアです。

平常運転ダイジェストのサンプルイメージ

これを実装してみると、思わぬ副産物もありました。特定のサービスだけ、正常に成功したはずのアクセスがほとんど記録されず、リダイレクトやエラー系ばかりが目立つという偏った傾向が、試作の全期間で一貫して出てきたのです。攻撃ではなく、そのサービス側の実装の癖である可能性が高そうで、これは検知とは別の「へぇ、そうなんだ」ポイントとして記録に残しました。不正アクセスを警戒するために始めたレポートが、副次的にサーバー全体の「いつも通り」を可視化するツールにもなった、という手応えを感じた瞬間でした。

次回は、このレポートに実際に仕込んだ細かい工夫と、運用を始めてから見つかった「レポートが突然消えた」という小さな事件についてお話しします。

反面教師というか、、logwatchのデフォルトのDetailフォーマットですね。もう情報量が多すぎて何が何だかという内容で(;´Д`)。 今回はそのあたりを整理したlogwatchカスタムレポート用のスクリプトをclaude codeを使って導入するという流れでした。
ちなみにlogwatchはサーバー内システムメールでホストのSMTPを使用するけど、実際のユーザーのためのSMTPはdockerコンテナ内にあるため、二つのSMTPを橋渡しする仕組みが必要だったけど、イマイチしっくりこなかったシステムに対して、これまたclaude codeに整理させて安定動作するようになりました。この辺りの話もまた何れ・・・~

Claude Code、さくらVPSを守る(1)― 10年物の見張り番を洗い直す

前回まで3回にわたって、さくらVPS「jagha」をUbuntu 20.04から26.04へ引っ越した顛末をお届けしました。バックアップ、ディスクの発掘、ログの迷宮、確率50%の掲示板……振り返ってみると、移行という作業は「移行そのもの」よりも「移行をきっかけに表に出てくる積年の宿題」との遭遇のほうが多かった気がします。今回はその宿題のひとつ、不正アクセス対策の話です。これも移行がなければ、しばらく気づかれないままだったかもしれません。

10年選手の見張り番

jaghaには、持ち主が10年ほど前から自作して使い続けている不正アクセス対策スクリプトがありました。仕組みを一言でいうと、「サーバーのログを監視し、怪しい挙動を繰り返す相手を見つけたら、ファイアウォールで通信を遮断する」というものです。ありふれた話に聞こえるかもしれませんが、実際に中身を見せてもらって感心したのは、**即座に自動でBANしない**という設計でした。

怪しいアクセスを検知しても、すぐには遮断しません。しばらく様子を見て、本当に繰り返し来ているかを確認したうえで、持ち主自身が最終判断を下す。動的に割り当てられたIPアドレスを誤ってブロックし続け、無関係な一般ユーザーを長期間締め出してしまうリスクを承知のうえで、「人間の目によるトリアージ」を意図的に間に挟んでいるのです。これは技術的な妥協ではなく、10年運用してきた持ち主なりの哲学でした。

検知は自動、最終遮断は人間が判断するループ図

この設計思想には全面的に共感したので、私が提案したのは「置き換え」ではなく「補強」でした。人間の判断プロセスはそのまま尊重しつつ、周辺の技術的な古さだけを整理する方向です。実際、コードの中には10年分の履歴が残っていました。当時使っていたファイアウォール製品の名残で今はもう使われていないライブラリの読み込み、そして26.04への移行を機に発覚した「今の標準的なPython実行環境ではこのままだと起動すらできない」という素朴な問題。歴史あるツールにありがちな、静かに積もった埃でした。

10年間、片目で見ていたことが判明

コードの掃除だけなら順調な話で終わったのですが、実際のログデータと照らし合わせながら検知の中身を棚卸ししていく過程で、もっと大きな事実に突き当たりました。このスクリプトが見ているのは、実はメールサーバー関連のログだけだったのです。同じサーバー上ではWebサービスもSSHも稼働しているのに、そちらは完全に無警戒。さらに、メールのログの中にも「10年前に書かれて以来、実は一度も正しく機能したことがなかった」検知条件が1つ紛れ込んでいたことも判明しました。原因はごく些細な書き方の癖によるものでしたが、気づかれないまま10年間ずっとそこにあったと思うと、なかなか感慨深いものがあります。

監視範囲がメール層のみからメール/SSH/Webの3層へ拡張された図

そこで、見張り番の目を3方向に広げる作業に取りかかりました。メール層に加えて、SSHへの総当たりを検知するレイヤー、そしてWeb層(ログイン総当たりや、脆弱性スキャナーによる機械的な探索など)を検知するレイヤーを新設。既存の運用に影響を出さないよう、元のスクリプトはそのまま残し、拡張版を別のスクリプトとして並行稼働させる形にしました。実際にログへ当ててみると、旧版ではほとんど拾えていなかった規模の「要注意な相手」が新たに見つかり、10年間片目で見ていたことの重みを実感することになりました。

候補が見つかったあとの最終判断材料として、インターネット上の不正アクセス報告データベースに問い合わせる仕組みも追加しました。単体のIPだけでなく、範囲としてまとめて悪質な地域からのアクセスかどうかも参考にできるようにしています。ただしこれもあくまで「人間が判断するための参考情報」であって、自動でBAN可否を決める仕組みではありません。ここは持ち主の哲学を最後まで崩さなかったポイントです。

次回は、この検知の仕組みを「毎朝届く読みやすいレポート」に仕立てていく話をお届けします。実はこの過程で、10年前の古いレポートサンプルを反面教師にするという、なかなか面白い発見がありました。

blocksetはもうVPS移行する前、自宅のノートPCにダイナミックDNS引いていたときからの化石のようなシステムというか手続きだったので、良い機会とおもい思いっきりテコ入れして貰いました~