LLM自動投稿のh1重複と見出し階層崩れを30行で修正する

プログラミング

LLM自動投稿のh1重複と見出し階層崩れを30行で修正する

想定読者: LLMで記事を自動生成し、WordPressへ自動投稿するシステムを運用している方、あるいはその導入を検討されている方。MarkdownをHTMLに変換し、WP REST APIを通じて投稿するパイプラインを前提としています。


当社では、LLMによる記事の自動生成とWordPressへの投稿を1年以上にわたり、月間50〜100件のペースで続けています。この効率的な運用の中で、以下の2つの構造的な課題が頻繁に散見され、その解決が急務となっていました。特に、過去6ヶ月間に自動投稿した約500記事のうち、50〜75件で見出し階層のスキップが確認され、また記事本文の先頭にh1が残る問題も恒常的に発生していました。

LLMに記事作成を任せ、自動でWordPressに投稿する運用では、記事本文の構造が微妙に崩れるケースが少なくありません。特に顕著なのが、以下の2点です。

  1. 記事本文の先頭に # タイトル のh1が残存する(WordPressテーマ側のh1と重複)
  2. h2直下にh4が配置される(見出し階層のスキップ)

これらの問題は、LLMの出力の揺らぎによって発生し、システム上は「エラー」として扱われないため見過ごされがちです。しかし、SEOの観点からも、アクセシビリティのベストプラクティスからも推奨されません。記事が蓄積されるほど、検索エンジンの評価やユーザー体験に悪影響を及ぼす可能性があります。

当社の運用では、投稿直前の前処理として、MarkdownからHTMLへの変換後にnormalizeContentForPublishという小さな関数を挟んでいます。わずか30行程度のコードですが、これにより上記2つの問題はほぼ解消されました。

なぜh1重複が起きるのか

見出しの階層構造を表すアウトラインの図解

HTML コード内で重複する見出し要素の階層構造を示した図解

WordPressのほとんどのテーマは、記事タイトルを<h1>要素として出力する設計になっています。テーマ側が記事タイトルをページ全体の主要な見出し(h1)として保証するため、記事本文中に別途h1を含めるべきではありません。

しかし、LLMに「記事をMarkdown形式で記述せよ」と指示すると、出力の2〜3割で本文の先頭に# タイトルが付与される傾向が見られます。当社がClaude 3.5 Sonnetを用いて行った実測では、プロンプトを工夫し「タイトルはfrontmatterのみとし、本文にはh1を含めない」と明記してもなお、生成される記事の2〜3割で先頭にh1が残る結果となりました。LLMは指示を完全に遵守しないことがあるのです。

結果として、WordPressに投稿されたHTMLは以下のようになります。

<h1>記事タイトル</h1>

<article>
  <h1>記事タイトル</h1>        
  <p>導入...</p>
  <h2>最初のセクション</h2>
</article>

このように1ページ内にh1が2つ存在すると、Googleなどの検索エンジンは「どの見出しが記事の主要な主題なのか」を判断しにくくなります。また、スクリーンリーダーを利用するユーザーにとっても、記事の主題が曖昧になり、コンテンツの理解を妨げる要因となります。

なぜ見出し階層スキップ(h2 → h4)が起きるのか

LLMは、テキストの「強調のトーン」を表現する際に、しばしばh4を意図的に使用することがあります。例えば、h2の直後にh3を飛ばしてh4が来るような構造は、アクセシビリティガイドライン(WCAG 1.3.1 情報と関係性)に反するだけでなく、HTMLセマンティクスの観点からも推奨されません。

LLMは文書構造を意味的な階層としてではなく、「強調の度合い」として捉える傾向があるため、「少し強めに、しかしh2ほどではない」と感じると、h4を直接配置してしまうのです。h3を挟めば解決する問題ですが、プロンプトで厳しく制約しても再発することがあります。当社の6ヶ月間の実測では、約10〜15%の記事でこの現象が検出されました。

実装: normalizeContentForPublish

見出しレベル(h1〜h4)の入れ子構造と階層関係を視覚化したダイアグラム

見出し階層を視覚化したブロック構造図

この関数で実現したいことは以下の2点です。

  1. 記事本文の先頭に残存するh1要素を1つだけ除去する(Markdown形式の# タイトルとHTML形式の<h1>タイトル</h1>の両方に対応)。
  2. h2の直後にh4が続く見出し階層スキップを、h3に「格上げ」して修正する(HTMLベース)。

以下がその全コードです。

/**
 * 投稿本文を正規化する関数。
 *
 * - 記事本文の先頭に存在する重複h1(WordPressテーマのタイトルと重複する`# タイトル`や`<h1>タイトル</h1>`)を除去します。
 * - h2の直後にh4が続く見出し階層スキップをh3に修正します(HTMLを前提とした処理)。
 *
 * 背景: WordPressテーマは通常、記事タイトルを<h1>として出力するため、本文側にh1が残ると重複が生じます。
 * また、見出し階層の飛ばしはアクセシビリティ(a11y)やSEOに悪影響を及ぼします。
 */
export function normalizeContentForPublish(content: string, title: string): string {
  if (!content) return content;
  let out = content;

  // 1) 先頭のh1除去(MarkdownおよびHTMLの両形式に対応)
  const titleTrimmed = title.trim();
  // 完全一致するHTML形式のh1を除去
  out = out.replace(
    new RegExp(`^\\s*<h1[^>]*>\\s*${escapeRegex(titleTrimmed)}\\s*<\\/h1>\\s*`, "i"),
    "",
  );
  // 完全一致するMarkdown形式のh1を除去(HTML変換前の残骸対応)
  out = out.replace(new RegExp(`^\\s*#\\s+${escapeRegex(titleTrimmed)}\\s*\\n`), "");
  // 上記に該当しないh1でも、先頭にあるものは1つだけ除去(LLMの出力揺らぎ対策)
  out = out.replace(/^\s*<h1[^>]*>[\s\S]*?<\/h1>\s*/i, "");

  // 2) 見出し階層スキップの保守的補正: h2直後のh4をh3に格上げ(HTMLベース)
  out = promoteSkippedHeadings(out);
  return out;
}

/**
 * 正規表現の特殊文字をエスケープするヘルパー関数。
 * タイトルに特殊文字が含まれる場合にRegExpエラーを防ぎます。
 */
function escapeRegex(text: string): string {
  return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}

/**
 * 見出し階層スキップ(h2 -> h4)を修正し、h3に格上げする関数。
 */
function promoteSkippedHeadings(html: string): string {
  // HTMLを地の文と見出しタグで分割
  const parts = html.split(/(<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>)/i);
  const result: string[] = [];
  let lastLevel = 2; // 初期値はh2を想定(h1は別途除去されるため)

  for (const part of parts) {
    const m = part.match(/^<h([1-6])([^>]*)>([\s\S]*?)<\/h\1>$/i);
    if (!m) {
      result.push(part); // 見出しタグでない場合はそのまま追加
      continue;
    }
    const level = Number(m[1]); // 現在の見出しレベル

    // h2の直後にh4が来た場合のみh3に格上げ
    if (level === 4 && lastLevel === 2) {
      result.push(`<h3${m[2]}>${m[3]}</h3>`);
      lastLevel = 3; // 格上げ後はh3として扱う
    } else {
      result.push(part); // その他の場合はそのまま追加
      lastLevel = level; // 最後の見出しレベルを更新
    }
  }
  return result.join("");
}

どこで呼ぶか — パイプライン上の位置

複数のステップを経るコンテンツ処理パイプラインのワークフロー図

段階的に進むコンテンツ処理パイプラインの流れ

この正規化処理を適用する位置は非常に重要です。MarkdownからHTMLへの変換後、WordPress REST APIで投稿する直前に挟み込むのが最適です。

上記のコードを your-project/src/utils/normalize.ts に配置し、以下のように publish() 関数内で呼び出してください。

// your-project/src/utils/normalize.ts に上記のコードを配置
// ... (normalizeContentForPublish 関数、escapeRegex 関数、promoteSkippedHeadings 関数) ...
// publish() 関数内での呼び出し例
import { normalizeContentForPublish } from './utils/normalize'; // パスはプロジェクト構造に合わせて調整してください

async publish(article: Article): Promise<PublishResult> {
  // 1. Markdown → HTML 変換
  const html = await markdownToHtml(article.content);
  
  // 2. 正規化処理を適用
  const normalized = normalizeContentForPublish(html, article.title);
  
  // 3. WordPress REST APIを通じて投稿
  return this.wpClient.createPost({
    content: normalized, // 正規化されたHTMLを渡す
    title: article.title,
    // その他の投稿データ(カテゴリ、タグ、ステータスなど)...
  });
}

なぜMarkdown段階ではなくHTML段階で処理するのか:

  • promoteSkippedHeadings関数は、<h2><h4>といったHTMLタグの構造を解析して置換を行います。Markdownの######といった記法には直接対応していません。
  • h1除去については、MarkdownとHTMLの両方の正規表現を同じ関数内に含めているため、HTML段階で呼び出しても冪等(何度実行しても同じ結果になる)に機能します。

関数名をnormalizeContentForPublishと汎用的にしましたが、実態は「HTML正規化 + 冪等なMarkdown先頭h1除去」の複合処理です。もし気になる場合は、機能を2つの関数に分割することも可能です。

実装の細かい注意点

コードエディタでのデバッグ作業を行う開発環境

プログラム開発とデバッグのワークスペース

escapeRegex の重要性

記事タイトルには、()?*などの正規表現のメタ文字がごく普通に含まれることがあります。例えば、「Claude Code は何者か?」や「Docker Compose v2 と v1 (legacy) の違い (2024年版)」といったタイトルが挙げられます。これらの特殊文字をエスケープせずにRegExpコンストラクタに渡すと、SyntaxErrorが発生し、投稿フロー全体が停止してしまいます。これは地味ながら致命的な問題であるため、escapeRegex関数の適用は必須です。

h1を3段階で削除する理由

先頭のh1削除処理を3つの正規表現に分けているのは、LLMの多様な出力揺らぎを確実にカバーするためです。

  1. HTMLでタイトルと完全一致: <h1>タイトルそのもの</h1> のように、WordPressのタイトルと完全に一致するHTML形式のh1を除去します。
  2. Markdownでタイトルと完全一致: # タイトルそのもの のように、HTML変換前のMarkdown形式でタイトルと完全に一致するh1を除去します。これは、Markdownパーサーがh1を適切に処理しなかった場合の残骸に対応するためです。
  3. HTMLで不一致でも先頭1つだけ: <h1 class="foo">微妙に違うタイトル</h1> のように、タイトルとは完全に一致しないものの、LLMが本文冒頭にh1を生成してしまったケースに対応します。\s*で先頭から探し、最初に現れるh1タグを1つだけ除去します。

3番目の処理は、「LLMが本文冒頭にh1を付けたがるが、その内容が微妙に変わっている」というケースへの保険です。複数h1が存在する場合でも、先頭の1つだけを削る設計にしています。2つ目以降のh1は、コンテンツ側の意図的な構造である可能性を考慮し、過剰な修正を避けるため触れません。

見出しスキップはh2 → h4のみを対象とする理由

理論上はh3→h5やh1→h3なども見出しスキップの候補ですが、当社の実運用で確認されるのはほぼh2→h4のパターンに限定されます。1年以上の運用で500以上の記事を処理した結果、h2→h4が圧倒的に高い頻度で発生していました。対象を広げすぎると、「本当はh4を意図的に使いたかった(例えばFAQセクションの質問見出しや、複雑なネストされた説明など)」ケースまで書き換えてしまい、誤検知が増える可能性があります。

そのため、最も頻度が高いパターンのみを修正するという保守的な方針を採用しています。もし将来的に他のパターンへの対応が必要になった場合は、段階的にh3直後のh5といったルールを追加していくのが賢明でしょう。

なぜHTMLパーサーを使用しないか

split(/(<h[1-6][^>]*>[\s\S]*?<\/h[1-6]>)/i)という正規表現を用いることで、HTML文字列を地の文と見出しタグの配列に効率的に分解できます。このアプローチは、hタグ内にさらにhタグがネストされるような本来無効なHTML構造は想定していません。

当社の実運用において、そのような不正な構造がLLMから出力されたことは一度もなく、また仮に出力されたとしてもWordPress側で弾かれる可能性が高いです。そのため、jsdomなどの本格的なHTMLパーサーに依存関係を増やすメリットは薄いと判断しました。わずか30行で解決できる問題に対して、HTMLパーサーの導入は過剰な選択と言えるでしょう。

本番投稿前の確認チェックリスト

自動化関数を導入した後も、以下の項目を投稿直前に必ず確認してください。

  • 画像ファイルの存在確認:記事内に埋め込まれた全ての画像がWordPressのメディアライブラリにアップロードされ、正しく参照されているかを確認してください。URLが404エラーを返していないか、ブラウザの開発者ツールなどでチェックすることを推奨します。
  • リンクの正確性:内部リンクが相対パス(例: /blog/xxx)または絶対URL(例: https://example.com/blog/xxx)で統一され、適切に機能しているかを確認してください。リンク切れがないか、実際にクリックして動作を検証することをお勧めします。
  • ブラウザでの表示確認:投稿後、実際のWordPressサイトで記事を開き、見出し階層が意図通りに表示されているか、画像が正しくレンダリングされているか、レイアウトが崩れていないかなど、目視で最終確認を行ってください。

効果

  • h1重複の完全解消: 本文先頭のh1重複は100%解消されます。これにより、SEOおよびアクセシビリティにおける恒常的なノイズが完全に排除されます。
  • 見出しスキップの自動修正: 当社の実測では、10〜15%の記事で見出しスキップが発生していましたが、この自動修正が機能することで、手動での修正作業が一切不要になります。
  • プロンプト改善との両立: この前処理は最終的なセーフティネットであり、根本治療ではありません。LLMのプロンプト改善は並行して継続すべきです。プロンプトで修正できる出力の揺らぎを減らすことで、他の潜在的なバグも減少させることができます。

まとめ

記事生成と品質改善の自動化ワークフロー

LLMで生成したテキストの品質改善と自動化ワークフロー

LLMに記事作成を任せる運用では、「正しく書かせるための努力」だけでは不十分であり、「間違って書かれたものを修正する努力」を両輪で進める必要があります。今回ご紹介した前処理は、地味ながらも30行程度のコードで広範な効果を発揮する、まさに「割のいい投資」と言えるでしょう。

同様の構成でLLM自動投稿を運用している方は、おそらく同じような課題に直面しているはずです。このコードをそのまま導入していただくことで、品質向上が期待できます。発展形として、h5やh6の取り扱い、あるいは<p>タグ内の<strong>要素をh3に昇格させる処理なども考えられますが、それらはまた別の記事で詳しく扱う予定です。

コメント