英語日記をAIに添削してもらいWordPressに投稿する

2024.03.27

はじめに

私は英語日記を書いてWordPressに投稿していますが、ただ書くだけでは自分の英語が合っているのか、自然なのかわかりません。これまでは、WordPressで記事を書く⇒書いた文章をChatGPTで添削してもらう⇒添削結果をWordPressの記事に追記するという流れで行っていました。しかし、手動での作業が多く手間なので、AIの添削からWordPressへの投稿までを自動で行う仕組みを作成したいと思います。

前提

  • GitHub Actionsで自動化を行います
  • Node.jsや関連モジュールのインストール、OpenAIのAPI KEY発行などは済んでいるものとします
  • WordPressはエックスサーバーでホスティングしています

成果物

最終的に出来上がるのはこのようなものです。

Visual Studio Codeでマークダウン形式で英語日記を書きます。(添削してもらうために不自然な英語を書いています)

---
year: 2024
month: 03
day: 19
title: development
---

Tuesday, 19 March, 2024

Today is very cold.

I have orange and bread for breakfast. I have curry for lunch. I will going to have pasta for dinner.

By the way, I want to eat cakes and scorns very much now.

masterブランチにpushすると、GitHub Actionsが実行されます。

実行が完了すると、AIによる添削結果とともに記事がWordPressに投稿されます。

Automatic Correction Results by AI
The correction results are as follows:

Overall Comments
Your diary entry is quite good! Just a few corrections needed for grammar and spelling. Keep up the good work in writing English diaries!

Revised Diary
Tuesday, 19 March 2024

Today is very cold.

I had orange and bread for breakfast. I had curry for lunch. I will be having pasta for dinner.

By the way, I really want to eat cakes and scones right now.

Explanation of Corrections
1. Changed “I have” to “I had” for past tense consistency.
2. Changed “will going to have” to “will be having” for future tense.
3. Corrected the spelling of “scones.”
4. Used “really want” to express a stronger desire for cakes and scones. (complete)

↓Google翻訳したもの

AIによる自動補正結果
修正結果は以下の通りです。

全体的なコメント
あなたの日記はとてもいいですね! 文法とスペルを少し修正するだけです。 これからも頑張って英語日記を書いてください!

改訂日記
2024 年 3 月 19 日火曜日

今日はとても寒いです。

朝食にオレンジとパンを食べました。 お昼にカレーを食べました。 夕食にパスタを食べます。

ところで、今すごくケーキとスコーンが食べたいです。

訂正内容の説明
1. 過去形の一貫性を保つために、「私は持っています」を「私は持っていました」に変更しました。
2. 未来形の「will going to have」を「will be getting」に変更しました。
3.「スコーン」のスペルを修正しました。
4. ケーキやスコーンに対する強い欲求を表現するために「本当に欲しい」を使用します。 (完了)

WordPress REST APIを使えるようにする

アプリケーションパスワードの取得

WordPressに記事を自動的に投稿するには、WordPress REST APIを使います。以前はプラグインとして提供されていたようですが、現在は標準機能として使えます。

REST API Handbook | Developer.WordPress.org

記事の一覧表示や取得は認証不要で実行できますが、投稿や更新をする場合は認証が必要になります。認証方法はいくつかありますが、WordPressのバージョン5.6から導入されたアプリケーションパスワードを使います。

まず、WordPressにログインします。

左端のメニューのUsers⇒All Usersを選択し、ユーザ名をクリックします。

下の方にスクロールすると、Application Passwordsというセクションがあります。任意のわかりやすい名前を入力し、「Add New Application Password」をクリックします。

クリックすると、パスワードが表示されるので、安全な場所にコピーします。この画面を閉じると、後から取得することはできないので注意してください。(コピーし忘れた場合は、Revokeして新たに取得し直します)

エックスサーバーのアクセス制限解除

続いてエックスサーバーのREST API アクセス制限を解除します。国内からWordPressを利用する分にはONのままで問題ありませんが、今回はGitHub Actionsから利用するため解除します。

エックスサーバーのサーバーパネルにログインし、「WordPressセキュリティ設定」をクリックします。

ドメインを選択します。

REST APIアクセス制限を「OFFにする」を選択し、設定します。

これでWordPress REST APIを使った自動投稿の準備はできました。

添削して投稿するスクリプトの作成

続いてGitHub Actionsから実行するJavaScriptファイルを作成します。このファイルでやりたいことは以下です。

  • 更新のあったmdファイルの中身を取得する
  • 日記の本文をOpenAIのAPIに渡して添削結果を返してもらう
  • 元の日記と添削結果を1記事としてWordPressに投稿する

更新のあったmdファイルの中身を取得する

更新のあったファイルの取得はGitHub Actionsのワークフロー内で行い、環境変数を介してJavaScriptファイルに渡されるようにします。

そのためJSファイル内では以下のようにして中身を取得します。

const mdContent = process.env.MD_CONTENT

// front matterを除いた本文を取得
const originalContent = mdContent.replace(/---[\s\S]*---/, '')
// front matter部分からyear, month, day, titleを取得
const year = mdContent.match(/year: (\d{4})/)[1]
const month = mdContent.match(/month: (\d{1,2})/)[1].toString().padStart(2, '0')
const day = mdContent.match(/day: (\d{1,2})/)[1].toString().padStart(2, '0')
const title = mdContent.match(/title: (.*)/)[1]

今回作成する日記ファイルでは、Front Matterにタイトルや日付を記載しているため、正規表現を用いて情報を取得するようにしました。

日記の本文をOpenAIのAPIに渡して添削結果を返してもらう

日記をOpenAIに添削してもらう関数は以下のようにしました。

/**
 * 自動添削を行い結果を返す
 * @param {string} originalContent
 * @returns
 */
const getCorrection = async (originalContent) => {
  const OpenAiSource = new OpenAI({
    apiKey: process.env.OPENAI_API_KEY,
  })

  const system_message = `
You are an American English conversation instructor whose native language is English. Please provide feedback on English diaries written by students.
Check the diaries for correct grammar, absence of spelling errors, and natural-sounding English.
Please reply with the correction results in the following format. The parts indicated by {} are the areas you need to correct. Please leave the other parts unchanged and reply as they are.

<h2 class="wp-block-heading">Automatic Correction Results by AI</h2>
The correction results are as follows:
<h3 class="wp-block-heading">Overall Comments</h3>
{Write feedback or comments to the student about the content of the diary in English from the perspective of an English conversation teacher.}
<h3 class="wp-block-heading">Revised Diary</h3>
{Please write the entire revised diary in English here.}
<h3 class="wp-block-heading">Explanation of Corrections</h3>
{Please provide explanations for the corrections in English. Make the explanations clear and easy to understand, including relevant knowledge and native customs to help beginner English learners in their future studies.}
`

  const user_message = `英語日記の本文は「${originalContent}」です。`
  const messages = [
    {
      "role": "system", "content": system_message
    },
    { "role": "user", "content": user_message },
  ]

  const completion = await OpenAiSource.chat.completions.create({
    messages,
    model: "gpt-3.5-turbo",
  });
  const status = completion?.choices[0].finish_reason === 'stop' ? 'complete' : 'incomplete'
  return `${completion.choices[0].message.content} (${status})`
}

プロンプトは、以下の文章をChatGPTに英訳してもらったものです。

あなたは、英語を母国語とするアメリカ人英会話講師です。生徒が書いた英語日記の添削をしてください。
添削は、文法が正しいか、スペルミスがないか、自然な英語になっているか、という観点でチェックしてください。
添削結果は、以下の形式で返信してください。{}で示した箇所はあなたが修正する箇所です。その他の部分は全く変えずにそのまま返信してください。
<h2 class="wp-block-heading">AIによる自動添削結果</h2>
添削結果は、以下の通りです。
<h3 class="wp-block-heading">総評</h3>
{ここに日記の内容について、英会話教師が生徒に向けた感想や意見などを英語で書いてください。}
<h3 class="wp-block-heading">修正後の文章</h3>
{ここに修正後の日記全体を英語で書いてください。}
<h3 class="wp-block-heading">修正箇所の説明</h3>
{ここに修正箇所の説明を英語で行ってください。英語初心者の今後の学習に役立つように、関連する知識やネイティブの慣習なども含めつつわかりやすく説明をしてください。}

当初は日本語で返してもらった方がわかりやすいかと思いましたが、英語の方が少ないトークン数で多くの情報を格納できるので英語にしました。(英会話教室という設定なら、日記の添削も英語で行うはずですし)

また、日記の長さや添削の量によっては途中でレスポンスが途切れる可能性がありますが、コスト面を考慮して続きを取得することはせずに途切れたままにすることにしました。その代わりに、結果が全て返された場合はcomplete、途切れた場合はincompleteという単語を最後につけて、完全なものかどうかを後から判断できるようにしました。 レスポンスのオブジェクトは以下のようになっており、finish_reasonstopの場合は自然な停止、lengthの場合はトークン数オーバーなど、停止の理由が格納されています。

{
  "id": "chatcmpl-123",
  "object": "chat.completion",
  "created": 1677652288,
  "model": "gpt-3.5-turbo-0125",
  "system_fingerprint": "fp_44709d6fcb",
  "choices": [{
    "index": 0,
    "message": {
      "role": "assistant",
      "content": "\n\nHello there, how may I assist you today?",
    },
    "logprobs": null,
    "finish_reason": "stop"
  }],
  "usage": {
    "prompt_tokens": 9,
    "completion_tokens": 12,
    "total_tokens": 21
  }
}

OpenAI Platform

元の日記と添削結果を1記事としてWordPressに投稿する

元の日記とOpenAIに添削してもらった内容を元にWordPressに投稿します。

新規記事として投稿する場合もありますが、既存の記事を修正する場合もありえます。日記なので、同じ日付で投稿した場合は同じ日に複数の記事が作成されるのではなく、既にある記事を上書き更新したいと思いました。

そこで、まずは既に記事が存在しているかを確認します。

/**
 * 既存の記事を取得しあればIDを返す
 * @param {string} year
 * @param {string} month
 * @param {string} day
 * @returns
 */
const getExistingPost = async (year, month, day) => {
  const response = await fetch(`https://WordPressドメイン名/wp-json/wp/v2/posts?after=${year}-${month}-${day}T00:00:00&before=${year}-${month}-${day}T23:59:59`)
  const data = await response.json()
  if (data.length > 0) return data[0].id
  return undefined
}

WordPress REST APIには記事のIDを指定して1記事を取得するRetrieve a PostというAPIもありますが、記事IDがわからないため、List PostsというAPIを使って日付で記事を特定します。

Posts – REST API Handbook | Developer.WordPress.org

Front Matterの日付を元に、クエリパラメータのafterbeforeを指定して、記事が取得できるかどうかを確認します。取得できた場合は記事IDを返します。

既存の記事があるかどうかを確認できたら、記事を投稿します。

const headers = {
  "Content-Type": "application/json",
  Authorization:
    "Basic " + Buffer.from(`${process.env.WP_USERNAME}:${process.env.WP_APPLICATION_PASSWORD}`).toString("base64"),
}

/**
 * wordpressに投稿する
 * @param {number} id
 * @param {string} title
 * @param {string} postBody
 */
const postToWordpress = async (id, title, postBody) => {
  const path = id ? `/${id}` : ""
  const body = JSON.stringify({
    title: title,
    content: postBody,
    status: "publish",
  });
  const response = await fetch(`https://WordPressドメイン名/wp-json/wp/v2/posts${path}`, {
    method: "POST",
    headers,
    body,
  });
  console.log(response.status)
}

記事を新規投稿する場合と更新する場合の違いは、パスパラメータにidがあるかどうかです。そこで、idが取得できた場合のみエンドポイントにパスパラメータを追加するようにしました。これにより、その日にまだ投稿されていない場合は新規投稿し、既に投稿されている場合は更新することができます。

ヘッダーにはBasic認証でWordPressのユーザ名、上で取得したアプリケーションパスワードを使用してAuthorizationを作成します。

リクエストボディのstatusで公開状態を制御できます。publishfuturedraftpendingprivateが指定できます。今回は公開された状態で投稿したいのでpublishを指定します。

後はこれらの関数を呼び出すだけです。

getCorrection(originalContent).then(async correctionResult => {
  const id = await getExistingPost(year, month, day)
  await postToWordpress(id, title, originalContent + "\n" + correctionResult)
})

GitHub Actionsワークフローの作成

ワークフローは以下のようにしました。

name: Correction and Posting
on:
  push:
    branches: [master]

jobs:
  job:
    name: Correction and Posting
    runs-on: ubuntu-latest

    steps:
      - name: Checkout Repository
        uses: actions/checkout@v4
        with:
          fetch-depth: 0

      - name: Set Up Node.js
        uses: actions/setup-node@v4
        with:
          node-version: latest

      - name: Install Dependencies
        run: npm install

      - name: Run Script
        run: |
            for file in $(git diff ${{github.event.before}}..${{github.event.after}} --name-only -- '*.md') ; do
              # ファイルの内容を環境変数に設定
              export MD_CONTENT=$(cat $file)
              # 添削と投稿
              node ./scripts/post.js
            done
        env:
          GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
          OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
          WP_USERNAME: ${{ secrets.WP_USERNAME }}
          WP_APPLICATION_PASSWORD: ${{ secrets.WP_APPLICATION_PASSWORD }}

日記を投稿するだけで特にブランチを分ける予定はないので、masterブランチにpushされたときに実行されます。

Run Scriptのところでは差分のあったファイルのうち日記のファイル(.mdファイル)のみを取得し、内容を環境変数MD_CONTENTに設定します。OPENAI_API_KEYWP_USERNAMEWP_APPLICATION_PASSWORDについてはあらかじめリポジトリのシークレットに登録しておきます。

こうすることにより、上で作成したJavaScriptファイルに必要な情報を渡して実行させることができました。

おわりに

手動での作業が面倒で続かなかった日記も、これできっと毎日更新されるはずです。

この記事がどなたかの参考になれば幸いです。