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

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

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): طريقة لتحديد نمط متسلسل من الأحرف. والغرض منه: البحث عن هذا النمط في نص ما. وله في بايثون مكتبتان:

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 على النحو التالي:

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 يتكون من ثلاثة أجزاء:
    • \w تطابق الأحرف الأبجدية والأرقام والشرطة السفلية
    • a تطابق الحرف a كما هو
    • \w تطابق الأحرف الأبجدية والأرقام والشرطة السفلية
  • وبذلك يصير النمط مطابقًا لأي حرفين يتوسطهما حرف a مثل: cat أو bat أو rat. والخارج من هذا النمط هو كلمة: dog

طرف الكلمة \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؛ فيتضمن أربعة صفات:

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

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

text = "Pushups 30 reps 3 sets"

pattern = r"\d+"

# مطابقة على النص كاملاً
print(re.match(pattern, text))

# مطابقة على الرقم وحده
print(re.match(pattern, "30"))
None
<re.Match object; span=(0, 2), match='30'>

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

text = "Pushups 30 reps 3 sets"

pattern = r"\d+"

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

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

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

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

text = "Pushups 30 reps 3 sets"

pattern = r"\d+"

ol = re.findall(pattern, text)
print(ol)
['30', '3']

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

text = "Pushups 30 reps 3 sets"

pattern = r"\d+"

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

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

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

العلم المعنى
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)

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

سبق أن رأينا تحديد المطابقة لوضع المحددات الكمية لغرض التكرار. لكن الآن ننظر إلي كيفية استخراج المجموعات من النص.

text = "Muhammad AlKhwarizmi, Polymath"

m = re.search(r"(\w+) (\w+), \w+", text)
if m:
    print(m.group(0)) # المجموعة الصفرية هي كامل المطابقة
    print('first_name:', m.group(1)) # القوسين الأوليين
    print('last_name:', m.group(2)) # القوسين الثانيين
Muhammad AlKhwarizmi, Polymath
first_name: Muhammad
last_name: AlKhwarizmi

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

text = "30 Reps 3 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))
30 Reps
30
3 Sets
3

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

من خصائص محرك مطابقة الأنماط في لغة بايثون خصوصًا؛ إمكانية تسمية المجموعات في النمط، ليتم استخراجها بالاسم. وتكون التسمية بعد القوس الأول هكذا: (?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 = "30 Reps 3 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': '30', 'sets': '3'}
30
3

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

قد استعملنا إجراءات المكتبة مباشرة في نحو: 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 حيث تجد فهرس لأنماط نصية شاركها المبرمجون الآخرون.

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

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

مصادر أخرى للتعلم

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