背景
自分のサイトに、JSONL と JSON の配列を相互に変換するツールを置いています。
ログやデータの受け渡しで JSONL をよく触るので、見比べるために作りました。
そのツールまわりを確かめているときに、Python 側で読んだら件数が合わない…ということがありました。
原因は、目に見えない改行っぽい文字でした。
その備忘録です。
JSONL(JSON Lines)は、1行に1つの JSON を書き、改行で区切って並べる形式だ。
NDJSON とも呼ばれ、1行ずつ読めるので大きなログでもメモリに全部載せずに処理できる。
仕様のページには、行の区切りは \n と書かれています。
https://jsonlines.org/
結論
JavaScript の JSON.stringify は、下記の3文字をエスケープせずにそのまま出力します。
- U+2028(LINE SEPARATOR)
- U+2029(PARAGRAPH SEPARATOR)
- U+0085(NEXT LINE、NEL と略される)
一方、Python の str.splitlines() は、この3文字も行の区切りとして扱います。
そのため、文字列の中にこれらを含むレコードがあると、1件が2行に割れて json.loads で読めなくなります。
https://docs.python.org/3/library/stdtypes.html#str.splitlines
実際に割れた例
Node.js(v24.11.1)で、下記の3件を JSON.stringify して1行ずつ書き出しました。
1件目のメッセージには U+2028、2件目には U+0085 が入っています(どちらも画面では見えないので、気づきにくい文字です)。
const fs = require('fs');
const recs = [
{ id: 1, msg: '前半\u2028後半' },
{ id: 2, msg: 'x\u0085y' },
{ id: 3, msg: 'ok' },
];
fs.writeFileSync('raw.jsonl', recs.map((v) => JSON.stringify(v)).join('\n') + '\n');
このファイルを Python 3.11.9 で読んだ結果が下記です。
splitlines() -> 5 行(json.loads できたのは 1 行だけ)
for line in f: -> 3 行
splitlines() だと、1件目と2件目がそれぞれ途中で割れて5行になり、読めたのは3件目だけでした。
for line in f: でファイルを1行ずつ読む方法だと、区切りは改行(\n、\r\n、\r)だけなので3行のまま読めます。
同じ Python でも、読み方で結果が変わるのがややこしいところです…
なぜエスケープされないのか
試しに、改行っぽい文字を1つずつ JSON.stringify('a' + 文字 + 'b') に通してみました。
U+000B -> "a\u000bb" (エスケープされる)
U+000C -> "a\fb" (エスケープされる)
U+001C -> "a\u001cb" (エスケープされる)
U+0085 -> "a{U+0085そのまま}b" (エスケープされない)
U+2028 -> "a{U+2028そのまま}b" (エスケープされない)
U+2029 -> "a{U+2029そのまま}b" (エスケープされない)
JSON の仕様(RFC 8259)でエスケープが必須なのは、U+0000〜U+001F の制御文字と " と \ だけだ。
U+2028・U+2029・U+0085 はその範囲の外なので、そのまま書いても正しい JSON になる。
https://www.rfc-editor.org/rfc/rfc8259#section-7
つまり JSON.stringify は仕様どおりで、困るのは「JSON の外側で行を割る」処理のほうです。
書く側で直す
読む側を全部 for line in f: にそろえられればいいのですが、相手のコードまでは選べないことが多いです。
なので、書く側で3文字だけ \u エスケープにしておくのが安全な気がします。
下記は、1件のオブジェクトを JSONL の1行にする関数で、JSON.stringify の結果のうちこの3文字だけを置き換えています。
上の例の recs に、そのまま使えます。
function toJsonlLine(v) {
return JSON.stringify(v).replace(/[\u0085\u2028\u2029]/g,
(c) => '\\u' + c.charCodeAt(0).toString(16).padStart(4, '0'));
}
const out = recs.map(toJsonlLine).join('\n') + '\n';
fs.writeFileSync('safe.jsonl', out);
出力は下記のようになります。
{"id":1,"msg":"前半\u2028後半"}
{"id":2,"msg":"x\u0085y"}
{"id":3,"msg":"ok"}
この \u2028 は JSON のエスケープなので、JSON.parse で読み戻すと元の文字に戻ります。
実際に読み戻して、3件とも元の文字列と一致しました。
このファイルなら、Python の splitlines() でも3行になり、3件とも json.loads できました。
ちなみに、私のツールの JSON→JSONL 変換も、今は素の JSON.stringify のままです。
ツールが JSONL を読むときの区切りは \n・\r\n・\r だけにしているので、ツールに読ませるぶんには割れません。
https://hashitosystem.com/tools/jsonl/
JSONL を JavaScript で書くなら、U+2028・U+2029・U+0085 の3文字だけは \u エスケープにしておく。
読む側を選べないデータほど、これで事故が1つ減ります。
本記事はAI補助で執筆した、個人開発の紹介記事です。