WordPress自動投稿パイプラインで画像が3枚表示された原因を調査した

WordPress自動投稿パイプラインで画像が3枚表示された原因を調査した コラム
WordPress自動投稿パイプラインで各セクションに画像が3枚並んでしまったバグ調査のイメージ

AI で記事を自動生成して WordPress に投稿するパイプラインを運用している。ある日、WordPress の下書き一覧を確認したら、セクションごとに画像が3枚ずつ並んでいる記事がいくつかあった。「見るに耐えない」という感想しか出ない見た目だった。

この記事は、その原因を調査して修正するまでの過程を記録したものだ。エラーは一切出ていないのに見た目だけ壊れる、いわゆる「失敗が見えない失敗」の一種で、200 で返るのに動かないデバッグ記録と根は同じだ。

パイプラインの構成

まず前提として、記事生成パイプラインの構成を整理しておく。


/write-article キーワード
  ↓
Step 1: 競合リサーチ
  ↓
Step 2: Claude でドラフト生成(Markdown)
  ↓
Step 3: セルフレビュー → リライト
  ↓
Step 4: call_comfyui.py で画像生成
         アイキャッチ 3バリエーション + H2ごとにセクション画像 3バリエーション
  ↓
Step 5: wp_post.py で WordPress に下書き投稿
         Markdown → HTML 変換 → REST API で POST

Python スクリプトが2本(画像生成と投稿)連携している。画像生成の中身(無料枠での画像生成)はComfyUI と Imagen 4 で画像を無料生成する記事に、ドラフト生成の並列化はLLM でセクションを並列生成する記事に分けて書いた。

記事生成パイプラインを構成する複数スクリプトが連携する様子のイメージ

症状の確認

WordPress の下書きを見ると、こんな状態だった。


<!-- セクション見出しの直後 -->
<figure class="wp-block-image size-large">
  <img src="...variation1.jpg" alt="...(バリエーション1)" />
</figure>
<figure class="wp-block-image size-large">
  <img src="...variation2.jpg" alt="...(バリエーション2)" />
</figure>
<figure class="wp-block-image size-large">
  <img src="...variation3.jpg" alt="...(バリエーション3)" />
</figure>

1セクションに3枚の画像が縦に並んでいる。アイキャッチ画像も記事冒頭に3枚並んでいる。全体で 18〜24 枚の画像が表示される状態だった。

1つの見出しの下に同じ用途の画像が3枚積み重なって表示される症状のイメージ

原因の追跡

call_comfyui.py を読む

画像生成スクリプトの insert_images_into_markdown 関数を読んだ。


def insert_images_into_markdown(md_content: str, images: list[dict]) -> str:
    """frontmatter 直後にアイキャッチ全バリエーションを、
    H2直後にセクション画像全バリエーションを挿入する."""

    # アイキャッチ(バリエーション全部)を frontmatter 直後に挿入
    featured_list = [i for i in images if i["type"] == "featured"]
    if featured_list:
        block = "\n" + "\n".join(
            f"![{img['alt']}(バリエーション{idx+1})]({img['rel_path']})"
            for idx, img in enumerate(featured_list)
        ) + "\n"
        # frontmatter の直後に挿入...

    # セクション画像をH2の直後に挿入(H2ごとに全バリエーション)
    for h2_title, sec_imgs in section_by_h2.items():
        block = "\n" + "\n".join(
            f"![{img['alt']}(バリエーション{idx+1})]({img['rel_path']})"
            for idx, img in enumerate(sec_imgs)
        ) + "\n"
        # H2 の直後に挿入...

docstring に「全バリエーションを挿入する」と書いてある。これは意図的な設計だった。

なぜ全バリエーションを入れるのか

レビューフローのために全バリエーションをMarkdownに埋め込んでいる。Step 6(generate_review.py)でブラウザ確認用の HTML を生成するとき、3枚並べて「どれが一番いいか」を人間が判断できるようにする意図があった。

理にかなった設計だ。しかし問題は次のステップにあった。

wp_post.py を読む

WordPress 投稿スクリプトの Markdown → HTML 変換部分を読むと、画像行をそのまま全部 <figure> に変換していた。


# 画像
elif re.match(r"^!\[.*?\]\(.*?\)", line.strip()):
    flush_list()
    m = re.match(r"^!\[(.+?)\]\((.+?)\)", line.strip())
    if m:
        alt, src = m.group(1), m.group(2)
        resolved = (image_url_map or {}).get(src, src)
        parts.append(
            f'<figure class="wp-block-image size-large" ...>'
            f'<img src="{resolved}" alt="{alt}" class="wp-image" />'
            f'</figure>'
        )

alt に「バリエーション2」「バリエーション3」が含まれているかどうかを確認せず、全部変換していた。レビュー用に埋め込んだ全バリエーションが、そのまま WordPress に投稿されていたというわけだ。

修正

修正は1箇所で済んだ。画像変換の直前にバリエーション2・3をスキップする条件を追加した。


# 画像
elif re.match(r"^!\[.*?\]\(.*?\)", line.strip()):
    flush_list()
    m = re.match(r"^!\[(.+?)\]\((.+?)\)", line.strip())
    if m:
        alt, src = m.group(1), m.group(2)
        # バリエーション2・3はスキップ(レビュー用に.mdに書かれているが投稿には1枚のみ)
        if re.search(r'(バリエーション[2-9])', alt):
            continue
        resolved = (image_url_map or {}).get(src, src)
        parts.append(...)

alt に「バリエーション2」以上が含まれる行を continue でスキップする。これで WordPress への投稿は常に1セクション1枚になる。

alt中の「バリエーション2・3」を判定して1枚だけ残し残りをスキップする修正のイメージ

もう一つの問題:HTMLがコードブロックに包まれる

同じ調査の過程で、別の問題も見つかった。一部の記事でセクション本文が全て <pre><code> タグで包まれ、HTMLのソースコードがそのまま表示される状態になっていた。SVG図やH2見出し・段落テキストがHTMLエンティティ(&lt;svg&gt; など)のまま画面に出てしまっていた。

原因の追跡

プロンプト(prompt-draft.md)に「インラインSVG図解ルール」があり、セクションに SVG で視覚的な図を入れるよう指示している。Claude が SVG+HTML を生成するとき、Markdown の慣習として htmlコードフェンス で全セクション内容をひとまとめに包む出力をすることがある。

wp_post.py はコードフェンスを <pre><code> に変換する設計だった。コードとして HTML を紹介する記事では正しい動作だが、セクション本文として書かれた HTML が誤ってコードブロックになってしまっていた。

どこで直すか:コード側 vs プロンプト側

直し方は2つ考えられた。

1つ目は wp_post.py 側で `html フェンスを生 HTML として展開する案だ。htmlフェンスを検出したら <pre><code> にせず、中身をそのまま出力する。


# wp_post.py 側で直すなら(今回は不採用)
if lang == "html":
    in_html_block = True   # コードにせず生HTMLとして展開する
else:
    parts.append(f'<pre class="wp-block-code"><code class="language-{lang}">')
    in_code = True

しかしこれは採用しなかった。理由は単純で、HTML のソースコードを「コードとして見せたい」記事——まさにこの記事のように <figure><svg> のソースを紹介する記事——では、 `html を勝手に展開されると逆に困るからだ。コード側で自動展開すると、HTML を解説する記事が軒並み壊れる。「フェンスの中身がサンプルコードなのか、描画してほしい本文なのか」はフェンスの言語指定だけでは区別できない。

つまりこれは変換器のバグではなく、入力(Markdown)の側で <svg> `html で包んでしまったことが原因だ。だから直すべきは入力を作る LLM のプロンプトだった。

本文のHTMLがコードブロックとしてエスケープ表示されてしまう問題のイメージ

根本対策:プロンプトに明示指示を追加

再発防止のため、prompt-draft.md の末尾に以下を追加した。


## SVG・HTML の扱い

- SVG や HTML を本文中に直接埋め込む場合は、```html コードブロックで包まない
- インライン SVG は Markdown 本文にそのまま(生の `<svg>...</svg>` として)書く
- `<svg>` タグを ```html ``` で囲むと、WordPress でソースコードとして表示されるため絶対にやらない

既存の問題記事を修正する

既に投稿済みの下書きにも同じ問題があった。WordPress REST API を使って一括修正するスクリプトを書いた。


// バリエーション2・3の <figure> を削除する関数
function removeVariantImages(html: string): string {
  return html.replace(
    /<figure[^>]*>[\s\S]*?<img[^>]+alt="[^"]*バリエーション[23][^"]*"[^>]*\/?>[^<]*<\/figure>\n?/g,
    ""
  );
}

// ```html コードブロックを展開する関数
function expandHtmlCodeBlocks(raw: string): string {
  return raw.replace(
    /<pre><code class="language-html">([\s\S]*?)<\/code><\/pre>\n?/g,
    (_, inner: string) => {
      return inner
        .replace(/&#x3C;/g, "<")
        .replace(/&#x3E;/g, ">")
        .replace(/&amp;/g, "&")
        // ...その他エンティティのデコード
        .trim() + "\n\n";
    }
  );
}

WordPress REST API の POST /wp/v2/posts/:idcontent フィールドを上書きして修正した。

振り返り

今回の問題は「意図的な設計がパイプラインの別のステップで想定外の動作を引き起こした」パターンだ。

call_comfyui.py が全バリエーションをMarkdownに書く設計は正しい。レビュー時に3枚並べて選べるのは便利だ。問題は wp_post.py がそのレビュー用フォーマットを知らないまま全部変換してしまったこと。

同様に、prompt-draft.md の SVG 指示はセクションを視覚的に強化するためのものだったが、Claude が Markdown の慣習に従って `html で包んだことで意図と逆の動作になった。

パイプラインが複数のスクリプトと LLM を組み合わせるほど、「ここはこういう意図」という文脈が伝わらない箇所が生まれる。それぞれのスクリプトが相手の出力フォーマットを知っている必要がある。

✅ まとめ

問題 原因 修正箇所
画像が3枚ずつ表示 wp_post.py がバリエーションを区別せず全変換 alt テキストでバリエーション2・3をスキップ
HTML がコードブロックで表示 Claude が SVG を \\\`html で包む プロンプト側で SVG を \\\`html で包まないよう明示(コード側の自動展開は採らない)

コンテンツ自動化パイプラインは「動いている」状態から「きれいに動いている」状態にするまでのデバッグが意外と奥深い。各スクリプトが互いの出力を正しく解釈しているかを定期的にチェックする仕組みを入れておくのが長期運用のコツだ。

コメント