GASでAIに送る前に
個人情報をマスキングする方法

社内のスプレッドシートをAIに要約させたい。でも氏名やメールアドレスは外に出したくない。 GASのreplace()とマスタ照合で、送信前に個人情報を伏せる実装を解説します。

|対象: GAS / スプレッドシート / Claude API / 情報管理

Table of Contents

なぜ送信前マスキングが必要か

結論から言うと、AIに投げた文字列は社外のサーバーに届くからです。GASからUrlFetchApp(GASから外部へHTTPリクエストを送る機能)でAPIを呼ぶ以上、送った本文はインターネットを経由します。社内規程や取引先との契約で「顧客情報を第三者に提供しない」と定めている場合、そのままの本文を送るのは避けたほうが安全です。

運用ルールで「個人情報が含まれる行はAIに投げない」と決めることもできます。ただし人の判断に依存すると、忙しいときほど漏れます。コードで機械的に伏せるほうが確実です。しかも一度書けば、あとは全部の処理に効きます。

運用ルールで守る

実装は不要だが、担当者の判断に依存する。件数が増えるほど漏れやすく、確認コストも積み上がる

コードで伏せる

AIを呼ぶ関数の中で必ず通るため、付け忘れが起きにくい。取りこぼしはあるが、処理の一貫性は保てる

なお、AIサービスごとに入力データの取り扱い条件は異なります。学習に使われるかどうか、保存期間はどれくらいかは、利用するサービスの公式ドキュメントで確認してください。この記事は「どんな条件でも送っていい情報だけを送る」という前提の実装です。

何を伏せるか|対象の洗い出し

マスキングの対象は、大きく2種類に分かれます。この区別が実装方針を決めます。

A. 形が決まっているもの(正規表現で拾える)

メールアドレス、電話番号、郵便番号、クレジットカード番号、口座番号、URLに埋め込まれたトークンなど。桁数や記号の並びが決まっているため、正規表現(文字列のパターンを表す記法)でほぼ確実に検出できます。

B. 形が決まっていないもの(正規表現では拾えない)

氏名、会社名、住所の番地以降、社内の案件コードなど。パターンで書けないため、既存のマスタ(顧客一覧シートなど)と突き合わせて置き換えます。マスタにない名前は当然すり抜けるので、この方式は補助と考えてください。

最も効果が大きいのは、実はそもそも送る列を絞ることです。要約したいのが本文だけなら、氏名列や連絡先列はAIに渡す必要がありません。マスキングを書く前に、「この処理に本当に必要な列はどれか」を先に決めてください。

実装1|正規表現で定型パターンを伏せる

まずは一番シンプルな形です。元に戻す必要がないなら、これだけで足ります。

// 形が決まっている情報を、正規表現でまとめて伏せる
function maskFixedPatterns(text) {
  if (!text) return "";

  return String(text)
    // メールアドレス
    .replace(/[\w.+-]+@[\w-]+\.[\w.-]+/g, "【メールアドレス】")
    // 電話番号(ハイフンあり・なし、+81表記に対応)
    .replace(/(\+81[-\s]?|0)\d{1,4}[-\s]?\d{1,4}[-\s]?\d{3,4}/g, "【電話番号】")
    // 郵便番号
    .replace(/〒?\d{3}-\d{4}/g, "【郵便番号】")
    // クレジットカードらしき16桁
    .replace(/\b(?:\d[ -]?){15}\d\b/g, "【カード番号】")
    // URLに含まれるトークン等も落とす
    .replace(/https?:\/\/\S+/g, "【URL】");
}

function testMask() {
  const src = "田中様(tanaka@example.co.jp / 090-1234-5678)より、〒150-0001 の住所変更依頼";
  console.log(maskFixedPatterns(src));
  // 田中様(【メールアドレス】 / 【電話番号】)より、【郵便番号】 の住所変更依頼
}

ポイントは/gフラグです。これがないと最初の1件しか置換されません。1つの本文にメールアドレスが3つ入っていることは普通にあるので、必ず付けてください。

電話番号のパターンは意図的にゆるくしています。桁数を厳密に書くと、03-1234-567809012345678の両方を拾えません。個人情報のマスキングでは、取りこぼすより多めに伏せるほうが安全です。注文番号がたまたま電話番号の形に一致して伏せられても、実害はほとんどありません。

置換の順番にも注意が必要です。URLを先に伏せると、URL内のメールアドレスも一緒に消えます。逆にメールを先に処理すると、URLの中にメール形式の文字列があった場合に【メールアドレス】が混じった変なURLが残ります。実データで一度確認してから順番を決めてください。

実装2|あとから元に戻せるプレースホルダ方式

「AIに返信文の下書きを書かせて、そのまま送りたい」というケースでは、全部を【メールアドレス】にしてしまうと困ります。そこで、連番付きのプレースホルダ(差し替え用の目印)に置き換え、対応表をGAS側に持っておきます。

// あとで元に戻せるマスキング(プレースホルダ方式)
// 返り値: { masked: マスキング済み文字列, map: 復元用の対応表 }
function maskReversible(text) {
  const map = {};       // 【メール1】 -> tanaka@example.co.jp
  const counters = {};  // 種類ごとの連番

  const rules = [
    { label: "メール", re: /[\w.+-]+@[\w-]+\.[\w.-]+/g },
    { label: "電話", re: /(\+81[-\s]?|0)\d{1,4}[-\s]?\d{1,4}[-\s]?\d{3,4}/g },
    { label: "郵便番号", re: /〒?\d{3}-\d{4}/g },
  ];

  let masked = String(text || "");

  rules.forEach((rule) => {
    const seen = {}; // 同じ値には同じ番号を割り当てる
    masked = masked.replace(rule.re, (hit) => {
      if (seen[hit]) return seen[hit];

      counters[rule.label] = (counters[rule.label] || 0) + 1;
      const token = "【" + rule.label + counters[rule.label] + "】";

      seen[hit] = token;
      map[token] = hit;
      return token;
    });
  });

  return { masked: masked, map: map };
}

// AIの回答に含まれるプレースホルダを、元の値に戻す
function unmask(text, map) {
  let result = String(text || "");
  Object.keys(map).forEach((token) => {
    result = result.split(token).join(map[token]);
  });
  return result;
}

工夫は2点です。1つは同じ値には同じ番号を割り当てること。本文中に同じメールアドレスが3回出てきたら、すべて【メール1】になります。これで「同一人物の話である」という文脈がAIに伝わり、要約や分類の精度が落ちにくくなります。

もう1つは対応表を外に出さないこと。mapはGASのメモリ上にだけ置き、AIには渡しません。AIの回答を受け取ったあと、unmask()で元の値に戻してから人に見せる、という流れになります。

復元にはあえてsplit().join()を使っています。replaceに文字列を渡すと1件しか置換されず、正規表現を組み立てると記号のエスケープを考える必要があるためです。全件置換をシンプルに書くならこの形が確実です。

実装3|氏名・社名はマスタ照合で置き換える

氏名は正規表現で書けません。「田中」も「山田太郎」も、パターンとしては普通の日本語だからです。代わりに、すでに社内にある顧客マスタや取引先一覧のシートを使います。

// 氏名・社名など「形が決まっていない」情報はマスタ照合で置き換える
// 「顧客マスタ」シート: A列=実名, B列=種別(氏名 / 会社名)
function buildNameRules_() {
  const sheet = SpreadsheetApp.getActive().getSheetByName("顧客マスタ");
  const rows = sheet.getRange(2, 1, Math.max(sheet.getLastRow() - 1, 0), 2).getValues();

  return rows
    .filter((row) => row[0])
    .map((row) => ({ name: String(row[0]).trim(), kind: String(row[1] || "氏名") }))
    // 長い名前から先に置換する(「山田太郎」を「山田」より先に処理する)
    .sort((a, b) => b.name.length - a.name.length);
}

function maskNames(text, rules) {
  let masked = String(text || "");
  const counters = {};

  rules.forEach((rule) => {
    if (masked.indexOf(rule.name) === -1) return;

    counters[rule.kind] = (counters[rule.kind] || 0) + 1;
    const token = "【" + rule.kind + counters[rule.kind] + "】";

    // 正規表現ではなく split/join で置換(記号を含む名前でも壊れない)
    masked = masked.split(rule.name).join(token);
  });

  return masked;
}

重要なのが長い名前から先に置換する並べ替えです。マスタに「山田」と「山田太郎」の両方がある場合、短いほうから処理すると「山田太郎」が「【氏名1】太郎」という中途半端な形になります。文字数の降順にソートしておけば、この事故を防げます。

マスタが数千件を超えると、1本文あたりの照合回数が増えて処理が重くなります。その場合は、マスタ全件ではなく「その行に紐づく顧客名だけ」を渡す形に変えてください。問い合わせ表に顧客IDの列があるなら、その1件だけを置換対象にすればほぼ十分です。

この方式はマスタにない名前は素通りする点を必ず理解しておいてください。新規の問い合わせで初出の氏名は伏せられません。マスキングは「送るデータを最小限にする」設計と組み合わせて初めて意味を持ちます。

問い合わせ表をマスキングしてAI要約する

ここまでの部品を、実際の処理につなぎます。設計の要点はAIを呼ぶ関数の内側でマスキングすることです。呼び出し側に任せると、新しい機能を足したときに付け忘れます。

// マスキングを通してからAIに投げる入口をひとつに絞る
function askClaudeSafely_(rawText, nameRules) {
  // 1. 固有名詞 → 2. 定型パターン の順に伏せる
  const withoutNames = maskNames(rawText, nameRules);
  const { masked } = maskReversible(withoutNames);

  const apiKey = PropertiesService.getScriptProperties().getProperty("ANTHROPIC_API_KEY");

  const payload = {
    model: "claude-opus-5",
    max_tokens: 512,
    system:
      "あなたは問い合わせ内容を要約する担当です。" +
      "本文中の【氏名1】【メール1】などは伏せ字です。そのままの表記で使い、推測で実名を補わないでください。",
    messages: [{ role: "user", content: "次の問い合わせを80字以内で要約してください。\n" + masked }],
  };

  const res = UrlFetchApp.fetch("https://api.anthropic.com/v1/messages", {
    method: "post",
    contentType: "application/json",
    headers: { "x-api-key": apiKey, "anthropic-version": "2023-06-01" },
    payload: JSON.stringify(payload),
    muteHttpExceptions: true,
  });

  if (res.getResponseCode() !== 200) {
    // 生の本文はログに出さない(ステータスコードだけ残す)
    console.error("Claude APIエラー: " + res.getResponseCode());
    return "";
  }

  const json = JSON.parse(res.getContentText());
  return json.content[0].text;
}

システムプロンプト(AIの役割や回答ルールを固定する指示)で、伏せ字の扱いを明示しています。これを書かないと、AIが【氏名1】を「田中様」のような一般的な名前に勝手に置き換えて要約することがあります。存在しない実名が出力に混じるほうが厄介なので、伏せ字はそのまま使うよう指示してください。

あとはシート側から回すだけです。処理済みの行を飛ばす作りにしておくと、途中で止まっても再実行できます。

// 問い合わせ表を1行ずつマスキングして要約し、C列に書き戻す
// A列=受信日時, B列=問い合わせ本文, C列=AI要約
function summarizeInquiries() {
  const sheet = SpreadsheetApp.getActive().getSheetByName("問い合わせ");
  const lastRow = sheet.getLastRow();
  if (lastRow < 2) return;

  const values = sheet.getRange(2, 1, lastRow - 1, 3).getValues();
  const nameRules = buildNameRules_(); // マスタは1回だけ読む

  const output = [];
  let processed = 0;

  for (let i = 0; i < values.length; i++) {
    const body = values[i][1];
    const done = values[i][2];

    if (!body || done) {
      output.push([done]); // 既に要約済みの行はそのまま
      continue;
    }

    output.push([askClaudeSafely_(body, nameRules)]);
    processed++;
  }

  sheet.getRange(2, 3, output.length, 1).setValues(output);
  console.log("要約した件数: " + processed); // 本文は出力しない
}

マスタの読み込みをループの外に出している点にも注目してください。1行ごとにgetValues()を呼ぶとシートとの往復が件数ぶん発生し、6分の実行時間制限に近づきます。読み込みは1回、書き戻しもsetValues()で1回です。

実務での注意点

1. ログに生データを残さない

デバッグ中にconsole.log(body)と書いて、そのまま本番に残るパターンが一番多い漏れ方です。GASの実行ログはスクリプトの権限を持つ人が閲覧できるため、シート本体より広い範囲に見えることがあります。ログには件数や行番号だけを出し、内容を出すならマスキング後の文字列にしてください。エラー処理でレスポンス全文を出すのも同じ理由で避けます。

2. 伏せすぎると文脈が壊れる

「安全のために数字を全部伏せる」といった設計にすると、金額や個数まで消えて要約が意味を成さなくなります。伏せる対象は最初に洗い出した項目に限定し、実データで出力を確認しながら調整してください。番号付きプレースホルダを使えば、伏せつつ関係性は残せます。

3. APIキーはコードに書かない

マスキングを頑張っても、APIキーがソースに直書きされていては意味がありません。PropertiesService(スクリプトごとに設定値を保存する機能)に入れ、コードにはキー名だけを書きます。スクリプトを他部署にコピーして渡すときも、キーが付いてこないので安全です。

4. マスキングは免罪符ではない

正規表現もマスタ照合も、取りこぼしは必ずあります。「マスキングしているから何を送ってもいい」という運用にはしないでください。送るデータを最小限に絞ったうえで、最後の安全網としてマスキングを置く、という順番が正しい設計です。扱う情報の種類によっては、社内の情報管理規程や委託先への確認が先に必要になります。

5. 実データでテストする

マスキング処理は、実際の本文を10〜20件通してみないと精度が分かりません。AIに送る前のmaskedを確認用シートに書き出し、目視でチェックする工程を最初に入れてください。半角と全角の混在、番号の区切り記号の揺れなど、想定外は必ず出てきます。

まとめ

AIに社内データを渡すときは、送信前にコードで個人情報を伏せます。メールアドレスや電話番号のように形が決まったものは正規表現で、氏名や社名は顧客マスタとの照合で置き換えます。AIの回答を実名に戻したいなら、【メール1】のような連番プレースホルダにして対応表をGAS側に持たせてください。同じ値に同じ番号を割り当てると、伏せたまま文脈が保てます。

実装で外せないのは3点です。マスキングはAIを呼ぶ関数の内側に置いて経路を1本にする。ログに生データを出さない。そして、そもそも渡す列を最小限に絞る。この順番で設計すれば、社内データを扱うAI自動化でも安心して運用できます。

よくある質問

扱う情報と社内ルール次第です。多くのAPIは入力内容を外部のサーバーに送信するため、社内規程や取引先との契約で「第三者に提供しない」と決めている情報は、そのまま送れないことがあります。判断を毎回人に委ねるより、送信前にコードで機械的に伏せてしまうほうが事故が起きにくくなります。データの取り扱い条件は利用するAIサービスの公式ドキュメントと自社の規程で必ず確認してください。

この記事のマスキングは「AIに送る文字列から個人を特定できる部分を置き換える」処理を指します。元に戻せない形にするのが一般的な匿名化、対応表を持って元に戻せる形にするのが仮名化に近い考え方です。本記事では両方を扱い、AIの回答を元の表記に戻したい場合は復元できるプレースホルダ方式を使います。法令上の用語の定義は所管のガイドラインを確認してください。

消せません。メールアドレスや電話番号のように形が決まっているものは正規表現で確実に拾えますが、氏名・住所・社内の固有名詞は形が一定ではないため取りこぼします。定型パターンは正規表現、固有名詞はスプレッドシートのマスタ照合、というように二段構えにするのが現実的です。それでも完全ではない前提で、送る情報自体を最小限に絞ってください。

伏せ方次第です。「田中様」を全部「■■」にすると誰の話か分からなくなりますが、「【氏名1】」のように番号付きのプレースホルダにすれば、同一人物であることはAIに伝わります。要約や分類の精度を保ちたいときは、消すのではなく置き換えるのがコツです。

console.logに元の本文をそのまま出力しないでください。GASの実行ログはスクリプトの編集権限があれば閲覧できるため、シート本体より広い範囲に見えてしまうことがあります。ログにはマスキング後の文字列か、行番号と件数だけを出すようにします。エラー時も同様で、例外メッセージにAPIへ送るペイロードを含めないようにしてください。

AIに投げる関数の内側で呼ぶのが安全です。呼び出し側でマスキングする設計にすると、新しい呼び出し箇所を追加したときに付け忘れが起きます。Claude APIを叩く関数の先頭でマスキングを通してからペイロードを組み立てれば、経路が1本に絞られます。

社内データを外に出さずに、
AIで自動化する。

個人情報を含む問い合わせ対応、顧客データの集計、社内文書の要約まで。情報の扱いを設計したうえで、GAS×AIの自動化をご提案します。

GAS開発サービスを見る