JSON Toolkit

דיבוג JSON ממוזער: משורה 1 עמודה 23611 ועד לבת השבור המדויק

שורה תחתונה: קובץ JSON של 4 MB בשורה אחת מדווח על שורה 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 היסט תו תחילת ה-token הבעייתי שונה מ-Python ב-9

המסקנה חדה: ה-char N של Python וה-position N של Node הם אותו מספר ומתיישרים. jq משתמש בשיטה משלו — אל תבזבזו זמן על יישור ביניהם.

שלושת המספרים שונים לא מפני שמישהו חישב לא נכון, אלא מפני שהם סופרים דברים שונים. Python ו-Node מדווחים היכן המפענח עצר; jq מדווח היכן מתחיל ה-token שהוא לא הצליח לפענח. בין השניים יושבים כמה תווים שכבר נצרכו — 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:

# הערה: -c (תווים), ולא -b (בתים)
$ 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. גישה שלישית: אין runtime, או נתונים שאסור שייגעו בדיסק — הדביקו בדפדפן

שתי הגישות הראשונות דורשות משהו מותקן והרשאה לכתוב קבצים. אם אתם על מחשב נייד חשוף, או שהמטען מלא באימיילים של לקוחות ובכתובות IP והמשפט "הדבקתי את זה לאיזה אתר" הוא לא משפט שאתם רוצים בדוח האירוע — האפשרות השלישית מתאימה.

הדביקו את המחרוזת כולה למפענח JSON מקוון והוא מציב את הסמן על התו הבעייתי ומסמן את השורה והעמודה; היתרון של כלי מקומי הוא שאותם 4 MB לעולם לא עוזבים את המחשב ולעולם לא נוחתים בלוגים של מישהו אחר.

שתי הסתייגויות:

  1. בחרו כלי שמצהיר במפורש שהוא מעבד הכול בדפדפן, ולעולם לא כזה ששולח POST עם התוכן שלכם ל-backend;
  2. הדבקה סובלת קבצים גדולים מאוד, אבל מעבר לכמה עשרות מגה-בייט הטאב עצמו אוכל זיכרון — בשלב הזה חזרו לגישה הראשונה.

6. היסט תו אינו היסט בית: המלכודת השנייה בנתוני CJK

הזו נושכת את שתי הגישות במטענים מעורבים של סינית/אנגלית.

JSONDecodeError.pos הוא היסט תו (ספירת Unicode code points), מפני שהוא נובע מפענוח של 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]                                    # תווים 25-35
'城市":"杭州"},'
>>> open('bench/minified-json-error-position/cn.json','rb').read()[25:35]      # בתים 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 ברור: שמות בתוך אובייקט SHOULD להיות ייחודיים, וכשהם לא, ההתנהגות בלתי צפויה — רוב המימושים שומרים על המופע האחרון, אחרים זורקים. כל שלושת הכלים שנמדדו כאן שומרים על האחרון:

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 קובע בבירור ש-literal של מספר אינו מכיל NaN ואינו מכיל Infinity. ה-json.loads של Python מקבל את שניהם כברירת מחדל, כי ל-float שלו יש את הערכים האלה:

>>> json.loads('{"discount":NaN}')
{'discount': nan}
>>> json.dumps(json.loads('{"discount":NaN}'))
'{"discount": NaN}'    # נכתב חזרה כמו שהוא

העבירו את הפלט הזה ל-Node ותקבלו:

SyntaxError: Unexpected token 'N', "{"discount":NaN}" is not valid JSON

במילים אחרות: נתונים רעים עוברים את Python ומתפוצצים אצל הצרכן במורד הזרם.

מלכודת 3: שלמים מעבר ל-2^53 מאבדים דיוק בשקט

מסמך אחד, שני ערכים שונים:

JSON source   {"order_no":12345678901234567890}
Python        12345678901234567890          <- נשמר כמו שהוא
Node          12345678901234567000          <- שלוש הספרות האחרונות נשחקו

הסיבה בשורה אחת: 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 ms לעומת 9.4-9.9 ms בגרסה המקלה, בערך פי 3.7 איטית יותר (3.6x עד 3.8x לאורך חמישה סבבים של חמש ריצות כל אחד, חציון לכל סבב) — ה-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 כדי לדחוס חזרה לשורה אחת לפני משלוח או commit; מעל כמה MB עברו לזרימה במקום, כמתואר בפענוח, תצוגה וזרימה של JSON גדול.

שאלות נפוצות

למה שלושת הכלים מדווחים על מיקום שונה?

מפני שהם סופרים דברים שונים. ה-char N של Python וה-position N של Node משמעותם "היכן המפענח עצר" — בדוגמה הזו שניהם 23610, ולכן הם תואמים. jq מדווח היכן מתחיל ה-token הבעייתי, 23619 כאן, תשעה תווים נוספים קדימה. איש לא חישב לא נכון ואין צורך בתיקון; השוו מספרים רק בתוך אותו כלי.

להשתמש ב-cut -c או ב-head -c?

השתמשו ב-cut -c. ה-JSONDecodeError.pos הוא היסט הנספר בתווי Unicode, ו-cut -c חותך לפי תו, ולכן השניים מסכימים. head -c, tail -c ו-dd חותכים לפי בית, מה שסוטה במסמכי UTF-8 המכילים טקסט CJK ואף יכול לחתוך תו לשניים. בדוגמה הסינית, אותו היסט מניב 城市":"杭州"}, מתצוגת התווים ו-DD00000"," מתצוגת הבתים.

הטיפול במפתחות כפולים באמת לא מוגן בכל מקום?

כן. סעיף 4 של RFC 8259 רק אומר ששמות SHOULD להיות ייחודיים ומציין שההתנהגות בלתי צפויה כשהם לא. Python, Node ו-jq — כולם נמדדו כאן — מיישמים "האחרון מנצח" בלי לפלוט שום אזהרה. כדי לתפוס זאת צריך object_pairs_hook משלכם, במחיר של כ-3.7 פעמים זמן פענוח.

למה Python קורא NaN בסדר בעוד Node זורק?

סעיף 6 של RFC 8259 אוסר NaN ו-Infinity כ-literal של מספר. ה-json.loads של Python מקבל אותם כברירת מחדל מפני של-float של Python עצמו יש את הערכים האלה, כך שנתונים רעים עוברים את Python ונכתבים חזרה כמו שהם. ה-JSON.parse של Node הולך בקפדנות אחרי המפרט וזורק SyntaxError על הפלט הזה. הוספת parse_constant בנקודת הכניסה דוחה אותו מיד.

מספרי הזמנה בני 20 ספרות תמיד מאבדים דיוק ב-JSON?

רק כשהצרכן מפענח אותם לדיוק כפול של IEEE 754. ה-int של Python הוא בעל דיוק בלתי מוגבל ושומר על 12345678901234567890; ה-Number.MAX_SAFE_INTEGER של JavaScript הוא 9007199254740991, ולכן אותו שדה הופך ל-12345678901234567000. MDN מציין במפורש שה-reviver רץ אחרי הפענוח, ולכן שום דבר לא ניתן לשחזור במורד הזרם — התיקון האמין היחיד הוא שהיצרן יפלוט מחרוזות.