-1

الأرقام العربية الشرقية التي تبتلعها استمارتك

في مجتمع تطوير الويب هنا، سألت shahd_84 قبل أيام عن أسهل طريقة مجانية لعمل صفحة هبوط. جاءت الإجابات كلها بأسماء أدوات، وتعليق واحد فقط سأل السؤال الصحيح: هل تحتاج الصفحة إلى تحويل فعلي، أي وسيلة تواصل تعمل؟ هذا المنشور عن الخطوة التي تلي ذلك مباشرة. الاستمارة تعمل، والزائر يكتب رقمه، والسجل يُحفظ، ولا يوجد رقم في السجل.

هذا ما حصل عندنا.

وصلتني سجلات في جدول الاتصالات باسم كامل وحقل هاتف فارغ. لا خطأ في السجل، لا استثناء في اللوغ، ولا شكوى من أحد. الاستمارة نفسها كانت تعمل بلا مشكلة من عمّان. عند العدّ، تبيّن أن معظم السجلات الفارغة قادمة من زوار في الخليج.

السبب كان سطراً واحداً كتبته أنا لتنظيف رقم الهاتف قبل التحقق منه.

نظامان للأرقام، ولا أحد منهما خطأ

في يونيكود ثلاث مجموعات تعنينا هنا:

  • أرقام ASCII المعتادة 0-9.
  • الأرقام العربية الشرقية ٠١٢٣٤٥٦٧٨٩، وتُسمّى عندنا شائعاً الأرقام الهندية، ونطاقها U+0660 إلى U+0669.
  • الأرقام الفارسية والأردية الممتدة ۰۱۲۳۴۵۶۷۸۹، ونطاقها U+06F0 إلى U+06F9. شكل بعضها قريب من سابقتها لكن نقاط الترميز مختلفة تماماً، وهذا وحده يكفي لكسر أي مقارنة نصية.

ومعها محرفان يمران دون انتباه: فاصلة الآلاف العربية U+066C وفاصلة العشرية العربية U+066B.

الزائر لا يختار أياً من هذا. لوحة المفاتيح العربية على بعض تخطيطات iOS و Android تُخرج الأرقام العربية الشرقية افتراضياً، واللصق من مستند Word أو من رسالة WhatsApp أو من موقع حكومي يحمل معه ما كُتب هناك. من وجهة نظر الزائر، هو كتب رقم هاتفه. من وجهة نظر الكود، وصلت سلسلة محارف لا تطابق [0-9].

سلسلة العطل، خطوة بخطوة

هذا ترتيب ما حدث، وهو المهم أكثر من العطل نفسه:

  1. المُنظّف يحذف كل ما ليس رقماً ASCII: preg_replace('/[^0-9+]/', '', $phone). المدخل كان ٠٧٩١٢٣٤٥٦٧، فصار سلسلة فارغة.
  2. المُنظّف يعمل قبل التحقق، لأنه في وسيط middleware، ووسائط الطلب في Laravel تسبق FormRequest.
  3. وسيط ConvertEmptyStringsToNull الذي يأتي مع الإطار يحوّل السلسلة الفارغة إلى null.
  4. قاعدة التحقق كانت 'phone' => 'nullable|string|max:20'. القيمة null تمر.
  5. العمود phone يقبل NULL. السجل يُحفظ.

لا استثناء، لا رسالة خطأ للزائر، ولا سطر في اللوغ. الاستمارة أعادت رسالة نجاح، لأن العملية نجحت فعلاً. ما فشل هو المعنى، لا التنفيذ.

لماذا لا يظهر هذا في أي لوحة قياس

هنا يكمن الضرر الحقيقي. عدد إرساليات الاستمارة لم ينخفض، بل ارتفع. معدل التحويل في أدوات التحليل كما هو، لأن التحويل عندها هو الوصول إلى صفحة الشكر. لا يوجد مقياس واحد في اللوحة يقول لك إن نصف عملائك المحتملين من سوق كامل صاروا أسماء بلا وسيلة اتصال.

العرض الوحيد للعطل هو موظف مبيعات يفتح سجلاً ولا يجد ما يتصل به، ويفترض أن الزائر لم يكمل الاستمارة. هذا الافتراض معقول تماماً، ولهذا عاش العطل طويلاً.

مصيدتان تبدوان حلاً وليستا كذلك

الأولى: التطبيع الموحّد NFKC لا يحل هذا. الاعتقاد الشائع أن Normalizer::normalize بصيغة FORM_KC يردّ الأرقام العربية الشرقية إلى ASCII. لا يفعل، لأن هذه المحارف ليست ذات تفكيك توافقي إلى أرقام ASCII. لا تصدقني، شغّل هذا:

php -r 'var_dump(Normalizer::normalize("٠٧٩", Normalizer::FORM_KC) === "079");'

الثانية: توسيع التحقق ليقبل الأرقام العربية الشرقية يجعل الحالة أسوأ. المحرك PCRE يجعل \d مقتصراً على ASCII حتى مع اللاحقة u، ما لم تُفعّل خاصية UCP صراحة:

php -r 'var_dump(preg_match("/^\d+$/u", "٠٧٩١٢٣٤٥٦٧"));'
php -r 'var_dump(preg_match("/(*UCP)^\d+$/u", "٠٧٩١٢٣٤٥٦٧"));'

الأول يعيد 0 والثاني يعيد 1. من يكتشف هذا يميل إلى إضافة (*UCP) والانتهاء من الموضوع. النتيجة أسوأ من العطل الأصلي: التحقق صار يمر، والقيمة تُخزَّن كما هي، ثم تنكسر عند أول استعمال. جرّب:

php -r 'var_dump((int) "٥", is_numeric("٥"), filter_var("٥", FILTER_VALIDATE_INT));'

(int) "٥" يساوي صفراً. الصفر رقم صحيح تماماً، ويمر في كل تحقق تكتبه بعد ذلك. وحقل كمية أو مبلغ يصير صفراً بصمت أخطر من حقل هاتف يصير فارغاً.

القاعدة التي خرجت بها: طبّع المدخل عند الحدود، ولا توسّع التحقق.

الإصلاح

دالة تطبيع واحدة، تُستدعى قبل أي تحقق وقبل أي تنظيف:

function normalize_digits(string $value): string
{
    static $map = [
        '٠' => '0', '١' => '1', '٢' => '2', '٣' => '3', '٤' => '4',
        '٥' => '5', '٦' => '6', '٧' => '7', '٨' => '8', '٩' => '9',
        '۰' => '0', '۱' => '1', '۲' => '2', '۳' => '3', '۴' => '4',
        '۵' => '5', '۶' => '6', '۷' => '7', '۸' => '8', '۹' => '9',
        '٬' => '',  '٫' => '.',
    ];

    return strtr($value, $map);
}

strtr بمصفوفة يعمل على مستوى السلاسل لا البايتات، فلا حاجة إلى mb_* هنا.

وما يقابلها في المتصفح، لتطبيع الحقل عند blur قبل أن يرى المستخدم رسالة رفض لا يفهم سببها:

const normalizeDigits = (s) =>
  s.replace(/[٠-٩]/g, (d) =>
      String.fromCharCode(d.charCodeAt(0) - 0x0660 + 48))
   .replace(/[۰-۹]/g, (d) =>
      String.fromCharCode(d.charCodeAt(0) - 0x06F0 + 48));

انتبه أن type="tel" و inputmode="numeric" لا يطبّعان شيئاً. الأول يغيّر لوحة المفاتيح المقترحة فقط، وكلاهما يقبل أي نص.

الحارس الذي كان يجب أن يكون موجوداً من البداية

هذا هو الجزء الذي أندم على تأخيره أكثر من العطل نفسه. المُنظّف لا يجوز له أن يحوّل حقلاً غير فارغ إلى حقل فارغ ويمضي:

$clean = preg_replace('/[^0-9+]/', '', normalize_digits($raw));

if ($raw !== '' && $clean === '') {
    Log::warning('sanitizer emptied a non-empty field', [
        'field' => 'phone',
        'raw_length' => mb_strlen($raw),
        'codepoints' => array_map(
            fn ($c) => 'U+' . strtoupper(dechex(mb_ord($c))),
            mb_str_split($raw)
        ),
    ]);

    return back()->withErrors(['phone' => 'تعذّر قراءة الرقم، أعد إدخاله']);
}

تسجيل نقاط الترميز لا الرقم نفسه، حتى لا تُدخل بيانات شخصية إلى اللوغ. لو كان هذا السطر موجوداً، لكان العطل قد ظهر في اليوم الأول بدل أن يُكتشف من عدّ يدوي.

المبدأ العام: كل مُنظّف يستطيع أن يُفرغ قيمة هو مسار فقدان بيانات صامت، سواء كان يعالج أرقاماً أو أسماء أو عناوين.

افحص قاعدتك اليوم

استعلامان يعملان على MySQL 5.7 و 8 معاً.

الأول يكشف الصفوف التي نجت فيها محارف غير ASCII داخل حقل يُفترض أنه أرقام. CHAR_LENGTH تعدّ المحارف و LENGTH تعدّ البايتات، فاختلافهما يعني وجود محرف متعدد البايتات:

SELECT id, phone, CHAR_LENGTH(phone) AS chars, LENGTH(phone) AS bytes
FROM contact_forms
WHERE phone IS NOT NULL
  AND CHAR_LENGTH(phone) <> LENGTH(phone);

والثاني يكشف الاتجاه الزمني للفقد:

SELECT DATE_FORMAT(created_at, '%Y-%m') AS month,
       COUNT(*) AS total,
       SUM(phone IS NULL OR phone = '') AS missing
FROM contact_forms
GROUP BY month
ORDER BY month;

القفزة المفاجئة في عمود missing تحدد الشهر الذي دخل فيه المُنظّف. تحذير واجب: هذا التاريخ يخص لحظة تسجيل الصف، لا لحظة نشر الكود. طابقه مع سجل النشر عندك قبل أن تبني عليه استنتاجاً.

وإن كنت على MySQL 8، هذا يعمل مباشرة لأن محرك التعابير فيه يدعم يونيكود:

SELECT id, phone FROM contact_forms WHERE phone REGEXP '[٠-٩۰-۹]';

على 5.7 لن يعمل، لأن المحرك القديم يعمل على البايتات. استعمل فحص الطول أعلاه بدلاً منه.

ما زال مفتوحاً عندي

أكتب هذا القسم لأن إخفاءه سيجعل المنشور أنظف مما تستحقه الحالة.

الأرقام التي ضاعت ضاعت. لم نكن نحتفظ بنسخة خام من الطلب قبل المعالجة، فلا يوجد ما نعود إليه. إن كنت تحتفظ بها فأنت في وضع أفضل مني، وهذا وحده سبب كافٍ للاحتفاظ بها.

المسح الرجعي للصفوف المتضررة غير منفّذ ولم يُتحقق منه على الإنتاج حتى لحظة كتابة هذا المنشور، فلا أستطيع تقديمه كعمل منجز.

ومسارات الإدخال الأخرى لم تُدقّق كلها بعد: الاستيراد من ملفات CSV، واللصق من محادثات، وأي تكامل خارجي يكتب في الجدول نفسه دون المرور بوسيط الطلب. المُنظّف الذي أصلحته يحمي مساراً واحداً، وهذا لا يعني أن الجدول محمي.

وهناك حقول أخرى تحمل المخاطرة نفسها ولم أفحصها: الكميات، والمبالغ، وأرقام الهوية، والتواريخ المكتوبة يدوياً. في كل واحد منها، النتيجة صفر أو قيمة فارغة تمر في التحقق بلا اعتراض.

الخلاصة العملية

شغّل استعلام CHAR_LENGTH مقابل LENGTH على كل عمود عندك يُفترض أنه أرقام. ثم افتح كل موضع في الكود تحذف فيه محارف من مدخل مستخدم، واسأل عن كل واحد منها سؤالاً واحداً: هل يستطيع هذا السطر أن يعيد سلسلة فارغة من مدخل غير فارغ؟ إن كان يستطيع، فأنت أمام مسار فقدان بيانات صامت، سواء ظهر عندك اليوم أو لم يظهر بعد.

إن وجدتم عندكم شيئاً من هذا، اكتبوه في التعليقات، خاصة إن كان المسار عندكم مختلفاً عن المسار الذي وصفته. يهمني تحديداً من جرّب المسح الرجعي للصفوف المتضررة وكيف تعامل مع غياب القيمة الأصلية.

يرجى الدخول لحسابك أو تسجيل حساب لتستطيع إضافة تعليق
حساب جديد دخول

لا يوجد تعليقات بعد، كن أول من يبدأ النقاش