ترميز الروابط: كيف يعمل الترميز بالنسبة المئوية

يستبدل ترميز الروابط كل بايت لا يجوز أن يظهر كما هو في عنوان الويب بعلامة % يليها قيمة ذلك البايت بالنظام الست عشري.

لا يجوز أن يحتوي الرابط (URL) إلا على مجموعة صغيرة من محارف ASCII، ولعدد منها وظيفة محددة: / يفصل مقاطع المسار، و? يبدأ الاستعلام، و& يفصل المعاملات، و# يحدّد الجزء الداخلي (fragment). وما إن تحتوي قطعة من البيانات على مسافة أو حرف عربي أو حرف مُعلَّم مثل é أو أحد تلك المحارف حتى يلزم ترميزها. ترميز الروابط، ويُسمّى أيضًا الترميز بالنسبة المئوية (percent-encoding)، معرّف في RFC 3986. وما يقوله فعلًا للخادم هو: «هذا المحرف جزء من القيمة، وليس فاصلًا».

المبدأ: البايت الواحد يصبح ثلاثة محارف

يُكتب المحرف المرمَّز على شكل % يليه رقمان ست عشريان يمثّلان قيمة بايت واحد (RFC 3986، القسم 2.1). رمز المسافة 32، أي 20 بالنظام الست عشري، فتصبح %20. ويمر ترميز أي نص بثلاث خطوات:

  1. حوّل النص إلى بايتات UTF-8.
  2. اترك على حالها البايتات التي تمثل المحارف غير المحجوزة (حروف ASCII والأرقام و- . _ ~).
  3. استبدل كل بايت آخر بـ % وقيمته الست عشرية، ويُفضَّل أن تكون بأحرف كبيرة.

يشغل المحرف الخارج عن ASCII عدة بايتات في UTF-8، ولهذا قد يتحوّل حرف واحد إلى عدة مجموعات مئوية:

المحرفبايتات UTF-8الشكل المرمَّز
مسافة20%20 (أو + في النماذج)
&26%26
=3D%3D
+2B%2B
éC3 A9%C3%A9
€E2 82 AC%E2%82%AC
😀F0 9F 98 80%F0%9F%98%80

وهكذا تصبح «café & thé» على الشكل caf%C3%A9%20%26%20th%C3%A9. واختيار UTF-8 ليس اعتباطيًا: فمعيار RFC 3986 يوصي به (القسم 2.5)، وهو ما تستخدمه المتصفحات واللغات الحديثة. وكانت بعض الأنظمة القديمة ترمّز النص بـ Latin-1، حيث é بايت واحد يصبح %E9. ومفكّك ترميز UTF-8 يرفض هذا التسلسل: ففي JavaScript تطلق decodeURIComponent('%E9') الخطأ URIError: URI malformed (أي أن الرابط مشوّه).

المحارف المحجوزة وغير المحجوزة

يقسّم RFC 3986 المحارف إلى مجموعتين:

  • غير المحجوزة (القسم 2.3): A-Z a-z 0-9 - . _ ~. لا تحتاج إلى ترميز أبدًا، ويطلب المعيار ألا تُرمَّز: فـ %41 وA يعنيان الشيء نفسه.
  • المحجوزة (القسم 2.2): الفواصل العامة : / ? # [ ] @ والفواصل الفرعية ! $ & ' ( ) * + , ; =. وهي التي تمنح الرابط بنيته.

تُختصر القاعدة في جملة واحدة: يُكتب المحرف المحجوز كما هو حين يؤدي دور الفاصل، ويُرمَّز حين يكون جزءًا من البيانات. قارن:

https://example.com/search?q=bread&milk      -> q = "bread", plus an empty parameter "milk"
https://example.com/search?q=bread%26milk    -> q = "bread&milk"

أما كل المحارف الأخرى، ومنها المسافات وعلامات الاقتباس والعلامة % نفسها والحروف المُعلَّمة والحروف العربية والرموز التعبيرية، فيجب ترميزها أينما ظهرت. لذلك تُكتب علامة % على الشكل %25: فـ «100 %» مع المسافة تصبح 100%20%25.

المسافة: %20 أم +؟

وفق RFC 3986 تُرمَّز المسافة على شكل %20. غير أن نماذج HTML تستخدم صيغة مختلفة، هي application/x-www-form-urlencoded، تصبح فيها المسافة +، ويجب أن تُكتب علامة الجمع الحقيقية على شكل %2B. وتستخدمها المتصفحات للنماذج المرسلة بطريقة GET، ومعظم الخوادم تفك ترميز سلاسل الاستعلام بهذه الطريقة.

وهذا يؤدي إلى خلط شائع. ففي المسار تكون + مجرد علامة جمع، أما في سلسلة الاستعلام فتُقرأ عادةً مسافة: البحث عن «C++» المرسل دون ترميز، ?q=C++، يصل إلى الخادم على أنه «C» تليها مسافتان. وترميزه الصحيح هو ?q=C%2B%2B.

اللغةالمسافة %20 (RFC 3986)المسافة + (النماذج)
JavaScriptencodeURIComponent()URLSearchParams
PHPrawurlencode()urlencode()، http_build_query()
Pythonurllib.parse.quote()quote_plus()، urlencode()

الشكلان كلاهما صحيحان في سلسلة الاستعلام، ما دام الطرف الذي يفك الترميز يتبع الاصطلاح نفسه الذي اتبعه الطرف الذي رمّز.

الترميز في JavaScript: أي دالة تستخدم؟

في JavaScript دالتان مدمجتان تختلفان في المحارف التي تتركها دون مساس. للقيمة المفردة، سواء كانت معامل استعلام أو مقطع مسار، استخدم encodeURIComponent: فهي ترمّز كل شيء باستثناء A-Z a-z 0-9 - _ . ! ~ * ' ( ). أما encodeURI فتفترض أنها تتلقى رابطًا كاملًا بنيته صحيحة أصلًا، لذا تترك الفواصل مثل & و/ و? على حالها، حتى حين تكون جزءًا من بياناتك. والقائمة الدقيقة للمحارف التي تتجاوزها كل دالة، والحالات التي تبقى فيها encodeURI الأداة الصحيحة، مشروحة في دليل encodeURI أم encodeURIComponent.

وحين تبني رابطًا من أجزائه، فالخيار الأكثر أمانًا أن تدع واجهة URL البرمجية ترمّز كل جزء في موضعه الصحيح:

const url = new URL('https://example.com/search');
url.searchParams.set('q', 'café & thé');
url.searchParams.set('sort', 'price');
url.href;  // 'https://example.com/search?q=caf%C3%A9+%26+th%C3%A9&sort=price'

لاحظ أن searchParams تتبع اصطلاح النماذج، لذا تخرج المسافات على شكل +.

أمثلة في JavaScript وPHP وPython

إليك القيمة نفسها مرمَّزة ومفكوكة الترميز في اللغات الثلاث، بالاصطلاحين كليهما للمسافة:

// JavaScript
encodeURIComponent('café & thé');                 // 'caf%C3%A9%20%26%20th%C3%A9'
new URLSearchParams({ q: 'café & thé' }).toString(); // 'q=caf%C3%A9+%26+th%C3%A9'
decodeURIComponent('caf%C3%A9%20%26%20th%C3%A9'); // 'café & thé'

// PHP (file saved as UTF-8)
rawurlencode('café & thé');                // caf%C3%A9%20%26%20th%C3%A9
urlencode('café & thé');                   // caf%C3%A9+%26+th%C3%A9
http_build_query(['q' => 'café & thé']);   // q=caf%C3%A9+%26+th%C3%A9
urldecode('caf%C3%A9+%26+th%C3%A9');       // café & thé

# Python
from urllib.parse import quote, quote_plus, urlencode, unquote_plus
quote('café & thé')                    # 'caf%C3%A9%20%26%20th%C3%A9'
quote_plus('café & thé')               # 'caf%C3%A9+%26+th%C3%A9'
urlencode({'q': 'café & thé'})         # 'q=caf%C3%A9+%26+th%C3%A9'
unquote_plus('caf%C3%A9+%26+th%C3%A9') # 'café & thé'

هناك تفصيلان يستحقان المعرفة. في Python تترك quote() الشرطة المائلة على حالها افتراضيًا: quote('a/b') تعيد a/b. ولترميز قيمة مستقلة مرّر safe=''، فتحصل على a%2Fb. وفي PHP ترمّز urlencode() علامة التلدة أيضًا إلى %7E، بينما تتركها rawurlencode() كما هي، كما يشترط RFC 3986.

الترميز المزدوج

ترميز نص مرمَّز أصلًا يحوّل كل % إلى %25. فتصبح %20 على الشكل %2520، وتصبح caf%C3%A9 على الشكل caf%25C3%25A9. ومع اصطلاح النماذج تكون علامة + هي ما يكشف المشكلة: ففي PHP تعيد urlencode(urlencode('a b')) القيمة a%2Bb، التي يفك الخادم ترميزها إلى «a+b» بدلًا من «a b».

والسبب هو نفسه في الغالب: طبقتان من الشيفرة ترمّزان القيمة نفسها، كمكتبة HTTP ترمّز معاملات سبق أن رمّزتها بنفسك. والفخ نفسه موجود في جهة فك الترميز: ففي PHP تحتوي $_GET على قيم مفكوكة الترميز أصلًا، واستدعاء urldecode() عليها يفك الترميز مرة ثانية، وهذا يحوّل، من بين أمور أخرى، علامات + المشروعة إلى مسافات. القاعدة: رمّز القيمة الخام مرة واحدة لحظة دخولها إلى الرابط، وفك ترميزها مرة واحدة حين تقرؤها.

حالة واحدة فقط تبرّر ما يبدو ترميزًا مزدوجًا: رابط يُمرَّر معاملًا داخل رابط آخر، كما في redirect=. فإن كان الرابط الداخلي يحتوي أصلًا على %20، فإن شكله المرمَّز يحتوي على %2520، وهذا صحيح: يفك المستقبِل الترميز مرة واحدة فيستعيد الرابط الأصلي.

الترميز وفك الترميز أونلاين

للتحقق من قيمة دون كتابة أي شيفرة، توفّر أداة ترميز الروابط الوضعين encodeURIComponent وencodeURI، لتقارن نتيجتيهما على نصك أنت. أما أداة فك ترميز الروابط فتفك الترميز أثناء الكتابة وتقرأ + على أنها مسافة، كما تفعل النماذج، لذا يجب إدخال علامة الجمع الحقيقية فيها على شكل %2B. وكل ذلك يجري داخل متصفحك.

الأسئلة الشائعة

لماذا تصبح المسافة %20 في الرابط؟

رمز محرف المسافة 32 بالنظام العشري، أي 20 بالنظام الست عشري. وترميز الروابط يكتب البايت على شكل % يليها رقماه الست عشريان، ومن هنا جاءت %20.

هل يجب ترميز الحروف العربية والحروف المُعلَّمة في الرابط؟

نعم. يُحوَّل الحرف، مثل é، إلى بايتات UTF-8، ثم يُرمَّز كل بايت: فتصبح é على الشكل %C3%A9. وكثيرًا ما تعرض المتصفحات الشكل المفكوك في شريط العنوان، لكن الطلب نفسه يحمل الشكل المرمَّز.

ما الفرق بين %20 و+؟

الترميز القياسي للمسافة وفق RFC 3986 هو %20. أما + فتعني مسافة في بيانات النماذج فقط، أي عمليًا في سلاسل الاستعلام؛ وفي المسار هي علامة جمع.

ما المحارف التي لا تحتاج إلى ترميز أبدًا؟

المحارف غير المحجوزة في RFC 3986: حروف ASCII غير المُعلَّمة، والأرقام، والشرطة، والنقطة، والشرطة السفلية، والتلدة. وكل المحارف الأخرى يجب ترميزها حين تكون جزءًا من البيانات.