今日で、メールマガジンの配信が30回目となりました。
バックナンバーは公開していないのですが、
溜まってきた本文の内容は、どこかで提供できたらなと。
そのために、メルマガ本文はGAS(Google Apps Script)で自動収集しています。

※こんな感じです
収集の構造を考える
「全部自動化すべきか?」
と言われれば、必要がないケースもあると考えています。
例えば、頻度が低い(月1とか)発信や、単発の企画など。
収集方法は大きく分けて、
- 手動
- 自動
の2つがあります。
手動であれば、毎日メルマガの執筆後にExcelやスプレッドシートにコピペするだけで済みます。
おそらく1分くらいです。
1分とはいえ、365日続ければ365分。
約6時間に相当します。
少しでも効率化できるポイントがあれば、まず試してみる。
ということで、GASで自動集計する方法を選択しました。
なお、メルマガのテンプレは以下の形です。
●さん、こんにちは!宮本です。
今日は、●●など。
─────────────────────
■セミナー情報
─────────────────────
◇2月のセミナーを告知しています。
(2/20(金)10:00~12:00)
●2/20(金)税理士のための「分析の技術」 入門セミナー
https://free-to-blog.com/tax-accountant-analysis-technique-seminar/
※2/8(日)までの申込は限定価格です。
◇オンラインストア
セミナー動画を販売しています。
https://free-to-design.co.jp/
◇セミナー開催情報
こちらのページで開催情報を記載しています。
https://free-to-blog.com/seminar/
◇最新の動画販売情報
●実務で使える! Excelで所得税シミュレーション講座
https://free-to-design.co.jp/product/excel-income-tax-simulation/
●タスク管理迷子のひとり士業へ|Excelで一生困らない仕組み作り
https://free-to-design.co.jp/product/solo-professional-task-management-excel/
─────────────────────
■書籍情報
─────────────────────
いずれもKindle Unlimitedの対象です。
◇痒い所に手が届く(ブログ運営Tips)
https://amzn.asia/d/06WALzbI
◇行動力の設計図
https://amzn.asia/d/cihPk79
◇「3兄弟育児」という日常戦争
https://amzn.asia/d/j3yWiZ3
◇習慣化の技術
https://www.amazon.co.jp/dp/B0GHZSW9JG
◇公認会計士が教えるAutoHotkey超入門
https://amzn.to/4jQxnTs
◇独立までの道のり: やらない後悔、やる覚悟
https://amzn.to/4q9RGh0
─────────────────────
■サービスメニュー
─────────────────────
◇宮本大樹の個別コンサルティング
https://free-to-blog.com/consulting/
◇メールコンサルティング
https://free-to-blog.com/mail-consulting/
◇会計事務所HP
https://miyamoto-kaikeishi.com/
─────────────────────
■慣れると目的を忘れる【30歩目|free to walk】
─────────────────────
●●
【監査の経験から】
●●
【ひとりでの活用】
●●
─────────────────────
■独立後の行動
─────────────────────
●●
●●さん、では、また明日。
─────────────────────
■昨日の発信
─────────────────────
◇ブログ
●●
◇YouTube
●●
─────────────────────
■感想を募集しています!
─────────────────────
メルマガの感想がありましたら
info@free-to-design.co.jp
までお寄せください。
GASの活用方法
①プロジェクトの作り方
このような形でコードを書いていきます。

特定のスプレッドシートなどに紐づけない場合は、こちらからアクセスします。
アクセス先の「+新しいプロジェクト」を押下し、コードの記述を開始します。

②実際にコードを書く前に
「何を実現したいか?」
を明確に言語化します。
- メルマガの特定箇所(本文)の文字を抽出したい。
- No、日付、タイトル、本文、日記、IDを表として格納したい。
- 重複しないようにしたい。
- 返信、転送は除外したい。
- ■見出しをキーに出力したい。
- 毎日8:30に起動するようにしたい。
といったように。
ここがあいまいだと、上手く設計ができません。
仮にAIに聞くとしても、この辺りが明確でないとコードエラーが頻繁に発生し、
「結局使えなかった…」という着地になってしまうことも少なくありません。
GASに限りませんが、「設計図」は必ず1度は書くようにします。
なお、完璧な設計図である必要はありません。
イメージとしては、50~60点くらい。
コードを仕上げていく過程で過不足も見えてきますので、少しずつ仕上げる形でOKです。
③実際のコード解説(読み飛ばしOK)
少し長くなりますので、もし興味があれば。
応用いただく場合は、
SPREADSHEET_ID
は各自のものに差し替えてください。
※ 以下のコードは、GASの基本文法を一通り触った方向けです。
「まずはGASに慣れたい」という方は、全体の考え方だけ掴んでいただければ十分です。
また別の機会に入門用記事を発信する予定ですので、読み飛ばしてください。
私自身、GASのコードをすべて暗記しているわけではありません。
実際には「やりたいこと」を言語化し、
AIと対話しながらコードを組み立てています。
ただ、
- 何を自動化するか
- どこまで自動化するか
- 運用上どこが問題になりやすいか
ここは、人間側が設計しないといけない部分だと考えています。
詳細はこちら
全体の処理フロー
- スプレッドシートを開く
- 初回だけヘッダー行を作る
- 既存のメールIDを読み込んで Set 化(重複排除)
- 既存Noの最大値を拾って、次のNoを決める
- Gmail検索(30日以内・最大200スレッド)
- 各メールについて
- Re/Fwd除外
- messageId重複除外
- 本文を正規化(改行)
- 「■見出し」でセクション分割
- タイトル抽出
- 本文(メイン)抽出+保険カット
- 独立後の行動抽出
- 古い順に並び替えて、まとめてシートに追記
- 書式を前行からコピーして見た目を揃える
完成形
/**
* Gmailから「free to walk」関連のメールを抽出して
* 指定スプレッドシートに記録するスクリプト(messageId重複排除 / セクション分割抽出 / No付与)
*
* ✅ A列に No を連番で追加
* ✅ processedラベルは使わない
* ✅ シートの messageId で重複排除(取りこぼし低減)
* ✅ Re/Fwd/返信/転送 は除外
* ✅ 「■見出し」で本文を分割して抽出(独立後の行動が取れない問題を潰す)
*/
function collectFreeToWalkEmails() {
const SPREADSHEET_ID = "●●";
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheets()[0]; // gid=0 相当
// 1) ヘッダー作成(初回のみ)
if (sheet.getLastRow() === 0) {
sheet.appendRow(["No", "日付", "タイトル", "本文(メイン)", "独立後の行動", "メールID"]);
sheet.getRange(1, 1, 1, 6).setBackground("#f3f3f3").setFontWeight("bold");
sheet.setFrozenRows(1);
}
// 2) 既存のメールIDを読み込み → Set化(重複排除)
const existingIds = loadExistingMessageIds_(sheet);
// 3) 既存の最大Noを取得 → 次のNoの起点にする
let nextNo = loadMaxNo_(sheet) + 1;
// 4) Gmail検索(30日以内)
const searchQuery = 'subject:("free to walk" OR "free-to-walk") newer_than:30d';
const threads = GmailApp.search(searchQuery, 0, 200);
if (threads.length === 0) {
console.log("対象スレッドが見つかりませんでした。");
return;
}
const items = [];
for (const thread of threads) {
for (const message of thread.getMessages()) {
const subject = message.getSubject();
// ✅ Re/Fwd系を除外
if (isReplyOrForwardSubject_(subject)) continue;
const messageId = message.getId();
if (existingIds.has(messageId)) continue;
const dateObj = message.getDate();
const body = normalizeBody_(message.getPlainBody());
// ■見出しでセクション化
const sections = splitByHeading_(body);
// タイトル:本文中の「歩目|free to walk」見出しが最優先。無ければ件名
const mainHeading = findHeadingKey_(sections, /歩目|\s*free to walk/i);
const title = (mainHeading ? mainHeading.replace(/^■\s*/, "").trim() : subject.trim());
// 本文(メイン):歩目見出しセクション(独立後の行動・昨日の発信などを含まないように除去)
let mainContent = "";
if (mainHeading) {
mainContent = sections[mainHeading] || "";
// メイン内に余計なブロックが混ざることがあるので、ここで切る(保険)
mainContent = cutBeforeNextHeadingLike_(mainContent, /独立後の行動|昨日の発信|感想を募集/i);
}
// 独立後の行動:見出しが多少揺れても拾う(「■ 独立後の行動」「■独立後の行動:」など)
const actionHeading = findHeadingKey_(sections, /独立後の行動/);
const independentAction = actionHeading ? (sections[actionHeading] || "") : "";
items.push({
dateObj,
dateStr: Utilities.formatDate(dateObj, "Asia/Tokyo", "yyyy/MM/dd HH:mm"),
title,
mainContent: cleanupContent_(mainContent),
action: cleanupContent_(independentAction),
messageId
});
existingIds.add(messageId);
}
}
// 5) 一括書き込み(古い順)+ No を連番付与
if (items.length > 0) {
items.sort((a, b) => a.dateObj - b.dateObj);
const rows = items.map(it => {
const no = nextNo++;
return [no, it.dateStr, it.title, it.mainContent, it.action, it.messageId];
});
const startRow = sheet.getLastRow() + 1;
// 値を書き込み
sheet.getRange(startRow, 1, rows.length, 6).setValues(rows);
// 直前行の書式をコピー(format only)
if (startRow > 2) { // ヘッダー直下だけのときは除外
const source = sheet.getRange(startRow - 1, 1, 1, 6);
const target = sheet.getRange(startRow, 1, rows.length, 6);
source.copyTo(target, { formatOnly: true });
}
console.log(`${rows.length} 件のメールを追記しました。`);
} else {
console.log("新規追加はありませんでした(重複は除外済み)。");
}
}
/**
* 既存のメールID(6列目)を読み込んで Set で返す
*/
function loadExistingMessageIds_(sheet) {
const lastRow = sheet.getLastRow();
if (lastRow < 2) return new Set(); // ヘッダーのみ or 空
// 6列目(メールID)
const values = sheet.getRange(2, 6, lastRow - 1, 1).getValues();
const set = new Set();
for (const [id] of values) {
if (id) set.add(String(id).trim());
}
return set;
}
/**
* 既存No(1列目)の最大値を返す
*/
function loadMaxNo_(sheet) {
const lastRow = sheet.getLastRow();
if (lastRow < 2) return 0;
const values = sheet.getRange(2, 1, lastRow - 1, 1).getValues();
let maxNo = 0;
for (const [v] of values) {
const n = Number(v);
if (!isNaN(n) && n > maxNo) maxNo = n;
}
return maxNo;
}
/**
* 改行などを正規化(CRLF→LF)
*/
function normalizeBody_(text) {
if (!text) return "";
return String(text).replace(/\r\n/g, "\n").replace(/\r/g, "\n");
}
/**
* 本文を「■見出し」単位で分割して連想配列で返す
* key: 見出し行(例: "■独立後の行動")
* val: 次の見出しまでの本文
*/
function splitByHeading_(body) {
const lines = body.split("\n");
const sections = {};
let currentKey = "__PRE__";
sections[currentKey] = [];
for (const line of lines) {
// 行頭に空白があってもOK、■の後も空白OK
if (/^\s*■\s*/.test(line)) {
currentKey = line.trim(); // 見出し行をそのままキーにする
if (!sections[currentKey]) sections[currentKey] = [];
continue;
}
sections[currentKey].push(line);
}
// 配列→文字列
const out = {};
for (const k of Object.keys(sections)) {
out[k] = sections[k].join("\n");
}
return out;
}
/**
* 見出しキーの中から、patternにマッチする最初のキーを返す
*/
function findHeadingKey_(sections, pattern) {
for (const k of Object.keys(sections)) {
if (k === "__PRE__") continue;
if (pattern.test(k)) return k;
}
return "";
}
/**
* 文章中に「次の見出しっぽいワード」が出たらそこで切る(保険)
*/
function cutBeforeNextHeadingLike_(text, pattern) {
if (!text) return "";
const idx = text.search(pattern);
return idx >= 0 ? text.slice(0, idx) : text;
}
/**
* 抽出したテキストのクリーニング
*/
function cleanupContent_(text) {
if (!text) return "";
return String(text)
.replace(/^[ \t]*[─]{5,}.*$/gm, "") // 罫線だけの行を削除
.replace(/\n{3,}/g, "\n\n") // 3つ以上の連続改行を2つに集約
.trim();
}
/**
* Re/Fwd/返信/転送 などの件名は除外
*/
function isReplyOrForwardSubject_(subject) {
if (!subject) return false;
return /^\s*(re:|fw:|fwd:|返信:|転送:)/i.test(subject.trim());
}
// ここまで:収集ロジック(既存)
// collectFreeToWalkEmails()
// 各種 helper 関数たち
/**
* 毎朝 8:30 に自動実行するトリガーを作る(1回だけ実行)
*/
function createDailyTrigger_0830() {
// 既存の同名トリガーを削除(事故防止)
const triggers = ScriptApp.getProjectTriggers();
for (const t of triggers) {
if (t.getHandlerFunction() === "collectFreeToWalkEmails") {
ScriptApp.deleteTrigger(t);
}
}
ScriptApp.newTrigger("collectFreeToWalkEmails")
.timeBased()
.everyDays(1)
.atHour(8)
.nearMinute(30)
.create();
}解説
function collectFreeToWalkEmails() {「これからメールを集めますよ」という宣言をします。
const SPREADSHEET_ID = "●●";
const ss = SpreadsheetApp.openById(SPREADSHEET_ID);
const sheet = ss.getSheets()[0];どこに保存するかを指定します。
constは定数の設定です。
●SPREADSHEET_ID
→ 保存先のスプレッドシートのID
●openById
→ そのスプレッドシートを開く
●[0]
→ 1枚目のシートを使う
if (sheet.getLastRow() === 0) {
sheet.appendRow(["No", "日付", "タイトル", "本文(メイン)", "独立後の行動", "メールID"]);
}初回だけヘッダー(見出し行)を作ります。
シートの1行目が空 ➡ 1行目に見出しを入れる
をIF文で示しています。
const existingIds = loadExistingMessageIds_(sheet);シートに記載している「メールID」を記憶しておきます。
同じメールを何度実行しても二重登録されないようにするためです。
let nextNo = loadMaxNo_(sheet) + 1;No(通し番号)の続きから始めるようにシート内のNoを取得しておきます。
loadMaxNoは関数名で、後程詳細が出てきます。
const searchQuery = 'subject:("free to walk" OR "free-to-walk") newer_than:30d';
const threads = GmailApp.search(searchQuery, 0, 200);Gmailの検索設定を定義しています。
●件名に「free to walk」を含む
●30日以内
●最大200スレッドまで
という条件です。
for (const thread of threads) {
for (const message of thread.getMessages()) {返信・転送メールは除外
if (isReplyOrForwardSubject_(subject)) continue;
Re: や Fwd: が付いたメールはスキップ
continue =「このメールは無視して次へ」を示します。
if (existingIds.has(messageId)) continue;すでに保存済みならスキップします。
上記で格納したIDと照合し、一致するものは処理しないようにしています。
const body = normalizeBody_(message.getPlainBody());
const sections = splitByHeading_(body);
ここがこのコードの肝です。
- メール本文を取得
- ■ 見出し ごとに分割
をしています。
結果はこんなイメージです。
■ 28歩目|free to walk
(本文)
■ 独立後の行動
(行動内容)以下ではタイトルを決めています。
const mainHeading = findHeadingKey_(sections, /歩目|\s*free to walk/i);
const title = mainHeading ? 見出し : 件名;
const actionHeading = findHeadingKey_(sections, /独立後の行動/);このコードで本文と「独立後の行動」を分けています。
sheet.getRange(startRow, 1, rows.length, 6).setValues(rows);まとめてシートに記載してます。
(1行ずつ書かず、配列という機能を使っています)
ざっくりの概要はこんなところです。
情報をデータとして集約しておく
上記でも少し触れていますが、情報をデータとして集約するクセをつけましょう。
いざ「活用したい」となった時に、バラバラでは使うことができません。
私の場合、
- ブログ記事 ➡ WP All Export(プラグイン)
- メルマガ ➡ GAS
- タスク ➡ Excel
といったように情報をデータとして集約するようにしています。
ぜひ、このクセを付けていただければと。
では、また次回。
編集後記
◇日記
昨日は、早朝にラン・メルマガ・YouTube・ブログを。
午前中は、個別コンサルティング。
午後は、税務記事の執筆を。
◇ブログネタ経緯
GASを作ったので、その話とメルマガを絡めて。
◇1日1新
QNTメモ