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なんてカンマで切ればいい」と思っていたら、実データで3回ぶっ壊れた

0
Posted at

CSVのパースは「カンマで切ればいい」と思っていました。実データで3回壊してしまいました。

しかも壊れ方が静かです。例外は出ません。行がずれたまま、それらしい数字が出ます。
間違った結果が正常に見えるのが、この手のバグの一番たちの悪いところでした。

Search Console の「ページ」CSVをブラウザ内で解析するツールを作ったときの記録です。
判定はクライアントサイドで完結させ、CSVはサーバーに送らない構成にしています
(Next.js 16 + TypeScript)。

同じものを書く人が同じ3つを踏まないように、壊れる入力と、それを通す実装を並べます。

1. split("\n") は、フィールド内改行で壊れる

Search Console のエクスポートにはフィールド内改行が入ります。
ダブルクォートで囲まれた中に改行があるので、1レコードが2物理行にまたがります。

"サイト名
の続き",52,2100,2.48%,4.3

素朴に text.split("\n") すると、ここでレコードがずれて以降が全部壊れます。
1行目だけ見て動作確認すると、絶対に気づきません。

正しく読むには、RFC 4180 に沿ってクォートの内側にいるかどうかを持ちながら1文字ずつ進める必要があります。

ここで一度、自分で状態機械を書こうとしました。やめました。

lib/parseGsc.ts
import Papa from "papaparse";

// 区切り文字は Papa に自動判定させる(CSV / TSV 両対応)
const parsed = Papa.parse<string[]>(text, { /* ... */ });

正規表現で頑張らないこと。 「クォートの中か外か」は状態なので、
状態を持たない道具で解こうとすると必ずどこかで破れます。

そして状態機械を自前で書くのも、たいてい割に合いません。
クォートのエスケープ、CRLF、末尾の改行有無、区切り文字の自動判定——
どれも「知っていれば書ける」けれど、知らないと気づかないまま通ってしまう類のものです。
このセクションで言いたかったのは「正しく実装しよう」ではなく、
**「ここは既製品に任せる場所だ」**でした。

2. Excelで一度開いたCSVは、もう別のファイルになっている

元のエクスポートは UTF-8 BOMなしです。
ところが**一度Excelで開いて保存すると、BOM付きか Shift_JIS(CP932)**に変わります。

利用者は「開いて中身を確認しただけ」のつもりです。悪意も操作ミスもありません。
それでもファイルは書き換わっています。

BOMが残ると1列目のヘッダーが 上位のページ になり、完全一致の判定が落ちます。
見た目は同じなので、ヘッダーをprintしても違いが分かりません。

lib/readCsvFile.ts
// UTF-16 の BOM 判定は省略。BOM を剥がしてから判定するのが要点
let body = bytes;
if (bytes.length >= 3 && bytes[0] === 0xef && bytes[1] === 0xbb && bytes[2] === 0xbf) {
  body = bytes.subarray(3); // UTF-8 BOM
}

try {
  return new TextDecoder("utf-8", { fatal: true }).decode(body);
} catch {
  // 日本語のShift_JISは、UTF-8として読むとほぼ必ず不正バイトになる
  return new TextDecoder("shift_jis").decode(body);
}

BOMは、デコードの前に剥がします。 TextDecoder("utf-8")
BOM付きUTF-8をエラーにせず読めてしまうので、fatal: true にしていても
先頭に U+FEFF が残ります。「文字コード判定」と「BOM除去」は別の仕事で、
順番を逆にすると、この節で言っているヘッダー不一致がそのまま起きます。

fatal: true を外してはいけません。 外すと不正バイトが置換文字(U+FFFD)になり、
Shift_JISを取りこぼしたことに気づけません。 例外が飛ぶからこそフォールバックできます。
「エラーを握りつぶさない」ではなく、エラーを検出手段として使う場面です。

3. .xlsx の中身もZIPなので、シグネチャでは区別できない

エクスポートするとCSVが複数入ったZIPが落ちてくるので、ZIPのままドロップできるようにしました。
解凍を求めるのは、それだけで離脱の理由になります。

ここで2つ問題が出ます。

① 中に複数のCSVがある。 GSCのZIPには「ページ.csv」だけでなく「クエリ.csv」「国.csv」
「デバイス.csv」も入っています。先にクエリ側を試すと誤判定するので、
ページCSVらしい順に並べてから順に試します。

lib/readCsvFile.ts
const score = (n: string): number => {
  const b = base(n).toLowerCase();
  if (b === "ページ.csv" || b === "pages.csv") return 0; // 完全一致
  if (b.includes("ページ") || b.includes("pages")) return 1; // 部分一致
  if (b.includes("クエリ") || b.includes("queries")) return 3; // 誤爆しやすいので後ろへ
  return 2;
};

名前で決め打ちしないこと。 ZIP内のファイル名がShift_JISで格納されていると
ページ が化けます。順位付けにしておけば、外れても後続の候補で拾えます。
__MACOSX/._ で始まるエントリは捨てます。

.xlsx の中身もZIP。 シグネチャ(PK\x03\x04)だけでは区別できません。

lib/readCsvFile.ts
export function looksLikeXlsx(entryNames: string[]): boolean {
  return entryNames.some((n) => n === "[Content_Types].xml" || n.startsWith("xl/"));
}

中身を見て判別し、専用のメッセージを出しています。これが無いと
**「ZIPだがCSVが無い」**という、原因の分からない表示になります。
.xlsx を落とした人に必要なのは「CSVで出し直してください」の一言だけです。

おまけ: 数値の扱いで気をつけたこと

  • 順位ごとの平均CTRは単調に下がりません。 13位で1.07%まで下がったあと19位で2.91%に上がるので、
    そのまま使うと**「順位を下げたほうがスコアが上がる」**矛盾が出ます
  • max(0, expected - actual) を挟む。 指名検索の多いページは実CTRが期待を大きく超えます
  • 表示回数の下限を引く。 表示17回でクリックが1回増えるとCTRが0%→5.9%に跳ねます。
    この数字で何かを判断してはいけません

まとめ

3つとも共通していたのは、例外が出ないまま間違った結果が出ることでした。

  • split("\n") … 行がずれる。それらしい数字が出る
  • BOM / Shift_JIS … ヘッダーが一致しない。見た目は同じ
  • .xlsx … ZIPとして開ける。中身が違うだけ

なので、対策も同じ形になります。壊れた入力を、壊れたと分かる形で受け取る。
fatal: true で例外を出させるのも、.xlsx を名指しで弾くのも、そのためでした。

静かに間違えるより、うるさく落ちるほうがいい。


期待CTRに何を置くか(AI Overview による58%のクリック減をどう補正したか)は別に書きました。

このパーサが動いているのは → https://rewrite-radar.com

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?