Налагодження мініфікованого JSON: від рядка 1, стовпця 23611 до точного зламаного байта
Головне: Файл JSON на 4 МБ в один рядок повідомляє «рядок 1, стовпець 23611», і це нічого не дає. На локально виміряних зразках цей посібник пояснює, чому Python, Node і jq показують три різні числа, як зміщення символів веде просто до зламаного байта й які три пастки тихо псують дані після виправлення синтаксису.

Коротко: номери рядків і стовпців мають сенс лише в багаторядкових документах. Щойно JSON мініфікують в один рядок, кожна помилка живе в рядку 1 — потрібне вам число не номер рядка, а індекс символу.
Мініфікований документ JSON має рівно один рядок, тож пара рядок/стовпець у повідомленні про помилку згортає позицію на чотири мільйони байтів у одну непотрібну фразу. Вихід — повністю пропустити рядок/стовпець і читати зміщення символів. Скромне
(char 23610)у кінці повідомлення — саме те число, яке вас рятує.
Кожне число нижче отримано локально. Зразок — однорядковий документ із 13 000 подій оплати, загалом 2 011 084 символи, де одну кому навмисно видалено після event_id події №154. Середовище: Python 3.11.1, Node 22.13.1 і jq 1.7.
1. Спершу факти: три парсери, три числа
Той самий зламаний файл, поданий трьом інструментам:
Python 3.11.1 Expecting ',' delimiter: line 1 column 23611 (char 23610)
Node 22.13.1 Expected ',' or '}' after property value in JSON at position 23610 (line 1 column 23611)
jq 1.7 Expected separator between values at line 1, column 23619 exit code 5
Поруч:
| Інструмент | Повідомлене значення | Одиниця | Відлік від | Чому рядок/стовпець не працює |
|---|---|---|---|---|
Python JSONDecodeError.pos |
23610 | Зміщення символів | Початок буфера | Єдине, чому можна вірити |
Python lineno / colno |
1 / 23611 | Рядок і стовпець | Початок рядка | Лише один рядок, тож нічого не каже |
Node position |
23610 | Зміщення символів | Початок буфера | Надійне, хоч повідомлення й приховує поле |
jq column |
23619 | Зміщення символів | Початок проблемного токена | Відрізняється від Python на 9 |
Висновок прямий: char N у Python і position N у Node — те саме число, вони збігаються. jq використовує власну схему — не витрачайте час на її узгодження.
Три числа різняться не тому, що хтось помилився в обчисленні, а тому що вони рахують різні речі. Python і Node повідомляють, де парсер зупинився; jq повідомляє, де починається токен, який він не зміг розібрати. Між ними лежить кілька вже спожитих символів — 9 у цьому зразку.
2. Чому «рядок 1, стовпець N» — шум у такому файлі
Номери рядків — похідна величина, що припускає «минув якийсь час від останнього переводу рядка». Мініфікований документ має один \n, зазвичай у кінці, тому:
linenoзавжди 1;colnoвироджується в «скільки символів від початку того єдиного рядка»;- а той рядок має чотири мільйони символів завширшки.
Перейдіть до стовпця 23611 у редакторі — і не побачите нічого корисного. Гірше того, інструменти на кшталт less і vim повзуть на надзвичайно довгих рядках; посунути курсор стрілками — це двадцять хвилин.
Переверніть це: зміщення — природна величина, а рядок/стовпець обчислюються з неї. Використовуйте природну величину.
3. Підхід перший: у Python беріть e.pos і ігноруйте lineno
JSONDecodeError несе три числа, і лише pos варто читати. lineno і colno — похідні значення, які на однорядковому документі згортаються в ніщо.
# locate.py - Python 3.11.1
import json
import sys
path = sys.argv[1]
raw = open(path, encoding='utf-8').read()
try:
json.loads(raw)
except json.JSONDecodeError as error:
print(f'{type(error).__module__}.{type(error).__name__}: {error}')
start = max(0, error.pos - 60)
end = min(len(raw), error.pos + 60)
print(f'\nchar {start}..{end} of {len(raw)}:')
print(raw[start:end])
print(' ' * (error.pos - start) + '^')
sys.exit(1)
print('valid JSON')
Запустіть на зламаному файлі:
$ python3 locate.py bench/minified-json-error-position/broken.json
json.decoder.JSONDecodeError: Expecting ',' delimiter: line 1 column 23611 (char 23610)
char 23550..23670 of 2011083:
ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",
^
Каретка позначає місце, де бракує коми. Двадцять хвилин стають однією командою.
Наскільки широке вікно брати: ±60 символів майже завжди охоплює повну пару ключ/значення. Вужче — втрачаєте контекст; ширше — вивід заповнює екран. Глибоко вкладені документи можуть потребувати ±120.
4. Підхід другий: у контейнері немає Python — jq плюс cut
Виробничі контейнери часто постачаються без Python, але майже завжди мають jq або можуть отримати його однією командою apt-get install jq. jq дає вам гучну відмову:
$ jq . bench/minified-json-error-position/broken.json
jq: parse error: Expected separator between values at line 1, column 23619
$ echo $?
5
Код виходу 5 означає невалідний вхід, тож під set -e він перериває скрипт так само, як grep, що повертає 1. Це перевага.
jq не має прапорця для друку проблемних байтів, тож передайте позицію cut:
# Note -c (characters), not -b (bytes)
$ cut -c23550-23670 bench/minified-json-error-position/broken.json
"ua":"Mozilla/5.0 Chrome/141.0.0.0"},{"event_id":"evt_000154""user_id":100154,"email":"user154@example.com","plan":"pro",
Коли кома повертається, jq робить те, у чому справді добрий:
$ jq -r '.events | length' bench/minified-json-error-position/payload.json
13000
$ jq -r '.events[154] | "\(.event_id) \(.plan) \(.amount_cents)"' bench/minified-json-error-position/payload.json
evt_000154 pro 74479
5. Підхід третій: немає середовища виконання або дані не повинні торкатися диска — вставте у браузер
Перші два підходи потребують встановленого інструмента й дозволу писати файли. Якщо ви на голому ноутбуці, або корисне навантаження повне клієнтських електронних листів та IP-адрес, і фраза «я вставив це на якийсь сайт» — це речення, якого ви не хочете бачити у звіті про інцидент, третій варіант підходить.
Вставте весь рядок у онлайн-парсер JSON — він поставить каретку на проблемний символ і позначить рядок та стовпець; перевага локального інструмента в тому, що ці 4 МБ ніколи не залишають машину й не потрапляють у чужі журнали.
Два застереження:
- Обирайте інструмент, який явно каже, що обробляє все в браузері, а не той, що надсилає ваш вміст на бекенд через POST;
- Вставлення витримує дуже великі файли, але після кількох десятків мегабайтів сама вкладка з'їдає пам'ять — тоді поверніться до першого підходу.
6. Зміщення символів — не зміщення байтів: друга пастка з даними CJK
Це кусає обидва підходи на змішаних китайсько-англійських навантаженнях.
JSONDecodeError.pos — це зміщення символів (лічба кодових точок Unicode), бо воно є результатом розбору str. cut -c ріже за символами й збігається з ним. Але head -c, tail -c і dd ріжуть за байтами, і в UTF-8 один зсув збиває все.
Китайський зразок робить це наочним (5 замовлень, 148 символів, 222 байти):
>>> raw = open('bench/minified-json-error-position/cn.json', encoding='utf-8').read()
>>> raw[25:35] # characters 25-35
'城市":"杭州"},'
>>> open('bench/minified-json-error-position/cn.json','rb').read()[25:35] # bytes 25-35
b'DD00000","'
Те саме зміщення: символьний вигляд потрапляє на назву поля, байтовий — на хвіст ідентифікатора замовлення. Один символ CJK займає 3 байти в UTF-8, і дрейф накопичується з позицією.
Правило: прочитавши e.pos, ріжте через cut -c або зріз Python, ніколи через head -c / dd bs=1 skip=N. Якщо маєте лише байтові інструменти, спершу конвертуйте: len(raw[:error.pos].encode('utf-8')).
7. Синтаксис виправлено, дані все ще неправильні: три тихі пастки
Саме тому налагодження не може зупинитися на «воно розібралося». Кожен із наведених випадків або ніколи не кидає виняток, або кидає його значно пізніше після використання значення.
Пастка 1: повторювані ключі — перемагає останній, ніхто не скаржиться
Розділ 4 RFC 8259 чіткий: імена в об'єкті повинні бути унікальними, а коли ні — поведінка непередбачувана; більшість реалізацій зберігають останнє входження, інші кидають виняток. Усі три інструменти, виміряні тут, зберігають останнє:
Input {"order_id":"A-1001","total":4200,"order_id":"A-1002"}
Node {"order_id":"A-1002","total":4200}
Python {'order_id': 'A-1002', 'total': 4200}
jq {"order_id":"A-1002","total":4200}
Жодного попередження. Якщо перезаписане поле було amount_cents, ви дізнаєтеся, коли звірка розійдеться на три центи.
Пастка 2: NaN та Infinity — Python їх приймає, Node — ні
Розділ 6 RFC 8259 прямо каже, що числові літерали не містять NaN і Infinity. json.loads у Python приймає обидва за замовчуванням, бо його власний float має такі значення:
>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}' # written straight back out
Передайте цей вивід Node — і отримаєте:
SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON
Іншими словами: неправильні дані проходять Python і детонують у споживача нижче за течією.
Пастка 3: цілі числа понад 2^53 тихо втрачають точність
Один документ, два різні значення:
JSON source {"order_no":12345678901234567890}
Python 12345678901234567890 <- kept as-is
Node 12345678901234567000 <- last three digits flattened
Причина в одному рядку: Number.MAX_SAFE_INTEGER у JavaScript — це 9007199254740991 (2^53-1). MDN окремо зазначає, що reviver виконується після того, як значення вже розібрано в число JavaScript, тож точність втрачається до reviver — її не відновити на боці десеріалізації. Єдине виправлення — виробник має серіалізувати великі ID як рядки.
Порівняння трьох пасток:
| Пастка | Чи кидається щось | Хто помічає першим | Як перехопити |
|---|---|---|---|
| Повторювані ключі | Ні | Звірка нижче за течією | Явна перевірка через object_pairs_hook |
| NaN / Infinity | Node кидає, Python мовчить | Споживачі нижче за течією | Відхиляти на межі через parse_constant |
| Цілі понад 2^53 | Ні | Код, що порівнює рівність | Передавати як рядки |
Спіймати перші дві за один прохід
# check.py - Python 3.11.1
import json
import sys
from collections import Counter
def no_dupes(pairs):
counts = Counter(key for key, _ in pairs)
dupes = sorted(key for key, n in counts.items() if n > 1)
if dupes:
raise ValueError(f'duplicate key(s): {dupes}')
return dict(pairs)
def reject_constant(name):
raise ValueError(f'non-standard JSON constant: {name}')
raw = open(sys.argv[1], encoding='utf-8').read()
try:
data = json.loads(raw, object_pairs_hook=no_dupes, parse_constant=reject_constant)
except json.JSONDecodeError as error:
start, end = max(0, error.pos - 60), min(len(raw), error.pos + 60)
print(f'{error}\n\nchar {start}..{end} of {len(raw)}:')
print(raw[start:end])
print(' ' * (error.pos - start) + '^')
sys.exit(1)
except ValueError as error:
print(f'rejected: {error}')
sys.exit(1)
print(f'valid JSON, {len(raw)} chars')
Ціна, сказана чесно: на тому самому зразку на 2 011 084 символи ця строга версія дає 35,2–36,2 мс проти 9,4–9,9 мс у м'якої, приблизно в 3,7 раза повільніше (від 3,6 до 3,8 раза за п'ять раундів по п'ять запусків, медіана на раунд) — object_pairs_hook запускає Counter для кожного об'єкта. Тримайте її поза щасливим шляхом CI і вмикайте, лише коли доставка виглядає підозріло.
8. Контрольний список
Після виправлення файлу так і тягне відформатувати його для читабельності, але той самий зразок вимірюється як 2 011 084 символи в мініфікованому вигляді (separators=(',',':')) і 2 869 100 символів за indent=2 — на 43% більше. Ця вага подорожує в історію Git, кеші CI й мережеву передачу, тож тримайте гарний варіант локально й стискайте назад в один рядок перед відправкою.
- [ ] Спершу шукайте
(char N)/position N; ігноруйтеline 1 column N; - [ ] Python використовує
e.pos, jq поєднується зcut -c— ті самі зміщення символів, тож ніколи не узгоджуйтеcolumnjq з ними; - [ ] Для документів CJK ріжте за символами (
cut -c, зрізи Python), ніколиhead -c/dd; - [ ] Скрипти в контейнері мають трактувати код виходу 5 jq як «невалідний вхід»;
- [ ] Перевіряйте підозрілі дані через
object_pairs_hook+parse_constantі приймайте ціну приблизно 3,7 раза; - [ ] Будь-який ID понад 2^53 відправляйте як рядок; не сподівайтеся, що reviver його врятує;
- [ ] Гарно відформатований вивід (виміряно +43%) лишається локальним: використовуйте /minify, щоб стиснути назад в один рядок перед відправкою чи комітом; понад кілька МБ переходьте на потокову обробку, описану в Розбір, рендеринг і потокова обробка великого JSON.
FAQ
Чому всі три інструменти повідомляють різну позицію?
Бо рахують різні речі. char N у Python і position N у Node обидва означають «де парсер зупинився» — у цьому зразку обидва 23610, тож збігаються. jq повідомляє, де починається проблемний токен, тут 23619, на дев'ять далі. Ніхто не помилився в обчисленні й корекція не потрібна; порівнюйте числа лише в межах одного інструмента.
Використовувати cut -c чи head -c?
cut -c. JSONDecodeError.pos — зміщення, лічене в символах Unicode, а cut -c ріже за символами, тож вони узгоджуються. head -c, tail -c і dd ріжуть за байтами, що дрейфує на документах UTF-8 з текстом CJK і може навіть розрізати символ навпіл. На китайському зразку те саме зміщення дає 城市":"杭州"}, із символьного вигляду й DD00000"," із байтового.
Чи справді обробка повторюваних ключів ніде не захищена?
Так. Розділ 4 RFC 8259 лише каже, що імена повинні бути унікальними, і зазначає, що поведінка непередбачувана, коли це не так. Python, Node і jq, виміряні тут, усі застосовують «перемагає останній» без жодного попередження. Щоб це спіймати, потрібен власний object_pairs_hook ціною приблизно в 3,7 раза більшого часу розбору.
Чому Python читає NaN нормально, а Node кидає?
Розділ 6 RFC 8259 забороняє NaN та Infinity як числові літерали. json.loads у Python приймає їх за замовчуванням, бо власний float Python має ці значення, тож неправильні дані проходять Python і пишуться назад. JSON.parse у Node суворо дотримується специфікації й кидає SyntaxError на такому виводі. Додавання parse_constant на вході відхиляє його негайно.
Чи 20-значні номери замовлень завжди втрачають точність через JSON?
Лише коли споживач розбирає їх у double IEEE 754. int у Python має довільну точність і зберігає 12345678901234567890; Number.MAX_SAFE_INTEGER у JavaScript — 9007199254740991, тож те саме поле стає 12345678901234567000. MDN прямо зазначає, що reviver виконується після розбору, тож нижче за течією нічого не відновити — єдине надійне виправлення: виробник має видавати рядки.