7  التعبير النمطي

التعبير النمطي

التعبير النمطي

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

text = """
Pushups 30 reps 3 sets
5 reps 2 sets Pullups
2 Sets 15 Reps One-leg Squats
4 sets 8 reps 22.5 lbs Dumbbell Rows
4 sets 8 reps 15.25kg Dumbbell Rows
"""

التعبير النمطي (Regular Expression): طريقة لتحديد نمط من الرموز. والغرض منه: البحث عن هذا النمط في نص ما. وله في بايثون مكتبتان:

وسيكون حديثنا في هذا الباب عن المكتبة re. فنقوم باستيرادها كغيرها:

import re

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

الأحرف الخاصة (meta-characters): هي أحرف ذات دلالة معيَّنة غير ما تبدو؛ وهي:

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

فئة الأحرف

سنبدأ أولاً بشرح محدد فئة الأحرف [ ] (Character Class) ويعمل كالآتي:

  • [abc] يطابق a أو b أو c
  • [^abc] يطابق أي حرف غير a أو b أو c
  • [a-c] يطابق أي حرف من a إلى c: a أو b أو c
  • [3-7] يطابق أي رقم من 3 إلى 7: 3 أو 4 أو 5 أو 6 أو 7
  • [a-zA-Z] يطابق أي حرف صغير أو كبير من a إلى z أو A إلى Z
  • [a-zA-Z0-9_] يطابق أي حرف صغير أو كبير أو رقم من a إلى z أو A إلى Z أو 0 إلى 9 أو _ (الشرطة السفلية)

وبهذا تعرف دلالة:

  • علامة ^ في نحو: [^ فإنها تدل على عكس الفئة
  • علامة - في نحو: [a-b] فإنها تدل على الأحرف بين الحرفين (شاملة لهما)

تأمل المثال التالي:

text = "I am 21 years old"
pattern = "[0-9][0-9]"
match = re.search(pattern, text)
print(match)
<re.Match object; span=(5, 7), match='21'>
  • لاحظ أن النمط: [0-9][0-9] يطابق أي رقمين متتاليين من 0 إلى 9.
  • ويأتي الإجراء re.search للبحث داخل النص text عن النمط pattern؛ والنتيجة من النوع Match إذا وُجد النمط، وإلا فيكون None.

ولاستخراج النص المطابق نستعمل الفعل match.group() هكذا:

if match:
    print(match.group())
21

فئات لها رمز

ينضم إلى الأحرف الآنف ذكرها:

\d وعكسها \D
يطابق أي رقم عشري؛ وهو مكافئ: [0-9].
\w وعكسها \W
يطابق الأحرف الأبجدية والأرقام والشرطة السفلية [a-zA-Z0-9_]. وبالنسبة لأنماط Unicode (str)، فإنه يطابق الأحرف الأبجدية الرقمية في Unicode (كما هو معرّف بواسطة str.isalnum())، بالإضافة إلى الشرطة السفلية _.
\s وعكسها \S
يطابق المسافات البيضاء؛ وهو مكافئ: [ \t\n\r\f\v] (لاحظ وجود المسافة).
\b وعكسها \B
يطابق طرف كلمة

ولأن بايثون تعتبر علامة \ في النصوص علامةً على أحرف خاصة (Escape Character) مثل:

  • \n للسطر الجديد
  • \t للمسافة البيضاء
  • \r للرجوع إلى البداية
  • \f للصفحة
  • \v للمسافة العمودية

ولتجنب التعارض بين حرف \ المقصود في النص البايثوني المعروف، والاختصارات التي نريدها؛ فقد وضعت بايثون حرف r لتعطيل خصوصية الحرف \ حتى تُكتَب الأنماط النصية بمى يسمى النص الخام (raw string) كما سيأتي.

الأرقام

text = "I am 21 years old"
pattern = r"\d\d"
match = re.search(pattern, text)
if match:
    print(match.group())
21
نصيحة

دائمًا استعمل النص الخام (بالعلامة r) عند كتابة الأنماط.

الحروف

وأما لمطابقة الحروف فإننا قد نستعمل الاختصار \w، والنمط التالي يقول: أي حرفين يتوسطهما حرف a مثل: cat أو bat أو rat. والخارج من هذا النمط هو كلمة: dog.

animals = [
    "cat",
    "bat",
    "dog",
    "rat",
]

pattern = r"\wa\w"

for animal in animals:
    match = re.search(pattern, animal)
    if match:
        print(match.group())
cat
bat
rat

لاحظ أن النمط \wa\w يتكون من ثلاثة أجزاء:

  1. \w تطابق الأحرف الأبجدية والأرقام والشرطة السفلية
  2. a تطابق الحرف a كما هو
  3. \w تطابق الأحرف الأبجدية والأرقام والشرطة السفلية

طرف الكلمة \b

يطابق الرمز \b، وتستعمل للسياقات التي نريد فيها المطابقة الكليَّة للكلمة، لا أن تكون جُزءًا من كلمة أخرى.

text1 = "My ear"
text2 = "I arrived early"
text3 = "This is the end of the year"

لاحظ في المثال التالي أن النمط لا يطابق إلا النص الأوَّل، وذلك لأنه يشترط أن تكون الكلمة ذات حافة في الأول وفي الأخير:

entire = r"\b" + "ear" + r"\b"
print(re.search(entire, text1))
print(re.search(entire, text2))
print(re.search(entire, text3))
<re.Match object; span=(3, 6), match='ear'>
None
None

لاحظ في المثال التالي مطابقة الأول والثاني، لكوْن اشتراط الحافة في البداية فقط:

starts = r"\b" + "ear"
print(re.search(starts, text1))
print(re.search(starts, text2))
print(re.search(starts, text3))
<re.Match object; span=(3, 6), match='ear'>
<re.Match object; span=(10, 13), match='ear'>
None

وأما هذا المثال الأخير، فيطابق الأول والأخير، لكوْنهما ذوي حافة في النهاية:

ends   = "ear" + r"\b"
print(re.search(ends, text1))
print(re.search(ends, text2))
print(re.search(ends, text3))
<re.Match object; span=(3, 6), match='ear'>
None
<re.Match object; span=(24, 27), match='ear'>

التكرار والتحديد

تستعمل علامات التكرار / المحددات الكمية (Quantifiers) لتعيين مرات التكرار من النمط السابق. وهي على النحو التالي:

  • {3} ثلاث مرات بالضبط
  • {2,4} من 2 إلى 4 مرات
  • {3,} ثلاث مرات أو أكثر
  • + مرة أو أكثر
  • * صفر أو أكثر
  • ? صفر أو مرة واحدة فقط

ولتحديد مجموعة من الأحرف ضمن النمط نستعمل القوسين الدائريين: ( ) حول النمط ليكون مجموعة مطابقة (Match Group).

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

prices = [
    "it costs 123",
    "I bought it for 12.3 last time",
    "I paid 12.34 SAR for it"
]

فتستعمل المنط التالي:

pattern = r"\d+(\.\d+)?"

ويتكون من ثلاثة أجزاء:

  • \d+ ويتكون من جزئين:
    • \d رقم
    • + مرة أو أكثر
  • (...)? ما بين القوسين: صفر أو مرة واحدة فقط
    • \. نحتاج علامة \ لتعطيل خصوصية حرف النقطة ولولاه لدلت على مطابقة أي حرف
    • \d+ رقم، مرة أو أكثر
for p in prices:
    match = re.search(pattern, p)
    if match:
        print('price:', match.group())
price: 123
price: 12.3
price: 12.34

المطابقة

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

  • re.match(pattern, string, flags=0) -> Match | None
    • مطابقة النمط في بداية السلسلة النصية.
  • re.search(pattern, string, flags=0) -> Match | None
    • البحث عن أول تطابق للنمط في السلسلة النصية.
  • re.findall(pattern, string, flags=0) -> list
    • إنشاء قائمة من جميع التطابقات للنمط في السلسلة النصية.
  • re.finditer(pattern, string, flags=0) -> Iterator[Match[str]]
    • إنشاء مكرر لجميع التطابقات للنمط في السلسلة النصية.

فأما الشيء الناتج عن هذه المطابقة، وهو Match؛ فيتضمن أربعة صفات:

وبالمثال يتضح المقال:

text = "Pushups 20 reps 4 sets"

pattern = r"\d+"

الأمر الأول: المطابقة بالفعل .match() تكون من بداية النص. وبالتالي فإنك لو طابقت على هذا النص كاملاً فلن تطابقها، مع أن المطابقة تنفع مع الرقم وحده. مطابقة على النص كاملاً:

print(re.match(pattern, text))
None

مطابقة على الرقم وحده (متحققة):

print(re.match(pattern, "20"))
<re.Match object; span=(0, 2), match='20'>

لذلك نستعمل المطابقة بالفعل .search() للبحث عن النمط في أي موضع من النص:

print(re.search(pattern, text))
<re.Match object; span=(8, 10), match='20'>

ويمكننا الوصول لصفات المطابقة:

m = re.search(pattern, text)
print(m.group())
print(m.start(), m.end())
print(m.span())
20
8 10
(8, 10)

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

ol = re.findall(pattern, text)
print(ol)
['20', '4']

فإن أردت المطابقة (Match) (لا النص المطابَق)، فاستعمال الإجراء .finditer() على النحو التالي:

it = re.finditer(pattern, text)
for m in it:
    print(m)
<re.Match object; span=(8, 10), match='20'>
<re.Match object; span=(16, 17), match='4'>

ضبط عملية المطابقة

تستعمل الأعلام لضبط التطابق من عدة وجوه:

العلم المعنى
ASCIIA يجعل الرموز مثل \w\b\s و \d تطابق فقط الأحرف ASCII.
DOTALLS يجعل الرمز . يطابق أي حرف، بما في ذلك الأسطر الجديدة.
IGNORECASEI يجعل المطابقة غير حساسة لحالة الأحرف.
LOCALEL يجعل المطابقة تأخذ في الاعتبار الإعدادات المحلية.
MULTILINEM يتيح المطابقة متعددة الأسطر، مما يؤثر على الرموز ^ و $.
VERBOSEX (لـ “الموسعة”) يتيح كتابة تعبيرات نمطية منظمة بشكل أوضح وأسهل للفهم.

ونمثل باستعمال العلم re.IGNORECASE إذْ نحتاج إليه في مطابقة الكلمات اللاتينية، لاحظ الفرق في المطابقتين:

text = "She is she."

for m in re.finditer(r"[a-z]+", text):
    print(m)

print()

for m in re.finditer(r"[a-z]+", text, re.IGNORECASE):
    print(m)
<re.Match object; span=(1, 3), match='he'>
<re.Match object; span=(4, 6), match='is'>
<re.Match object; span=(7, 10), match='she'>

<re.Match object; span=(0, 3), match='She'>
<re.Match object; span=(4, 6), match='is'>
<re.Match object; span=(7, 10), match='she'>

ولاستعمال مجموعة أعلام فإننا ندمجه بعلامة | على النحو التالي:

re.search(pattern, text, re.IGNORECASES | re.LOCALE)

استخراج المجموعات من النص

وتستعمل المحددات الكمية (َQuantifiers) لوصف التاريخ بأنه: أربعة أرقام ثم شرطة ثم رقمان ثم شرطة ثم رقمان. وأما الأقواس فلتقسيمها عند القراءة.

text = "This dates back to 1970-06-29"
pattern = r"(\d{4})-(\d{2})-(\d{2})"
match = re.search(pattern, text)

if match:
    print(match.group(0))
    print(match.group(1))
    print(match.group(2))
    print(match.group(3))
1970-06-29
1970
06
29

لاحظ أن المجموعة 0 تطابق الكُلّ. ولذلك فإننا عندما كنا نستعمل .group() من قبل، كان الأصل استخراج كل النص إذا طابق النمط. لكننا الآن نعلم أن 1 و 2 و 3 الآن تطابق أجزاءً في النمط نفسه بحسب الأقواس الموضوعة فيه.

وهذا مثال آخر فيه تضمين قوسين داخل قوسين، وبه تتضح دلالة الأرقام المعطاة للإجراء .group(n) بوضوح:

text = "20 Reps 4 Sets"

m = re.search(r"((\d+) Reps) ((\d+) Sets)", text)
if m:
    print(m.group(1))
    print(m.group(2))
    print(m.group(3))
    print(m.group(4))
20 Reps
20
4 Sets
4

وعليه فقِس.

تسمية المجموعات

من خصائص محرك مطابقة الأنماط في لغة بايثون خصوصًا؛ إمكانية تسمية المجموعات في النمط، ليتم استخراجها بالاسم. وتكون التسمية بعد القوس الأول هكذا: (?P<name>...) حيث تمثل الـ ... النمط النصي. وذلك على النحو التالي:

text = "Muhammad AlKhwarizmi, Polymath"

m = re.search(r"(?P<first_name>\w+) (?P<last_name>\w+), \w+", text)
if m:
    print(m.groupdict())
    print(m.group('first_name'))
    print(m.group('last_name'))
{'first_name': 'Muhammad', 'last_name': 'AlKhwarizmi'}
Muhammad
AlKhwarizmi

وكذلك يكون في المجموعات المضمنة:

text = "20 Reps 4 Sets"

m = re.search(r"((?P<reps>\d+) Reps) ((?P<sets>\d+) Sets)", text)
if m:
    print(m.groupdict())
    print(m.group('reps'))
    print(m.group('sets'))
{'reps': '20', 'sets': '4'}
20
4

وهذا يسهِّلُ استخراج الأنماط كثيرًا من النصوص.

ملاحظة

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

تفسير التعبير النمطي مرة واحدة

قد استعملنا إجراءات المكتبة مباشرة في نحو: re.match() و re.search() وغيرها. فهي تأخذ النمط وتفسره ثم تنفذه بحزمة مكتوبة بلغة سي (C). فإذا كان النمط يستعمل كثيرًا فإن عملية التفسير تتم عدة مرات، وذلك هدر! ولتفسير النمط مرة واحدة ثم تطبيقه عدة مرات (من غير تكرار التفسير) نستعمل إجراء التفسير re.compile() على النحو التالي:

text1 = "She is she."
text2 = "They are they."

patternc = re.compile(r"[a-z]+", re.IGNORECASE)

for m in patternc.finditer(text1):
    print(m)

print()

for m in patternc.finditer(text2):
    print(m)
<re.Match object; span=(0, 3), match='She'>
<re.Match object; span=(4, 6), match='is'>
<re.Match object; span=(7, 10), match='she'>

<re.Match object; span=(0, 4), match='They'>
<re.Match object; span=(5, 8), match='are'>
<re.Match object; span=(9, 13), match='they'>

وحتى يتبين الفرق، قارنها مع القطعة التي لم نستعمل فيها التفسير المسبق؛ فهو يتكررفي كل مرة:

text1 = "She is she."
text2 = "They are they."

for m in re.finditer(r"[a-z]+", text1, re.IGNORECASE):
    print(m)

print()

for m in re.finditer(r"[a-z]+", text2, re.IGNORECASE):
    print(m)
<re.Match object; span=(0, 3), match='She'>
<re.Match object; span=(4, 6), match='is'>
<re.Match object; span=(7, 10), match='she'>

<re.Match object; span=(0, 4), match='They'>
<re.Match object; span=(5, 8), match='are'>
<re.Match object; span=(9, 13), match='they'>

تحرير التعبيرات النمطية

ننصح باستعمال أدوات تحرير التعبير النمطي مثل: regex101 فهي أفضل بكثير من كتابته دون أداة.

  • في القائمة الجانبية اختر نكهة (Flavour) Python
  • في الحقل الأول تكتب التعبير النمطي
  • في الصندوق الكبير تضع النص الذي تريد مطابقته

وكذلك يوجد محرر آخر مثل regexr وفي القائمة الجانبية تجد Community Patterns حيث تجد فهرس لأنماط نصية شاركها المبرمجون الآخرون. أو في: regexHQ.

وهكذا فإنك تعدل على النمط وتزيد في النصوص، حتى تصل إلى أفضل نمط لتنسخه وتضعه عندك في البرنامج.

وهذا المحرر يستعمل نفس محرك: pythex.

مصادر أخرى لتعلم التعبيرات النمطية

دروس تفاعلية لتعلم التعبيرات النمطية:

مسألة: ترتيب سجل التمارين الملخبط

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

نص السجل:

text = """
Pushups 30 reps 3 sets
5 reps 2 sets Pullups
2 Sets 15 Reps One-leg Squats
4 sets 8 reps 22.5 lbs Dumbbell Rows
4 sets 8 reps 15.25kg Dumbbell Rows
"""