CSV整形に泣かされていませんか?—日付・金額・文字コードの崩れを自動整形

実際のビジネスシーンで使える自動化100例をGASで実装するシリーズ「GAS自働化100本ノック」。その8本目

CSV三大整形

  1. 日付の表記ゆれ

  2. 金額が文字列化

  3. 不要列だらけ

CSVファイルの表記ゆれの例

あなたもこんなCSVファイルに泣かされたことはありませんか?

  1. 日付は `2026/4/2` `2026.4.3` `2026-04-07` が混在

  2. 金額は `¥60,000` `32000円` `"54,000″` のようにそれぞれ装飾が違い、文字列扱いになってしまって `SUM` も効かない

  3. 会計システムから出る「取引番号」は使うけれど、「税込区分」「仕訳コード」など使わない列もたくさん入っていて、毎回手で削除…

スプレッドシートにCSVをインポートした瞬間に、値そのものが変質してしまうのもよくある事故です。

  • ゼロ落ち — 取引番号 `00123` が数値とみなされて `123` になり、後の照合で「該当なし」が大量発生

  • 勝手に日付変換 — 伝票番号 `1-2-3` が `2003/1/2` に、品番 `MAR1` が `2026/3/1` に化けて、検索しても見つからない

  • 文字化け — Shift_JIS出力のCSVをUTF-8として読み込むと「繧ィ繝ゥ繝シ」のような文字列に。国産の会計・販売管理ソフトはShift_JIS出力が今でも多い

日付・金額・文字コードの崩れを自動整形

GASなら、フォルダにCSVを入れておけば、必要な列だけ選別し、日付・金額の表記ゆれも整形済みで1枚のシートに出力することが可能です。

GASでできること

  1. 設定シートでフォルダ、文字コード、必要な列名を指定

  2. 指定したフォルダに置かれたCSVファイルを全部読み込む

  3. 設定シートで指定した必要な列だけを取り出す

  4. 日付・金額・全角の表記揺れを整形しながら「取込結果」シートに自動で書き出す

そのコードがこちら

コード.gs

// コード.gs
/**
 * Drive上のフォルダに置かれたCSVを読み込み、整形してスプレッドシートに書き出す
 *   - 設定シートで「取込先列 / CSV列名 / 型(date/money/number/text)」を指定
 *   - 文字コード(Shift_JIS / UTF-8)、フォルダIDも設定シートで指定
 *   - 日付の表記揺れ(2026/4/2, 2026.4.2, 2026-04-07)を YYYY-MM-DD に統一
 *   - 金額の "¥" "," "円" を取り除いて数値化、全角数字も半角化
 * 結果は「取込結果」シートを毎回クリアして書き直す(全件入れ替え)。
 */

const SETTINGS_SHEET = '設定';
const RESULT_SHEET = '取込結果';
const SOURCE_COL_NAME = '元ファイル名';
const MAPPING_HEADER_LABEL = '取込先列';

function importCsv() {
  const ss = SpreadsheetApp.getActiveSpreadsheet();
  const ui = SpreadsheetApp.getUi();

  const settings = ss.getSheetByName(SETTINGS_SHEET);
  if (!settings) {
    ui.alert('「' + SETTINGS_SHEET + '」シートが必要です。');
    return;
  }

  const folderId = String(settings.getRange('B1').getValue() || '').trim();
  const charset = String(settings.getRange('B2').getValue() || '').trim() || 'UTF-8';
  if (!folderId) {
    ui.alert('「' + SETTINGS_SHEET + '」シートのB1にフォルダIDを入力してください。');
    return;
  }

  const mapping = loadMapping_(settings);
  if (mapping.length === 0) {
    ui.alert('「' + SETTINGS_SHEET + '」シートに列マッピングが見つかりません。A列に「' + MAPPING_HEADER_LABEL + '」と書いた見出し行を作り、その下の行から「取込先列名 / CSV列名 / 型」を1行ずつ記入してください。');
    return;
  }

  let folder;
  try {
    folder = DriveApp.getFolderById(folderId);
  } catch (e) {
    ui.alert('フォルダが見つかりません。IDを確認してください。');
    return;
  }

  const csvFiles = collectCsvFiles_(folder);
  if (csvFiles.length === 0) {
    ui.alert('CSVファイルが見つかりませんでした。');
    return;
  }

  const merged = [];
  const skipped = [];
  csvFiles.forEach(function(file) {
    const text = file.getBlob().getDataAsString(charset);
    const rows = Utilities.parseCsv(text);
    if (rows.length < 2) return;

    const header = rows[0].map(function(h) { return String(h).trim(); });
    const colIndexes = mapping.map(function(m) { return header.indexOf(m.csvName); });
    if (colIndexes.indexOf(-1) >= 0) {
      skipped.push(file.getName());
      return;
    }

    for (let r = 1; r < rows.length; r++) {
      const row = rows[r];
      if (row.every(function(c) { return String(c).trim() === ''; })) continue;
      const out = [file.getName()];
      mapping.forEach(function(m, j) {
        out.push(formatValue_(row[colIndexes[j]], m.type));
      });
      merged.push(out);
    }
  });

  const outHeader = [SOURCE_COL_NAME].concat(mapping.map(function(m) { return m.outName; }));
  const sheet = ss.getSheetByName(RESULT_SHEET) || ss.insertSheet(RESULT_SHEET);
  sheet.clear();
  sheet.getRange(1, 1, 1, outHeader.length).setValues([outHeader]);
  if (merged.length > 0) {
    sheet.getRange(2, 1, merged.length, outHeader.length).setValues(merged);
    applyColumnFormats_(sheet, mapping, 2, merged.length);
  }
  sheet.getRange(1, 1, 1, outHeader.length)
    .setBackground('#f3f3f3')
    .setFontWeight('bold')
    .setHorizontalAlignment('center');

  SpreadsheetApp.flush();

  let msg = '取込完了。\n\n' +
    '取込ファイル数: ' + (csvFiles.length - skipped.length) + ' / ' + csvFiles.length + '\n' +
    'データ行数: ' + merged.length;
  if (skipped.length > 0) {
    msg += '\n\n必須列が見つからずスキップ:\n- ' + skipped.join('\n- ');
  }
  ui.alert(msg);
}

/**
 * 設定シートのA列から「取込先列」見出し行を見つけ、その次の行以降から
 * 「取込先列 / CSV列名 / 型」を読み取る。A列が空の行で終了。
 * 見出し行を動的に探すので、設定シート上部の行構成(フォルダID/文字コード/キー列など)
 * が増減してもマッピング開始行を書き換える必要がない。
 */
function loadMapping_(settings) {
  const lastRow = settings.getLastRow();
  if (lastRow < 1) return [];
  const colA = settings.getRange(1, 1, lastRow, 1).getValues();
  let headerRow = -1;
  for (let i = 0; i < colA.length; i++) {
    if (String(colA[i][0] || '').trim() === MAPPING_HEADER_LABEL) {
      headerRow = i + 1;
      break;
    }
  }
  if (headerRow === -1 || headerRow >= lastRow) return [];
  const values = settings.getRange(headerRow + 1, 1, lastRow - headerRow, 3).getValues();
  const mapping = [];
  for (let i = 0; i < values.length; i++) {
    const out = String(values[i][0] || '').trim();
    const csv = String(values[i][1] || '').trim();
    const type = String(values[i][2] || '').trim().toLowerCase();
    if (!out) break;
    mapping.push({
      outName: out,
      csvName: csv || out,
      type: type || 'text'
    });
  }
  return mapping;
}

function collectCsvFiles_(folder) {
  const csvFiles = [];
  const files = folder.getFiles();
  while (files.hasNext()) {
    const f = files.next();
    if (f.getName().toLowerCase().endsWith('.csv')) csvFiles.push(f);
  }
  csvFiles.sort(function(a, b) {
    return a.getName() < b.getName() ? -1 : a.getName() > b.getName() ? 1 : 0;
  });
  return csvFiles;
}

function formatValue_(raw, type) {
  if (raw === null || raw === undefined) return '';
  const s = zenkakuToHankaku_(String(raw)).trim();
  if (s === '') return '';
  if (type === 'date') return parseDate_(s);
  if (type === 'money' || type === 'number') return parseNumber_(s);
  return s;
}

/**
 * 2026/4/2, 2026.4.2, 2026-4-7, 2026/04/06 などをDate型に揃える。
 * パースに失敗したら元の文字列をそのまま返す(読み手が気づけるように)。
 */
function parseDate_(s) {
  const t = s.replace(/[.\/]/g, '-');
  const m = t.match(/^(\d{4})-(\d{1,2})-(\d{1,2})/);
  if (!m) return s;
  const y = Number(m[1]);
  const mo = Number(m[2]);
  const d = Number(m[3]);
  if (mo < 1 || mo > 12 || d < 1 || d > 31) return s;
  return new Date(y, mo - 1, d);
}

/** "¥1,200" "1,200円" "¥1.200" などから数値を取り出す。失敗時は元の文字列を返す。 */
function parseNumber_(s) {
  const t = s.replace(/[¥¥,円\s]/g, '');
  if (t === '' || isNaN(Number(t))) return s;
  return Number(t);
}

/** 全角の英数字を半角に変換。日本語の本文はそのまま残る。 */
function zenkakuToHankaku_(s) {
  return String(s).replace(/[0-9A-Za-z]/g, function(c) {
    return String.fromCharCode(c.charCodeAt(0) - 0xFEE0);
  });
}

/** 列ごとの数値フォーマットを適用(元ファイル名列の次から)。 */
function applyColumnFormats_(sheet, mapping, startRow, rowCount) {
  mapping.forEach(function(m, i) {
    const col = i + 2;
    const range = sheet.getRange(startRow, col, rowCount, 1);
    if (m.type === 'date') range.setNumberFormat('yyyy-mm-dd');
    else if (m.type === 'money') range.setNumberFormat('#,##0');
    else if (m.type === 'number') range.setNumberFormat('0');
  });
}

function onOpen() {
  SpreadsheetApp.getUi()
    .createMenu('自動ボタン')
    .addItem('CSV取込(全件入れ替え)', 'importCsv')
    .addToUi();
}

使い方

① 取込先のスプレッドシートを新規作成
整形結果を書き出す新規スプレッドシートを1つ用意します。

② 拡張機能 → Apps Script にコードを貼り付けて保存

③ Driveに取込元CSVを入れるフォルダを作る
例: `CSVファイル_取込元` というフォルダを作り、整形の必要なCSVをここに保存。
フォルダURLからフォルダIDをコピーします。
`https://drive.google.com/drive/folders/【ここの部分】` です。

④ 取込先スプレッドシートに「設定」という名前のシートを作成する

設定シートの例
  • B1: ③でコピーしたフォルダIDを貼り付ける

  • B2: 文字コード。取込元CSVに合わせて`Shift_JIS`か`UTF-8`を指定。空欄のままなら `UTF-8` 扱い。(Shift_JISのCSVを空欄で取り込むと文字化けするので、必ず指定してください)

  • A5以降: 列マッピング辞書。A列に出力したい列名(自分が見やすい名前)、B列にCSV側のヘッダー名(実際にCSVファイルに書かれている文字列)、C列に型を `date` / `money` / `number` / `text` のいずれかで指定。CSVに20列あっても、ここで指定した列だけ取込結果シートに出力されます

⑤ スプレッドシートをリロード → 「自動ボタン」→「CSV取込(全件入れ替え)」を実行
初めて実行すると承認ダイアログが表示されます。手順は第1回と同じです。
「取込結果」シートが自動生成され、フォルダ内のCSVから取り込まれた整形済みデータが出力されます。

整形済みCSVを取込結果シートに出力

日付は全部 `YYYY-MM-DD` のDate型に、金額は  `¥` `,` `円` の装飾を取り除いた数値型に、全角の `2026` も半角の `2026` に整形されました。

CSV側の列に左右されず、設定シートで指定した「取引番号 / 日付 / 商品名 / 金額 / 担当者」だけが抽出されます。

ワンポイント・レッスン

Utilities.parseCsv() と Blob.getDataAsString() — 文字コードを指定してCSVをパースする

`importCsv` 関数の中、`forEach` ループの先頭にあるこの2行(56,57行目)が、CSV取込の心臓部です。

const text = file.getBlob().getDataAsString(charset);
const rows = Utilities.parseCsv(text);

GASは Drive上のファイルを「Blob(バイナリの塊)」として取り出し、好きな文字コードで文字列に復号できます。会計ソフトのShift-JIS出力でも、`charset` を `’Shift_JIS’` に変えるだけで文字化けせずに読み込めます。

  • `file.getBlob()` — ファイルの中身を「バイトの塊」として取り出す。テキストかバイナリかに関係なく、一旦この形にする

  • `.getDataAsString('Shift_JIS’)` — 指定した文字コードで文字列に復号する。`’UTF-8’` `’Shift_JIS’` `’EUC-JP’` などが使える

  • `Utilities.parseCsv(text)` — CSVのルール(カンマ区切り、ダブルクオートで囲まれたフィールドの中の `"` と改行)を正しく解釈して、2次元配列にしてくれる

file.getBlob()              ← Driveの中身を取り出す(まだバイト列)
  └ .getDataAsString('Shift_JIS')  ← 文字コードを指定して文字列に
        └ Utilities.parseCsv(text)  ← CSVの構文を解釈して2次元配列に
            ├ ["1001","2026/4/2","オフィスチェア","¥60,000","田中"]
            ├ ["1002","2026.4.3","デスクライト","32000円","佐藤"]
            └ ...

ここで重要なのは、`Utilities.parseCsv` が CSVの厄介な仕様をすべて正しく扱える ことです。

  • クォート内のカンマ — `"¥60,000″` のようにダブルクオートで囲まれたフィールドの中のカンマは区切りではない

  • クォート内の改行 — 住所の備考欄に `"東京都千代田区<改行>千代田1-1″` のような改行が混じっても、1行として正しく扱われる

  • クォートのエスケープ — 商品名 `15″" モニター` のように `""` で書かれた箇所が `15″ モニター` に正しく復元される

自前で `text.split(',’)` や `text.split('\n’)` で済ませようとすると、これらのケースでフィールドが裂けて、住所と電話番号が入れ替わるような 列ズレが連鎖的に発生 します(実務でこの種の事故は本当によく起きる)。

CSVは見た目以上に仕様が緩く、人間が手で作ったCSVほどクォート抜け・エスケープミスが混じりやすい——なので、自前分割は避けて必ず `parseCsv` に任せるのがGASでの定石です。

組み込みの「ファイル → インポート」でも文字コードは自動判定されますが、毎回ダイアログで選び直す 必要があります。
GASなら設定シートのB2に文字コードを書いておくだけで、CSVが何度差し替わっても文字化けゼロで読み込めます。

まとめ

  • Driveのフォルダに置いたCSVを、設定シートで指定した「必要な列・型」だけ取り出して整形済みで1枚に書き出します。

  • 日付(`/` `.` `-` /全角)・金額(`¥` `,` `円`)の表記揺れも自動で吸収。

  • 文字コードはShift-JIS/UTF-8の切り替えに対応します。


取込元フォルダのCSVファイルを毎回入れ替えるのは面倒ですね?

入れ替えなくても、取り込み済みのファイルは自動でスキップできれば、週次・月次運用の効率が上がります。

拡張コードでそれを実現しましょう!

拡張コードでできること

  • 取込ログ — 「いつ/どのファイルから/何行取り込んで/何行重複でスキップしたか」を全部シートに残す

  • 処理済みファイルを記録 — 取込ログシートに記録し、次回実行時に「すでに取り込んだファイル」はスキップ。フォルダから抜く必要なし

  • キー列で重複行を除外 — 設定シートで一意キー列(取引番号・注文ID・伝票番号など、業務に合わせて自由に指定)を選んでおけば、既存シートに同じキーがあれば追記しない

  • 取込結果をクリア — ゼロから取り直したいときの一括リセットも標準装備

拡張コードなら重複行をスキップ

この記事の続きはnoteで公開しています。

noteで続きを読む

GAS,自動化自動化

Posted by botw