重複行を削除する前に残す行を確認|GASで候補をdry-run一覧化

重複行を削除する前に残す行を確認|GASで候補をdry-run一覧化

ツールおじ|GoogleアカウントをAIエージェント化

ツールおじ|GoogleアカウントをAIエージェント化

スプレッドシートに同じ管理IDの行が増えると、「どちらを消せばよいか」が分からないまま集計だけがずれていきます。重複削除を先に自動化すると、更新日時が新しい行や入力項目が多い行まで失うおそれがあります。

この記事では、元シートを変更せず、重複グループごとに残す候補と確認対象の行番号を別シートへ出す無料GASを紹介します。キーの実値は出力せず、ハッシュ化した識別子と件数だけをログへ残します。

この無料コードでできること

  • 指定した列を正規化し、完全一致する重複グループを検出する
  • 更新日時が新しい行、入力済みセルが多い行の順で「残す候補」を付ける
  • 元データを削除・並べ替え・上書きせず、新しい確認シートだけを作る
  • 管理IDそのものを確認シートや実行ログへ残さない
  • dry-run以外の設定では停止し、自動削除へ進まない

このコードが決めるのは「機械的な残し方の候補」までです。実際に削除するか、内容を統合するかは、確認シートを見て人が判断してください。

準備するシート

元シート名を「データ」にして、1行目へ次の見出しを置きます。

  • 管理ID
  • 更新日時
  • 状態
  • メモ

たとえば、同じ管理IDの行が2件あり、一方だけ更新日時やメモが新しいケースを少量のテストデータで作ります。実データへ適用する前に、必ずスプレッドシートをコピーしてください。

コピーして使えるGAS

const DUPLICATE_PLAN_CONFIG = {
  sourceSheetName: 'データ',
  keyHeaders: ['管理ID'],
  updatedAtHeader: '更新日時',
  maxRows: 1000,
  dryRun: true,
  reportPrefix: '重複確認'
};

function previewDuplicateKeepPlanFree() {
  const config = DUPLICATE_PLAN_CONFIG;
  if (config.dryRun !== true) {
    throw new Error('安全のためdryRun=true以外では実行できません。');
  }
  if (!Array.isArray(config.keyHeaders) || config.keyHeaders.length === 0) {
    throw new Error('keyHeadersを1列以上指定してください。');
  }

  const spreadsheet = SpreadsheetApp.getActiveSpreadsheet();
  const source = spreadsheet.getSheetByName(config.sourceSheetName);
  if (!source) {
    throw new Error('元シートが見つかりません: ' + config.sourceSheetName);
  }

  const values = source.getDataRange().getValues();
  if (values.length < 2) {
    throw new Error('見出し行と1行以上のデータを用意してください。');
  }
  if (values.length - 1 > config.maxRows) {
    throw new Error('確認上限を超えています。行数: ' + (values.length - 1));
  }

  const headers = values[0].map(normalizeCellFree_);
  const requiredHeaders = config.keyHeaders.concat([config.updatedAtHeader]);
  const missingHeaders = requiredHeaders.filter(function(header) {
    return headers.indexOf(normalizeCellFree_(header)) === -1;
  });
  if (missingHeaders.length > 0) {
    throw new Error('不足している見出し: ' + missingHeaders.join(', '));
  }
  if (new Set(headers).size !== headers.length) {
    throw new Error('同名の見出しがあります。1行目を確認してください。');
  }

  const keyIndexes = config.keyHeaders.map(function(header) {
    return headers.indexOf(normalizeCellFree_(header));
  });
  const updatedAtIndex = headers.indexOf(normalizeCellFree_(config.updatedAtHeader));
  const groups = new Map();
  const invalidRows = [];

  values.slice(1).forEach(function(row, offset) {
    const rowNumber = offset + 2;
    if (row.every(function(value) { return normalizeCellFree_(value) === ''; })) return;

    const keyParts = keyIndexes.map(function(index) {
      return normalizeCellFree_(row[index]).toLowerCase();
    });
    if (keyParts.some(function(value) { return value === ''; })) {
      invalidRows.push(rowNumber);
      return;
    }

    const normalizedKey = keyParts.join('|');
    const item = {
      rowNumber: rowNumber,
      keyHash: shortHashFree_(normalizedKey),
      updatedAt: toTimestampFree_(row[updatedAtIndex]),
      filledCount: row.filter(function(value) {
        return normalizeCellFree_(value) !== '';
      }).length
    };
    if (!groups.has(normalizedKey)) groups.set(normalizedKey, []);
    groups.get(normalizedKey).push(item);
  });

  const reportRows = [];
  let duplicateGroups = 0;
  groups.forEach(function(items) {
    if (items.length < 2) return;
    duplicateGroups += 1;
    items.sort(compareKeepCandidateFree_);
    items.forEach(function(item, index) {
      reportRows.push([
        item.keyHash,
        item.rowNumber,
        index === 0 ? '残す候補' : '要確認',
        index === 0 ? '更新日時と入力数を比較した先頭候補' : '削除せず内容を目視確認',
        item.updatedAt === null ? '無効または空欄' : '有効',
        item.filledCount
      ]);
    });
  });

  const timeZone = Session.getScriptTimeZone() || 'Asia/Tokyo';
  const suffix = Utilities.formatDate(new Date(), timeZone, 'yyyyMMdd_HHmmss');
  const reportName = config.reportPrefix + '_' + suffix;
  if (spreadsheet.getSheetByName(reportName)) {
    throw new Error('同名の確認シートがあります。時間を空けて再実行してください。');
  }
  const report = spreadsheet.insertSheet(reportName);
  const output = [[
    'キー識別子', '元シート行', '判定', '確認理由', '更新日時', '入力済みセル数'
  ]].concat(reportRows);
  report.getRange(1, 1, output.length, output[0].length).setValues(output);
  report.setFrozenRows(1);

  const summary = {
    checkedRows: values.length - 1,
    duplicateGroups: duplicateGroups,
    reportRows: reportRows.length,
    invalidKeyRows: invalidRows.length,
    sourceChanged: false,
    reportSheet: reportName,
    dryRun: true
  };
  console.log(JSON.stringify(summary));
  return summary;
}

function compareKeepCandidateFree_(a, b) {
  const aTime = a.updatedAt === null ? -1 : a.updatedAt;
  const bTime = b.updatedAt === null ? -1 : b.updatedAt;
  if (aTime !== bTime) return bTime - aTime;
  if (a.filledCount !== b.filledCount) return b.filledCount - a.filledCount;
  return a.rowNumber - b.rowNumber;
}

function normalizeCellFree_(value) {
  return String(value === null || value === undefined ? '' : value)
    .normalize('NFKC')
    .replace(/\s+/g, ' ')
    .trim();
}

function toTimestampFree_(value) {
  if (value === '' || value === null || value === undefined) return null;
  const date = value instanceof Date ? value : new Date(value);
  const timestamp = date.getTime();
  return Number.isFinite(timestamp) ? timestamp : null;
}

function shortHashFree_(text) {
  const bytes = Utilities.computeDigest(
    Utilities.DigestAlgorithm.SHA_256,
    text,
    Utilities.Charset.UTF_8
  );
  return bytes.map(function(byte) {
    return ('0' + ((byte + 256) % 256).toString(16)).slice(-2);
  }).join('').slice(0, 12);
}

実行手順

  1. 対象スプレッドシートをコピーし、コピー側で試します。
  2. 拡張機能からApps Scriptを開き、コードを貼り付けます。
  3. シート名と見出しが設定値に合うことを確認します。
  4. `previewDuplicateKeepPlanFree` を選び、少量データで実行します。
  5. 初回だけ必要な権限を確認し、作成された「重複確認_日時」シートを開きます。
  6. 元シート行を見比べ、残す・統合する・保留するを人が決めます。

実行ログへ出るのは確認行数、重複グループ数、確認対象件数、キー不足件数などの集計だけです。管理IDやメモ本文はログへ出しません。

判定の考え方

同じキーの行が複数ある場合、コードは次の順で残す候補を並べます。

  1. 有効な更新日時があり、より新しい行
  2. 空欄ではないセルが多い行
  3. それでも同じなら、元シートで上にある行

これは一般的な確認順です。業務上の正しさを保証するものではありません。たとえば「確定済みを優先する」「発送済みは統合対象にしない」といったルールがある場合は、自動削除せず、確認理由を追加してから使ってください。

失敗しやすい点と制限

  • キー列が空欄の行は重複判定へ入れず、件数だけを報告します。
  • 全角・半角、前後空白、英字の大文字・小文字は正規化しますが、似た名前や住所の曖昧一致は行いません。
  • 更新日時を解釈できない行は、日時なしとして後順位にします。
  • 上限は1000行です。大量データでは列数を減らし、分割して確認してください。
  • 元シートは変更しませんが、確認結果を新しいシートへ書く権限は必要です。
  • 作成された確認シートにも行番号などの業務情報が含まれます。共有範囲を確認してください。

公開前・実データ適用前チェック

  • 元データのコピーを作った
  • キー列が業務上の一意性を表している
  • 更新日時の形式を少量データで確認した
  • 「残す候補」を正解ではなく目視確認の順番として扱う
  • 自動削除、行の上書き、外部送信を追加していない
  • ログと確認シートへ個人情報の実値を出していない

まとめ

重複データの整理では、削除処理より先に「何が重複し、どの行を残す候補にするか」を説明できる状態にすることが重要です。この無料コードは、元シートを守ったまま、行番号と匿名化したキーで確認計画を作ります。

注文・入金・発送まで継続して整理したい方は、関連する有料Tipsも確認できます。

GasおじのTips商品一覧を見る

※学習・検証用の例です。実データへ適用する前にコピー環境で確認してください。Google公式・Google公認の商品ではありません。


あなたも記事の投稿・販売を
始めてみませんか?

Tipsなら簡単に記事を販売できます!
登録無料で始められます!

Tipsなら、無料ですぐに記事の販売をはじめることができます Tipsの詳細はこちら
 

この記事の販売者

ツールおじ|GoogleアカウントをAIエージェント化

おう、Gasおじだ。 AIツールとSaaSに散々突っ込んで遠回りした結果、最後に強かったのは「今あるGoogleアカウント」だった。 今ではAIエージェントであらゆるタスクを自動化して無双してるぞ。 GAS、スプレッドシート、フォーム、Gmail、AIエージェントを組み合わせれば、個人でも小さな会社でも、手作業だらけの業務はかなり自動化できる。 ここでは机上のAI論じゃなく、現場で動くテンプレ、コード、手順だけを置いていくぞ。 高いツールを増やす前に、まずお前さんのGoogleアカウントを仕事するエージェントに変えようじゃないか。

この販売者が書いた他の記事

  • Gmailの定型連絡を「送信せず」まとめて下書き化する|Gmail下書きテンプレGAS Pro

    ¥2,980
    1 %獲得
    (29 円相当)
  • 毎朝のToDoを区分・優先度・期限つきでまとめる Morning ToDo Mail Pro

    ¥2,980
    1 %獲得
    (29 円相当)
  • Gmailの自動ラベルが競合する前に確認|GASで仕分けルールをdry-run監査

関連のおすすめ記事

  • Gmailの定型連絡を「送信せず」まとめて下書き化する|Gmail下書きテンプレGAS Pro

    ¥2,980
    1 %獲得
    (29 円相当)
    ツールおじ|GoogleアカウントをAIエージェント化

    ツールおじ|GoogleアカウントをAIエージェント化

  • USBメモリで持ち運べるLinux Mint 22.1と永続的な書き込み領域の作成法【Rufus だけで作成する方法を初心者向けに徹底解説】

    ¥500
    1 %獲得
    (5 円相当)
    SmartSenior

    SmartSenior

  • 毎朝のToDoを区分・優先度・期限つきでまとめる Morning ToDo Mail Pro

    ¥2,980
    1 %獲得
    (29 円相当)
    ツールおじ|GoogleアカウントをAIエージェント化

    ツールおじ|GoogleアカウントをAIエージェント化