Astro×Google AdSense手動広告の自動挿入ガイド|rehypeとllms.txt完全分離
作成日:

Astro×Google AdSense手動広告の自動挿入ガイド|rehypeとllms.txt完全分離


🔧 論旨

個人ブログや技術メディアに Google AdSense を導入した際、手軽だからと「自動広告(Auto ads)」をオンにしたら、せっかく整えた見出しの真上や吹き出しの隙間に巨大なバナーが割り込み、画面がガタガタに揺れて愕然とした経験はないでしょうか。かといって全記事の原稿ファイルに手動で広告タグをコピペして回るのは、数百記事を超えると保守不能になります。

結論からお伝えすると、Astro で美観と収益、そして AI 検索時代のコーパス品質を両立させる最適解は、「自動広告をオフにして手動ユニット 2 枠(記事内+記事下)に絞り、ビルド時の Rehype プラグインで対象カテゴリの H2 直前へ自動注入する設計」 です。本記事では、原稿ファイル(.mdx / .md)も /llms-full.txt も 1 文字も汚さずに運用できる実践コードをすべて公開します。

🔧 合わせて読みたい深まる関連情報
🔧 対象となる方
  • Google AdSense の 「自動広告」によるレイアウト崩れや CLS(画面のガタつき) に悩んでいる Astro サイト運営者の方

  • 数十〜数百本の記事原稿を汚さず、特定の位置(最初の H2 直前など)へ一括で記事内広告を差し込みたい エンジニア・ブロガーの方

  • サイト内に「収益化するレビュー記事」と 「広告を一切出さない思想・ドキュメント系ページ」が混在 しており、スクリプト読み込み自体を完全に分けたい方

なぜAdSense自動広告はレイアウトを壊すのか

Google AdSense の管理画面で「自動広告」を有効にすると、head 内に 1 行のスクリプトを貼るだけで Google 側のクローラーが DOM 構造を解析し、ページ内のあらゆる隙間へ自動的に広告枠を生成します。一見すると魔法のように楽な仕組みですが、構造化にこだわった Astro サイトでは大きな副作用を引き起こします。

比較軸AdSense 自動広告まかせ原稿ファイルへの手動直貼りRehype ビルド時自動挿入(本方式)
デザイン維持見出しや枠の隙間に予測不能な挿入が起きる狙った位置に出せる狙った位置(1 番目 H2 直前)に固定
CLS(画面揺れ)後読み展開で本文が大きく押し下げられるCSS 制御次第で軽減可能unfilled 折りたたみで最小化
原稿の保守性原稿は汚れない数百ファイルの修正が地獄になる原稿変更ゼロ・1 ファイルで一括制御
llms.txt 純度純粋だが UI が崩れる原稿走査時に広告タグ残骸が混じる恐れ100% 純粋な本文のみを AI へ提供
カテゴリ別分離URL 除外設定の反映が遅くスクリプトは残る手動管理で貼り間違いが起きるビルド時に adsbygoogle.js ごと完全除外
質問するおじさん

自動広告をオンにしたら、せっかく綺麗に作った比較表の途中やフッターの隙間にドカンと広告が出てきて、スマホで読んどると指が勝手に当たってしまうんよね。読者としてもあれは一番ストレスが溜まるんじゃ。

解説ナビゲーター

本当にその通りですね!特に当サイトのように「じっくり読んでほしい体系的な構想パート」と「実機レビュー・検証録パート」が同じドメイン内にある場合、自動広告だと全ページで重い広告スクリプトが動いてしまいます。手動広告ユニットを ビルド時の AST(構文木)変換 で制御するのが一番スマートな解決策なんです。


llms.txtを汚さないRehype自動挿入の全体像

AI学習コーパスと広告HTMLを分離する2階層構造

現代の個人メディア運営で見落としがちなのが、ChatGPT や Gemini、Perplexity などの AI 検索エンジンに向けた全文データ(/llms-full.txt や /ai-corpus.jsonl)と、広告コードとの干渉です。もし各記事の .mdx ファイル本文に直接 <AdSenseUnit /> を書き込んでしまうと、原稿テキストを抽出してコーパスを生成するスクリプト側で毎回タグ除去の正規表現を増やす羽目になります。

そこで当サイトでは、「MDX 原稿そのものには広告を 1 文字も書かず、HTML へ変換される直前の Rehype(HTML 構文木)レイヤーでのみ記事内広告を差し込む」 という 2 階層パイプラインを採用しました。

原稿を汚さずAIコーパスとブラウザ向けHTMLを分岐させるビルド構造
Single Source
第1層:純粋な原稿ソース(src/content/blog/ 配下の .mdx)

記事のテキスト、図解、吹き出し、アフィリエイト定義IDのみを記述。AdSense用のタグやスクリプトは一切含めません。

Build Pipeline
第2層:AI検索向けコーパス生成 & Rehype AST 変換
AIコーパス出力系(llms-full.txt等)

MDXの生ソースから本文と構造のみを抽出するため、広告タグのノイズが1ビットも混入しません。

HTMLビルド系(rehypeInArticleAdsense)

検証録カテゴリの構文木にだけ、最初のH2直前へ記事内広告ノードを自動注入します。

Output HTML
第3層:レイアウト統合(BlogPost.astro + BaseHead.astro)

広告有効ページのみ head に adsbygoogle.js を1回読み込み、記事末尾のディスプレイ広告とあわせて計2枠のみをクリーンに表示します。

MDXソース段階では広告コードが存在しないため、llms.txt生成系と完全に疎結合になります

AI国家記事と非公開ページを完全除外する条件分岐

もうひとつの重要な設計が、「広告を出さないページでは <head> のローダースクリプト(adsbygoogle.js)すら読み込ませない」 という徹底分離です。BaseHead.astro に enableAdSense?: boolean プロパティ(初期値 false)を設け、レビュー記事用の BlogPost.astro 側からのみ条件付きで true を渡しています。

---
// src/components/BaseHead.astro(抜粋・パブリッシャーIDはダミー表記)
interface Props {
  title: string;
  description: string;
  enableAdSense?: boolean;
}
const { title, description, enableAdSense = false } = Astro.props;
---

{
  enableAdSense && (
    <script
      is:inline
      async
      src="https://pagead2.googlesyndication.com/pagead/js/adsbygoogle.js?client=ca-pub-XXXXXXXXXXXXXXXX"
      crossorigin="anonymous"
    ></script>
  )
}

さらに BlogPost.astro 側でも、Astro.url.pathname.startsWith("/blog/ai-nation-concept") やフロントマターの hidden === true をチェックし、対象外のページでは <BaseHead> へのフラグも記事下ユニットの描画もすべてオフになる二重ロックをかけています。これにより、思想・論文系ページの初期読み込み速度(Lighthouse スコア)は一切犠牲になりません。


H2直前への自動注入プラグインとCLS防止CSS

導入枠の有無を見分けるAST走査アルゴリズム

記事内広告を差し込む位置として最も自然で読者の視線を邪魔しないのは、「冒頭の導入パート(論旨・対象読者枠)を読み終え、最初の本論 <h2> 見出しに入る直前」 です。

ただし、数百本の記事の中には「冒頭に GadgetSection などの導入枠がある .mdx 記事」だけでなく、「1 行目からいきなり <h2> 見出しで始まる古い .md 記事」が混ざるケースもあります。もし後者で無条件に 1 番目の <h2> 直前へ広告を入れてしまうと、タイトルのすぐ真下に広告が出る不格好な配置になってしまいます。

そこで自作した src/plugins/rehype-in-article-adsense.mjs では、以下の分岐ロジックを実装しました。

記事の書き出し構造に応じた挿入位置の自動切り替え
自動判定
1番目の H2 直前へ挿入
導入枠から始まる記事(標準)
  • 判定条件:最初の実コンテンツ要素が h2 以外(導入ボックス、段落、吹き出しなど)
  • 動作:導入を読み終えた区切りとなる「1番目の h2 の直前」に記事内広告を1枠だけ挿入します。
2番目の H2 直前へ挿入
いきなりH2見出しで始まる記事
  • 判定条件:import や空行を除いた最初の実要素がいきなり h2 タグ
  • 動作:ファーストビュー直下の広告露出を避け、「2番目の h2 の直前」へ自動シフトします(H2が1個以下なら挿入見送り)。

実際のプラグインコードは以下の通りです(astro.config.mjs の markdown.rehypePlugins と mdx() の両方で有効になるよう登録します)。

// src/plugins/rehype-in-article-adsense.mjs
// ※ ca-pub-XXXXXXXXXXXXXXXX と 1234567890 はご自身のIDへ置き換えてください
export function rehypeInArticleAdsense() {
  return (tree, file) => {
    if (!tree || !Array.isArray(tree.children)) return;

    const rawPath = String(file?.path || file?.history?.[0] || "")
      .split("\\")
      .join("/");

    // 1. 対象のブログ配下のみ処理し、特定カテゴリ・内部資料・バックアップは完全除外
    if (!rawPath.includes("/src/content/blog/")) return;
    if (
      rawPath.includes("/ai-nation-concept/") ||
      rawPath.includes("/内部資料/") ||
      rawPath.includes("/bk/")
    ) {
      return;
    }

    // 2. 非公開記事(hidden / private)は除外
    const frontmatter = file?.data?.astro?.frontmatter;
    if (frontmatter?.hidden === true || frontmatter?.private === true) return;

    const children = tree.children;

    // 3. 二重挿入を防ぐ冪等性ガード
    const alreadyInserted = children.some(
      (node) =>
        (node?.type === "mdxJsxFlowElement" && node.name === "AdSenseUnit") ||
        (node?.type === "element" &&
          Array.isArray(node.properties?.className) &&
          node.properties.className.includes("adsense-unit"))
    );
    if (alreadyInserted) return;

    // 4. 最初の実コンテンツ要素と全 <h2> の位置を走査
    let firstMeaningfulNode = null;
    const h2Indices = [];

    for (let i = 0; i < children.length; i++) {
      const node = children[i];
      if (!node) continue;
      if (
        node.type === "mdxjsEsm" ||
        node.type === "comment" ||
        (node.type === "text" && (!node.value || !node.value.trim())) ||
        (node.type === "element" && node.tagName === "hr")
      ) {
        continue;
      }
      if (!firstMeaningfulNode) firstMeaningfulNode = node;
      if (node.type === "element" && node.tagName === "h2") {
        h2Indices.push(i);
      }
    }

    if (h2Indices.length === 0) return;

    const startsWithH2 =
      firstMeaningfulNode?.type === "element" &&
      firstMeaningfulNode.tagName === "h2";
    const targetH2Index = startsWithH2
      ? h2Indices.length >= 2
        ? h2Indices[1]
        : -1
      : h2Indices[0];

    if (targetH2Index < 0) return;

    // 5. MDXと通常Markdownの両形式に対応してノードを生成・挿入
    const isMdx = /\.mdx$/i.test(rawPath);
    const adNode = isMdx
      ? {
          type: "mdxJsxFlowElement",
          name: "AdSenseUnit",
          attributes: [
            { type: "mdxJsxAttribute", name: "variant", value: "in-article" },
          ],
          children: [],
        }
      : {
          type: "element",
          tagName: "div",
          properties: {
            className: ["adsense-unit", "adsense-in-article"],
          },
          children: [
            {
              type: "element",
              tagName: "ins",
              properties: {
                className: ["adsbygoogle"],
                style: "display:block; text-align:center;",
                "data-ad-layout": "in-article",
                "data-ad-format": "fluid",
                "data-ad-client": "ca-pub-XXXXXXXXXXXXXXXX",
                "data-ad-slot": "1234567890",
              },
              children: [],
            },
            {
              type: "element",
              tagName: "script",
              properties: {},
              children: [
                {
                  type: "text",
                  value: "(adsbygoogle = window.adsbygoogle || []).push({});",
                },
              ],
            },
          ],
        };

    children.splice(targetH2Index, 0, adNode);
  };
}

なお、.mdx ファイルに対して mdxJsxFlowElement として <AdSenseUnit variant="in-article" /> を注入した場合、MDX 側のスコープに AdSenseUnit コンポーネントが渡されている必要があります。ページテンプレート側(例:[...slug].astro の <Content components={{ AdSenseUnit }} />)でグローバルコンポーネントとして渡しておくことで、各記事ファイルに import AdSenseUnit を書かなくてもシームレスに解決できます。

広告在庫なし時の空白枠を消すhasセレクタ術

手動広告ユニットでもうひとつ対策しておきたいのが、時間帯や閲覧環境によって広告が配信されなかった場合(data-ad-status="unfilled")に、上下のマージンと空の <ins> 高さが残って不自然な空白地帯ができる現象です。

Google 公式ドキュメントでも案内されている ins.adsbygoogle[data-ad-status="unfilled"] に加え、モダンブラウザで標準使えるようになった CSS の :has() 擬似クラス を組み合わせると、外側のラッパー div の上下余白ごとピタッとゼロに折りたためます。

---
// src/components/AdSenseUnit.astro(抜粋・IDはダミー表記)
interface Props {
  variant: "in-article" | "display";
}
const { variant } = Astro.props;
---

{
  variant === "in-article" ? (
    <div class="adsense-unit adsense-in-article">
      <ins
        class="adsbygoogle"
        style="display:block; text-align:center;"
        data-ad-layout="in-article"
        data-ad-format="fluid"
        data-ad-client="ca-pub-XXXXXXXXXXXXXXXX"
        data-ad-slot="1234567890"
      ></ins>
      <script is:inline>
        (adsbygoogle = window.adsbygoogle || []).push({});
      </script>
    </div>
  ) : (
    <div class="adsense-unit adsense-display">
      <ins
        class="adsbygoogle"
        style="display:block"
        data-ad-client="ca-pub-XXXXXXXXXXXXXXXX"
        data-ad-slot="9876543210"
        data-ad-format="auto"
        data-full-width-responsive="true"
      ></ins>
      <script is:inline>
        (adsbygoogle = window.adsbygoogle || []).push({});
      </script>
    </div>
  )
}

<style>
  .adsense-unit {
    display: block;
    width: 100%;
    max-width: 100%;
    box-sizing: border-box;
    overflow-x: hidden;
    clear: both;
  }
  .adsense-in-article {
    margin: 2rem auto;
  }
  .adsense-display {
    margin: 2.8rem auto 1rem;
  }
  /* 広告在庫なし(unfilled)時は親ラッパーの余白ごと折りたたむ */
  .adsense-unit:has(ins.adsbygoogle[data-ad-status="unfilled"]) {
    display: none;
    margin: 0;
  }
  ins.adsbygoogle[data-ad-status="unfilled"] {
    display: none !important;
  }
</style>

スマホ表示で横スクロールが発生しないよう、.adsense-unit に max-width: 100%; overflow-x: hidden; を指定しておくのも、モバイルフレンドリーを維持する大切な防波堤です。


AIクローラーが広告枠をバグと誤認する罠と対策

質問するおばさん

ところで、AI に自分のブログ記事の URL を渡して「この記事のデザインや構成をチェックして!」と頼んだとき、手動広告の枠はどう見えるのかしら?

解説ナビゲーター

実はそこに AI 監査ならではの面白い落とし穴 があるんです!実際に運用してみて判明した注意点を共有しますね。

外部の AI(ChatGPT の Web ブラウジング機能や簡易スクレイパーなど)に公開記事の URL を読ませると、それらのクローラーは JavaScript(adsbygoogle.js)を実行せずに生の静的 HTML だけを取得することが多々あります。すると AI の目には、記事の途中に <div class="adsense-unit"><ins class="adsbygoogle"></ins></div> という 「中身が空っぽのタグ」 だけが置かれているように見えます。

その結果、事情を知らない AI に記事監査をさせると、「H2 直前に空の div と ins タグが残っており、不要な余白バグの原因になっています。削除を推奨します」と誤診してくることがあります。

🔧 ⚠️ AIエディタ・AI監査と共存するための3つの運用ルール
  1. プロジェクトの仕様書(GEMINI.md 等)に手動広告の仕様を明記する : AI エディタが「空のタグがあるから消しておきました!」と親切心で AdSenseUnit.astro や Rehype プラグインを勝手に削除する事故を防ぐため、ルールファイルに「ビルド時自動挿入の稼働中ユニットであり削除厳禁」と明記しておきます。

  2. 原稿ファイル(.mdx)への直書き禁止を徹底する : Rehype プラグインがすでに 1 枠目を自動挿入しているため、執筆担当の AI が原稿内に手動で広告タグを重ね書きしないよう、プリフライト検査やルールでロックをかけます(プラグイン側にも alreadyInserted の二重挿入防止ガードを入れています)。

  3. 広告描画の最終確認は必ず実ブラウザ(または Playwright)で行う : テキストベースのスクレイパーではなく、実際に JavaScript を実行できる実機ブラウザ上で iframe が生成されているか、data-ad-status が正常に付与されているかを検証するのが確実です。

ノルウェージャンフォレストキャット

人間にはちゃんときれいな広告が見えとるのに、JavaScript を動かさない AI クローラーには透明な空き箱に見えるんでちね!仕様書にひとこと書いておくだけで誤削除を防げるなら安心でち!


まとめ:美観と収益とAI検索評価を三方良しにする

Google AdSense は「自動広告でページ中を広告だらけにするか、それとも広告を完全に諦めるか」の二択ではありません。手動ユニットを「最初の H2 直前の記事内広告」と「記事読了後のディスプレイ広告」の 計 2 枠だけに厳選し、Rehype プラグインでクリーンに自動注入する ことで、読者の読書体験もサイトの品格も損なわずに運用できます。

🔧 ✅ 本設計で幸せになる人・向かない人
  • 導入して幸せになる人 : Astro で記事数が増えてきた個人ブロガー・技術メディア運営者で、デザイン崩れゼロ・原稿汚染ゼロ・カテゴリ別完全分離を実現したい方

  • 別の選択肢が向く人 : 記事ごとの細かなレイアウトよりも、とにかく 1 分で設定を終えて広告枠数を最大化したい短期トレンドサイト運営者の方

ご自身の Astro プロジェクトへ同じ仕組みを導入したい場合は、以下のプロンプトをコピーして最上位モデルの AI エディタへ渡してみてください。ディレクトリ構成やレイアウト名に合わせて安全に組み込んでくれます。

🔧 🤖 自分の環境に合わせてAIに構築させるコピペ用プロンプト

※軽量モデル(Flash 等)では AST の条件分岐や既存レイアウトとの整合確認を読み飛ばす恐れがあるため、必ず 『Gemini 4 Argon』(または同等以上の最上位推論モデル)を選択して実行してください。

以下の記事の設計思想と実装コードを参照し、私の Astro プロジェクトに「原稿(.md/.mdx)を汚さない Google AdSense 手動ユニット自動挿入基盤」を導入してください。
参照URL: https://lab.chihouzakki.com/blog/review/astro-adsense-manual-rehype-clean-llms-guide/

【作業ルール(厳守)】
1. いきなりファイルを書き換えず、まず私のプロジェクトの astro.config.*、ヘッド共通コンポーネント、記事レイアウト、MDXレンダリング箇所(Content components)を調査し、変更計画を提示して私の合意を得てください。
2. 広告を出したい対象ディレクトリ(またはカテゴリ)と、完全に除外したいディレクトリ(adsbygoogle.js 自体を読み込まないページ)を私に確認してください。
3. パブリッシャーID(ca-pub-...)と広告スロットID(記事内・記事下)は環境変数または設定定数として安全に管理し、data-ad-status="unfilled" 時の余白折りたたみCSSと二重挿入防止ガードを必ず含めてください。

読者にとっても書き手にとっても気持ちの良いページ構造を整えて、長く愛されるメディアを育てていきましょう!

すっきり整った部屋の隅で気持ちよさそうに丸くなって眠るノルウェージャンフォレストキャット

▲ 画面のガタつきも原稿の手直しもなくなれば、安心して昼寝できます