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?

日本語CSVの壊れ方20例を、ブラウザだけで監査する

0
Posted at

3行まとめ

  • CSVは見た目が表でも、引用符や改行の置き方で取り込み結果が変わります。
  • 人工データ20例で、よくある構造の乱れを再現しました。
  • 記事末尾のHTMLを保存すると、自分のCSVを端末内だけで確認できます。

なぜ取り込み前に見るか

CSVは、カンマと改行で表を表す簡素な形式です。そのぶん、1個の引用符や区切り記号の違いが、後ろの行までずらすことがあります。表計算ソフトで自然に見えても、別の仕組みへ取り込むと列がずれる場合があります。

そこで、値の意味を調べる前に、まず構造を確認します。見る順番は、文字の読み取り、引用符、行ごとの列数、見出し、値の表記です。先に構造を確認すると、取り込み後に起きたずれを追う範囲を小さくできます。

20例の一覧

番号 見本 何が壊れているか どう検出するか
01 normal-basic 異常なし(基本形) 指摘が0件になることを確認
02 normal-quoted 異常なし(カンマと引用符を含むセル) 引用符内のカンマと二重引用符を正しく読む
03 column-short データ行の列が1つ足りない 見出しの列数と各行を比較
04 column-extra データ行の列が1つ多い 見出しの列数と各行を比較
05 unclosed-quote 引用符が閉じていない ファイル末尾まで引用中かを確認
06 unexpected-quote セル途中に引用符がある 引用符の開始位置を確認
07 embedded-newline 引用符内のセルに改行がある 引用中に現れた改行を記録
08 duplicate-header 見出し名が重複している 前後の空白を除いて見出しを比較
09 empty-header 空の見出しがある 見出し行の空セルを確認
10 whitespace-header 見出しの前後に空白がある 元の値と空白を除いた値を比較
11 bom 先頭にBOMがある 先頭文字がU+FEFFかを確認
12 replacement-character 文字化けを示す置換文字がある U+FFFDを探す
13 semicolon-delimiter 区切りがセミコロンになっている 引用符の外にある区切り候補を探す
14 mixed-delimiter カンマとセミコロンが混在している 引用符の外にある区切り候補を探す
15 blank-line データ途中に空行がある 引用符の外で空の行を探す
16 trailing-empty-column 行末に余分な空列がある 列数超過と末尾の空セルを組み合わせる
17 fullwidth-numeric 数値に全角数字が混ざっている 全角数字の範囲を探す
18 mixed-date-format 日付の書式が混在している ハイフン、スラッシュ、日本語形を分類
19 duplicate-row 同じデータ行が重複している セル列を連結した値を既出行と比較
20 mixed-line-ending CRLFとLFの改行が混在している 改行コードの種類を数える

見本の名前は、同梱したfixturesフォルダのファイル名と対応しています。氏名や住所などは含めず、品名も人工の名前にしています。

監査の考え方

中心は、文字列を先頭から1文字ずつ読む小さな状態機械です。状態機械とは、いま引用符の内側か外側かを覚えながら次の文字を判断する方法です。単純に改行で分割するとセル内改行を行の終わりと取り違えるため、この状態が必要です。

監査関数はCSV文字列を受け取り、指摘の配列を返します。各指摘には種類、行、列、重さ、直し方の提案を入れます。画面側はこの配列を表にするだけなので、監査と表示を分けて試せます。

const issues = auditCsv(csvText);
// [{ type, line, column, severity, suggestion }, ...]

行ごとの列数は見出し行を基準にします。ただし、引用符が閉じていない行は先に引用符の問題を直す必要があるため、その行では列数の指摘を重ねません。1つの原因から似た指摘を増やしすぎないためです。

自分のCSVで確かめる手順

  1. 下のコードをテキストエディタへ貼ります。
  2. ファイル名を csv-audit.html として保存します。
  3. 保存したファイルをブラウザで開きます。
  4. 「CSVファイルを選ぶ」から確認したいCSVを選びます。
  5. 行・列・直し方の提案を見て、元CSVの該当箇所を確認します。

選んだファイルは端末内で読み取ります。画面はUTF-8を先に試し、読めない場合はShift_JISを試します。

<!doctype html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <meta name="viewport" content="width=device-width, initial-scale=1">
  <title>CSV 構造監査票</title>
  <style>
    :root {
      color-scheme: light;
      --ink: #17312d;
      --muted: #58706b;
      --paper: #fffdf6;
      --back: #e9f0e9;
      --line: #b8c8bd;
      --accent: #d95032;
      --accent-dark: #a52e1a;
      --ok: #26715e;
      --warn: #9a5b0a;
      --repair: #a52e1a;
      --shadow: 0 18px 48px rgb(25 55 47 / 14%);
    }
    * { box-sizing: border-box; }
    html { background: var(--back); }
    body {
      margin: 0;
      color: var(--ink);
      font-family: -apple-system, BlinkMacSystemFont, "Hiragino Sans", "Hiragino Kaku Gothic ProN", "Noto Sans JP", "Yu Gothic Medium", "游ゴシック Medium", YuGothic, Meiryo, sans-serif;
      background:
        linear-gradient(90deg, transparent 31px, rgb(38 113 94 / 7%) 32px, transparent 33px),
        linear-gradient(rgb(38 113 94 / 5%) 1px, transparent 1px),
        var(--back);
      background-size: 64px 100%, 100% 32px;
      min-height: 100vh;
      word-break: auto-phrase;
    }
    @supports not (word-break: auto-phrase) {
      body { word-break: keep-all; overflow-wrap: anywhere; }
    }
    .page {
      width: min(100% - 32px, 64rem);
      margin: 0 auto;
      padding: 40px 0 64px;
    }
    header {
      display: grid;
      grid-template-columns: 1fr auto;
      gap: 24px;
      align-items: end;
      margin-bottom: 20px;
    }
    .eyebrow {
      margin: 0 0 8px;
      color: var(--accent-dark);
      font-size: .76rem;
      font-weight: 800;
      letter-spacing: .16em;
    }
    h1 {
      margin: 0;
      font-size: clamp(2rem, 7vw, 4.4rem);
      line-height: .98;
      letter-spacing: -.055em;
    }
    .stamp {
      border: 3px double var(--accent);
      color: var(--accent-dark);
      padding: 9px 12px;
      font-weight: 800;
      letter-spacing: .12em;
      transform: rotate(-3deg);
    }
    .sheet {
      position: relative;
      overflow: hidden;
      border: 1px solid var(--line);
      border-radius: 4px 18px 4px 4px;
      background: var(--paper);
      box-shadow: var(--shadow);
    }
    .sheet::before {
      content: "";
      position: absolute;
      inset: 0 auto 0 28px;
      width: 1px;
      background: rgb(217 80 50 / 20%);
      pointer-events: none;
    }
    .intro, .picker, .summary, .report { padding: 26px clamp(22px, 6vw, 58px); }
    .intro { border-bottom: 1px solid var(--line); }
    .intro p { max-width: 45rem; margin: 0; color: var(--muted); line-height: 1.8; }
    .privacy { margin-top: 10px !important; color: var(--ok) !important; font-weight: 700; }
    .picker { display: grid; gap: 14px; }
    .file-label {
      display: grid;
      min-height: 148px;
      place-items: center;
      gap: 8px;
      padding: 24px;
      border: 2px dashed var(--ok);
      border-radius: 10px;
      background: rgb(38 113 94 / 5%);
      cursor: pointer;
      text-align: center;
      transition: transform .18s ease, background .18s ease;
    }
    .file-label:hover { transform: translateY(-2px); background: rgb(38 113 94 / 9%); }
    .file-label:focus-within { outline: 3px solid rgb(217 80 50 / 30%); outline-offset: 3px; }
    .file-label strong { font-size: 1.15rem; }
    .file-label span { color: var(--muted); font-size: .88rem; }
    input[type="file"] { max-width: 100%; }
    .summary { display: none; border-block: 1px solid var(--line); background: #f5f1e5; }
    .summary.visible { display: grid; grid-template-columns: 1fr auto; gap: 18px; align-items: center; }
    .summary h2, .report h2 { margin: 0 0 6px; font-size: 1.35rem; }
    .summary p { margin: 0; color: var(--muted); }
    .count { font-size: 2.2rem; font-weight: 900; color: var(--accent-dark); }
    .report { display: none; }
    .report.visible { display: block; }
    .empty {
      padding: 28px;
      border-left: 6px solid var(--ok);
      background: rgb(38 113 94 / 8%);
      color: var(--ok);
      font-weight: 800;
    }
    .table-wrap { overflow-x: auto; }
    table { width: 100%; border-collapse: collapse; min-width: 660px; }
    th, td { padding: 13px 10px; border-bottom: 1px solid var(--line); text-align: left; vertical-align: top; }
    th { color: var(--muted); font-size: .78rem; letter-spacing: .08em; }
    td { line-height: 1.55; }
    .badge { display: inline-block; border-radius: 999px; padding: 3px 9px; color: white; font-size: .76rem; font-weight: 800; }
    .badge.notice { background: var(--warn); }
    .badge.repair { background: var(--repair); }
    footer { padding: 18px clamp(22px, 6vw, 58px); color: var(--muted); font-size: .8rem; }
    @media (max-width: 560px) {
      .page { width: min(100% - 20px, 64rem); padding-top: 22px; }
      header { grid-template-columns: 1fr; }
      .stamp { justify-self: start; }
      .intro, .picker, .summary, .report { padding-inline: 24px 18px; }
      .summary.visible { grid-template-columns: 1fr; }
      .count { font-size: 1.8rem; }
    }
    @media (prefers-reduced-motion: reduce) {
      * { scroll-behavior: auto !important; transition: none !important; }
    }
  </style>
</head>
<body>
  <main class="page">
    <header>
      <div>
        <p class="eyebrow">端末内・取り込み前点検</p>
        <h1>CSV<br>構造監査票</h1>
      </div>
      <div class="stamp" aria-hidden="true">取込前確認</div>
    </header>
    <section class="sheet" aria-labelledby="tool-heading">
      <div class="intro">
        <p id="tool-heading">CSVを取り込む前に、列数・引用符・見出し・文字コードの手がかりなどをまとめて確認します。</p>
        <p class="privacy">ファイルはこの端末の中だけで読み取ります。外部への送信はありません。</p>
      </div>
      <div class="picker">
        <label class="file-label">
          <strong>CSVファイルを選ぶ</strong>
          <span>UTF-8 と Shift_JIS を順に確認します</span>
          <input id="csv-file" type="file" accept=".csv,text/csv">
        </label>
        <p id="read-status" role="status" aria-live="polite"></p>
      </div>
      <div id="summary" class="summary" aria-live="polite">
        <div>
          <h2 id="file-name">監査結果</h2>
          <p id="encoding-name"></p>
        </div>
        <div id="issue-count" class="count"></div>
      </div>
      <section id="report" class="report" aria-labelledby="report-title">
        <h2 id="report-title">見つかった点</h2>
        <div id="report-body"></div>
      </section>
      <footer>判定は取り込み前の手がかりです。利用先の仕様と元データも合わせて確認してください。</footer>
    </section>
  </main>
  <script type="module">
const ADVICE = {
  column_count: "見出しと同じ列数になるよう、区切り記号と空のセルを確認してください。",
  unclosed_quote: "閉じていない引用符を補い、セル全体を引用符で囲んでください。",
  unexpected_quote: "セル途中の引用符は二重にするか、セル全体を引用符で囲んでください。",
  embedded_newline: "取り込み先がセル内改行に対応するか確認してください。",
  duplicate_header: "見出し名を重複しない名前に変えてください。",
  empty_header: "空の見出しに名前を付けてください。",
  whitespace_header: "見出しの前後にある空白を削除してください。",
  bom: "先頭のBOMを取り除くか、BOM対応の読み込み方法を選んでください。",
  replacement_character: "元の文字コードで読み直し、置換文字が残らないようにしてください。",
  delimiter_mixed: "区切り記号をカンマ1種類にそろえてください。",
  blank_line: "空行を削除するか、取り込み側で読み飛ばす設定にしてください。",
  trailing_empty_column: "行末の余分な区切り記号を削除してください。",
  fullwidth_numeric: "数値に使う数字を半角へそろえてください。",
  mixed_date_format: "日付形式を1種類にそろえてください。",
  duplicate_row: "重複した行が必要か確認し、不要なら1行にしてください。",
  mixed_line_ending: "改行コードを1種類にそろえてください。",
};

function issue(type, line, column, severity) {
  return { type, line, column, severity, suggestion: ADVICE[type] };
}

function parseCsv(text, delimiter) {
  const rows = [];
  const parseIssues = [];
  let fields = [];
  let fieldLines = [];
  let field = "";
  let line = 1;
  let rowLine = 1;
  let fieldLine = 1;
  let inQuotes = false;
  let quoteLine = 0;
  let quoteColumn = 0;
  let rowHasSyntaxError = false;
  let embeddedCandidate = null;

  const pushField = () => {
    fields.push(field);
    fieldLines.push(fieldLine);
    field = "";
    fieldLine = line;
  };

  const pushRow = () => {
    pushField();
    if (embeddedCandidate && !rowHasSyntaxError) parseIssues.push(embeddedCandidate);
    const blank = fields.length === 1 && fields[0] === "";
    rows.push({ values: fields, fieldLines, line: rowLine, blank, syntaxError: rowHasSyntaxError });
    fields = [];
    fieldLines = [];
    rowHasSyntaxError = false;
    embeddedCandidate = null;
  };

  for (let index = 0; index < text.length; index += 1) {
    const character = text[index];
    const next = text[index + 1];

    if (character === '"') {
      if (inQuotes && next === '"') {
        field += '"';
        index += 1;
      } else if (inQuotes) {
        inQuotes = false;
      } else if (field === "") {
        inQuotes = true;
        quoteLine = line;
        quoteColumn = fields.length + 1;
      } else {
        field += character;
        rowHasSyntaxError = true;
        parseIssues.push(issue("unexpected_quote", line, fields.length + 1, "要修正"));
      }
      continue;
    }

    if (character === delimiter && !inQuotes) {
      pushField();
      fieldLine = line;
      continue;
    }

    if (character === "\r" || character === "\n") {
      const isPair = character === "\r" && next === "\n";
      if (inQuotes) {
        field += "\n";
        embeddedCandidate ??= issue("embedded_newline", rowLine, fields.length + 1, "注意");
      } else {
        pushRow();
        rowLine = line + 1;
        fieldLine = line + 1;
      }
      if (isPair) index += 1;
      line += 1;
      continue;
    }

    field += character;
  }

  if (inQuotes) {
    rowHasSyntaxError = true;
    parseIssues.push(issue("unclosed_quote", quoteLine, quoteColumn, "要修正"));
  }

  const endedWithNewline = /(?:\r\n|\r|\n)$/.test(text);
  if (fields.length > 0 || field !== "" || (!endedWithNewline && text !== "")) pushRow();

  return { rows, parseIssues };
}

function findForeignDelimiter(text) {
  let inQuotes = false;
  let line = 1;
  for (let index = 0; index < text.length; index += 1) {
    const character = text[index];
    if (character === '"') {
      if (inQuotes && text[index + 1] === '"') index += 1;
      else inQuotes = !inQuotes;
    } else if (!inQuotes && (character === ";" || character === "\t")) {
      return line;
    } else if (character === "\n") {
      line += 1;
    } else if (character === "\r" && text[index + 1] !== "\n") {
      line += 1;
    }
  }
  return 0;
}

function mixedLineEndingLine(text) {
  const endings = [];
  for (let index = 0, line = 1; index < text.length; index += 1) {
    if (text[index] === "\r" && text[index + 1] === "\n") {
      endings.push({ kind: "crlf", line });
      index += 1;
      line += 1;
    } else if (text[index] === "\r") {
      endings.push({ kind: "cr", line });
      line += 1;
    } else if (text[index] === "\n") {
      endings.push({ kind: "lf", line });
      line += 1;
    }
  }
  if (new Set(endings.map(({ kind }) => kind)).size < 2) return 0;
  const firstKind = endings[0]?.kind;
  return endings.find(({ kind }) => kind !== firstKind)?.line ?? 1;
}

function dateKind(value) {
  if (/^\d{4}-\d{1,2}-\d{1,2}$/.test(value)) return "hyphen";
  if (/^\d{4}\/\d{1,2}\/\d{1,2}$/.test(value)) return "slash";
  if (/^\d{4}年\d{1,2}月\d{1,2}日$/.test(value)) return "japanese";
  return "";
}

export function auditCsv(text, options = {}) {
  if (typeof text !== "string") throw new TypeError("CSVは文字列で渡してください。");
  const delimiter = options.delimiter ?? ",";
  const issues = [];

  if (text.startsWith("\uFEFF")) issues.push(issue("bom", 1, 1, "注意"));

  const foreignDelimiterLine = delimiter === "," ? findForeignDelimiter(text) : 0;
  if (foreignDelimiterLine) {
    issues.push(issue("delimiter_mixed", foreignDelimiterLine, 1, "要修正"));
  }

  const { rows, parseIssues } = parseCsv(text, delimiter);
  issues.push(...parseIssues);

  const lineEndingLine = mixedLineEndingLine(text);
  if (lineEndingLine) issues.push(issue("mixed_line_ending", lineEndingLine, 1, "注意"));

  for (const row of rows) {
    if (row.blank) issues.push(issue("blank_line", row.line, 1, "注意"));
  }

  const nonblankRows = rows.filter(({ blank }) => !blank);
  const header = nonblankRows[0];
  if (!header) return issues;

  const normalizedHeaders = header.values.map((value, index) => {
    const withoutBom = index === 0 ? value.replace(/^\uFEFF/, "") : value;
    return withoutBom.trim();
  });
  const seenHeaders = new Set();
  normalizedHeaders.forEach((value, index) => {
    const raw = header.values[index].replace(index === 0 ? /^\uFEFF/ : /$^/, "");
    if (value === "") {
      issues.push(issue("empty_header", header.line, index + 1, "要修正"));
    } else if (seenHeaders.has(value)) {
      issues.push(issue("duplicate_header", header.line, index + 1, "要修正"));
    } else {
      seenHeaders.add(value);
    }
    if (raw !== raw.trim() && value !== "") {
      issues.push(issue("whitespace_header", header.line, index + 1, "注意"));
    }
  });

  const dataRows = nonblankRows.slice(1);
  for (const row of dataRows) {
    if (!row.syntaxError && row.values.length !== header.values.length) {
      const column = row.values.length < header.values.length
        ? row.values.length + 1
        : header.values.length + 1;
      issues.push(issue("column_count", row.line, column, "要修正"));
    }
    if (row.values.length === header.values.length + 1 && row.values.at(-1) === "") {
      issues.push(issue("trailing_empty_column", row.line, row.values.length, "要修正"));
    }
  }

  const dateKinds = new Set();
  const seenRows = new Set();
  for (const row of dataRows) {
    const rowKey = row.values.join("\u001F");
    if (seenRows.has(rowKey)) {
      issues.push(issue("duplicate_row", row.line, 1, "注意"));
    } else {
      seenRows.add(rowKey);
    }

    row.values.forEach((value, index) => {
      if (value.includes("\uFFFD")) {
        issues.push(issue("replacement_character", row.fieldLines[index] ?? row.line, index + 1, "要修正"));
      }
      if (/[0-9]/.test(value)) {
        issues.push(issue("fullwidth_numeric", row.fieldLines[index] ?? row.line, index + 1, "注意"));
      }
      const kind = dateKind(value);
      if (kind && dateKinds.size > 0 && !dateKinds.has(kind)) {
        issues.push(issue("mixed_date_format", row.fieldLines[index] ?? row.line, index + 1, "注意"));
      }
      if (kind) dateKinds.add(kind);
    });
  }

  return issues;
}


const labels = {
  column_count: "列数の違い",
  unclosed_quote: "閉じていない引用符",
  unexpected_quote: "セル途中の引用符",
  embedded_newline: "セル内改行",
  duplicate_header: "見出しの重複",
  empty_header: "空の見出し",
  whitespace_header: "見出し前後の空白",
  bom: "BOM(先頭の目印)",
  replacement_character: "文字化けの手がかり",
  delimiter_mixed: "区切り記号の混在",
  blank_line: "空行",
  trailing_empty_column: "行末の余分な列",
  fullwidth_numeric: "全角数字",
  mixed_date_format: "日付形式の混在",
  duplicate_row: "重複行",
  mixed_line_ending: "改行コードの混在"
};

function decodeCsv(buffer) {
  try {
    return { text: new TextDecoder("utf-8", { fatal: true }).decode(buffer), encoding: "UTF-8" };
  } catch {
    try {
      return { text: new TextDecoder("shift_jis", { fatal: true }).decode(buffer), encoding: "Shift_JIS" };
    } catch {
      return { text: new TextDecoder("utf-8").decode(buffer), encoding: "UTF-8(読み取りに注意)" };
    }
  }
}

function appendCell(row, value) {
  const cell = document.createElement("td");
  cell.textContent = value;
  row.append(cell);
}

function renderIssues(issues) {
  const body = document.querySelector("#report-body");
  body.replaceChildren();
  if (issues.length === 0) {
    const message = document.createElement("p");
    message.className = "empty";
    message.textContent = "この監査で指摘する点は見つかりませんでした。";
    body.append(message);
    return;
  }

  const wrap = document.createElement("div");
  wrap.className = "table-wrap";
  const table = document.createElement("table");
  const head = document.createElement("thead");
  const headRow = document.createElement("tr");
  for (const title of ["重さ", "場所", "種類", "直し方の提案"]) {
    const cell = document.createElement("th");
    cell.scope = "col";
    cell.textContent = title;
    headRow.append(cell);
  }
  head.append(headRow);
  table.append(head);
  const rows = document.createElement("tbody");
  for (const item of issues) {
    const row = document.createElement("tr");
    const severityCell = document.createElement("td");
    const badge = document.createElement("span");
    badge.className = item.severity === "要修正" ? "badge repair" : "badge notice";
    badge.textContent = item.severity;
    severityCell.append(badge);
    row.append(severityCell);
    appendCell(row, item.line + "行 / " + item.column + "列");
    appendCell(row, labels[item.type] ?? item.type);
    appendCell(row, item.suggestion);
    rows.append(row);
  }
  table.append(rows);
  wrap.append(table);
  body.append(wrap);
}

document.querySelector("#csv-file").addEventListener("change", async (event) => {
  const file = event.target.files[0];
  if (!file) return;
  const status = document.querySelector("#read-status");
  status.textContent = "読み取っています…";
  try {
    const decoded = decodeCsv(await file.arrayBuffer());
    const issues = auditCsv(decoded.text);
    document.querySelector("#file-name").textContent = file.name;
    document.querySelector("#encoding-name").textContent = "読み取り: " + decoded.encoding;
    document.querySelector("#issue-count").textContent = issues.length + "件";
    document.querySelector("#summary").classList.add("visible");
    document.querySelector("#report").classList.add("visible");
    renderIssues(issues);
    status.textContent = "監査が終わりました。";
  } catch (error) {
    status.textContent = "読み取れませんでした。別のCSVファイルで確認してください。";
  }
});
  </script>
</body>
</html>

限界

  • 値が業務上正しいか、名前や番号が正しいかは判断しません。
  • 文字コードの判定はUTF-8とShift_JISの読み取り結果による簡易判定です。
  • 日付として見る形は、ハイフン、スラッシュ、日本語表記の3種類です。
  • 区切りの基準はカンマです。タブやセミコロンが別の区切り候補として指摘します。
  • 大きなファイルでは、ブラウザが読み取り中に重くなる場合があります。
  • セル内改行やBOMは利用先が対応していれば使えるため、重さを「注意」としています。

この監査は元CSVを書き換えません。指摘を手がかりに、利用先の仕様と元データを合わせて判断してください。

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?