メルマガ配信内容の自動収集術|GASで本文を蓄積して「資産化」する

今日で、メールマガジンの配信が30回目となりました。

バックナンバーは公開していないのですが、
溜まってきた本文の内容は、どこかで提供できたらなと。

そのために、メルマガ本文はGAS(Google Apps Script)で自動収集しています。

※こんな感じです

Contents

収集の構造を考える

「全部自動化すべきか?」

と言われれば、必要がないケースもあると考えています。

例えば、頻度が低い(月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と対話しながらコードを組み立てています。

ただ、

  • 何を自動化するか
  • どこまで自動化するか
  • 運用上どこが問題になりやすいか

ここは、人間側が設計しないといけない部分だと考えています。

詳細はこちら

全体の処理フロー

  1. スプレッドシートを開く
  2. 初回だけヘッダー行を作る
  3. 既存のメールIDを読み込んで Set 化(重複排除)
  4. 既存Noの最大値を拾って、次のNoを決める
  5. Gmail検索(30日以内・最大200スレッド)
  6. 各メールについて
    • Re/Fwd除外
    • messageId重複除外
    • 本文を正規化(改行)
    • 「■見出し」でセクション分割
    • タイトル抽出
    • 本文(メイン)抽出+保険カット
    • 独立後の行動抽出
  7. 古い順に並び替えて、まとめてシートに追記
  8. 書式を前行からコピーして見た目を揃える

完成形

/**
 * 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メモ

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!
Contents