GAS×WordPressで自動投稿を実現する― しずかなニュースの開発記録

小さなプロダクト

前回の記事では、GASで取得したデータをWordPressへ自動投稿する仕組みをどのように実現するかを検討し、最終的に 「GAS → JSON → WordPress側のプラグインで受け取って投稿」 という方式を採用する方針としました。
(→「WordPressに自動投稿する仕組み ― しずかなニュースの開発記録」)

この方式のメリットは、WordPress側で複雑な認証設定を行う必要がなく、サーバーによっては海外からのアクセス制限を回避できる点にあります。また、WordPress側で投稿処理を行うことで、投稿ページ/固定ページの追加・更新、ドラフト保存、即時公開 など、柔軟なワークフローを実現できることも大きな利点です。

本記事では、WordPress初心者でも実践できるよう、シンプルな自作プラグインを作成し、GASから渡されたJSONデータを投稿として保存する手順 を紹介します。

全体の作業の流れ

今回の流れは、大きく次の 4 ステップです。

  1. WordPressプラグインの作成
    GASから送られてきたJSONを受け取り、投稿として保存するプラグインを作成します
  2. プラグインの有効化
    作成したプラグインを指定の場所に格納し、利用できる状態にします
  3. GASコードの整備
    投稿データをJSONとして返すコードを作成します
  4. Webアプリとしてデプロイ
    URLを発行し、WordPressプラグインから参照できるようにします。

この流れで構築することで、GAS → WordPress の自動連携が可能になります。

プラグイン作成

ここでは、GASから取得したJSONデータを受け取り、WordPressの投稿として登録するためのプラグインを作成します。WordPressのプラグインは、基本的に PHP ファイルを所定のフォルダへ配置すれば動作します。

作成するファイル名

  • フォルダ名:gas-pull-auto-publisher
  • プラグイン本体:gas-pull-auto.php

プラグインの役割は次のとおりです。

  • 指定のURL(GAS Webアプリ)へアクセスしてJSONを取得
  • JSONを解析し、記事情報(タイトル・本文など)を抽出
  • WordPressに投稿を作成(または更新)
  • 処理ログを残す(任意)
gas-pull-auto.php(クリックして開く)
<?php
/**
 * Plugin Name: GAS Pull Auto Publisher (Draft Only, Minimal)
 */
if (!defined('ABSPATH')) exit;

/** ===== 設定 ===== */
define('GAS_PULL_FEED', 'https://script.google.com/macros/s/*******/exec?token=*******'); //
define('GAS_PULL_DEFAULT_ID', '1');                         // ← 投稿設定シートの既定ID列(B列=1)
define('GAS_PULL_META_KEY', '_external_id');
define('GAS_LOG_ENDPOINT', 'https://script.google.com/macros/s/*******/exec'); // ← /exec に修正
define('GAS_TOKEN', 'tkn_5zX8hJq2LmR9vTnY');

/** ===== ロギング(GASのスプレシートへ) ===== */
function gas_log($level, $message, $context = []) {
    $payload = [
        'token'   => GAS_TOKEN,
        'level'   => $level,
        'message' => $message,
        'context' => $context,
    ];
    wp_remote_post(GAS_LOG_ENDPOINT, [
        'timeout' => 10,
        'headers' => ['Content-Type' => 'application/json'],
        'body'    => wp_json_encode($payload),
    ]);
}

/** ===== 取得URL作成(?id= の付与) ===== */
function gas_build_feed_url($id_param = null) {
    $id = $id_param !== null && $id_param !== '' ? $id_param : GAS_PULL_DEFAULT_ID;
    // すでに token は入っている前提。id を追加する
    return add_query_arg(['id' => $id], GAS_PULL_FEED);
}

/** ===== 取得&反映ロジック(下書き既定) ===== */
function gas_pull_run_once($override_id = null) {
    $feed_url = gas_build_feed_url($override_id);

    $res = wp_remote_get($feed_url, ['timeout' => 20]);
    if (is_wp_error($res)) { gas_log('ERROR', 'wp_remote_get error', ['err'=>$res->get_error_message(), 'url'=>$feed_url]); return; }

    $items = json_decode(wp_remote_retrieve_body($res), true);
    if (!is_array($items)) { gas_log('ERROR','invalid json', ['body'=>wp_remote_retrieve_body($res)]); return; }

    foreach ($items as $it) {
        $ext_id = isset($it['id']) ? sanitize_text_field($it['id']) : '';
        if (!$ext_id) { gas_log('WARN','skip: no id'); continue; }

        // ====== 本文整形(保険つき)======
        $raw = isset($it['content']) ? (string) $it['content'] : '';
        $clean_content = wp_kses_post($raw);
        // ====== 本文整形ここまで ======

        // 受け取り可能な追加フィールド
        $incoming_status = isset($it['status']) ? sanitize_key($it['status']) : '';
        $incoming_type   = isset($it['type'])   ? sanitize_key($it['type'])   : 'post'; // ← post/page を想定
        $incoming_slug   = isset($it['slug'])   ? sanitize_title($it['slug'])  : '';

        $allowed_status = ['draft','publish','pending','future','private'];
        if (!in_array($incoming_status, $allowed_status, true)) {
            $incoming_status = 'draft';
        }
        if ($incoming_type !== 'page' && $incoming_type !== 'post') {
            $incoming_type = 'post';
        }

        // 既存検索(post/page両方見に行く)
        $q = new WP_Query([
            'post_type'   => 'any',
            'meta_key'    => GAS_PULL_META_KEY,
            'meta_value'  => $ext_id,
            'post_status' => ['draft','publish','future','pending','private']
        ]);

        $postarr = [
            'post_title'   => wp_kses_post($it['title'] ?? '(no title)'),
            'post_content' => $clean_content,
            'post_status'  => $incoming_status,
            'post_type'    => $incoming_type,
        ];

        if ($q->have_posts()) {
            // 既存更新
            $postarr['ID'] = $q->posts[0]->ID;

            // スラッグが来ていれば更新(不要ならこの行をコメントアウト)
            if ($incoming_slug) {
                $postarr['post_name'] = $incoming_slug;
            }

            $post_id = wp_update_post($postarr, true);
            if (is_wp_error($post_id)) {
                gas_log('ERROR','update failed', ['err'=>$post_id->get_error_message(), 'ext_id'=>$ext_id]);
            } else {
                gas_log('INFO','updated', ['id'=>$post_id, 'ext_id'=>$ext_id, 'type'=>$incoming_type, 'status'=>$incoming_status]);
            }
        } else {
            // 新規挿入
            if ($incoming_slug) {
                $postarr['post_name'] = $incoming_slug;
            }
            $post_id = wp_insert_post($postarr, true);
            if (!is_wp_error($post_id) && $post_id) {
                update_post_meta($post_id, GAS_PULL_META_KEY, $ext_id);
                gas_log('INFO','inserted', ['id'=>$post_id, 'ext_id'=>$ext_id, 'type'=>$incoming_type, 'status'=>$incoming_status]);
            } else {
                gas_log('ERROR','insert failed', ['err'=>is_wp_error($post_id)?$post_id->get_error_message():$post_id, 'ext_id'=>$ext_id]);
            }
        }
    }
}

/** ===== WP-Cron (有効化時: 5分後に1回、以後60分ごと) ===== */
register_activation_hook(__FILE__, function(){
    if (!wp_next_scheduled('gas_pull_cron_hook')) {
        add_filter('cron_schedules', function($s){ $s['hourly_custom'] = ['interval'=>3600,'display'=>__('Every 60 Minutes')]; return $s; });
        wp_schedule_event(time()+300, 'hourly_custom', 'gas_pull_cron_hook');
    }
});
register_deactivation_hook(__FILE__, function(){
    $ts = wp_next_scheduled('gas_pull_cron_hook');
    if ($ts) wp_unschedule_event($ts, 'gas_pull_cron_hook');
});
add_filter('cron_schedules', function($s){ $s['hourly_custom'] = ['interval'=>3600,'display'=>__('Every 60 Minutes')]; return $s; });
add_action('gas_pull_cron_hook', function(){ gas_pull_run_once(null); });

/** ===== 手動実行(URLで叩ける) ===== */
/* 例:
   https://example.com/?gas_pull_run=1&key=tkn_5zX8hJq2LmR9vTnY       ← 既定ID
   https://example.com/?gas_pull_run=1&key=tkn_5zX8hJq2LmR9vTnY&id=2  ← この回だけID=2
*/
add_action('init', function(){
    if (isset($_GET['gas_pull_run']) && isset($_GET['key']) && $_GET['key'] === GAS_TOKEN) {
        $id = isset($_GET['id']) ? sanitize_text_field($_GET['id']) : null;
        gas_pull_run_once($id);
        nocache_headers();
        header('Content-Type: text/plain; charset=utf-8');
        echo 'GAS pull executed. (id=' . ($id ?: GAS_PULL_DEFAULT_ID) . ')';
        exit;
    }
});

プラグイン導入方法

ここでは、作成したプラグインをWordPressに配置し、有効化する手順を解説します。

1.サーバーへ格納

  • /wp-content/plugins/
  1. 作成したプラグインフォルダ(例: gas-auto-post)を用意
  2. サーバー上の以下のディレクトリにアップロード
/wp-content/plugins/
  └── gas-auto-publisher/
       └── gas-pull-auto.php

アップロード後、WordPress側で認識される状態になります。

2. プラグインの有効化

  1. WordPress管理画面へログイン
  2. 左メニュー「プラグイン」→「インストール済みプラグイン」
  3. 作成したプラグインが一覧に表示されるので「有効化」をクリック

以上で、WordPress側の準備は完了です。

GASコード作成

GAS 側では、(A) HTML本文を生成する関数(B) doGet() でJSONを返すエンドポイント の2つを用意します。GASの役割は「投稿に必要な最小情報(タイトル・本文など)をJSONで返す」ことです。

(A) HTML本文を生成する関数(クリックして開く)
/**
 * exportArticlesHtml(kwd対応版)
 * - 投稿シートを読み込み、WP本文HTMLを生成
 *
 * 投稿シート列
 *   B:タイトル
 *   C:要約
 *   D:媒体
 *   E:URL
 *   F:kwd_en
 *   G:kwd_jp
 *   H:kwd_desc
 *   I:title_en
 */
function exportArticlesHtml() {
  const SHEET_NAME = '投稿';
  const HEADER_ROW = 1;
  const START_ROW  = HEADER_ROW + 1;

  const ss = SpreadsheetApp.getActive();
  const sh = ss.getSheetByName(SHEET_NAME);
  if (!sh) throw new Error(`シートが見つかりません: ${SHEET_NAME}`);

  const lastRow = sh.getLastRow();
  if (lastRow < START_ROW) return '';

  // B〜I(8列)を取得(列B=2)
  const values = sh.getRange(START_ROW, 2, lastRow - HEADER_ROW, 8).getValues();

  // 整形
  const items = values
    .map(([title, summary, source, url, kw_en, kw_jp, kw_desc,title_en]) => ({
      title:   String(title   || '').trim(),
      summary: String(summary || '').trim(),
      source:  String(source  || '').trim(),
      url:     String(url     || '').trim(),
      kw_en:   String(kw_en   || '').trim(),
      kw_jp:   String(kw_jp   || '').trim(),
      kw_desc: String(kw_desc || '').trim(),
      title_en: String(title_en || '').trim(),

    }))
    .filter(r => r.title && r.url);

  // HTML エスケープ
  const esc = s => String(s)
    .replaceAll('&','&amp;')
    .replaceAll('<','&lt;')
    .replaceAll('>','&gt;')
    .replaceAll('"','&quot;')
    .replaceAll("'",'&#39;');

  // 件数
  const header = `<p class="news-count">件数:${items.length}</p>`;

  // 記事単位HTML
  const rows = items.map((it, idx) => {
    const title  = esc(it.title);
    const source = esc(it.source);
    const url    = esc(it.url);
    const title_en    = esc(it.title_en);

    const summaryHtml = esc(it.summary).replace(/\n/g, '<br>');

    // --- ✅キーワード表示 ---
    let kwHtml = '';
    if (it.kw_en || it.kw_jp || it.kw_desc) {
      kwHtml = `
    <div class="news-keywords">
      ${it.kw_en   ? `<p><strong>Keyword(EN):</strong> ${esc(it.kw_en)} (${esc(it.kw_jp)})</p>` : ''}
      ${it.kw_desc ? `<p><small>${esc(it.kw_desc)}</small></p>` : ''}
    </div>`;
    }

    return `
<article class="news-card" aria-label="記事 ${idx + 1}">
  <h2 class="news-title">${title}</h2>
  <b>${title_en}</b>
  ${source ? `<div class="news-meta">引用元:<a href="${url}" target="_blank" rel="noopener noreferrer">${esc(source)}</a></div>` : ''}
  ${summaryHtml ? `<p class="news-summary">${summaryHtml}</p>` : ''}
  ${kwHtml}
</article>`;
  }).join('\n');

  const content = header + '\n' + rows;
  Logger.log(content);

  return content;
}
(B) doGet() でJSONを返すエンドポイント(クリックして開く)

// 設定 /
var FEED_TOKEN = ‘xxxx’; // テスト用トークン
var SHEET_ID = ‘yyyyy’; // 投稿設定/ログ格納先SS

/ 共通: 投稿設定ロード /
function getPostConfig_(postId) {
var SHEET_NAME = ‘投稿設定’;
var sh = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME);
if (!sh) throw new Error(‘シート「投稿設定」が見つかりません’);

var values = sh.getDataRange().getValues();
if (!values.length) throw new Error(‘投稿設定が空です’);

// 1行目:ID行(例: A1=「投稿パターン」, B1=1, C1=2 …)
var headerRow = values[0];
// 指定IDの列インデックスを探す(なければB列=1にフォールバック)
var wanted = (postId != null && postId !== ”) ? String(postId).trim() : ”;
var colIndex = -1;
for (var c = 1; c < headerRow.length; c++) {
if (String(headerRow).trim() === wanted) { colIndex = c; break; }
}
if (colIndex === -1) colIndex = 1; // 既定はB列(ID=1)

// A列のキー → 対応列の値 でオブジェクト化(2行目以降)
var cfg = {};
for (var r = 1; r < values.length; r++) {
var key = String(values[r][0]).trim();
if (!key) continue;
cfg[key] = values[r][colIndex];
}
return cfg;
}

/ JSONフィード(WPが取りに来る) /
// 例: https://script.google.com/…/exec?token=…&id=1
function doGet(e) {
if (!e || e.parameter.token !== FEED_TOKEN) {
return ContentService.createTextOutput(‘Unauthorized’)
.setMimeType(ContentService.MimeType.TEXT);
}

// どの投稿パターンIDの列を使うか(?id=1 等)
var postId = e.parameter.id; // 未指定OK(B列=1を使う)

// 投稿設定を取得
var cfg = getPostConfig_(postId);

// 本文生成(あなたの既存関数)。{content, count} 返却にも耐える
var content1 = exportArticlesHtml();
if (typeof content1 === ‘object’ && content1 && typeof content1.content === ‘string’) {
content1 = content1.content;
}
content1 = String(content1 || ”);

// 不要タグを排除
content1 = content1
.replace(/?<\/style>/gi, ”) .replace(/?<\/script>/gi, ”)
.replace(/]+rel=[“‘]stylesheet[“‘][^>]*>/gi, ”)
.trim();

// 投稿設定の項目を反映
var titleBase = cfg[‘タイトル’] ? String(cfg[‘タイトル’]).trim() : ‘無題’;
var statusRaw = (cfg[‘Status’] || ”).toString().toLowerCase(); // publish / draft など
var status = (statusRaw === ‘publish’ || statusRaw === ‘draft’) ? statusRaw : ‘draft’;
var slug = (cfg[‘slug’] || ”).toString().trim(); // 任意
var headerText = (cfg[‘ヘッダー文’] || ”).toString();
var footerText = (cfg[‘フッター文’] || ”).toString();
var htmlPattern = (cfg[‘htmlパターン’] || ”).toString(); // 任意(必要ならcontent生成側で利用)

// ヘッダー/フッターを本文へ(必要な場合だけ)
var finalContent = content1;
if (headerText) finalContent = headerText + ‘\n\n’ + finalContent;
if (footerText) finalContent = finalContent + ‘\n\n’ + footerText;

// タイトルに時刻を付す(従来仕様)
var d = new Date();
var formatted = d.getFullYear() + ‘/’ + (d.getMonth() + 1) + ‘/’ + d.getDate() + ‘ ‘ + d.getHours() + ‘時’;
var title = titleBase + ‘(’ + formatted + ‘現在)’;

// ID の決め方:
// 既存の “同じIDなら更新扱い” 仕様に合わせ、slug があれば slug を、なければ titleBase を使用
// 必要なら別ルールに変更してください
var feedId = slug || titleBase;

var item = {
id: feedId,
title: title,
content: finalContent,
status: status,
// 形式(Post/Page)を渡す必要があるなら、WP側の取り込みで解釈できるよう
// 任意フィールドとして追加(WPプラグイン側対応が必要)
type: String(cfg[‘形式(Post/Page)’] || ”).toLowerCase(), // ‘post’ | ‘page’
slug: slug
// , html_pattern: htmlPattern
};

return ContentService.createTextOutput(JSON.stringify([item]))
.setMimeType(ContentService.MimeType.JSON);
}

/ WP→GAS ログ受け(POST) /
function doPost(e) {
var SHEET_NAME = ‘logs’;
try {
var data = JSON.parse(e.postData.contents || ‘{}’);
if (data.token !== FEED_TOKEN) {
return ContentService.createTextOutput(‘Unauthorized’).setMimeType(ContentService.MimeType.TEXT);
}
var sh = SpreadsheetApp.openById(SHEET_ID).getSheetByName(SHEET_NAME) ||
SpreadsheetApp.openById(SHEET_ID).insertSheet(SHEET_NAME);
sh.appendRow([
new Date(),
data.level || ‘INFO’,
data.message || ”,
(typeof data.context === ‘string’) ? data.context : JSON.stringify(data.context || {})
]);
return ContentService.createTextOutput(‘ok’).setMimeType(ContentService.MimeType.TEXT);
} catch (err) {
return ContentService.createTextOutput(‘error’).setMimeType(ContentService.MimeType.TEXT);
}
}


レスポンス仕様

今回の doGet(e) は、配列(Array)で1件以上の投稿オブジェクトを返す 形になっています。WordPress 側のプラグインは、この配列をそのまま json_decode してループ処理します。

返却オブジェクトの主なフィールドは次のとおりです(コード準拠)。

  • id重複判定用の一意キー。本実装では「slug があれば slug、なければ titleBase」を使用します。
  • title … タイトル。タイトル 設定値に YYYY/M/D H時** の時刻を付与** して生成します。
  • content … 記事本文の HTML 文字列。exportArticlesHtml() の結果に、必要に応じて ヘッダー/フッター文 を前後に連結し、<style> / <script> / stylesheet <link> を除去してから返します。
  • status … 投稿ステータス。投稿設定Statuspublish / draft)を元に、不正値は draft** にフォールバック**。
  • type … 投稿タイプ。形式(Post/Page) を小文字化して設定(post / page)。不正値は post
  • slug … 任意。指定がある場合は WP 側でスラッグに反映されます。
返却例
[
  {
    "id": "silentnews",                  
    "title": "しずかなニュース(2025/11/10 10時現在)",
    "content": "&lt;p class=\"news-count\">件数:20&lt;/p>
&lt;article class=\"news-card\" ...>…&lt;/article>",
    "status": "draft",
    "type": "post",
    "slug": "silentnews"
  }
]
備考
  • 重複回避:WP プラグインは id_external_id メタに保存し、同一 id が来た場合は 更新モード に切り替えます。
  • スラッグの扱いslug が渡っている場合のみ、新規挿入時/更新時ともに post_name** を更新**します(コード上で明示)。
  • 安全化:本文は WordPress 側で「許可されたタグだけ」に整えて保存され、GAS 側でも <style><script><link rel="stylesheet"> などを事前に取り除いています。

デプロイ

GAS を Webアプリ として公開し、取得用のURLを発行します。再デプロイするとURLが変わる 点に注意してください(WordPress側の設定も更新が必要)。

1. 新規デプロイ

  1. GASエディタ右上 [デプロイ] → [新しいデプロイ]
  2. 種類:ウェブアプリ を選択
  3. 説明:任意(例:WP自動投稿API v1)
  4. 実行するユーザー:自分(自分の権限でシート等へアクセス)
  5. アクセスできるユーザー全員 または リンクを知っている全員(WPサーバーから取得できるように)
  6. デプロイ を押す → Web アプリのURL を控える

2. URLをWordPress側へ設定

  • 取得した WebアプリURL を、プラグインの設定値(例:GAS_PULL_FEED)に貼り付けます。
  • 簡易トークンを使う場合:https://script.google.com/.../exec?token=xxxx のようにプラグイン側と合わせておく。

3. 再デプロイ時の注意

  • コードを修正して 再デプロイ すると、URLが変わる 場合があります(GAS側の仕様)。
  • 運用上の負担を減らすには、同じデプロイを更新する運用、または リバースプロキシ/中継URL を用意して、WP側は固定URLを見る構成も検討できます。

動作確認

ここまで準備ができたら、実際に WordPress 側で投稿が作成されるかを確認します。

1. 手動で実行

下記URLにアクセスすると、GAS 側のデータを即時に取得できます。
https://example.com/?gas_pull_run=1&key=xxxx

  • example.com → あなたの WordPress ドメイン
  • key=xxxxGAS_TOKEN に設定した値

アクセスしてしばらくすると、

GAS pull executed. (id=1)

のようなテキストが返り、処理が完了します。

2. WordPress 管理画面で確認

  1. WordPress 管理画面にログイン
  2. 左メニュー → 投稿(または固定ページ)
  3. 新しい投稿が作成されているか確認
    ※ステータス draft の場合は 下書き として保存されています。

問題なく作成されていれば成功です。


まとめ

本記事では、GASで生成したJSONをWordPress側のプラグインで受け取り、自動投稿する仕組みを解説しました。この方式を採用することで、WordPress側での認証設定が簡略化され、構造がシンプルになるだけでなく、投稿・更新の制御をすべてWordPress側で行える点が大きな利点です。また、GAS側で取得したデータを自由に加工し、HTMLとして整形してから返すため、用途に応じた柔軟な表現が可能になります。

さらに、この仕組みは 「定期的な情報の更新」や「外部コンテンツの自動収集・公開」 といったユースケースに向いており、ニュースまとめ、スクレイピング結果の公開、社内データの定期反映など、幅広く応用できます。

次回は、今回構築した仕組みを定期的に実行し、完全に自動化する方法を紹介します。運用をよりスムーズにしたい方は、ぜひ続けてご覧ください。