WordPressブログの記事タイトル・タグ・カテゴリ・本文をGASで一括抽出してみた【非エンジニアのための実装メモ】

SEO施策などでブログやサイトのテコ入れを始めるとき、「どの記事をリライトすべきか?」「カテゴリや構成に偏りはないか?」といった整理が必要になりますよね。

まさに今、私も自サイトの記事を見直している最中で、記事ごとのタイトル/URL/カテゴリ/タグ/H1/H2/本文を一覧で見たくなりました。

プラグインでもある程度できるのですが、カスタマイズ性や再利用性を考えるといまいち。
そこで今回は、Google Apps Script(GAS)を使って、HTMLを直接パースする方法で一括抽出する仕組みを作ってみました。

1. 既存の方法で困ったこと

  • WordPressの標準エクスポート機能では「本文」や「見出し構成」が取りづらい
  • 管理画面では一覧性がなく、検索性も低い
  • REST APIは使えるが、認証やフィールド取得の面倒さがある
  • プラグインは極力入れたくない(セキュリティ/速度/管理面から)

2. GASで実装した方法

実装の流れはこんな感じです(Cocoon用の設定になってます):

  1. WordPressのsitemap.xml(投稿記事・固定ページ)を取得
  2. 各URLをループでクロール
  3. HTMLから以下の要素を正規表現で抽出:
    • <title>(ページタイトル)
    • <meta name="description">(ディスクリプション)
    • <h1>, <h2>(見出し)
    • <meta property="article:section">(カテゴリ)
    • .entry-contentブロックから本文(HTMLタグ除去済)
  4. スプレッドシートに整形出力(1記事1行、H2とカテゴリは「/」区切り)

3. ChatGPTとの共創プロセス

このスクリプト(記事の一番下に載せています)は、最初から全部自作したわけではなく、ChatGPTと一緒に作ったものです。

最初に私から以下のような取得条件を提示しました:

  • APIは使わず、HTMLでパースする
  • タイトル・メタ情報・H1/H2/カテゴリ/本文を精度高く取りたい
  • 複数のsitemap.xmlに対応したい
  • HTMLのノイズ要素(<script>, <style>)を除去したい

これらを伝えた上で、ChatGPTにベースのスクリプトを生成させ、そこから改造と調整を加えていくスタイルで進めました。

とくに、以下の部分はAIが生成・調整してくれたものを採用しています:

  • sitemap.xmlからのURL抽出ループ
  • <h2>タグ内部のHTMLを除去してテキスト化
  • .entry-contentの範囲指定と本文のHTML除去処理
  • Googleスプレッドシートへの出力整形

4. 得られた成果と応用例

このスクリプトによって、以下のことが可能になりました:

  • 薄い記事やH2構成が弱い記事を一覧から発見できる
  • タグやカテゴリの偏りを見える化 → 再構成の判断材料に
  • 本文の「書き出し」だけを一覧化 → リライトの優先度判断に
  • 「カテゴリ別の文字数分布」なども、シート上で分析できる

さらにClarityやGSCのデータと突合すれば、「改善すべきページ×読まれてない記事」なども可視化できそうです。

5. まとめ:データを“取れる”と、ブログ運営が変わる

普段はGA4やBigQueryでクライアントのデータを見る立場ですが、
自分のメディアを“構造的に見る”手段を持つことが、思った以上に有効でした。

ChatGPTとGASを組み合わせれば、非エンジニアでも

  • 「どの記事がどう見えているか」
  • 「どう整理・改善していくか」

を、自分で可視化して判断するツールが作れます。

ぜひ気になる方は、真似して使ってみてください。

6. 参考:今回作成したGASスクリプト

下記が今回作成したスクリプトです。
出力先のシート名は「ブログ情報リスト」になっています。
このGASをそのままスプレッドシートで実行すると、当ブログの記事情報一覧が出てきます。

function crawlMultipleSitemaps() {
  const sitemapUrls = [
    'https://saito-online.com/wp-sitemap-posts-post-1.xml',
    'https://saito-online.com/wp-sitemap-posts-page-1.xml'
  ];

  const sheetName = "ブログ情報リスト";
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  let sheet = ss.getSheetByName(sheetName);
  if (!sheet) {
    sheet = ss.insertSheet(sheetName);
  } else {
    sheet.clearContents();
  }

  // カラムに「カテゴリー」を追加
  sheet.appendRow(["URL", "タイトル", "Meta Description", "H1", "H2一覧(複数ある場合は「/」区切り)", "カテゴリ(複数ある場合は「/」区切り)", "本文"]);

  sitemapUrls.forEach(sitemapUrl => {
    const xml = UrlFetchApp.fetch(sitemapUrl).getContentText();
    const urls = [...xml.matchAll(/<loc>(.*?)<\/loc>/g)].map(match => match[1]);

    urls.forEach((url, i) => {
      try {
        const html = UrlFetchApp.fetch(url).getContentText();
        const title = (html.match(/<title.*?>(.*?)<\/title>/i) || [])[1] || '';
        const metaDesc = (html.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"]+)["']/i) || [])[1] || '';
        const h1 = (html.match(/<h1[^>]*>(.*?)<\/h1>/i) || [])[1] || '';

        // H2抽出とタグ除去
        const h2Matches = [...html.matchAll(/<h2[^>]*>(.*?)<\/h2>/gi)].map(match => {
          const innerText = match[1].replace(/<[^>]+>/g, '').trim(); // spanなどを除去
          return innerText;
        });
        const h2List = h2Matches.join(" / ");

        // カテゴリー抽出
        const categoryMatches = [...html.matchAll(/<meta[^>]*property=["']article:section["'][^>]*content=["']([^"]+)["']/gi)];
        const categoryList = categoryMatches.map(match => match[1].trim()).join(" / ");

        // 本文抽出(指定の範囲のみ)
        const bodyMatch = html.match(/<div class="entry-content cf" itemprop="mainEntityOfPage">([\s\S]*?)<footer class="article-footer entry-footer">/i);
        let bodyHtml = bodyMatch ? bodyMatch[1] : '';
        const bodyText = bodyHtml
          .replace(/<script[\s\S]*?<\/script>/gi, '')
          .replace(/<style[\s\S]*?<\/style>/gi, '')
          .replace(/<[^>]+>/g, '')
          .replace(/\s+/g, ' ')
          .trim();
        const snippet = bodyText.substring(0, 4000);

        // データ行追加
        sheet.appendRow([url, title, metaDesc, h1, h2List, categoryList, snippet]);
        Utilities.sleep(3000); // アクセス制限対策
      } catch (e) {
        Logger.log("Error on: " + url + " → " + e.message);
        sheet.appendRow([url, "ERROR", "", "", "", "", ""]);
      }
    });
  });

  SpreadsheetApp.flush();
}