تصحيح JSON المضغوط: من السطر 1 العمود 23611 إلى البايت المكسور بدقة
الخلاصة: ملف JSON بحجم 4 MB في سطر واحد يبلّغ عن السطر 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 MB لا تغادر الجهاز أبدًا ولا تهبط في سجلات أحد آخر.
تحذيران:
- اختر أداة تقول صراحةً إنها تعالج كل شيء في المتصفح، لا أداة ترسل محتواك إلى خادم خلفي بـ 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","'
الإزاحة نفسها: يهبط عرض الحرف على اسم حقل، ويهبط عرض البايت على ذيل معرّف طلب. والحرف الصيني الواحد يشغل 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 في Python نفسه يحمل هاتين القيمتين:
>>> 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 — ولا يمكن استعادتها في جهة إزالة التسلسل. والإصلاح الوحيد أن يسلسل المنتج المعرّفات الكبيرة كنصوص.
المزالق الثلاثة مقارنةً:
| المزلق | هل يثير أي شيء؟ | من يلاحظ أولًا | كيف تعترضه |
|---|---|---|---|
| مفاتيح مكررة | لا | المطابقة اللاحقة | فحص صريح عبر 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.7x؛ - [ ] أرسل أي معرّف فوق 2^53 كنص؛ ولا تتوقع أن ينقذه reviver؛
- [ ] يبقى الناتج المنسق (بقياس +43%) محليًا: استخدم /minify للضغط إلى سطر واحد قبل الشحن أو الإرسال؛ وفوق بضعة MB انتقل إلى التدفق بدلًا من ذلك، وهو ما يغطيه تحليل 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.7x من زمن التحليل.
لماذا تقرأ Python قيمة NaN جيدًا بينما يرمي Node؟
يحظر الفصل 6 من RFC 8259 استخدام NaN وInfinity كحرفيات أعداد. ويقبل 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 يعمل بعد التحليل، فلا يمكن استعادة شيء في الجهة اللاحقة — والإصلاح الموثوق الوحيد أن يصدر المنتج نصوصًا.