はじめに
AIエージェント製品のマーケティングを担当しています。業務の一つに、AI・ビジネス・テクノロジーの時事ネタを扱うLinkedInの朝投稿があります。
毎朝続けるには、ネタ探しから下書きまでの手間を減らす必要がありました。かといって、AIに丸投げした文章は自分の主張とずれを感じます。
そこで、Google Apps Script(GAS)とスプレッドシート、Gmailだけで「候補を選ぶ→視点を足す→下書きを確認する」までをメール上で完結させる仕組みを作りました。この記事では、そのコンセプトと作りを紹介します。
コンセプト:AIに任せる部分と、自分が握る部分を分ける
設計で一番こだわったのは「どこに人が入るか」です。
よく「Human-in-the-Loop(HIL)」と言われますが、この仕組みはそれとは少し違います。HILはAIが主導し、人は要所で承認や修正をする形です。今回は逆で、主導するのは人、AIは下ごしらえ役です。
理由はシンプルで、AIが参照する社内の知識基盤が、まだ十分に育っていないからです。この段階でAIに判断を寄せると、もっともらしいけれど「自分の言葉ではない」「不正確な内容」の投稿になります。
そのため、次の3つは必ず自分で介入するようにしました。
- 記事のセレクト:どのニュースを扱うかは自分が選ぶ
- 校正チェック:論点のずれや抽象的すぎる表現を自分で直す
- キービジュアルの作り込み:見た目の印象は最後まで自分で詰める
逆に、情報収集・候補の一覧化・叩き台の生成といった「作業」はAIとスクリプトに任せます。
なぜメールなのか
毎朝の作業なので、専用ツールを開く手間すら省きたいと考えました。メールなら通勤中のスマホでも確認できます。
また、候補メールにボタンを置くことで、「選ぶ」「OKを出す」がワンタップで済みます。返信文を書いて指示する方式も試しましたが、自分の考えを入れる工程があるため、ボタン+入力画面のほうが使いやすいと判断しました。
全体像
朝、候補メールが届き、ボタンを2回押して視点を一言足せば、投稿用の下書きが手元に届く流れです。
人が手を動かすのは「セレクト」「視点入力」「校正とOK」「キービジュアル」の4か所だけです。
構成要素
使っているのはGoogle Workspaceの標準機能と、生成AIのAPIだけです。
| 要素 | 役割 |
|---|---|
| Googleアラート | 複数キーワードで時事ネタを収集し、Gmailに配信 |
| Googleトレンド | 扱うキーワードの温度感を確認(人が参照) |
| スプレッドシート | 候補記事・ステータス・入力した視点・下書きを1行で管理 |
| Apps Script | 候補抽出、メール送信、ボタンの受け口(Webアプリ)、AI呼び出し |
| Gmail | 候補メール・下書きメールの受け取りと操作の場 |
| 社内MDファイル | 文体や主張の方針。プロンプトに含めて下書きの軸にする |
スプレッドシートの「候補」シートは、次の列構成を想定しています。
| 列 | 内容 |
|---|---|
| A:ID | 候補ごとの一意なID |
| B:取得日時 | 抽出した日時 |
| C:タイトル | 記事タイトル |
| D:URL | 元記事のリンク |
| E:ステータス | 候補/選択済み/下書き済み/OK |
| F:視点 | 自分が入力したコメント |
| G:下書き | AIが生成した本文 |
作り方
処理は「候補を集める」「候補メールを送る」「ボタンを受ける」「下書きを作って送る」の4つに分かれます。
APIキーやIDはコードに直書きせず、スクリプトプロパティ(SHEET_ID、WEBAPP_URL、TOKEN、STYLE_MD_ID、ANTHROPIC_API_KEY)に入れておきます。
共通の関数
function prop(key) {
return PropertiesService.getScriptProperties().getProperty(key);
}
function getSheet() {
return SpreadsheetApp.openById(prop('SHEET_ID')).getSheetByName('候補');
}
// IDから行を探す(1行目はヘッダー)
function findRow(id) {
const sheet = getSheet();
const values = sheet.getDataRange().getValues();
const i = values.findIndex(r => r[0] === id);
return i < 1 ? null : { sheet, rowIndex: i + 1, values: values[i] };
}
function esc(s) {
return String(s).replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
}
function button(label, url, color) {
return `<a href="${url}" style="display:inline-block;margin:8px 8px 0 0;padding:6px 16px;` +
`background:${color};color:#fff;border-radius:16px;text-decoration:none">${label}</a>`;
}
1. Googleアラートから候補を集める
Googleアラートのメールに含まれるリンクを抜き出し、スプレッドシートに追記します。
function collectCandidates() {
const sheet = getSheet();
const known = new Set(sheet.getDataRange().getValues().map(r => r[3]));
const threads = GmailApp.search('from:googlealerts-noreply@google.com newer_than:1d');
const rows = [];
threads.forEach(t => t.getMessages().forEach(m => {
const html = m.getBody().replace(/&/g, '&');
const re = /<a[^>]+href="https:\/\/www\.google\.com\/url\?[^"]*url=([^&"]+)[^"]*"[^>]*>([\s\S]*?)<\/a>/g;
let match;
while ((match = re.exec(html)) !== null) {
const url = decodeURIComponent(match[1]);
const title = match[2].replace(/<[^>]+>/g, '').trim();
if (!title || known.has(url)) continue;
known.add(url);
rows.push([Utilities.getUuid(), new Date(), title, url, '候補', '', '']);
}
}));
if (rows.length) {
sheet.getRange(sheet.getLastRow() + 1, 1, rows.length, rows[0].length).setValues(rows);
}
}
2. ボタン付きの候補メールを送る
候補ごとに「これにする」ボタンを付けたHTMLメールを自分宛てに送ります。ボタンのリンク先は、後述のWebアプリです。
function sendCandidateMail() {
const items = getSheet().getDataRange().getValues().slice(1)
.filter(r => r[4] === '候補').slice(-10);
if (!items.length) return;
const base = `${prop('WEBAPP_URL')}?token=${prop('TOKEN')}`;
const html = items.map(r => `
<div style="margin:0 0 16px;padding:12px;border:1px solid #ddd;border-radius:8px">
<a href="${r[3]}">${esc(r[2])}</a><br>
${button('これにする', `${base}&action=select&id=${r[0]}`, '#0a66c2')}
</div>`).join('');
const subject = `【投稿候補】${Utilities.formatDate(new Date(), 'Asia/Tokyo', 'M/d')}`;
GmailApp.sendEmail(Session.getActiveUser().getEmail(), subject, '', { htmlBody: html });
}
3. ボタンの受け口(Webアプリ)
ボタンを押すとdoGetが呼ばれます。「これにする」なら視点の入力画面を返し、「OK」ならステータスを更新します。
function doGet(e) {
const p = e.parameter;
if (p.token !== prop('TOKEN')) return HtmlService.createHtmlOutput('無効なリンクです');
const row = findRow(p.id);
if (!row) return HtmlService.createHtmlOutput('候補が見つかりません');
if (p.action === 'select') {
const t = HtmlService.createTemplateFromFile('input');
t.id = p.id;
t.title = row.values[2];
t.url = row.values[3];
t.view = row.values[5];
t.token = p.token;
return t.evaluate().addMetaTag('viewport', 'width=device-width, initial-scale=1');
}
if (p.action === 'ok') {
row.sheet.getRange(row.rowIndex, 5).setValue('OK');
return HtmlService.createHtmlOutput('OKにしました。キービジュアルを仕上げて投稿しましょう。');
}
}
// 入力画面から呼ばれる
function submitView(id, view, token) {
if (token !== prop('TOKEN')) throw new Error('無効なリクエストです');
const row = findRow(id);
row.sheet.getRange(row.rowIndex, 5, 1, 2).setValues([['選択済み', view]]);
generateDraft(id);
}
入力画面(input.html)は、スマホでも打ちやすいようにテキストエリアとボタンだけにしています。
<!DOCTYPE html>
<html>
<body style="font-family:sans-serif;padding:16px">
<h3><?= title ?></h3>
<p><a href="<?= url ?>" target="_blank">元記事を開く</a></p>
<p>この記事に対する自分・自社の視点</p>
<textarea id="view" rows="8" style="width:100%"><?= view ?></textarea>
<button id="btn" onclick="send()">この視点で下書きを作る</button>
<p id="msg"></p>
<script>
function send() {
const btn = document.getElementById('btn');
const msg = document.getElementById('msg');
btn.disabled = true;
msg.textContent = '作成中…';
google.script.run
.withSuccessHandler(() => { msg.textContent = '下書きをメールで送りました'; })
.withFailureHandler(err => { msg.textContent = 'エラー:' + err.message; btn.disabled = false; })
.submitView('<?= id ?>', document.getElementById('view').value, '<?= token ?>');
}
</script>
</body>
</html>
4. AIで下書きを作り、メールで送る
社内の方針MDファイルと、入力した視点をプロンプトに入れて下書きを生成します。視点を「必ず軸にする」と明示するのがポイントです。
function generateDraft(id) {
const row = findRow(id);
const [, , title, url, , view] = row.values;
const style = DriveApp.getFileById(prop('STYLE_MD_ID')).getBlob().getDataAsString();
const prompt = [
'以下の方針に沿って、LinkedIn投稿の下書きを400〜600字で書いてください。',
'# 方針', style,
'# 元記事', title, url,
'# 書き手の視点(必ずこれを軸にする)', view,
].join('\n');
const res = UrlFetchApp.fetch('https://api.anthropic.com/v1/messages', {
method: 'post',
contentType: 'application/json',
headers: { 'x-api-key': prop('ANTHROPIC_API_KEY'), 'anthropic-version': '2023-06-01' },
payload: JSON.stringify({
model: 'claude-sonnet-5-5',
max_tokens: 1500,
messages: [{ role: 'user', content: prompt }],
}),
muteHttpExceptions: true,
});
const json = JSON.parse(res.getContentText());
if (!json.content) throw new Error(res.getContentText());
const draft = json.content.filter(c => c.type === 'text').map(c => c.text).join('\n');
row.sheet.getRange(row.rowIndex, 5, 1, 3).setValues([['下書き済み', view, draft]]);
sendDraftMail(id, title, draft);
}
function sendDraftMail(id, title, draft) {
const base = `${prop('WEBAPP_URL')}?token=${prop('TOKEN')}&id=${id}`;
const html = `
<p><b>${esc(title)}</b></p>
<div style="white-space:pre-wrap;padding:12px;background:#f5f5f5;border-radius:8px">${esc(draft)}</div>
${button('OK', `${base}&action=ok`, '#057642')}
${button('書き直し', `${base}&action=select`, '#666')}`;
GmailApp.sendEmail(Session.getActiveUser().getEmail(), `【下書き】${title}`, '', { htmlBody: html });
}
「書き直し」を押すと、前回の視点が入った状態で入力画面が開くので、足したり削ったりして再生成できます。
5. デプロイとトリガー
- 「デプロイ」→「新しいデプロイ」→種類「ウェブアプリ」。実行ユーザーは「自分」、アクセスできるユーザーは「自分のみ」にします
- 発行されたURLをスクリプトプロパティ
WEBAPP_URLに設定します - 時間主導型トリガーで、
collectCandidatesを毎朝6時台、sendCandidateMailをその後の7時台に設定します
アクセスを「自分のみ」にしているので、ボタンはGoogleにログインしている自分の端末でしか動きません。TOKENは念のための二重チェックです。
時間主導型トリガーは、アラートに上がってくる件数を見ながら好みの時間に設定するのがいいと思います。
Workspace Studio版との使い分け
実は同じワークフローを、Google Workspace Studioでも組んでいます。Studio版は、記事の下書きとキービジュアルをドライブに保存する形です。
両方を試した結果、どちらかに寄せるのではなく、目的で使い分けることにしました。
| メール版(本記事) | Studio版 | |
|---|---|---|
| 使う人 | 自分ひとり | 投稿に関わるメンバー全員 |
| 目的 | 自分の主張を記事に乗せる | 投稿タスクをチームで共有する |
| 人の関わり方 | セレクト・校正・キービジュアルに主体的に介入 | 全体でOKになった記事を投稿 |
| 承認 | 自分 | 営業も含めた戦略を練る担当者 |
| 成果物の置き場 | Gmailとスプレッドシート | Googleドライブ |
知識基盤が育ち、AIの下書きが自分の主張とずれなくなってくれば、介入の比重は減らせるはずです。それまでは、個人の発信はメール版で「自分の言葉」を守り、チームの発信はStudio版で合意を取る、という分担にしています。
まとめ
この仕組みの要は、技術よりも「人が握る部分を先に決めた」ことです。
- 作業(収集・一覧化・叩き台)はスクリプトとAIに任せる
- 判断(何を扱うか・何を言うか・どう見せるか)は人が持つ
- 操作はメールのボタンに寄せ、毎朝の負担を最小にする
AIに任せる範囲は、知識基盤の育ち具合に合わせて少しずつ広げていけばいい、と考えています。同じように「AIに書かせると自分の言葉にならない」と感じている方の参考になればうれしいです。