JSON Toolkit

Налагодження мініфікованого JSON: від рядка 1, стовпця 23611 до точного зламаного байта

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

Середній~7 хв читанняОпубліковано 2026-11-01Оновлено 2026-11-01
Обкладинка статті про пошук помилок у мініфікованому JSON

Коротко: номери рядків і стовпців мають сенс лише в багаторядкових документах. Щойно 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 МБ ніколи не залишають машину й не потрапляють у чужі журнали.

Два застереження:

  1. Обирайте інструмент, який явно каже, що обробляє все в браузері, а не той, що надсилає ваш вміст на бекенд через POST;
  2. Вставлення витримує дуже великі файли, але після кількох десятків мегабайтів сама вкладка з'їдає пам'ять — тоді поверніться до першого підходу.

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 — ті самі зміщення символів, тож ніколи не узгоджуйте column jq з ними;
  • [ ] Для документів 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 виконується після розбору, тож нижче за течією нічого не відновити — єдине надійне виправлення: виробник має видавати рядки.