「メール 1 通で用語ページを公開する|Gmail・Apps Script・スプレッドシート・Python・WordPress をつなぐ」の内容を表す技術イラスト
UNIT 05自動化 FIG.00 · EYECATCH

メール 1 通で用語ページを公開する|Gmail・Apps Script・スプレッドシート・Python・WordPress をつなぐ

件名に語を書いたメールを送るだけで、AI が用語ページを書いて WordPress に公開し、URL を返信する仕組みの構成です。Gmail を Apps Script で読み、スプレッドシートを受付の列にして、PC の Python が処理します。なりすまし対策、上限、失敗時の扱い、コードをまとめます。

DOC NO.EKP-05-1376
DATE
REVA
READ約 14 分

件名に語を 1 つ書いたメールを送ると、10 分ほどで ekplabz の用語集(GLOSSARY)にその語のページが公開され、URL が返信で届く仕組みです。メールを送ったあとは、人の手を一切使いません。要点は次の 5 つです。

  • 入口は Gmail の「+」付きアドレス(`you+glossary@gmail.com`)。新しいアカウントは作らない
  • Gmail を読むのは Google Apps Script。PC に Gmail の認証情報を置かない
  • Google スプレッドシートを受付の列(キュー)にして、Gmail 側と PC 側を切り離す
  • PC の Python が受付を読み、用語を作る既存のスクリプトで AI 執筆 → WordPress REST API で公開する
  • なりすましは「送信済み(in:sent)にあるメールだけ受け付ける」で防ぎ、1 日の件数に上限を付ける
目次

構成

[スマホ・PC の Gmail] 件名「冪等性」
        │ 送信(宛先 you+glossary@gmail.com)
        ▼
[Gmail] ──5 分ごと──▶ [Apps Script] ──行を追加──▶ [スプレッドシート queue]
                                                        │
                              [PC の Python] ◀──読む────┘
                                   │ glossary.py make(OpenAI で執筆)
                                   ▼
                          [WordPress REST API] 用語ページを公開
                                   │ URL をシートに書き戻す
                                   ▼
[Apps Script] ──返信──▶ [Gmail]「公開しました: 冪等性 https://…」
部品役割動く場所認証
Gmail入口。件名が注文票Google本人のアカウント
Apps Scriptメールを拾ってシートに書く・結果を返信するGoogle本人のアカウント(初回に許可)
スプレッドシート受付の列。状態を 1 行 1 件で持つGoogle本人+サービスアカウント(編集者で共有)
Python(PC)受付を処理し、用語を作って公開するPCサービスアカウントの鍵、WordPress のアプリケーションパスワード、OpenAI の API キー
WordPress用語ページ(投稿タイプ `glossary`)を持つサーバーアプリケーションパスワード

スプレッドシートを間に置く理由

Apps Script から直接 PC は呼べず、PC から Gmail を読むには Gmail の OAuth トークンを PC に置く必要があります。シートを間に置くと、

  • Google 側(Gmail を読む)と PC 側(AI と WordPress の鍵を持つ)が、互いの認証情報を持たずに済む
  • PC が止まっていても受付はシートにたまり、起動したときにまとめて処理される
  • 1 行が 1 件の記録になり、いつ・何を・どうなったかが表で見える

PC 側は、リンク集の管理ですでに使っているサービスアカウントでシートを読み書きします。新しく増える認証情報はありません。

受付シート

スプレッドシートの最初のタブを受付にし、1 行目を見出しにします。

列見出し中身書く側
Areceivedメールの受信日時Apps Script
Bword語(件名の `/` より前)Apps Script
Cgenreジャンル(件名の `/` より後。空なら AI が選ぶ)Apps Script
Dstatus`queued` → `running` → `done` / `error`両方
Eresult公開した URL、または失敗の理由Python
FthreadGmail のスレッド ID(返信に使う)Apps Script
Gnotified返信した日時Apps Script

`status` が状態の唯一の正本です。Python は `queued` の行だけを拾い、拾った瞬間に `running` にするので、同じ行を二重に処理しません。

なりすまし対策

宛先の「+glossary」は誰でも推測できるため、宛先だけで受け付けると、外部の人のメールで AI の料金と公開が動いてしまいます。差出人(From)の欄は送る側が自由に書けるので、差出人の確認だけでも足りません。

そこで、Gmail の検索条件に `in:sent` を付けます。

in:sent to:you+glossary@gmail.com -label:glossary-done
  • 「送信済み」に入るのは、そのアカウントから実際に送ったメールだけ。外部から届いたメールは、差出人を偽っていても入らない
  • スマホの Gmail アプリから送ったメールも同じアカウントの送信済みに入るので、出先から使える
  • 処理したスレッドにはラベル `glossary-done` を付け、二度拾わない

加えて、Apps Script で 1 日の受付件数に上限(例: 20 件)を付けます。上限を超えた分はラベルを付けずに残し、翌日に受け付けます。

Apps Script(Gmail → シート、結果の返信)

スプレッドシートの「拡張機能 → Apps Script」に貼り、`poll` に 5 分ごとの時間主導トリガーを付けます。

const ALIAS = 'you+glossary@gmail.com';
const DONE = 'glossary-done';
const DAILY_MAX = 20;
const TZ = 'Asia/Tokyo';

function poll() {
  const sheet = SpreadsheetApp.getActiveSpreadsheet().getSheets()[0];
  const label = GmailApp.getUserLabelByName(DONE) || GmailApp.createLabel(DONE);
  const today = Utilities.formatDate(new Date(), TZ, 'yyyy-MM-dd');
  let count = sheet.getDataRange().getValues()
    .filter(r => r[0] instanceof Date && Utilities.formatDate(r[0], TZ, 'yyyy-MM-dd') === today).length;

  for (const t of GmailApp.search(`in:sent to:${ALIAS} -label:${DONE}`, 0, 20)) {
    if (count >= DAILY_MAX) break;
    const m = t.getMessages()[0];
    const [word, genre = ''] = m.getSubject().split('/').map(s => s.trim());
    if (word) {
      sheet.appendRow([m.getDate(), word, genre, 'queued', '', t.getId(), '']);
      count++;
    }
    t.addLabel(label);
  }
  notify(sheet);
}

function notify(sheet) {
  const rows = sheet.getDataRange().getValues();
  for (let i = 1; i < rows.length; i++) {
    const [, word, , status, result, thread, notified] = rows[i];
    if ((status === 'done' || status === 'error') && !notified && thread) {
      const head = status === 'done' ? '公開しました' : '失敗しました';
      GmailApp.getThreadById(thread).reply(`${head}: ${word}\n${result}`);
      sheet.getRange(i + 1, 7).setValue(new Date());
    }
  }
}

function install() {
  ScriptApp.getProjectTriggers().forEach(t => ScriptApp.deleteTrigger(t));
  ScriptApp.newTrigger('poll').timeBased().everyMinutes(5).create();
}
  • `install` を一度だけ手で実行するとトリガーが作られる(実行し直しても二重にならないよう、先に既存のトリガーを消す)。初回の実行で、Gmail とスプレッドシートへのアクセスの許可を求められる
  • 時間主導トリガーの間隔は 1・5・10・15・30 分から選ぶ。用語集の用途なら 5 分で足りる
  • 返信は元のスレッドに付くので、送ったメールの下に結果が並ぶ
  • 返信は自分のアドレス宛てで、「+glossary」宛てではないため、検索条件に当たらず二度拾われない
Apps Script のトリガーの編集画面。実行する関数 poll、イベントのソース 時間主導型、分ベースのタイマー、5 分おき
install() が作るトリガー(エディタの左の時計のアイコンで確認できる)

Python(シート → 用語ページの公開)

PC 側は、受付の行ごとに既存の `glossary.py make` を呼びます。用語の執筆(OpenAI)・同じ語の重複確認・WordPress への公開は、すべて `glossary.py` の中で行われます。

"""Process the glossary queue sheet: queued rows -> glossary.py make -> status and URL."""
import re
import subprocess
import sys

from google.oauth2 import service_account
from googleapiclient.discovery import build

KEY = "サービスアカウントの鍵ファイル"
SHEET_ID = "スプレッドシートの ID"
SCOPES = ["https://www.googleapis.com/auth/spreadsheets"]


def main() -> None:
    creds = service_account.Credentials.from_service_account_file(KEY, scopes=SCOPES)
    values = build("sheets", "v4", credentials=creds, cache_discovery=False).spreadsheets().values()

    def put(row: int, status: str, result: str = "") -> None:
        values.update(spreadsheetId=SHEET_ID, range=f"D{row}:E{row}", valueInputOption="RAW",
                      body={"values": [[status, result]]}).execute()

    rows = values.get(spreadsheetId=SHEET_ID, range="A2:G").execute().get("values", [])
    for row, r in enumerate(rows, start=2):
        r += [""] * (7 - len(r))
        if r[3] != "queued":
            continue
        put(row, "running")
        p = subprocess.run([sys.executable, "glossary.py", "make", r[1], "--genre", r[2] or "auto"],
                           capture_output=True, text=True, encoding="utf-8", timeout=600)
        url = re.findall(r"published \(\w+\): (\S+)", p.stdout)
        if p.returncode == 0 and url:
            put(row, "done", url[-1])
        else:
            put(row, "error", (p.stderr or p.stdout).strip().splitlines()[-1][:200])


if __name__ == "__main__":
    main()
  • サービスアカウントのアドレスを、スプレッドシートに編集者として共有しておく(読み取りだけでは状態を書き戻せない)
  • `–genre auto` は、ジャンルの候補(10 個)から AI に主ジャンルを選ばせる指定
  • 範囲をシート名なしで書く(`A2:G`)と、最初のタブが対象になる
  • Windows のタスクスケジューラで 5 分ごとに実行する。`python.exe` ではなく `pythonw.exe` で起動すると、黒い窓が出ない(`pythonw` では `print` の出力先がないので、記録はログファイルに書く)

タスクの登録は PowerShell で 1 回だけ行います。

$action = New-ScheduledTaskAction -Execute "C:\…\.venv\Scripts\pythonw.exe" -Argument "mail_queue.py" -WorkingDirectory "C:\…\glossary"
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 5)
$settings = New-ScheduledTaskSettingsSet -MultipleInstances IgnoreNew -ExecutionTimeLimit (New-TimeSpan -Minutes 30) -StartWhenAvailable
Register-ScheduledTask -TaskName "ekplabz glossary mail" -Action $action -Trigger $trigger -Settings $settings -Force
  • `-MultipleInstances IgnoreNew`: 前の回が終わっていなければ、次の回は起動しない(同じ行の二重処理を防ぐもう一段の安全策)
  • `-StartWhenAvailable`: スリープなどで実行時刻を逃したとき、復帰後すぐに実行する

実測

件名「PLA」のメール 1 通で、次のページが公開されました(ジャンルは件名で指定せず、AI が選択)。

項目結果
公開されたページPLA(読み・英語表記・一行定義・本文・関連用語つき)
AI が選んだジャンルCAD・3Dプリント
執筆 → 公開(PC 側の 1 行の処理)13 秒
返信同じスレッドに「公開しました: PLA」と URL
同じ語をもう一度送った場合`error` と「already exists」が書き戻される(既存のページは変わらない)

メールを送ってから返信が届くまでの時間は、2 つのタイマーで決まります。

区間最長
送信 → シートに行が入る(Apps Script)5 分
シート → 公開(PC のタスク+執筆)5 分+約 30 秒
公開 → 返信(Apps Script の次の回)5 分
合計約 15 分(平均は約 8 分)

失敗したときの扱い

起きること結果対処
PC が止まっている行は `queued` のまま残る起動後の最初の実行で処理される
処理中に PC が落ちた行が `running` のまま残る手で `queued` に戻すと再処理される
同じ語がもうある`glossary.py` が既存を検出して止まる → `error`書き直したいときは `–force` で手で実行
OpenAI・WordPress の一時的な失敗`error` と理由が返信で届く状態を `queued` に戻すと再処理
件名が空受け付けない(ラベルだけ付く)件名に語を書いて送り直す
上限を超えたその日は受け付けない翌日に自動で受け付ける

設定でつまずきやすい点

症状原因対処
メールが「アドレス不明」で戻る`+` の前の部分の打ち間違い(別の人のアドレスになる)自分のアドレスに `+glossary` を足しただけか確かめる。`+` 付きのアドレスは作成も設定も不要
送っても何も起こらない`install` ではなく `poll` だけを実行し、トリガーができていないエディタの左の時計のアイコンで、`poll`・時間主導型・5 分おきの行があるか確かめる
シートに行は入るが公開されないPC のタスクが登録されていない、または PC がスリープ中タスクスケジューラで前回の実行結果(0 = 正常)を確かめる
シートの日時が 8〜9 時間ずれるスプレッドシートのタイムゾーンが日本以外ファイル → 設定 → タイムゾーンを東京に。件数の上限は日時を Date として数えるので、動作には影響しない

制限と費用

項目値
Apps Script のトリガーの合計実行時間(無料の Google アカウント)1 日 90 分。5 分ごとに 1〜2 秒なら 1 日 10 分未満
Apps Script のメール送信(返信)1 日 100 通まで
用語 1 語の OpenAI の使用量(gpt-5.4-mini)入力 約 1,000・出力 約 1,600 トークン。料金はこれにモデルの単価を掛けたもの
1 日の上限(この仕組みで決めた値)20 語

関連

参考になったらシェアしてください
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

機械設計・油圧・CAD・Python・AIなど、ものづくりに関わる技術を扱っています。工学知識を整理・構造化し、設計や自動化に再利用できる形へ変えていくことを目指しています。

目次