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による鯖管理シリーズ、メモも兼ねて一気に書き上げた(書き上げて貰った)。もうclaudeも自分のやったことなので喋る喋る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ってんじゃねーよヽ(`Д´)ノプンプン

2022年明けましての投稿

新年のネタは、3Dプリンタによる鏡餅。丁度白シルクのフィラメントがあったので8cmサイズで製作しました。ミカンは本物です。A4ペーパーを二つ折りにして、裏白はインクジェット用OHPシートに印刷してそのまま乗せてますw

昨日の投稿では旅行物だけあった写真が多く、貼り付ける写真について一枚一枚、GIMPで開いてサイズ変更して上書きセーブしてアップロードという手順を取っていたため非常に面倒で時間のかかる作業だった。前々から気になっていたが何となく惰性で過ごしていた感がある。

それに加えて、こちらの環境ではSafariとWorpressのライブラリの相性が悪く、非常に反応が遅く、下手するとハングアップしてしまうので、このブログ書きの時だけMicrosoftのEdgeを起動していたりします。

その部分は差し当たっては仕方の無いことで、追々と問題を解決しなければと思うところですが、とりあえず画像のサイズ変換アップロード環境だけは早急に何とかしないと、ということで、pythonスクリプトで画像サイズ変換プログラムを自作しました(リサイズだけでたいしたことはしていない為)。

Pillowという画像ライブラリとpiexifというExif編集ライブラリを使用しています。長手方向の幅を2000以下としてファイル名を変えて(-fオプションを付けるとオーバーライト)保存する単純なプログラム。

ついでにExif情報のうちGPS情報を削除します。

まぁ自分の一眼レフカメラはGPS持ってないし、iPhoneの写真をMacからアップするときは写真をフォルダコピーした段階でGPS情報は消されているっぽいので、普通には問題無いのだが、OneDriveのカメラロールに保存された写真とかたまにGPS情報がベッタリ貼られたまま流通しがちなので注意が必要です。

Pillowとpiexifを使用したExif編集の仕組みは以下の通り。

import piexif
from PIL import Image

img = Image.open('sample.jpeg')
exif = img.info['exif']
exif_dict = piexif.load(exif).  # Exif辞書情報の取り出し

# 取り出した辞書情報を編集します
del exif_dict['GPS']     # GPS情報を削除

# 編集後保存可能なバイナリデータに戻します
exif = piexif.dump(exif_dict)

# リサイズなどした新しい画像データの作成
img_new = img.resize([with, height], Image.BICUBIC)

# 編集したExifを適用して保存します
img_new.save('output.jpeg', quality=95, exif=exif)

久々のプログラミング。そういえばMacでの開発環境は作っただけで実践は初めてだった。。。いろいろライブラリとか入れ忘れてたりして意外とドタバタしました。

wordpressのdocker化と既存データの移植

従来環境ではApache2+MySQLでしたが、dockerへの移植時はwebの情報を参考にnginx+php+fpm+mariadbとしました。DockerHubからwordpress+fpmとmariaDBの公式イメージを使用して構築しました。

version: '3.1'
services:
  db:
    image: mariadb:latest
    container_name: mariadb_wp
    volumes:
      - mariaDb:/var/lib/mysql
      - ./db_backup:/backup
    restart: always
    command: --innodb-read-only-compressed=OFF
    environment:
      MYSQL_ROOT_PASSWORD: password
      MYSQL_DATABASE: wpdata
      MYSQL_USER: user
      MYSQL_PASSWORD: password
      TZ: Asia/Tokyo
  php-fpm:
    image: wordpress:5.8-fpm
    container_name: php_wp
    volumes:
      - ./blog:/var/www/html
      - ./conf/upload.ini:/usr/local/etc/php/conf.d/upload.ini
    restart: always
    environment:
      WORDPRESS_DB_HOST: db
      WORDPRESS_DB_USER: webdata
      WORDPRESS_DB_PASSWORD: password
      WORDPRESS_DB_NAME: user
      TZ: Asia/Tokyo
  nginx:
    image: nginx
    container_name: nginx_wp
    depends_on:
      - php-fpm
    environment:
      - VIRTUAL_HOST=blog.marochanet.org
      - TZ=Asia/Tokyo
    volumes:
      - ./blog:/var/www/html
      - ./conf/default.conf:/etc/nginx/conf.d/default.conf
      - ./conf/nginx.conf:/etc/nginx/nginx.conf
    expose:
      - '443'
    restart: always

volumes:
  mariaDb:

networks:
  default:
    external:
      name: shared

最後のsharedネットワークへの参加とnginxホストの環境変数VIRTUAL_HOSTでリバースプロキシで構成する仮想ホスト名でアクセス出来るようになっています。この場合ここではport公開せずにexposeを使用します。

mariaDBはwordpressだけではなくその他django、rainloop、nextcloudでも使用するが、とりあえずこのcomposeプロジェクトで立ち上げておく(他のcomposeからもDBホストmariadb_wpでアクセス出来る)。

nginxホストの設定:nginx.conf と default.confはサンプルのまま

$ cat /conf/nginx.conf
user  nginx;
worker_processes  1;
error_log  /var/log/nginx/error.log warn;
pid        /var/run/nginx.pid;
events {
    worker_connections  1024;
}
http {
    include       /etc/nginx/mime.types;
    default_type  application/octet-stream;
    log_format  main  '$uri - $is_args - args :::'
                      '$remote_addr - $remote_user [$time_local] "$request" '
                      '$status $body_bytes_sent "$http_referer" '
                      '"$http_user_agent" "$http_x_forwarded_for"';
    access_log  /var/log/nginx/access.log  main;
    sendfile        on;
    keepalive_timeout  65;
    include /etc/nginx/conf.d/*.conf;
}

$ cat /conf/default.conf
server {
    index index.php;
    charset utf-8;
    server_name localhost;
    root /var/www/html;
    access_log /var/log/nginx/access.log;
    error_log /var/log/nginx/error.log;
    location ~* /wp-config.php {
        deny all;
    }
    location / {
        try_files $uri $uri/ /index.php$is_args$args;
    }
    location ~ \.php$ {
        fastcgi_split_path_info ^(.+\.php)(/.+)$;
        fastcgi_pass php-fpm:9000;
        fastcgi_index index.php;
        include fastcgi_params;
        fastcgi_param SCRIPT_FILENAME $document_root$fastcgi_script_name;
        fastcgi_param PATH_INFO $fastcgi_path_info;
    }
    location ~* .(html|css|js|jpe?g|png|gif|svg|mpg|flv|swf)$ {
        expires max;
        access_log off;
    }
}

このままアクセスすると、言語およびadminのパスワード設定など初期設定が始まる。今回は既存サイトの移植が目的だが、まずはこのまま初期化して素のwordpressがアクセス出来るようにします。そののちwp-contentsを既存サイトのものと置き換えます。この段階では一度ポートを閉じるなどしてアクセス出来ないようにしておく必要があるかも知れません。

$ sudo rm -rf wp-contents
$ sudo cp -rf [old position]/wp-contents .
$ sudo chown -R www-data.www-data wp-contents 

次にデータベースのコピーですが。まずは既存サイトのデータベースをバックアップします。たとえば同じホストのmysqlからバックアップするとして

$ mysql-dump --lock-all-tables -u root -p --databases wpdata > wpdata.sql
Passwrd:

今回はmysqlからmariadbへの移植も兼ねているので、そのままではエラーになるため、キーワードを入替えておく必要がある。

今回の場合ではunicodeの表現方式で

utf8mb4_0900_ai_ci => utf8mb4_unicode_ci

に変換しておく必要があった。

変換の後、コンテナのデータベースにリストアします。メンテナンス用にdbサービスにマウントしているdb_backupにコピーしてから

$ docker-compose exec db /bin/bash
db$ mariadb -u root -p
mariadb> drop database wpdata
mariadb> quit
db$ mariadb -u root -p &lt; /backup/wpdata.sql

すでに作成済のデータベースwpdataを一旦削除してから改めてバックアップデータをリストア

これにて無事移植が完了しました。

ここまで簡単な流れにまとまったけど、これでも結構失敗を繰り返したり、、あーでもないこーでもないと試した結果だったりします。その紆余曲折をまとめられれば良いのだが、やってるときは悩みながら突っ走ってしまうので、過程があまり残らないことが間々あるw

コンソール出力プラグイン

他サイトのブログを見ると、シェルコマンドの入出力の表記が綺麗なので、Wordpressでも出来ないかと調べてみたら結構あったので、プラグインを二つほど入れてみた。

Enlighter – Customizable Syntax Highlighter

SyntaxHighlighter Evolved

どちらもプログラムソースコード用で、コンソール出力も対応しているが、Enlighterはコピーコマンド付きでテーマを都度々設定する必要があり。Evolvedはコピーコマンドは無いがプラグイン設定でテーマ選択が固定出来る。両方の特徴を活かして、前者Enlighterはプログラムソースコード表示用とし、後者Evolvedは黒画面固定出来るということで、シェルコマンド画面用として使用することにしました。

サーバー引越などで再設定するときの覚え書きとします。

2013長良川花火大会

花火の写真は毎年うまく行かないので今年は動画で撮ってみることにした。ところが、これが凄いことに結構よく撮れてた。Androidスマホで人力保持での撮影。。。静止画よりもよく撮れるってなんだこりゃって感じ。しかし、所詮スマホってことで、夜景撮影でピントが合わせられない。どこかでピントを合わせて、そのまま目標に向けるのがいいっぽいか。

そんなこんなで、比較的ピントが合ってる動画を編集して貼り付け~っと思ったら。何かおかしい。。。

WordPressのメディアライブラリから貼り付けると、リンクのみでブラウザの再生環境に依存するようだ。

2013nagara-fw1

埋め込もうと思ったら、Youtubeか何かにアップロードしないといけないらしい。

こういうときは、Youtubeにアップするとき「限定公開」とすると良いそうだ。リンクを知っている人以外には存在が分からないので、他サイトへの貼り付け専用動画に出来そう。

ただし、この辺はYoutubeでも但し書きがあって、、いわゆる「きれいなアカウント」でないと、限定公開は出来ないそうです。動画消されたり、著作権関連で怒られたことある人は「きれいなアカウント」ではないそうですw