件名に語を 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 | 入口。件名が注文票 | 本人のアカウント | |
| Apps Script | メールを拾ってシートに書く・結果を返信する | 本人のアカウント(初回に許可) | |
| スプレッドシート | 受付の列。状態を 1 行 1 件で持つ | 本人+サービスアカウント(編集者で共有) | |
| 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 行目を見出しにします。
| 列 | 見出し | 中身 | 書く側 |
|---|---|---|---|
| A | received | メールの受信日時 | Apps Script |
| B | word | 語(件名の `/` より前) | Apps Script |
| C | genre | ジャンル(件名の `/` より後。空なら AI が選ぶ) | Apps Script |
| D | status | `queued` → `running` → `done` / `error` | 両方 |
| E | result | 公開した URL、または失敗の理由 | Python |
| F | thread | Gmail のスレッド ID(返信に使う) | Apps Script |
| G | notified | 返信した日時 | 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」宛てではないため、検索条件に当たらず二度拾われない

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 語 |
関連
- 用語集の作り方: 1 語 1 ページ、記事の本文から自動でリンク(GLOSSARY)
- サービスアカウントの仕組み: Search Console API を自作ダッシュボードにつなぐ
