0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

GAS×DocumentAppの基礎徹底:setAttributes と findText/replaceText、そして一斉メール差し込みまで(最小実装→実務対応)

Last updated at Posted at 2025-09-23

この記事でやること

  • DocumentApp の setAttributes で書式を一括適用
  • findText / replaceText で検索・置換の実践と注意点
  • スプレッドシート×テンプレで差し込み一斉メール(最小実装)
  • 実務向けにログ/再送/プレビュー/スロットリングを整える

[TOC]

1. 前提と環境

  • ランタイム:Google Apps Script(V8)

  • スクリプト種別:

  • ドキュメントにバインド:DocumentApp.getActiveDocument() が使いやすい

  • スタンドアロン:DocumentApp.openById(DOC_ID) を推奨

  • サンプルは日本語ロケール(ja-JP)を例示

2. 超最小サンプル:段落を追加して書式を一括適用

/**
 * ドキュメント末尾に段落を追加し、setAttributesで書式を一括適用
 */
function demoSetAttributes() {
  const doc = DocumentApp.getActiveDocument(); // スタンドアロンなら openById()
  const body = doc.getBody();

  const now = new Date();
  const today = now.toLocaleDateString('ja-JP'); // 例: 2025/9/23

  const p = body.appendParagraph(`今日は ${today} の学習メモです`);
  p.setAttributes({
    [DocumentApp.Attribute.BOLD]: true,
    [DocumentApp.Attribute.FOREGROUND_COLOR]: '#FF0000',
    [DocumentApp.Attribute.ALIGNMENT]: DocumentApp.TextAlignment.CENTER,
    [DocumentApp.Attribute.HEADING]: DocumentApp.ParagraphHeading.HEADING2
  });
}

使いどころ & 注意

  • setAttributes は複数の装飾をまとめて適用できるのが強み

  • 段落系(HEADING/ALIGNMENT)Paragraph、文字装飾は Text に

  • 文字列の一部だけ装飾するなら Text#setAttributes(start, end, attrs)

3. 検索と置換:単発→複数一致→安全な置換

3.1 単発検索と全置換(最小)

function demoSearchAndReplace() {
  const doc = DocumentApp.getActiveDocument();
  const body = doc.getBody();

  // 単発検索(最初の一致のみ)
  const m = body.findText('書式設定');
  Logger.log(m ? 'テキストが見つかりました' : 'テキストが見つかりませんでした');

  // 全置換(正規表現として解釈される)
  body.replaceText('書式設定', 'テキスト検索');
  Logger.log('置換が完了しました');
}

3.2 複数一致を走査して部分装飾(太字+色)

function highlightAllTodo() {
  const body = DocumentApp.getActiveDocument().getBody();
  let range; // RangeElement
  while (range = body.findText('TODO')) {        // 逐次検索
    const text = range.getElement().asText();
    const s = range.getStartOffset();
    const e = range.getEndOffsetInclusive();
    text.setAttributes(s, e, {
      [DocumentApp.Attribute.BOLD]: true,
      [DocumentApp.Attribute.FOREGROUND_COLOR]: '#1E90FF'
    });
  }
}

3.3 安全な置換のための正規表現エスケープ

replaceText は正規表現として解釈されます。[](){}+*?.| などを含むと意図外ヒットの原因に。
差し込みは{{name}}のような分かりやすい記号を採用し、エスケープ関数を用意すると堅牢です。

/** 正規表現のメタ文字をエスケープ */
function escapeRegExp(str) {
  return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

function safeReplaceAll(body, placeholder, replacement) {
  const pattern = escapeRegExp(placeholder); // 正規表現に安全化
  body.replaceText(pattern, replacement);     // すべて置換
}

4. 差し込み一斉メール(最小実装)

4.1 想定データ

  • スプレッドシート Recipients

A列: name / B列: email / C列: company(任意) / D列: sentAt(送信ログ)

  • ドキュメントテンプレ(任意のDoc)

本文に{{name}}, {{company}}などのプレースホルダを配置

4.2 実装(Docs→テキスト→置換→送信)

※装飾が不要な最小構成。装飾を維持したい場合は §5 参照。

function mailMergeFromSheet_min() {
  const ss = SpreadsheetApp.getActive();
  const sh = ss.getSheetByName('Recipients');
  const values = sh.getDataRange().getValues(); // 1行目はヘッダ想定

  const TEMPLATE_DOC_ID = 'PUT_YOUR_DOC_ID_HERE';

  const doc = DocumentApp.openById(TEMPLATE_DOC_ID);
  const templateText = doc.getBody().getText(); // 純テキストで取得

  for (let r = 1; r < values.length; r++) {
    const [name, email, company] = values[r];
    if (!email) continue;

    const html = templateText
      .replaceAll('{{name}}', name)
      .replaceAll('{{company}}', company || '');

    const subject = `【ご案内】${name} 様`;

    MailApp.sendEmail({
      to: email,
      subject,
      htmlBody: html
    });

    sh.getRange(r + 1, 4).setValue(new Date()); // D列: 送信時刻
    Utilities.sleep(150); // スロットリング
  }
}

5. 実務向け強化:ログ/再送/プレビュー/スロットリング

5.1 ログ+再送の型

  • status 列(E列)を追加:SENT / ERROR

  • 再送関数は status !== 'SENT' の行のみ処理

function mailMergeFromSheet_pro() {
  const ss = SpreadsheetApp.getActive();
  const sh = ss.getSheetByName('Recipients');
  const values = sh.getDataRange().getValues();

  const TEMPLATE_DOC_ID = 'PUT_YOUR_DOC_ID_HERE';
  const doc = DocumentApp.openById(TEMPLATE_DOC_ID);
  const templateText = doc.getBody().getText();

  const DEBUG_TO = ''; // プレビュー時は自分のメールを設定、空なら本番送信

  for (let r = 1; r < values.length; r++) {
    const [name, email, company, sentAt, status] = values[r];

    if (status === 'SENT') continue;       // 送信済みはスキップ
    if (!email) {
      sh.getRange(r + 1, 5).setValue('ERROR: no email'); // E列
      continue;
    }

    try {
      const html = templateText
        .replaceAll('{{name}}', name)
        .replaceAll('{{company}}', company || '');

      const subject = `【ご案内】${name} 様`;

      MailApp.sendEmail({
        to: DEBUG_TO || email,             // プレビュー時は自分宛へ
        subject,
        htmlBody: html
      });

      sh.getRange(r + 1, 4).setValue(new Date()); // D: sentAt
      sh.getRange(r + 1, 5).setValue('SENT');     // E: status
      Utilities.sleep(150);

    } catch (err) {
      sh.getRange(r + 1, 5).setValue(`ERROR: ${err.message}`);
      Logger.log(JSON.stringify({
        where: 'mailMergeFromSheet_pro',
        row: r + 1,
        message: err.message,
        stack: err.stack
      }));
    }
  }
}

5.2 正規表現ベースの安全置換(ユーティリティ)

function renderTemplateText(template, data) {
  // data 例: { name: '山田', company: 'ABC' }
  return Object.keys(data).reduce((acc, key) => {
    const ph = `{{${key}}}`;
    const pattern = new RegExp(escapeRegExp(ph), 'g');
    return acc.replace(pattern, data[key] ?? '');
  }, template);
}

5.3 クォータ対策と運用のポイント

  • 送信クォータに注意(件数の多い配信は時間主導トリガーで分割)

  • プレビュー→本番の2段階運用(DEBUG_TO を空にしたら本番)

  • 失敗時に止めない設計(try/catchでログを残して続行)

6. よくある落とし穴

  • replaceText は正規表現:[](){}+*?.| などはエスケープ必須。プレースホルダは {{name}} のように設計

  • 要素型の混同:段落系は Paragraph、文字装飾は Text に適用

  • 複数ヒットの扱い:findText は逐次検索。複数は while (range = ...) で走査

  • getActiveDocument() の文脈:スタンドアロンなら openById() を使う

  • 装飾つきHTMLを送りたい:Docs→HTMLの抽出はコツが要るので、まずは HtmlService のテンプレ路線が楽

7. まとめと次の一歩(PDF化・テンプレ管理)

  • 基礎3点(書式一括・検索・置換)だけで、差し込みメールは実現できる

  • 実務では ログ/再送/プレビュー/スロットリング を添えて“壊れない仕組み”へ

  • 次は Docsを複製→置換→PDF化→添付送信(請求書・参加証)に広げる

参考:PDF添付の最小例

function sendPdfAttachment() {
  const TEMPLATE_DOC_ID = 'PUT_YOUR_DOC_ID_HERE';
  const DEST_FOLDER_ID = 'PUT_YOUR_FOLDER_ID_HERE';

  const folder = DriveApp.getFolderById(DEST_FOLDER_ID);
  const copy = DriveApp.getFileById(TEMPLATE_DOC_ID).makeCopy(`ご案内_${Date.now()}`, folder);

  const doc = DocumentApp.openById(copy.getId());
  const b = doc.getBody();

  // シンプル置換
  b.replaceText('{{name}}', '山田太郎');
  b.replaceText('{{company}}', 'ABC株式会社');
  doc.saveAndClose();

  // PDF化して添付
  const pdfBlob = DriveApp.getFileById(copy.getId()).getAs(MimeType.PDF).setName('ご案内.pdf');
  MailApp.sendEmail({
    to: 'sample@example.com',
    subject: '資料送付',
    htmlBody: '資料をお送りします。',
    attachments: [pdfBlob]
  });
}

付録:ユーティリティ(コピペ用)
/** 正規表現エスケープ */
function escapeRegExp(str) {
  return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/** 段落のスタイル適用 */
function applyParagraphStyle(p, opts = {}) {
  const attrs = {
    [DocumentApp.Attribute.BOLD]: !!opts.bold,
    [DocumentApp.Attribute.FOREGROUND_COLOR]: opts.color || null,
    [DocumentApp.Attribute.ALIGNMENT]: opts.align || DocumentApp.TextAlignment.LEFT,
    [DocumentApp.Attribute.HEADING]: opts.heading || DocumentApp.ParagraphHeading.NORMAL
  };
  p.setAttributes(attrs);
}

/** findText で複数一致に装飾 */
function decorateAll(pattern, textAttrs) {
  const body = DocumentApp.getActiveDocument().getBody();
  let range;
  while (range = body.findText(pattern)) {
    const t = range.getElement().asText();
    const s = range.getStartOffset();
    const e = range.getEndOffsetInclusive();
    t.setAttributes(s, e, textAttrs);
  }
}

付録:ユーティリティ

/** 正規表現エスケープ */
function escapeRegExp(str) {
  return String(str).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

/** 段落のスタイル適用 */
function applyParagraphStyle(p, opts = {}) {
  const attrs = {
    [DocumentApp.Attribute.BOLD]: !!opts.bold,
    [DocumentApp.Attribute.FOREGROUND_COLOR]: opts.color || null,
    [DocumentApp.Attribute.ALIGNMENT]: opts.align || DocumentApp.TextAlignment.LEFT,
    [DocumentApp.Attribute.HEADING]: opts.heading || DocumentApp.ParagraphHeading.NORMAL
  };
  p.setAttributes(attrs);
}

/** findText で複数一致に装飾 */
function decorateAll(pattern, textAttrs) {
  const body = DocumentApp.getActiveDocument().getBody();
  let range;
  while (range = body.findText(pattern)) {
    const t = range.getElement().asText();
    const s = range.getStartOffset();
    const e = range.getEndOffsetInclusive();
    t.setAttributes(s, e, textAttrs);
  }
}

学習の背景や気づきはnoteにまとめました → note版はこちら

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?