CSV整形に泣かされていませんか?—日付・金額・文字コードの崩れを自動整形
実際のビジネスシーンで使える自動化100例をGASで実装するシリーズ「GAS自働化100本ノック」。その8本目
CSV三大整形
-
日付の表記ゆれ
-
金額が文字列化
-
不要列だらけ
あなたもこんなCSVファイルに泣かされたことはありませんか?
-
日付は `2026/4/2` `2026.4.3` `2026-04-07` が混在
-
金額は `¥60,000` `32000円` `"54,000″` のようにそれぞれ装飾が違い、文字列扱いになってしまって `SUM` も効かない
-
会計システムから出る「取引番号」は使うけれど、「税込区分」「仕訳コード」など使わない列もたくさん入っていて、毎回手で削除…
スプレッドシートにCSVをインポートした瞬間に、値そのものが変質してしまうのもよくある事故です。
-
ゼロ落ち — 取引番号 `00123` が数値とみなされて `123` になり、後の照合で「該当なし」が大量発生
-
勝手に日付変換 — 伝票番号 `1-2-3` が `2003/1/2` に、品番 `MAR1` が `2026/3/1` に化けて、検索しても見つからない
-
文字化け — Shift_JIS出力のCSVをUTF-8として読み込むと「繧ィ繝ゥ繝シ」のような文字列に。国産の会計・販売管理ソフトはShift_JIS出力が今でも多い
日付・金額・文字コードの崩れを自動整形
GASなら、フォルダにCSVを入れておけば、必要な列だけ選別し、日付・金額の表記ゆれも整形済みで1枚のシートに出力することが可能です。
GASでできること
-
設定シートでフォルダ、文字コード、必要な列名を指定
-
指定したフォルダに置かれたCSVファイルを全部読み込む
-
設定シートで指定した必要な列だけを取り出す
-
日付・金額・全角の表記揺れを整形しながら「取込結果」シートに自動で書き出す
そのコードがこちら
コード.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から取り込まれた整形済みデータが出力されます。
日付は全部 `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で公開しています。
