Google Apps ScriptでWordPressの記事修正や投稿をする【GAS】

前回記事では、GASを使っての既存の記事の一覧の取得および単発記事の中身を取得するものを作成しました。今度は逆に、記事の内容に対して「文章校正」をしたもので更新を掛けたり、生成AIであるGeminiに対して記事のテーマだけ与えて、タイトルおよび記事のディテールを作成するなどをしてみました。

どちらも若干不安定な部分があれど、概ね期待通りの結果が得られているので、とりあえず良しとしまとめてみました。

今回使用する素材

今回は文章校正および記事の生成という部分はGeminiを利用しています。WordPress的な観点から重要なのは新規に記事の投稿をする、既存の記事を更新するの2点になるので、前回記事とは対になる存在です。新規の記事投稿は公開までは行わず、下書きの状態で投稿します。

前回記事を踏まえた内容となるので、アプリケーションパスワードの生成などは以下のエントリーに従って作っておく必要があります。

Google Apps ScriptでWordPressの投稿記事を取得する【GAS】

事前準備

APIキーの取得

GeminiのAPIキーが必要です。以下のエントリーに独立してまとめています。課金されますので利用のしすぎには要注意。また課金されていない場合、学習に利用されてしまう恐れがあるので、きちんとGoogle Cloud上で課金アカウントとの紐付けなどをしておきましょう。

GeminiのAPIキーの取得と学習の可否

スクリプトプロパティに値を格納

前述までに取得しておいたGeminiのAPIキーについて、GASのスクリプトプロパティに値をセットします。前回記事で使ったプロパティはそのまま生かしていますので、それ以外のスクリプトプロパティについては前回記事を参照のこと。

  • apikey : Gemini APIのAPIキー

図:スクリプトプロパティに格納する

テンプレートのHTMLを作成する

今回は、このブログでよく使うパターンに合わせた出力用の下敷きとなるテンプレートのHTMLをリクエストに含めてGeminiに新規に記事の作成をやらせています。

このブログの記事の校正はちょっと特殊で、HeaderタグはH2から使うようにしている点と、冒頭に概要、利用する素材、本文、そして関連リンクという構成で、本文はテーマに合わせていくつかのヘッダ分けを構成して作っています。よって、以下のようなテンプレートを用意しています。これをindex.htmlとしてGAS側で用意します。

{冒頭の概要}
<h2><strong>今回利用する素材</strong></h2>
&nbsp;
&nbsp;
<h2><strong>本文</strong></h2>
{本文}

<h2><strong>関連リンク</strong></h2>
<ul>
  <li><a href="https://www.google.com/" target="_blank" rel="noopener"><strong>テストの記事</strong></a></li>
</ul>

コードと実行結果

ソースコード

グローバル設定

前回記事でもTXT生成先のフォルダのIDや、WordPressのURLなどのグローバル変数に加えて、Geminiを利用するので以下のようなエンドポイントURLを新規に追加しています。

//Gemini 2.5エンドポイントURL
const endpoint = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.5-pro:generateContent?key=";

下書き投稿用の関数

Geminiで新規記事作成の項目から呼び出されて利用されます。WordPressに対して記事のタイトルと記事の内容(HTMLテンプレートにより生成された内容)をもってしてリクエストを行い、下書きとして保存します。

またスプシの最終行に下書き記事として1行追加されるようにしています。

//下書きとして新規に投稿する関数
function postNewDraftFromArgument(postTitle, postContent) {
  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();

  try {
    // 引数が空でないかチェック
    if (!postTitle || !postContent || typeof postContent !== 'string') {
      throw new Error('タイトルまたは投稿内容が空か、無効な形式です。');
    }

    //投稿開始メッセージ
    spreadsheet.toast('WordPressへの投稿とDriveへのファイル作成を開始します...', '処理中', -1);

    //ファイル名をサニタイズ(ファイル名に使えない文字を削除)
    const fileName = `${postTitle.replace(/[\\/:"*?<>|]/g, '_')}.txt`;

    //指定されたフォルダにTXTファイルを作成
    const folder = DriveApp.getFolderById(FOLDER_ID); // FOLDER_IDは設定済みとします
    const file = folder.createFile(fileName, postContent, MimeType.PLAIN_TEXT);
    const newFileId = file.getId(); // ★作成したファイルのIDを取得

    //WordPressに投稿するデータを作成
    const postData = {
      'title': postTitle,
      'content': postContent,
      'status': 'draft'
    };

    //リクエストエンドポイント
    const endpoint = `${WP_URL}/wp-json/wp/v2/posts/`;

    // 認証情報を取得
    const scriptProperties = PropertiesService.getScriptProperties();
    const username = scriptProperties.getProperty('userid');
    const appPassword = scriptProperties.getProperty('apppw');

    if (!username || !appPassword) {
      throw new Error('スクリプトプロパティに「userid」と「apppw」が設定されていません。');
    }
    const encodedAuth = Utilities.base64Encode(`${username}:${appPassword}`);

    //リクエストオプション
    const options = {
      'method': 'post',
      'contentType': 'application/json',
      'headers': {
        'Authorization': `Basic ${encodedAuth}`
      },
      'payload': JSON.stringify(postData),
      'muteHttpExceptions': true
    };

    //WordPress APIにPOSTリクエストを送信
    const response = UrlFetchApp.fetch(endpoint, options);
    const responseCode = response.getResponseCode();
    const responseBody = response.getContentText();

    if (responseCode !== 201) {
      throw new Error(`下書きの投稿に失敗しました。(コード: ${responseCode})\n詳細: ${responseBody}`);
    }

    //レスポンスを取得する
    const jsonResponse = JSON.parse(responseBody);
    const newPostId = jsonResponse.id;
    const editLink = jsonResponse.link.replace('?p=', 'post.php?post=') + '&action=edit';

    //スプレッドシートの最終行に結果を追記
    const sheet = spreadsheet.getActiveSheet();
    const lastRow = sheet.getLastRow();

    // ★書き込むデータに、取得したTXTファイルIDを追加
    const newRowData = ['', '', postTitle, '', newFileId, newPostId, editLink];
    sheet.getRange(lastRow + 1, 1, 1, newRowData.length).setValues([newRowData]);

    //終了メッセージ
    spreadsheet.toast(`下書き投稿とファイル作成が完了しました。ID: ${newPostId}`, '完了', 15);

  } catch (e) {
    Logger.log(e);
    spreadsheet.toast(`エラー: ${e.message}`, 'エラー発生', 15);
  }
}

テーマに沿って記事を新規作成

ui.promptによるダイアログで、ユーザーから作成する記事のテーマを入力してもらい、それに従った記事をGeminiに作成させています。といっても記事そのものというより、index.htmlで用意した記事の雛形に対して結果を差し込み返してもらうのが目的で。そのまま使える記事を量産するのが目的ではありません。

ディテールだけ作ってもらって、自身で記事に厚みを持たせるのは人間の役目。故にかなり細かなプロンプトを指定しています。このサイト用にカスタマイズしてしまってるので、プロンプトは変更が必要になるでしょう。

記事のタイトルと差込後の本文を返してもらってるので、この後下書き投稿用の関数を呼び出してWordPressに下書き作成をさせています。

//テーマを与えてブログ記事のディテールを生成する
function blogMakerFunc(){
  //テーマの指定
  let ui = SpreadsheetApp.getUi();
  let theme = "";
  let ret = ui.prompt("ブログ記事のテーマを入れてください", 
      ui.ButtonSet.OK_CANCEL);
  
  //押されたボタンによって処理を分岐
  switch(ret.getSelectedButton()){
    //OKボタンを押した時の処理
    case ui.Button.OK:
      theme = ret.getResponseText();
      break;
    //キャンセルを押した時の処理
    case ui.Button.CANCEL:
      ui.alert("何もせずに閉じました。");
      return;
    case ui.Button.CLOSE:
      return;
  }

  //ブログを生成
  const result = JSON.parse(blogGenerator(theme));

  //結果からタイトルと記事を取り出す
  const postTitle = result.title;
  const postContent = result.blog;

  //WordPressに下書きとして追加する
  postNewDraftFromArgument(postTitle, postContent)

}

//Geminiにテーマを与えて記事を生成する
function blogGenerator(theme) {
  //プロパティの値を取得する
  let prop = PropertiesService.getScriptProperties()
  let apikey = prop.getProperty("apikey");

  //リクエストURLを構築する
  let url = endpoint + apikey;

  //スプレッドシートを取得
  let ss = SpreadsheetApp.getActiveSpreadsheet();

  //テンプレートを取得
  let template = HtmlService.createHtmlOutputFromFile('index')
            .setSandboxMode(HtmlService.SandboxMode.IFRAME).getContent();

  //プロンプトを構築する
  let prompt = `あなたはトップブロガーで、数字を稼げるブログを書き続けてるプロフェッショナルです。以下の要件に沿うテーマタイトルと、その内容に合致する
    ブログ記事を生成してください。

    #重要事項
    - 出力事例に従ったデータだけを返してください。余計な解説や説明は一切不要です。
    - 指定のHTMLテンプレートを利用し、差し込む形でHTMLを生成し、出力事例のようにJSON形式におさめて返してください。
   
    #テンプレートの構造
    - テンプレートには{冒頭の概要}という部分と、{本文}という部分があり、また関連リンクに<ul><li>を使った関連リンクの一覧があります。
    - テンプレートの構造を破壊することなくこのテンプレートを使ってデータを生成してください。
    - 生成結果のタグが<strong>などが&lt;strong&gt;となっていたり、<br>などが&lt;br&gt;といった表記になってることがあるので注意してください。必ずHTMLタグをに修正してください。
    
    #生成する内容
    - 指定のテーマに基づく、キャッチーなブログ記事のタイトルを生成し、出力事例のJSONのtitle要素に入れてください。
    - 指定のテーマに基づく、人の心に刺さるブログ記事を、出力事例のJSONのblog内にテンプレートを下敷きに生成してください。
    - {冒頭の概要}にブログ記事の概要を300字以内に要約して、差し替えてください。
    - {本文}には、いくつかの起承転結に分けたブロックを<h2>から始める形で、ブログ記事を詳細にブロックごとに作成し、差し替えてください。
    - <h2>から始まるブロックは最大で<h5>まで利用可能です。使う個数に制限はありません。わかりやすくヘッダ分けして構成を考えてください。
    - 今回利用する素材という<h2>から始まるブロックは特に何も書かないでください。
    - 関連リンクでは、指定のテーマに関連性のあるサイトをGoogle検索し、最新の実在するそのページのタイトルとURLを持って構築して差し替えてください。
  
    #制約事項
    - IT系の記事を指定のテーマに従って作成してください
    - 必要最低限の{本文}の構成は、事前準備、解説、ポイントなどをブロックにわけて、必要なものを配置してください。
    - 記事のタイトルは30文字以内に収めてください。
    - 記事の総文字数は10000字以内に収めてください。

    #最終処理
    - 出力事例に従って生成した結果をJSON.parseで処理をするので、「Bad control character in string literal」になったり、パース出来ないJSONにならないように、チェックをおこなってください
    - JSON.parseした時にエラーになるような場所があったら修正してから再生成してください。
    - 特に改行コードや特殊文字などが含まれていないようにチェックをお願いします。

    #指定のHTMLテンプレート
    ${template}
    
    #指定のテーマ
    ${theme}

    #出力事例
    {
      title : "ここにブログのタイトルを入れる",
      blog : "ここに生成したHTMLデータを入れる"
    }
  `;

  //payloadを構築する
  let payload = {
    'contents': {
      'parts': [
        {
          'text': prompt
        }
      ]
    },
    'generation_config': {
      'temperature': 0,
      'topP': 0,
      'maxOutputTokens': 14000
    }
  };

  //リクエストオプション
  let options = {
    'method': 'POST',
    'contentType': 'application/json',
    'payload': JSON.stringify(payload)
  };

  //Geminiにリクエスト
  let response = UrlFetchApp.fetch(url, options);
  let json = JSON.parse(response.getContentText());

  //結果を受け取る
  if (json && json.candidates && json.candidates.length > 0) {
    let result = json.candidates[0].content.parts[0].text;
    
    //余計な文字を除外する
    let ret = result.replace("```json","");
    ret = ret.replace("```","");
    ret = ret.replace("javascript","");
    ret = ret.replace("\n","");

    //処理完了
    return ret;
  } else {
    return false;
  }
}

文章校正して更新

記事の更新は、まず対象の記事IDの記事を事前に取得しておりファイル化済みの場合に動作するようにしています。ファイルからデータを読み取り、Geminiを利用して複数のチェック項目に従って文章のおかしな点をチェックし、本文を置き換えます。

HTMLの構造を破壊せずに本文のみを置き換えるようにしています。

記事の更新にはPUTメソッドを利用しており、POSTで実行してしまうとリビジョンが作成されずに更新されてしまうので要注意です。

//ブログ記事のデジタル校正を行う関数
function blogProofreading() {
  //スプシのUIを取得
  const ui = SpreadsheetApp.getUi();

  //スプレッドシートを取得する
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const sheet = ss.getSheetByName("記事一覧");
  const activeRange = sheet.getActiveRange();
  const activeRow = activeRange.getRow();

  // A列から記事ID、C列から記事タイトルを取得
  let postId = sheet.getRange(activeRow, 1).getValue();
  let postTitle = sheet.getRange(activeRow, 3).getValue();
  let fileId = sheet.getRange(activeRow, 5).getValue();

  // 記事IDが数値でなければ処理を中断
  if (typeof postId !== 'number' || postId <= 0) {
    ui.alert('処理中断', '選択された行のA列に有効な記事IDが見つかりません。記事IDが含まれる行を選択して実行してください。', ui.ButtonSet.OK);
    return;
  }

  //記事ファイルIDがなければ処理を中断
  if(fileId == "" || fileId == undefined || fileId == null){
    ui.alert('処理中断', '記事のファイル化がなされていません。まずは対象記事のファイル化を実行してください。', ui.ButtonSet.OK);
    return;
  }

  //生成中メッセージ表示
  ss.toast(`記事「${postTitle}」の記事を取得してファイルを作成中です...`, '作業中', -1);

  //Gemini校正後の記事の本文を取得(コード)
  const correctionContent = geminiProofreading(fileId);

  // スクリプトプロパティから認証情報を取得
  const scriptProperties = PropertiesService.getScriptProperties();
  const username = scriptProperties.getProperty('userid');
  const appPassword = scriptProperties.getProperty('apppw');

  if (!username || !appPassword) {
      throw new Error('スクリプトプロパティに「userid」と「apppw」が設定されていません。');
  }

  // Basic認証ヘッダーの作成
  const encodedAuth = Utilities.base64Encode(`${username}:${appPassword}`);

  // 共通のリクエストヘッダー
  const headers = {
    'Authorization': `Basic ${encodedAuth}`
  };

  //記事更新作業開始(PUTでリビジョン作成も実行される)
  ss.toast('更新作業中・・・', '処理中', -1);
  
  const updateEndpoint = `${WP_URL}/wp-json/wp/v2/posts/${postId}`;
  const updateOptions = {
    method: 'put',
    contentType: 'application/json',
    headers: headers,
    payload: JSON.stringify({ content: correctionContent }),
    muteHttpExceptions: true
  };

  //レスポンスを取得
  const updateResponse = UrlFetchApp.fetch(updateEndpoint, updateOptions);
  if (updateResponse.getResponseCode() !== 200) {
    throw new Error(`投稿の更新に失敗しました。(コード: ${updateResponse.getResponseCode()})\n詳細: ${updateResponse.getContentText()}`);
  }

  // 完了メッセージ表示
  ss.toast(`記事「${postTitle}」の更新が完了しました。`, '完了', 15);
}

//文章校正をGeminiで行う
function geminiProofreading(fileId){
  //プロパティの値を取得する
  let prop = PropertiesService.getScriptProperties()
  let apikey = prop.getProperty("apikey");

  //リクエストURLを構築する
  let url = endpoint + apikey;

  //fileIdから記事本文を取り出す
  try {
    // ファイルIDを使用してファイルを取得
    let file = DriveApp.getFileById(fileId);

    // ファイルがテキストファイルであることを確認(任意)
    if (file.getMimeType() === MimeType.PLAIN_TEXT) {
      // ファイルの本文を文字列として取得
      let content = file.getBlob().getDataAsString("UTF-8"); 
      
      //プロンプトを構築する
      let prompt = `あなたは、文章校正のプロです。おかしな言い回しやおかしな文字などを見つけることが得意です。校正対象文章の中で、
      以下の内容に基づいて、文章校正を行った結果を返してください。

        #重要事項
        - 校正し、チェック内容を反映した結果だけを返してください。余計な解説や説明は一切不要です。
        - 校正対象文章はWordPressで使ってるHTML形式です。このタグや構造を破壊しないように校正をして置き換えをしてください
        - 校正対象文章はWordPressで使ってるHTML形式なのでショートコードなどが入っていますが、これは校正対象外です。
        - 校正対象文章内の<pre>タグ内の内容はプログラムのコードなので、校正対象外です。

        #校正対象文章
        ${content}

        #チェックする内容
        - タイポ
        - ですます調の不統一
        - 慣用表現の言い回しも誤りチェック
        - 言葉の誤用((例:すべからくの使用例))
        - 商品名等で間違った記述
        - 句読点の使い方
        - 誤字脱字
        - 型と形のように使いまわし上の漢字の使用ミス
        - サービス名や製品名に対しては、前後に半角スペースが入ってるかチェックして、入っていなければ追加してください(例: google workspace )
        - くどい言い回しや、前後の文章と比較して連続して使われてるワード(例えば、「しかし」を連続で使ってる等は、回避すべき言い回しです)。

        #出力
        - チェック内容に基づいて校正対象文章内の該当部分を置き換えた結果をそのまま返してください。
        - チェック後の置き換えた結果内に、時々バッククォートで括られたおかしな修正点があることがあります。このような修正はしないようにしてください。
      `
      
      //payloadを構築する
      let payload = {
        'contents': {
          'parts': [
            {
              'text': prompt
            }
          ]
        },
        'generation_config': {
          'temperature': 0,
          'topP': 0,
          'maxOutputTokens': 24000
        }
      };

      //リクエストオプション
      let options = {
        'method': 'POST',
        'contentType': 'application/json',
        'payload': JSON.stringify(payload)
      };

      //Geminiにリクエスト
      let response = UrlFetchApp.fetch(url, options);
      let json = JSON.parse(response.getContentText());

      //結果を受け取る
      if (json && json.candidates && json.candidates.length > 0) {
        let result = json.candidates[0].content.parts[0].text;
        
        //余計な文字を除外する
        let ret = result.replace("\n\n","\n");

        //処理完了
        return ret;
      } else {
        return false;
      }
    } else {
      console.log('指定されたファイルはテキストファイルではありません。');
      return null;
    }
  } catch (e) {
    // エラー処理
    console.log('ファイルの取得中にエラーが発生しました: ' + e.toString());
    return null;
  }
}

実行結果

文章校正の場合には、既存の公開済みの記事に対して完全に置き換えがなされます。リクエスト結果として新しいリビジョンが作成されて履歴に残るようになっています。記事にも即反映します。

記事の新規作成の場合には、下書きの状態でWordPressに記事が投稿されるので、あとはWordPress上で作成されたディテールの内容を自身の編集で書き換えて手動で公開作業が必要になります

図:下書き投稿された様子

図:下書き投稿の結果

注意点

今回、Geminiを利用していますが、いくつか確認された問題点があります。

  • 文章校正ではバッククォートで囲まれた謎の文字が生成されることがある
  • 文章校正でおかしな改行が入ることがある。
  • 記事の新規作成では、タグがオカシナ文字に置き換わることがある(BRタグやSTRONGタグなど)。
  • 記事の新規作成では、関連リンクの作成がうまく動きません(リンク先がほぼ404エラー)
  • 記事の新規作成では、JSON.parseで失敗するケースがある。特に改行コードやHTML特殊文字などが含まれてるのが原因。

可能な限りプロンプトで調整していますが、これらの点に留意して下書き記事を修正する必要があります。

関連リンク

コメントを残す

メールアドレスが公開されることはありません。 が付いている欄は必須項目です

日本語が含まれない投稿は無視されますのでご注意ください。(スパム対策)