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文字ずつ進める必要があります。
ここで一度、自分で状態機械を書こうとしました。やめました。
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しても違いが分かりません。
// 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らしい順に並べてから順に試します。
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)だけでは区別できません。
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