اذهب إلى المحتوى

من المرجح أنك تعرف كيف تبحث عن النصوص بالضغط على Ctrl+f وإدخال الكلمات التي تريد البحث عنها، في حين أن التعابير النمطية Regular expressions تنقل الأمور إلى مرحلة أعلى: فهي تسمح لك بتحديد «نمط» النص الذي تبحث عنه، فلو لم تكن تعرف رقم هاتف الشركة في الورقة أمامك، لكنك تعيش في الولايات المتحدة أو كندا، فستعلم أن أرقام الهاتف تتألف من 3 أرقام ثم شرطة -، ثم 4 أرقام أخرى (وقد يبدأ برمز المنطقة وهو 3 أرقام في البداية). أي أنك ستعرف أن ما يلي هو رقم هاتف 415‎-555-1234 لكن 4,155,551,234 ليس رقمًا هاتفيًا.

يمكننا التعرف على مختلف أنماط النصوص بسهولة: فعناوين البريد الإلكتروني تحتوي الرمز @ في منتصفها، وعناوين URL للمواقع تحتوي على نقط وخطوط مائلة، والعناوين الأخبارية تستعمل نسق العنوان Title case، والتاغات في وسائل التواصل الاجتماعي تبدأ برمز # ولا تحتوي على فراغات …إلخ.

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

اقتباس

«معرفة التعابير النمطية تعني الفرق بين حل المشكلة في 3 خطوات وحلها في 3,000 خطوة. إذا كنت تستعملها فستنسى أن المشكلة التي حللتها بضغطات قليلة تأخذ من الناس كمية كبيرة من العمل المضني» – كوري دكتورو بتصرف.

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

مطابقة الأنماط دون استخدام التعابير النمطية

لنقل أننا نريد البحث عن رقم هاتف يتبع نظام الكتابة الأمريكي في سلسلة نصية، أي يحتوي 3 أرقام ثم شرطة ثم 3 أرقام ثم شرطة ثم 4 أرقام، مثل ‎415-555-4242.

لنكتب دالةً باسم isPhoneNumber()‎ للتحقق إن كانت تمثل السلسلة النصية رقم هاتف، وتعيد القيمة True أو False. احفظ الشيفرة الآتية في ملف باسم isPhoneNumber.py:

def isPhoneNumber(text):
   if len(text) != 12:
         return False
     for i in range(0, 3):
       if not text[i].isdecimal():
             return False
   if text[3] != '-':
         return False
     for i in range(4, 7):
       if not text[i].isdecimal():
             return False
   if text[7] != '-':
         return False
     for i in range(8, 12):
       if not text[i].isdecimal():
             return False
   return True

print('Is 415-555-4242 a phone number?')
print(isPhoneNumber('415-555-4242'))
print('Is ABC a phone number?')
print(isPhoneNumber(ABC))

حين تجربة المثال السابق فسينتج ما يلي:

Is 415-555-4242 a phone number?
True
Is ABC a phone number?
False

تحتوي الدالة isPhoneNumber()‎ على شيفرة تجري عدة عمليات تحقق للتأكد أن السلسلة النصية في المعامل text هي رقم هاتف صالح، وإذا فشلت أي تحقق من هذه التحققات فستعيد الدالة القيمة False.

تبدأ الشيفرة بالتحقق أن السلسلة النصية بطول 12 محرفًا تمامًا ➊، ثم تتحقق أن رقم المنطقة (أي أول 3 محارف في text) تحتوي على محارف رقمية فقط ➋، بقية الدالة تتحقق أن السلسلة النصية تتبع نمط أرقام الهواتف: يجب أن تكون أول شرطة في الرقم بعد رمز المنطقة ➌، ثم 3 أرقام ➍، ثم شرطة ➎، ثم 4 أرقام ➏، وإن أنهينا كل عمليات التحقق بنجاح فستعيد الدالة True ➐.

استدعاء الدالة isPhoneNumber() مع الوسيط '‎415‎-555-4242' سيعيد True، بينما استدعاؤها مع 'ABC' سيعيد False، إذ ستفشل أول عملية تحقق لأن السلسلة النصية 'ABC' ليست بطول 12 محرفًا.

إذا أردت العثور على رقم هاتف في سلسلة نصية طويل، فستحتاج إلى كتابة المزيد من الشيفرات لمطابقة نمط رقم الهاتف. بدِّل استدعاء الدالة print() في المثال السابق إلى ما يلي:

message = 'Call me at 415-555-1011 tomorrow. 415-555-9999 is my office.'
for i in range(len(message)):
   chunk = message[i:i+12]
   if isPhoneNumber(chunk):
          print('Phone number found: ' + chunk)
print('Done')

سينتج البرنامج الناتج الآتي:

Phone number found: 415-555-1011
Phone number found: 415-555-9999
Done

في كل دورة لحلقة for سنأخذ 12 محرفًا من المتغير message ونسندها إلى المتغير chunk ➊، فمثلًا في أول دورة لحلقة التكرار تكون قيمة i هي 0، وستسند القيمة message[0:12]‎ إلى المتغير chunk (أي السلسلة النصية 'Call me at 4')، وفي الدورة التالية تكون قيمة i تساوي 1، وستسند القيمة message[1:13]‎ إلى chunk (أي السلسلة النصية 'all me at 41')، بعبارة أخرى: في كل دورة من حلقة for ستتغير قيمة chunk بزيادة مكان بدء عملية الاقتطاع بمقدار واحد، ويكون طولها 12 محرفًا:

  • 'Call me at 4'
  • 'all me at 41'
  • 'll me at 415'
  • 'l me at 415-‎'
  • وهلم جرًا…

سنمرر بعدئذٍ المتغير chunk إلى الدالة isPhoneNumber() للتحقق إن كان يطابق نمط أرقام الهواتف ➋، وإذا طابقها فسنطبع القيمة المطابقة.

سنستمر في تنفيذ حلقة التكرار التي ستمر على السلسلة النصية كلها، وستختبر كل 12 محرفًا فيها على حدة، وتطبع قيمة chunk إن أعادت الدالة isPhoneNumber()‎ القيمة True، وبعد المرور على جميع محارف السلسلة النصية message فسنطبع الكلمة Done.

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

العثور على تعبير نصي باستخدام التعابير النمطية

يعمل البرنامج الذي كتبناه في القسم السابق عملًا صحيحًا، لكننا احتجنا إلى كتابة شيفرة كثيرة للقيام بأمر بسيط؛ فطول الدالة isPhoneNumber()‎ هو 17 سطرًا لكنها تستطيع مطابقة نمط واحد من أرقام الهواتف. فماذا عن الأرقام المكتوبة بالشكل 415.555.4242 أو ‎(415) 555-4242؟ ماذا لو كان هنالك رمز تحويل بعد رقم الهاتف مثل ‎415-555-4242 x99؟ ستفشل الدالة isPhoneNumber()‎ بمطابقتها. صحيحٌ أنك تستطيع كتابة شيفرات إضافية للتحقق من هذه الحالات، لكن هنالك طريقة أسهل بكثير.

التعابير النمطية Regular Expression أو اختصارًا Regex تصف نمط النصوص (ومن هنا أتى اسمها). فمثلًا ‎\d في صياغة التعابير النمطية تعني محرف رقمي، أي رقم مفرد من 0 حتى 9. ويستعمل التعبير النمطي ‎\d\d\d-\d\d\d-\d\d\d\d في بايثون لمطابقة النص الذي تطابقه الدالة isPhoneNumber()‎ السابقة: سلسلة من 3 أرقام، ثم شرطة، ثم 3 أرقام، ثم شرطة، ثم 4 أرقام. ولن تطابق أي سلسلة نصية لها نمط آخر التعبير النمطي ‎\d\d\d-\d\d\d-\d\d\d\d.

لكن يمكن أن تكون التعابير النمطية أعقد من ذلك بكثير، فمثلًا إضافة الرقم 3 بين قوسين مجعدين {3} يعني «طابق هذا النمط 3 مرات»، وبالتالي يمكننا كتابة التعبير النمطي السابق بشكل مختصر ‎\d{3}-\d{3}-\d{4}‎ وستكون النتيجة نفسها.

إنشاء كائنات Regex

جميع دوال التعابير النمطية موجودة في الوحدة re، وعلينا استيرادها أولًا:

>>> import re

ملاحظة: أغلبية الأمثلة في هذا المقال تستعمل الوحدة re، لذا من المهم أن تتذكر استيرادها في بداية كل سكربت أو حينما تعيد تشغيل محرر Mu، وإلا فستحصل على رسالة الخطأ NameError: name 're' is not defined.

تمرير سلسلة نصية تمثل التعبير النمطي إلى re.compile()‎ سيعيد كائن Regex.

لإنشاء كائن Regex يطابق نمط أرقام الهواتف السابق، فأدخل ما يلي إلى الطرفية التفاعلية. تذكر أن ‎\d تعني «محرف رقمي»، و \d\d\d-\d\d\d-\d\d\d\d هو التعبير النمطي الذي سيطابق رقم الهاتف.

>>> phoneNumRegex = re.compile(r'\d\d\d-\d\d\d-\d\d\d\d')

يحتوي المتغير phoneNumRegex الآن على كائن Regex.

مطابقة كائنات Regex

يمتلك الكائن Regex التابع search() الذي يبحث في السلسلة النصية التي مررت إليها لأي مطابقة للتعبير النمطية. سيعيد التابع search() القيمة None إذا لم يُطابَق التعبير النمطي في السلسلة النصية، وإذا عُثر على التعبير النمطي فسيعيد التابع search()‎ كائن Match، الذي فيه التابع group()‎ الذي يعيد النص الذي جرت مطابقته مع التعبير النمطي (سنشرح المجموعات لاحقًا فلا تقلق):

>>> phoneNumRegex = re.compile(r'\d\d\d-\d\d\d-\d\d\d\d')
>>> mo = phoneNumRegex.search('My number is 415-555-4242.')
>>> print('Phone number found: ' + mo.group())
Phone number found: 415-555-4242

اسم المتغير mo هو اسم عام لكائنات Match، قد يبدو المثال السابق معقدًا لكنه أقصر بكثير من برنامج isPhoneNumber.py.

بدايةً مررنا التعبير النمطي الذي نريده إلى re.compile()‎ وحفظنا كائن Regex الناتج في المتغير phoneNumRegex، ثم استدعينا التابع search()‎ على phoneNumRegex ومررنا السلسلة النصية التي نريد البحث فيها عن النمط إلى التابع search()‎.

ستخزن نتيجة البحث في المتغير mo، ونحن نعرف في هذا المثال أن التابع سيعيد الكائن Match، ولمعرفتنا أن المتغير mo سيحتوي على كائن Match وليس قيمة فارغة None، فاستدعينا التابع group() على mo لإعادة الناتج، ولأننا كتبنا mo.group() داخل الدالة print()‎ فسنعرض الناتج الذي جربت مطابقته 415‎-555-4242.

مراجعة لعملية لمطابقة التعابير النمطية

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

  1. استيراد الوحدة Regex بكتابة import re.
  2. إنشاء كائن Regex مع الدالة re.compile()‎، وتذكر أن تستعمل سلسلة نصية خام raw بكتابة r قبلها.
  3. تمرير السلسلة النصية التي تريد البحث فيها إلى التابع search()‎ الذي سيعيد كائن من النوع Match.
  4. استدعاء الدالة group()‎ للكائن Match التي تعيد السلسلة النصية المطابقة.

ملاحظة: صحيحٌ أنني أنصحك أن تجرب الشيفرات في الطرفية التفاعلية لكنك تستطيع استخدام تطبيقات ويب لاختبار التعابير النمطية التي تظهر لك بوضوح كيف يطابق التعبير النمطي النص الذي أدخلته. أنصحك بتجربة موقع pythex.

ميزات إضافية لمطابقة النصوص عبر التعابير النمطية

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

التجميع مع الأقواس

لنقل أنك تريد أن تفصل رقم المنطقة من بقية رقم الهاتف. إذا أضفنا أقواسًا في التعابير النمطية فستنشِئ مجموعات groups كما في (‎\d\d\d)-(\d\d\d-\d\d\d\d)، ثم يمكننا استخدام التابع group()‎ للكائن Match للحصول على النص المطابق من مجموعة معينة.

ستخزن القيمة المطابقة من أول مجموعة في المجموعة 1، والمجموعة الثانية في 2… وإذا مررنا القيمة 1 أو 2 إلى التابع group() فسنحصل على أجزاء مختلفة من النص الذي جرت مطابقته. أما تمرير القيمة 0 أو عدم تمرير أي قيمة إلى التابع group()‎ فسيعيد كامل النص الذي جرت مطابقته من التعبير النمطي:

>>> phoneNumRegex = re.compile(r'(\d\d\d)-(\d\d\d-\d\d\d\d)')
>>> mo = phoneNumRegex.search('My number is 415-555-4242.')
>>> mo.group(1)
'415'
>>> mo.group(2)
'555-4242'
>>> mo.group(0)
'415-555-4242'
>>> mo.group()
'415-555-4242'

إذا أردت الحصول على جميع المجموعات في آن واحد، فاستعمل التابع groups()‎ (لاحظ أن اسم التابع بالجمع وليس المفرد):

>>> mo.groups()
('415', '555-4242')
>>> areaCode, mainNumber = mo.groups()
>>> print(areaCode)
415
>>> print(mainNumber)
555-4242

يعيد التابع mo.groups()‎ صفًا tuple فيه أكثر من قيمة، ويمكنك استعمال الإسناد المتعدد لإسناد كل قيمة إلى متغير كما في السطر الآتي:

areaCode, mainNumber = mo.groups()‎

للأقواس معنى خاص في التعابير النمطية، لكن ماذا نفعل لو أردنا مطابقة الأقواس في النص؟ لنقل مثلًا أن رمز المنطقة في أرقام الهواتف التي تريد مطابقتها موجودة بين قوسين، وفي هذه الحالة سنحتاج إلى تهريب المحرفين ( و ) عبر شرطة مائلة خلفية:

>>> phoneNumRegex = re.compile(r'(\(\d\d\d\)) (\d\d\d-\d\d\d\d)')
>>> mo = phoneNumRegex.search('My phone number is (415) 555-4242.')
>>> mo.group(1)
'(415)'
>>> mo.group(2)
'555-4242'

سيطابق المحرفان المهربان ‎`)‎و ‎`(‎ في السلسلة النصية الخام الممررة إلى الدالة re.compile()‎ الأقواس في السلسلة النصية التي سنبحث فيها. هنالك معانٍ خاصة للمحارف الآتية في التعابير النمطية:

.  ^  $  *  +  ?  {  }  [  ]  \  |  (  )

في حال أردت مطابقة أحد تلك المحارف في النص الذي تريد البحث فيه، فعليك تهريبها بوضع خط مائل خلفي قبلها:

\.  \^  \$  \*  \+  \?  \{  \}  \[  \]  \\  \|  \(  \)

تأكد أنك لم تخلط بين الأقواس المهربة وبين أقواس المجموعات في التعابير النمطية، إذا ظهرت لك رسالة خطأ حول «قوس ناقص» أو «أقواس غير متوازنة» فاعلم أنك نسيت إغلاق قوس مجموعة غير مهرب كما في المثال الآتي:

>>> re.compile(r'(\(Parentheses\)')
Traceback (most recent call last):
    --snip--
re.error: missing ), unterminated subpattern at position 0

تقول لك رسالة الخطأ أن هنالك قوس مفتوح في الفهرس 0 من السلسلة النصية r'((Parentheses)' الذي لا يوجد له قوس إغلاق.

مطابقة أكثر من مجموعة باستخدام الخط العمودي

محرف الخط العمودي | الذي يسمى أيضًا بالأنبوب Pipe يستعمل في أي مكان تريد فيه مطابقة أحد التعابير الموفرة، أي لنقل أن لدينا التعبير النمطي r'Batman|Yasmin'‎ الذي سيطابق 'Batman' أو 'Yasmin'.

فلو كانت الكلمتان Batman و Yasmin موجودةً في السلسلة النصية التي سنبحث فيها، فستعاد أول قيمة تطابق إلى الكائن Match:

>>> heroRegex = re.compile (r'Batman|Yasmin')
>>> mo1 = heroRegex.search('Batman and Yasmin')
>>> mo1.group()
'Batman'

>>> mo2 = heroRegex.search('Yasmin and Batman')
>>> mo2.group()
'Yasmin'

ملاحظة: يمكنك العثور على جميع حالات المطابقة باستخدام التابع findall()‎ المشروحة في هذا المقال.

يمكنك أيضًا استخدام الخط العمودي لمطابقة تعابير فرعية مختلفة، فمثلًا لو قلنا أننا نريد مطابقة أي سلسلة نصية من 'Batman' و 'Batmobile' و 'Batcopter' و 'Batbat'، ولأن كل هذه السلاسل النصية تبدأ بالكلمة Bat فمن المنطقي أن نكتب هذه السابقة مرة واحدة، ويمكننا فعل ذلك عبر الأقواس كما يلي:

>>> batRegex = re.compile(r'Bat(man|mobile|copter|bat)')
>>> mo = batRegex.search('Batmobile lost a wheel')
>>> mo.group()
'Batmobile'
>>> mo.group(1)
'Mobile'

استدعاء التابع mo.group()‎ يعيد النص 'Batmobile' بينما mo.group(1) يعيد النص المطابق داخل مجموعة الأقواس الأولى، أي 'mobile'. استخدام الخط العمودي | يتيح لنا مطابقة أحد التعابير النمطية الموفرة، وإذا أردنا أن نطابق الخط العمودي نفسه في السلسلة النصية المبحوث فيها فلا ننسى تهريبه ‎|‎.

المطابقة الاختيارية عبر إشارة الاستفهام

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

>>> batRegex = re.compile(r'Bat(wo)?man')
>>> mo1 = batRegex.search('The Adventures of Batman')
>>> mo1.group()
'Batman'

>>> mo2 = batRegex.search('The Adventures of Batwoman')
>>> mo2.group()
'Batwoman'

لاحظ أن الجزء ‎(wo)?‎ يعني أن النمط wo هو مجموعة اختيارية، فسيطابَق التعبير النمطي لو احتوت السلسلة النصية على 0 أو 1 من السلسلة النصية wo داخلها. وهذا يعني أن التعبير النمطي سيطابق 'Batwoman' و 'Batman' معًا.

لو عدنا إلى مثال أرقام الهواتف السابق، يمكننا تعديل التعبير النمطي لكي يبحث عن أرقام الهواتف التي تحتوي أو لا تحتوي على رمز المنطقة:

>>> phoneRegex = re.compile(r'(\d\d\d-)?\d\d\d-\d\d\d\d')
>>> mo1 = phoneRegex.search('My number is 415-555-4242')
>>> mo1.group()
'415-555-4242'

>>> mo2 = phoneRegex.search('My number is 555-4242')
>>> mo2.group()
'555-4242'

يمكنك أن تقول أن ? يعني «طابق النمط الفرعي السابق 0 مرة أو مرة واحدة»، وإذا أردنا مطابقة علامة الاستفهام فلا ننسى تهريبها بكتابة ‎\?.

المطابقة صفر مرة أو أكثر باستخدام رمز النجمة

رمز النجمة * asterisk يعني «طابق صفر مرة أو أكثر»، فاستعمال النجمة يعني أن التعبير النمطي الذي يسبقها سيطابق لأي عدد من المرات في السلسلة النصية؛ فقد لا يكون موجودًا أو يكون مكررًا مرات كثيرة:

>>> batRegex = re.compile(r'Bat(wo)*man')
>>> mo1 = batRegex.search('The Adventures of Batman')
>>> mo1.group()
'Batman'

>>> mo2 = batRegex.search('The Adventures of Batwoman')
>>> mo2.group()
'Batwoman'

>>> mo3 = batRegex.search('The Adventures of Batwowowowoman')
>>> mo3.group()
'Batwowowowoman'

لاحظ أن التعبير النمطي ‎(wo)*‎ سيُطابَق 0 مرة في 'Batman'، ومرة واحدة في 'Batwoman'، وأربع مرات في 'Batwowowowoman'. إذا أردنا مطابقة النجمة نفسها فلا ننسَ تهريبها بكتابة ‎\*‎.

المطابقة مرة واحدة أو أكثر باستخدام إشارة الجمع

على خلاف إشارة النجمة * التي تطابق النمط صفر مرة أو أكثر، تعني إشارة الجمع أو إشارة الزائد + «مطابقة النمط مرة واحدة أو أكثر»، هذا يعني أن النمط الذي يسبق إشارة + يجب أن يكون موجودًا على الأقل مرة واحدة، على خلاف إشارة النجمة التي تسمح بألّا يكون النمط موجودًا في السلسلة النصية.

قارن بين أثر رمز النجمة في القسم السابق والمثال الآتي:

>>> batRegex = re.compile(r'Bat(wo)+man')
>>> mo1 = batRegex.search('The Adventures of Batwoman')
>>> mo1.group()
'Batwoman'

>>> mo2 = batRegex.search('The Adventures of Batwowowowoman')
>>> mo2.group()
'Batwowowowoman'

>>> mo3 = batRegex.search('The Adventures of Batman')
>>> mo3 == None
True

التعبير النمطي Bat(wo)+man لا يطابق السلسلة النصية 'The Adventures of Batman' لأن من المطلوب وجود التعبير الفرعي wo مرة واحدة على الأقل. إذا أردنا مطابقة إشارة الزائد فلا ننسى تهريبها ‎+.

مطابقة النمط لعدد معين من المرات باستخدام الأقواس المجعدة

إذا كانت لديك مجموعة تريد تكرارها لعدد معين من المرات، فأتبع تلك السلسلة برقم محاط بأقواس {}. فمثلًا التعبير النمطي ‎(Ha){3}‎ يطابق السلسلة النصية 'HaHaHa' لكنه لن يطابق 'HaHa' لأنها تحتوي على تكرارين للتعبير Ha فقط.

وبدلًا من كتابة رقم واحد، يمكننا تحديد مجال من التكرارات بكتابة الحد الأدنى، ثم فاصلة، ثم الحد الأقصى ضمن القوسين، مثلًا التعبير النمطي ‎(Ha){3,5}‎ سيطابق 'HaHaHa' و 'HaHaHaHa' و 'HaHaHaHaHa'.

يمكنك ألّا تحدد الرقم الأدنى أو الأقصى ضمن القوسين لتترك المجال مفتوحًا، فمثلًا ‎(Ha){3,} سيطبق 3 تكرارات أو أكثر من المجموعة (Ha)، بينما ‎(Ha){,5}‎ سيطابق المجموعة (Ha) من 0 حتى 5 تكرارات.

ستساعدنا الأقواس المجعدة على تقصير التعبير النمطي، إذ يتساوى التعبيران النمطيان الآتيان:

(Ha){3}
(Ha)(Ha)(Ha)

وسيتساوي أيضًا:

(Ha){3,5}
((Ha)(Ha)(Ha))|((Ha)(Ha)(Ha)(Ha))|((Ha)(Ha)(Ha)(Ha)(Ha))

جرب ما يلي في الطرفية التفاعلية:

>>> haRegex = re.compile(r'(Ha){3}')
>>> mo1 = haRegex.search('HaHaHa')
>>> mo1.group()
'HaHaHa'

>>> mo2 = haRegex.search('Ha')
>>> mo2 == None
True

سيطابق ‎(Ha){3} السلسلة النصية 'HaHaHa' لكنه لن يطابق 'Ha'، وبالتالي سيعيد التابع search()‎ القيمة None.

المطابقة الجشعة والمطابقة غير الجشعة

لمّا كان التعبير ‎(Ha){3,5}‎ يطابق 3 أو 4 أو 5 تكرارات من Ha في السلسلة النصية 'HaHaHaHaHa' فستتسائل لماذا يعيد استدعاء التابع group()‎ على الكائن Match السلسلة النصية 'HaHaHaHaHa' بدلًا من الاحتمالات الأخرى، ففي النهاية 'HaHaHa' و 'HaHaHaHa' هي مطابقات صالحة في التعبير النمطي (Ha){3,5}‎.

تكون التعابير النمطية في بايثون جشعة greedy افتراضيًا، وهذا يعني أنه في الحالات غير المحددة ستحاول التعابير النمطية مطابقة أطول سلسلة نصية ممكنة؛ أما المطابقة غير الجشعة non-greedy (وتسمى أحيانًا بالمطابقة الكسولة lazy) تطابق أقصر سلسلة نصية ممكنة، ونستطيع تحديد أننا نريد مطابقة غير جشعة عبر وضع علامة استفهام بعد قوس الإغلاق المجعد.

جرب ما يلي ولاحظ الاختلافات بين النسخة الجشعة وغير الجشعة وماذا ستطابق في السلسلة النصية نفسها:

>>> greedyHaRegex = re.compile(r'(Ha){3,5}')
>>> mo1 = greedyHaRegex.search('HaHaHaHaHa')
>>> mo1.group()
'HaHaHaHaHa'

>>> nongreedyHaRegex = re.compile(r'(Ha){3,5}?')
>>> mo2 = nongreedyHaRegex.search('HaHaHaHaHa')
>>> mo2.group()
'HaHaHa'

لاحظ أن علامة الاستفهام لها معنيان في التعابير النمطية، الأول هو المطابقة غير الجشعة، والثاني هو جعل المجموعة اختيارية. وهذان المعنيان غير مرتبطين ببعضهما.

التابع findall()‎

بالإضافة إلى التابع search() تمتلك كائنات Regex التابع findall()، وفي حين أن التابع search()‎ يعيد كائن Match لأول جزء مطابَق من السلسلة النصي التي نبحث فيها، يعيد التابع findall() جميع المطابقات في السلسلة النصية.

لنرى كيف يعيد التابع search()‎ الكائن Match على أول سلسلة نصية مطابقة:

>>> phoneNumRegex = re.compile(r'\d\d\d-\d\d\d-\d\d\d\d')
>>> mo = phoneNumRegex.search('Cell: 415-555-9999 Work: 212-555-0000')
>>> mo.group()
'415-555-9999'

وفي المقابل لن يعيد التابع findall()‎ الكائن Match، بل قائمة فيها سلاسل نصية، لطالما لم تكن هنالك مجموعات فرعية في التعبير النمطي؛ وتمثل كل سلسلة نصية في القائمة المعادة الجزء من النص المطابق للتعبير النمطي:

>>> phoneNumRegex = re.compile(r'\d\d\d-\d\d\d-\d\d\d\d') # لا توجد تعابير فرعية
>>> phoneNumRegex.findall('Cell: 415-555-9999 Work: 212-555-0000')
['415-555-9999', '212-555-0000']

أما لو كانت هنالك مجموعات أو تعابير فرعية في التعبير النمطي، فسيعيد التابع findall()‎ قائمةً من الصفوف tuples، ويمثل كل صف مطابقةً، وعناصر الصف هي السلاسل النصية المطابقة لكل تعبير فرعي في التعبير النمطي.

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

>>> phoneNumRegex = re.compile(r'(\d\d\d)-(\d\d\d)-(\d\d\d\d)') # توجد تعابير فرعية
>>> phoneNumRegex.findall('Cell: 415-555-9999 Work: 212-555-0000')
[('415', '555', '9999'), ('212', '555', '0000')]

لنلخص ما الذي يعيد التابع findall()‎:

عند استدعائه على تعبير نمطي لا يحتوي على مجموعات، مثل ‎\d\d\d-\d\d\d-\d\d\d\d فسيعيد التابع findall() قائمةً من السلاسل النصية مثل ‎['415-555-9999', '212-555-0000']. عند استدعائه على تعبير نمطي يحتوي على مجموعات أو تعابير نمطية فرعية، مثل (‎\d\d\d)-(\d\d\d)-(\d\d\d\d) فسيعيد التابع findall()‎ قائمةً من الصفوف التي تحتوي على سلاسل نصية، سلسلة نصية لكل مجموعة، مثل:

‎[('415', '555', '9999'), ('212', '555', '0000')]‎

فئات المحارف

تعلمنا في مثال أرقام الهواتف السابق أن \d يطابق أي رقم، أي أنه اختصار للتعبير ‎(0|1|2|3|4|5|6|7|8|9)‎. هنالك عدد من فئات المحارف المختصرة، والتي تستطيع معرفتها من الجدول 7-1.

اختصار فئة المحارف يمثل
\d أي محرف يمثل رقمًا من 0 حتى 9
\D أي محرف ليس رقمًا من 0 حتى 9
\w أي حرف أو رقم أو الشرطة السفلية، يمكننا أن نقول أنه يطابق محارف «الكلمات»
\W أي محرف ليس حرفًا أو رقمًا أو شرطةً سفلية (عكس ‎\w)
\s أي مسافة فارغة أو مسافة جدولة أو سطر جديد، يمكننا أن نقول أنه يطابق «الفراغات»
\S أي محرف ليس فراغًا أو مسافة جدولة أو سطر جديد

الجدول 7-1: اختصارات فئات المحارف الشائعة

تساعد فئات المحارف Character classes على اختصار التعابير النمطية، ففئة المحارف [0‎-5] تطابق الأرقام من 0 إلى 5 فقط، وهذا أكثر اختصارًا من كتابة (0‎|1|2|3|4|5)، لاحظ أننا نملك \d لمطابقة الأرقام فقط، لكن ‎\w يطابق الأرقام والأحرف والشرطة السفلية _، ولا يوجد اختصار لمطابقة الأحرف فقط، لكنك تستطيع أن تكتب [a-zA-Z] التي سنشرحها لاحقًا.

>>> xmasRegex = re.compile(r'\d+\s\w+')
>>> xmasRegex.findall('12 drummers, 11 pipers, 10 lords, 9 ladies, 8 maids, 7
swans, 6 geese, 5 rings, 4 birds, 3 hens, 2 doves, 1 partridge')
['12 drummers', '11 pipers', '10 lords', '9 ladies', '8 maids', '7 swans', '6
geese', '5 rings', '4 birds', '3 hens', '2 doves', '1 partridge']

سيطابق التعبير النمطي ‎\d+\s\w+ أي نص فيه رقم واحد أو أكثر ‎\d+‎ يكون متبوعًا بفراغ ‎\s ويكون متبوعًا برقم أو حرف أو شرطة سفلية
‎\w+‎. سيعيد التابع findall()‎ السلاسل النصية المطابقة من التعبير النمطي في قائمة.

كتابة فئات محارف مخصصة

هنالك حالات نحتاج فيها إلى استخدام مجموعة من المحارف لكن الفئات المختصرة الشائعة (مثل ‎\d و ‎\w و ‎\s …إلخ.) غير مناسبة لحالتنا؛ لذا يمكننا تعريف فئات المحارف بأنفسنا باستعمال الأقواس المربعة []. فمثلًا فئة المحارف [aeiouAEIOU] ستطابق أي حرف صوتي سواءً كان بالحالة الصغيرة أو الكبيرة:

>>> vowelRegex = re.compile(r'[aeiouAEIOU]')
>>> vowelRegex.findall('RoboCop eats baby food. BABY FOOD.')
['o', 'o', 'o', 'e', 'a', 'a', 'o', 'o', 'A', 'O', 'O']

يمكنك أيضًا تضمين مجال من الأرقام أو الأحرف باستخدام الشرطة، فمثلًا فئة المحارف [a-zA-Z0-9] ستطابق جميع الأحرف الصغيرة والأحرف الكبيرة والأرقام.

لاحظ أن رموز التعابير النمطية العادية لن تفسر داخل الأقواس المربعة، فلا حاجة إلى تهريب المحارف . أو * أو ? أو () بخط مائل خلفي. فمثلًا فئة المحارف [‎0-5.‎] تطابق الأعداد من 0 إلى 5 ونقطة. ولا حاجة إلى تهريبها وكتابة [0‎-5\.‎].

إذا وضعنا رمز القبعة caret ^ بعد قوس بداية فئة المحارف فسيعني «الرفض»، أي سيعكس محتويات فئة المحارف، أي أنها ستطابق أي محرف ليس موجودًا في فئة المحارف المحددة:

>>> consonantRegex = re.compile(r'[^aeiouAEIOU]')
>>> consonantRegex.findall('RoboCop eats baby food. BABY FOOD.')
['R', 'b', 'C', 'p', ' ', 't', 's', ' ', 'b', 'b', 'y', ' ', 'f', 'd', '.', '
', 'B', 'B', 'Y', ' ', 'F', 'D', '.']

فبدلًا من مطابقة الأحرف الصوتية، أصبحت فئة المحارف تطابق كل شيء عدا الأحرف الصوتية.

رمز القبعة ورمز الدولار

يمكننا أيضًا استخدام رمز القبعة ^ في بداية التعبير النمطي للإشارة أن المطابقة يجب أن تبدأ من بداية السلسلة النصية التي نبحث فيها عن النمط. وبالمثل يمكننا استخدام رموز الدولار $ في نهاية التعبير النمطي للإشارة أنه يجب أن تنتهي السلسلة النصية بالتعبير النمطي.

يمكننا أن نستعمل الرمزين ^ و $ معًا للإشارة إلى أن السلسلة النصية كلها يجب أن تطابق التعبير النمطي، أي لا يسمح بإجراء مطابقة جزئية على السلسلة النصية فقط.

لنأخذ مثالًا بسيطًا التعبير النمطي r'^Hello' الذي سيطابق أي سلسلة نصية تبدأ بالكلمة 'Hello':

>>> beginsWithHello = re.compile(r'^Hello')
>>> beginsWithHello.search('Hello, world!')
<re.Match object; span=(0, 5), match='Hello'>
>>> beginsWithHello.search('He said hello.') == None
True

التعبير النمطي ‎r'\d$'‎ يطابق السلاسل النصية التي تنتهي برقم من 0 إلى 9:

>>> endsWithNumber = re.compile(r'\d$')
>>> endsWithNumber.search('Your number is 42')
<re.Match object; span=(16, 17), match='2'>
>>> endsWithNumber.search('Your number is forty two.') == None
True

أما التعبير ‎r'^\d+$' فهو يطابق السلاسل النصية التي تحتوي على رقم واحد على الأقل:

>>> wholeStringIsNum = re.compile(r'^\d+$')
>>> wholeStringIsNum.search('1234567890')
<re.Match object; span=(0, 10), match='1234567890'>
>>> wholeStringIsNum.search('12345xyz67890') == None
True
>>> wholeStringIsNum.search('12  34567890') == None
True

لاحظ أن آخر استدعائين للتابع search()‎ يوضحان كيف يجب أن تطابق السلسلة النصية كلها النمط، وليس جزءًا منها.

حرف البدل

تسمى النقطة . في التعابير النمطية بحرف البدل wildcard، وهي تطابق أي محرف عدا السطر الجديد. فمثلًا:

>>> atRegex = re.compile(r'.at')
>>> atRegex.findall('The cat in the hat sat on the flat mat.')
['cat', 'hat', 'sat', 'lat', 'mat']

تذكر أن النقطة ستطابق محرفًا واحدًا فقط، وهذا هو السبب في أن التعبير النمطي طابق lat في السلسلة النصية flat فقط. إذا أردنا البحث عن رمز النقطة فلا ننسى تهريبه عبر خط مائل خلفي ‎\.‎.

مطابقة كل شيء مع النقطة والنجمة

نحتاج أحيانًا إلى مطابقة كل شيء، فمثلًا لو أردنا مطابقة السلسلة النصية 'First Name‎:‎' متبوعةً بأي نصف، فحينها سنستعمل النقطة والنجمة ‎.*‎. تذكر أن النقطة تعني أي حرف عدا السطر الجديد، والنجمة تعني تكرار النمط الذي يسبقها 0 مرة أو أكثر.

>>> nameRegex = re.compile(r'First Name: (.*) Last Name: (.*)')
>>> mo = nameRegex.search('First Name: Al Last Name: Sweigart')
>>> mo.group(1)
'Al'
>>> mo.group(2)
'Sweigart'

تجري النقطة والنجمة مطابقةً جشعةً، أي أنها تحاول أن تطابق النصوص أكثر ما يمكن، ولجعلها مطابقة غير جشعة فيمكننا استخدام النقطة والنجمة وإشارة الاستفهام ‎.*?‎ وبالتالي ستطابق بايثون النمط مطابقةً غير جشعة.

جرب المثال الآتي لترى الفرق بين النسخة الجشعة وغير الجشعة:

>>> nongreedyRegex = re.compile(r'<.*?>')
>>> mo = nongreedyRegex.search('<To serve man> for dinner.>')
>>> mo.group()
'<To serve man>'

>>> greedyRegex = re.compile(r'<.*>')
>>> mo = greedyRegex.search('<To serve man> for dinner.>')
>>> mo.group()
'<To serve man> for dinner.>'

يمكننا ترجمة التعبيرين النمطيين السابقين إلى «طابق قوس بدء زاوية ثم أي شيء ثم قوس إغلاق زاوية»، لكن السلسلة النصية '‎<To serve man> for dinner.>‎' فيها مطابقتين لقوس إغلاق زاوية، ففي النسخة غير الجشعة تطابق بايثون أقصر سلسلة نصية ممكنة '<To serve man>'؛ أما في النسخة الجشعة فتحاول بايثون مطابقة أطول سلسلة نصية ممكنة: '‎<To serve man> for dinner.>‎'.

مطابقة الأسطر الجديدة مع رمز النقطة

تعلمنا أن النقطة والنجمة ستطابق كل المحارف عدا السطر الجديد، لكن بتمرير وسيط ثاني إلى الدالة re.compile()‎ هو re.DOTALL فسنتمكن من استعمال النقطة لمطابقة جميع المحارف بما فيها السطر الجديد:

>>> noNewlineRegex = re.compile('.*')
>>> noNewlineRegex.search('Serve the public trust.\nProtect the innocent.
\nUphold the law.').group()
'Serve the public trust.'


>>> newlineRegex = re.compile('.*', re.DOTALL)
>>> newlineRegex.search('Serve the public trust.\nProtect the innocent.
\nUphold the law.').group()
'Serve the public trust.\nProtect the innocent.\nUphold the law.'

التعبير النمطي noNewlineRegex لم نمرر إليه re.DOTALL حين استدعاء re.compile()‎ لذا سيطابق النمط كل شيء حتى الوصول إلى محرف السطر الجديد؛ بينما newlineRegex مررنا إليه re.DOTALL حين استدعاء re.compile()‎ لذا سيطابق كل شيء، لهذا أعاد استدعاء newlineRegex.search() السلسلة النصية كاملة بما فيها الأسطر الجديدة.

مراجعة لرموز التعابير النمطية

شرحنا الكثير من الرموز في هذا المقال، لنراجع سريعًا ما الذي تعلمناه حول أساسيات التعابير النمطية:

  • يطابق ? المجموعة التي تسبقه 0 مرة أو 1 مرة.
  • يطابق * المجموعة التي تسبقه 0 مرة أو أكثر.
  • يطابق + المجموعة التي تسبقه 1 مرة أو أكثر.
  • يطابق {n} المجموعة التي تسبقه n مرة تمامًا.
  • يطابق {‎n,‎} المجموعة التي تسبقه n مرة أو أكثر.
  • يطابق {‎, m} المجموعة التي تسبقه 0 مرة حتى m مرة.
  • يطابق {n, m} المجموعة التي تسبقه n مرة على الأقل و m على الأكثر.
  • تجري ‎{n,m}?‎ أو ‎*?‎ أو ‎+? مطابقة غير جشعة.
  • يطابق ‎^spam السلاسل النصية التي تبدأ بالكلمة spam.
  • يطابق spam$‎ السلاسل النصية التي تنتهي بالكلمة spam.
  • يطابق الرمز . أي محرف عدا السطر الجديد.
  • تطابق فئة المحارف ‎\d و ‎\w و ‎\s رقمًا أو كلمةً أو فراغًا على التوالي وبالترتيب.
  • تطابق فئة المحارف ‎\D و ‎\W و \S أي شيء عدا أن يكون رقمًا أو كلمةً أو فراغًا على التوالي وبالترتيب.
  • تطابق فئة المحارف [abc] أي شيء بين القوسين المربعين (مثل a أو b أو c).
  • تطابق فئة المحارف [‎^abc] أي شيء ليس بين القوسين المربعين.

المطابقة غير الحساسة لحالة الأحرف

تطابق التعابير النمطية النص بنفس الحالة المحددة، فمثلًا تطابق التعابير النمطية الآتية سلاسل نصية مختلفة:

>>> regex1 = re.compile('RoboCop')
>>> regex2 = re.compile('ROBOCOP')
>>> regex3 = re.compile('robOcop')
>>> regex4 = re.compile('RobocOp')

لكن في بعض الأحيان لا يهمنا ما هي حالة الأحرف، وكل ما نريده هو مطابقة النص بغض النظر عن حالته، فحينها نريد جعل التعابير النمطية غير حساسة لحالة الأحرف، وذلك بتمرير وسيطٍ ثانٍ للدالة re.compile()‎ هو re.IGNORECASE أو re.I:

>>> robocop = re.compile(r'robocop', re.I)
>>> robocop.search('RoboCop is part man, part machine, all cop.').group()
'RoboCop'

>>> robocop.search('ROBOCOP protects the innocent.').group()
'ROBOCOP'

>>> robocop.search('Al, why does your programming book talk about robocop so much?').group()
'robocop'

استبدال السلاسل النصية عبر التابع sub()‎

لا تستعمل التعابير النمطية للعثور على أنماط من النصوص فقط، وإنما لاستبدالها أيضًا.

يقبل التابع sub()‎ في كائنات Regex معاملين، الأول هو السلسلة النصية التي نريد تبديل المطابقات إليها، والثاني هو السلسلة النصية التي نريد البحث فيها عن مطابقات لتبديلها. يعيد التابع sub()‎ سلسلةً نصية بعد تطبيق جميع عمليات الاستبدال:

>>> namesRegex = re.compile(r'Agent \w+')
>>> namesRegex.sub('CENSORED', 'Agent Alice gave the secret documents to Agent Bob.')
'CENSORED gave the secret documents to CENSORED.'

قد نحتاج أحيانًا إلى استخدام النص المطابق كجزء من النص الجديد، لذا يمكننا استخدام ‎\1 و \2 و ‎\3 في الوسيط الأول الممرر إلى التابع sub()‎ للوصول إلى المجموعات الفرعية المطابقة.

لنقل أننا نريد أن نخفي أسماء الأشخاص في مثالنا السابق، لكننا نريد إظهار الحرف الأول من اسمهم فقط، فحينها نستطيع استخدام التعبير النمطي Agent (\w)\w*‎ وتمرير r'\1‎****'‎ كأول معامل إلى التابع sub()، وستشير ‎\1 إلى السلسلة النصية المطابقة من النمط الفرعي الأول، أي المجموعة (‎\w) في التعبير النمطي:

>>> agentNamesRegex = re.compile(r'Agent (\w)\w*')
>>> agentNamesRegex.sub(r'\1****', 'Agent Alice told Agent Carol that Agent
Eve knew Agent Bob was a double agent.')
A**** told C**** that E**** knew B**** was a double agent.'

التعامل مع التعابير النمطية المعقدة

التعابير النمطية التي تطابق نصًا بسيطًا تكون سهلة الفهم، لكن إذا أردنا مطابقة النصوص المعقدة فستصبح التعابير النمطية معقدة وطويلة، يمكننا التعامل مع هذا الإشكال بالطلب من الدالة re.compile() أن تتجاهل الفراغات والتعليقات داخل السلسلة النصية التي تمثل التعبير النمطي، وهذا النمط يسمى verbose mode، ويمكن تفعيله بتمرير re.VERBOSE إلى المعامل الثاني في الدالة re.compile()‎.

فبدلًا من محاولة قراءة تعبير نمطي صعب مثل هذا:

phoneRegex = re.compile(r'((\d{3}|\(\d{3}\))?(\s|-|\.)?\d{3}(\s|-|\.)\d{4}
(\s*(ext|x|ext.)\s*\d{2,5})?)')

نستطيع تقسيم التعبير النمطي إلى عدة أسطر مع تعليقات توضح وظيفة كل قسم:

phoneRegex = re.compile(r'''(
    (\d{3}|\(\d{3}\))?            # رمز المنطقة
    (\s|-|\.)?                    # فاصل
    \d{3}                         # أول 3 أرقام
    (\s|-|\.)                     # فاصل
    \d{4}                         # آخر 4 أرقام
    (\s*(ext|x|ext.)\s*\d{2,5})?  # اللاحقة
    )''', re.VERBOSE)

لاحظ أننا استعملنا علامة الاقتباس الثلاثية لإنشاء سلسلة نصية متعددة الأسطر، لكي نستطيع تقسيم التعبير على عدة أسطر وتسهيل قراءته.

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

استخدام re.IGNORECASE و re.DOTALL و re.VERBOSE

ماذا لو أردنا استخدام re.VERBOSE لكتابة تعليقات في التعابير النمطية لكننا نريد أن نتجاهل حالة الأحرف أيضًا عبر re.IGNORECASE؟

للأسف لا تقبل الدالة re.compile()‎ غير قيمة واحدة كثاني وسيط لها، لكن يمكننا تجاوز هذه المحدودية بجمع القيم re.IGNORECASE و re.DOTALL و re.VERBOSE مع بضعها عبر الخط العمودي | وهو يسمى في هذا السياق بعامل OR الثنائي Bitwise OR.

أي أننا لو أردنا كتابة تعبير نمطي غير حساس لحالة الأحرف ويسمح بمطابقة محرف السطر الجديد برمز النقطة، فحينها سيكون استدعاء الدالة re.compile()‎ كما يلي:

>>> someRegexValue = re.compile('foo', re.IGNORECASE | re.DOTALL)

ولو أردنا تضمين الخيارات كلها فنكتب:

>>> someRegexValue = re.compile('foo', re.IGNORECASE | re.DOTALL | re.VERBOSE)

أصل هذه الصيغة من الإصدارات القديمة من بايثون، وشرح العوامل الثنائية خارج سياق هذه السلسلة. لاحظ أن هنالك خيارات أخرى يمكن تمريرها إلى الدالة re.compile()‎ لكنها غير شائعة الاستخدام، ويمكنك القراءة عنها في التوثيق الرسمي.

مشروع: برنامج استخراج أرقام الهواتف وعناوين البريد الإلكتروني

لنقل أنك موكل بمهمة مملة هي العثور على جميع أرقام الهواتف وعناوين البريد الإلكتروني في صفحة ويب طويلة أو مستند كبير. إذا كنت ستتصفح تلك الصفحة وتبحث عنها يدويًا فستأخذ منك وقتًا طويلًا وجهدًا كبيرًا؛ لكن إن كان لديك برنامج يمكنه البحث في الحافظة عن أرقام الهواتف وعناوين البريد الإلكتروني فسيكون ذلك رائعًا، فكل ما عليك فعله هو الضغط على Ctrl+A لتحديد كل النص ثم Ctrl+C لنسخه إلى الحافظة، ثم شغل برنامجك وسيعدل محتويات المحافظة ويضع فيها أرقام الهواتف وعناوين البريد الإلكتروني التي سيعثر عليها.

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

فمثلًا لو أردنا العمل على برنامج استخراج أرقام الهواتف وعناوين البريد الإلكتروني فسنحتاج إلى:

  1. الحصول على النص من الحافظة.
  2. العثور على جميع أرقام الهواتف وعناوين البريد الإلكتروني.
  3. لصق الناتج في الحافظة.

يمكنك أن تبدأ الآن بالتفكير كيفية كتابة ذلك في بايثون. سيحتاج برنامجك إلى:

  1. استخدام الوحدة pyperclip لنسخ ولصق النصوص من الحافظة.
  2. إنشاء تعبيران نمطيان، واحد لمطابقة أرقام الهواتف والثاني لمطابقة عناوين البريد الإلكتروني.
  3. العثور على جميع المطابقات لكلي التعبيرين النمطيين، وليس أول مطابقة فقط.
  4. تنسيق الناتج في سلسلة نصية واحدة جاهزة ولصقها في الحافظة.
  5. عرض رسالة خطأ إن لم يعثر على مطابقات في النص.

هذا هو المخطط العام للمشروع، وسنركز على كيفية حل كل خطوة حين كتابة الشيفرات. لاحظ أننا تعلمنا كيفية التعامل مع كل خطوة من الخطوات السابقة في بايثون.

الخطوة 1: إنشاء تعبير نمطي لأرقام الهواتف

علينا بداية إنشاء تعبير نمطي للبحث عن أرقام الهواتف، لننشِئ ملفًا باسم phoneAndEmail.py ونكتب فيه:

#! python3
# phoneAndEmail.py - البحث عن أرقام الهواتف وعناوين البريد في الحافظة

import pyperclip, re

phoneRegex = re.compile(r'''(
    (\d{3}|\(\d{3}\))?                # رمز المنطقة
    (\s|-|\.)?                        # فاصل
    (\d{3})                           # أول 3 أرقام
    (\s|-|\.)                         # فاصل
    (\d{4})                           # آخر 4 أرقام
    (\s*(ext|x|ext.)\s*(\d{2,5}))?    # اللاحقة
    )''', re.VERBOSE)

# TODO: إنشاء التعبير النمطي لعناوين البريد الإلكتروني

# TODO: العثور على المطابقات في الحافظة

# TODO: لصق الناتج في الحافظة

التعليقات التي تبدأ بالكلمة TODO هي لبنية التطبيق، وسنبدلها إلى شيفرات لاحقًا.

يبدأ رقم الهاتف برمز منطقة اختياري، لذا نجعل المجموعة الفرعية التي تدل على رمز المنطقة اختياريةً بإضافة علامة استفهام ?. ولأن رمز المنطقة يمكن أن يكون 3 أرقام (أي ‎\d{3}) أو 3 أرقام ضمن أقواس (أي ‎\(\d{3}\)‎) فاستعملنا الخط العمودي | للفصل بينهما. لاحظ أننا استطعنا إضافة التعليقات إلى التعبير النمطي متعدد الأسطر لكي نتذكر ماذا يفعل كل سطر.

يمكن أن يكون الفاصل في أرقام الهواتف فراغًا ‎\s أو شرطة - أو نقطة .، لذا فصلنا بين هذه الاحتمالات بخط عمودي.

بقية أقسام التعبير النمطي واضحة وسهلة: 3 أرقام، يتبعها فاصل، يتبعها 4 أرقام، وآخر قسم هو اللاحقة الاختيارية التي تبدأ بأي عدد من الفراغات يليها الكلمة ext أو x أو ext.‎، ويليها رقمين إلى 5 أرقام.

ملاحظة: من السهل أن نخلط بين التعابير النمطية التي تحتوي على مجموعات عبر استخدام الأقواس ()، وبين الأقواس المُهربة )‎ و ‎(. تذكر أن تتأكد أنك تستخدم النوع الصحيح من الأقواس إن حصلت على رسالة خطأ تشير إلى قوسٍ ناقص.

الخطوة 2: إنشاء تعبير نمطي لعناوين البريد الإلكتروني

علينا الآن إنشاء تعبير نمطي لمطابقة عناوين البريد الإلكتروني:

#! python3
# phoneAndEmail.py - البحث عن أرقام الهواتف وعناوين البريد في الحافظة

import pyperclip, re

phoneRegex = re.compile(r'''(
--مقتطع--

# إنشاء التعبير النمطي لعناوين البريد الإلكتروني
emailRegex = re.compile(r'''(
   [a-zA-Z0-9._%+-]+      # اسم المستخدم
   @                      # @ إشارة
   [a-zA-Z0-9.-]+         # اسم النطاق
    (\.[a-zA-Z]{2,4})       # اللاحقة
    )''', re.VERBOSE)

# TODO: العثور على المطابقات في الحافظة

# TODO: لصق الناتج في الحافظة

يكون اسم المستخدم جزءًا من البريد الإلكتروني ➊، وفيه محارف واحدة أو أكثر مما يلي: الأحرف الكبيرة والصغيرة، والأرقام، والنقطة، والشرطة السفلية، وإشارة النسبة المئوية، وإشارة زائد، وشرطة عادية. يمكننا جمع كل ما سبق في فئة المحارف الآتية [a-zA-Z0-9‎._%+-‎].

يفصل بين اسم المستخدم والنطاق برمز @ ➋؛ ولا يسمح اسم النطاق بنفس المحارف التي يسمح بها اسم المستخدم، ولذا يجب أن ننشِئ فئة محارف فيها الأرقام والأحرف والنقط والشرطات [a-zA-Z0-9‎.-‎] ➌.

آخر جزء هو اللاحقة، مثل ‎.com (يسمى تقنيًا بالنطاق في أعلى مستوى top-level domain) الذي هو رمز النقطة ويلي أي شيء، ويفترض أن يكون بين 2 و 4 محارف.

هنالك قواعد كثيرة جدًا لمطابقة عناوين البريد الإلكتروني، والتعبير النمطي الذي كتبناه لن يطابق جميع عناوين البريد الصالحة قاعديًا، لكنه سيطابق جميع عناوين البريد الشائعة.

الخطوة 3: العثور على جميع المطابقات في الحافظة

أصبح لدينا الآن التعابير النمطية التي ستطابق أرقام الهواتف وعناوين البريد الإلكتروني عبر الوحدة re في بايثون، واستطعنا الوصول إلى محتويات الحافظة عبر الدالة paste()‎ في الوحدة pyperclip. نحتاج الآن إلى استخدام التابع findall()‎ للكائن Regex لإعادة قائمة من الصفوف. تأكد أن برنامجك يشبه البرنامج الآتي:

   #! python3
   # phoneAndEmail.py - البحث عن أرقام الهواتف وعناوين البريد في الحافظة

   import pyperclip, re

   phoneRegex = re.compile(r'''(
--مقتطع--


   # العثور على المطابقات في الحافظة
   text = str(pyperclip.paste())

➊ matches = []
➋ for groups in phoneRegex.findall(text):
       phoneNum = '-'.join([groups[1], groups[3], groups[5]])
       if groups[8] != '':
           phoneNum += ' x' + groups[8]
       matches.append(phoneNum)
➌ for groups in emailRegex.findall(text):
       matches.append(groups[0])

   # TODO: لصق الناتج في الحافظة

هنالك صف لكل مطابقة ناتجة، وكل صف يحتوي على السلاسل النصية في كل مجموعة في التعبير النمطي، تذكر أن المجموعة 0 تطابق كامل التعبير النمطي، لذا ما يهمنا من الناتج هو المجموعة في الفهرس 0.

سنخزن المطابقات في قائمة باسم matches ➊، وسنبدأ برنامجنا بقائمة فارغة، ثم لدينا حلقتَي for. ففي الحلقة الخاصة بالبريد الإلكتروني نضيف محتويات المجموعة 0 إلى القائمة matches ➌؛ أما في الحلقة الخاصة بأرقام الهواتف فلا نريد أن نضيف ناتج المجموعة 0 إلى القائمة. صحيحٌ أن برنامجنا يستطيع مطابقة أرقام الهواتف بأكثر من صيغة، لكننا نريد إضافة أرقام الهواتف بصيغة واحدة معيارية، لذا ننشِئ المتغير phoneNum الذي يحتوي على قيمة المجموعات 1 و 3 و 5 و 8 من النص المطابق، وهي تمثل رقم المنطقة، وأول 3 أرقام، وآخر 4 أرقام، واللاحقة على التوالي وبالترتيب.

الخطوة 4: جمع المطابقات في سلسلة نصية ونسخها إلى الحافظة

أصبح لدينا الآن عناوين البريد الإلكتروني وأرقام الهواتف كقائمة من السلاسل النصية المخزنة في matches، إذا أردنا نسخ هذه القائمة إلى الحافظة فسنستعمل الدالة pyperclip.copy() التي تقبل سلسلة نصية، لكن المطابقات موجودة لدينا في قائمة، لذا نحن بحاجة إلى استخدام التابع join() على القائمة matches.

للتأكد أن برنامجنا يعمل كما ينبغي له، فلنطبع أي مطابقات إلى الطرفية، وإذا لم تطابق أي عناوين بريدية أو أرقام هواتف فسيخبر البرنامجُ المستخدمَ بذلك.

#! python3
# phoneAndEmail.py - البحث عن أرقام الهواتف وعناوين البريد في الحافظة

--مقتطع--
for groups in emailRegex.findall(text):
    matches.append(groups[0])

# Copy results to the clipboard.
if len(matches) > 0:
    pyperclip.copy('\n'.join(matches))
    print('Copied to clipboard:')
    print('\n'.join(matches))
else:
    print('No phone numbers or email addresses found.')

تشغيل البرنامج

للتجربة، افتح المتصفح وتوجه إلى صفحة تواصل معنا من موقع nostarch.com، ثم حددها كلها بالضغط على Ctrl+A ثم انسخها إلى الحافظة Ctrl+C، ثم شغل البرنامج. يجب أن يكون الناتج كما يلي:

Copied to clipboard:
800-420-7240
415-863-9900
415-863-9950
info@nostarch.com
media@nostarch.com
academic@nostarch.com
info@nostarch.com

أفكار لمشاريع مشابهة

هنالك تطبيقات كثيرة لعملية مطابقة أنماط من النص (واستبدلها عبر التابع sub()‎)، فمثلًا يمكنك:

العثور على جميع روابط URL التي تبدأ بالسابقة http://‎ أو https://‎. العثور على التواريخ بصيغها المختلفة مثل 3/14/2022 أو 03-14-2022 أو 2022/3/14 وتبديلها إلى صيغة واحدة قياسية موحدة. إزالة البيانات الحساسة مثل أرقام البطاقات البنكية من المستندات. العثور على الأخطاء الشائعة مثل الفراغات المكررة بين الكلمات، أو الكلمات المكررة خطأً، أو علامات الترقيم المكررة.

الخلاصة

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

تأتي الوحدة re مع أساس لغة بايثون، وتسمح لنا ببناء كائنات Regex، التي تمتلك عددًا من التوابع: التابع search()‎ للبحث عن مطابقة واحدة، والتابع findall()‎ للعثور على جميع المطابقات، والتابع sub()‎ لإجراء عمليات البحث والاستبدال.

ستجد المزيد من المعلومات في توثيق بايثون الرسمي عن التعابير النمطية، وفي موقع regular-expressions.info.

مشاريع تدريبية

لكي تتدرب، اكتب برامج لتنفيذ المهام الآتية.

التحقق من التاريخ

اكتب تعبيرًا نمطيًا يستطيع التعرف على التواريخ بالصيغة DD/MM/YYYY، افترض أن الأيام تتراوح بين 01 إلى 31، والأشهر من 01 إلى 12، والسنوات من 1000 إلى 2999. لاحظ أنه إذا كان اليوم أو الشهر متألف من رقم واحد فيجب أن يسبق بصفر 0.

لا حاجة أن يتعرف التعبير النمطي على الأرقام الصحيحة لكل شهر من أشهر السنة، أو إذا كانت السنوات كبيسة؛ فلا بأس أن يقبل تاريخًا مثل 31/02/2022 أو 31/04/2022.

خزن السلاسل النصية الناتج في متغيرات باسم month و day و year، ثم اكتب شيفرة تتحقق إن كان التاريخ صالحًا، فأشهر نسيان/أبريل وحزيران/يونيو وأيلول/سبتمبر وتشرين الثاني/نوفمبر لها 30 يومًا، وشهر شباط/فبراير له 28 يومًا، وبقية الأشهر 31 يومًا.

يكون طول شهر شباط/فبراير 29 يومًا في السنوات الكبيسة، والسنة الكبيرة هي كل سنة تكون قابلة للقسمة على 4، عدا السنوات القابلة للقسمة على 100؛ ما لم تكن السنة قابلة للقسم على 400. لاحظ أن هذه العمليات الحسابية تجعل من المستحيل كتابة تعبير نمطي يمكنه فعل كل ذلك.

التحقق من كلمة مرور قوية

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

قد تحتاج إلى اختبار كلمة المرور على أكثر من تعبير نمطي للتأكد من قوتها.

نسخة من الدالة strip()‎ باستخدام التعابير النمطية

اكتب دالةً تقبل سلسلةً نصيةً وتفعل فيها كما تفعله الدالة strip()‎ التي تعلمناها في المقال السابق.

إذا لم يمرر إليها أي وسائل بخلاف السلسلة النصية، فسنحذف جميع الفراغات من بداية ونهاية السلسلة النصية؛ وإلا فسنحذف المحارف المحددة في الوسيط الثاني الممرر إلى الدالة.

ترجمة -بتصرف- للفصل Pattern Matching With Regular Expressions من كتاب Automate the Boring Stuff with Python.

اقرأ أيضًا


تفاعل الأعضاء

أفضل التعليقات

لا توجد أية تعليقات بعد



انضم إلى النقاش

يمكنك أن تنشر الآن وتسجل لاحقًا. إذا كان لديك حساب، فسجل الدخول الآن لتنشر باسم حسابك.

زائر
أضف تعليق

×   لقد أضفت محتوى بخط أو تنسيق مختلف.   Restore formatting

  Only 75 emoji are allowed.

×   Your link has been automatically embedded.   Display as a link instead

×   جرى استعادة المحتوى السابق..   امسح المحرر

×   You cannot paste images directly. Upload or insert images from URL.


×
×
  • أضف...