Рассматриваемые
ранее примеры регулярных выражений имеют один существенный недостаток: они
способны находить соответствия там, где это не предполагалось. Например,
выражение вида:
import re
text = "подоходный налог"
match = re.findall(r"прибыль|обретение|доход", text)
print(match)
найдет подстроку
«доход» в слове «подоходный». Подобные моменты как раз и решаются с помощью
проверок. В данном случае для выделения слова «доход» целиком можно
воспользоваться проверкой \b – граница слова.
Фактически, это набор небуквенных и нецифровых символов. Запишем регулярное
выражение в виде:
match = re.findall(r"прибыль|обретение|\bдоход\b", text)
Теперь слово
«подоходный» пропущено и на выходе получаем пустую коллекцию. Но, если добавим
в текст это слово:
text = "подоходный налог, доход"
то оно будет
успешно найдено. Вот пример простейшей проверки. Если нужно для всех трех
вариантов выполнять такую проверку, то это можно записать так:
match = re.findall(r"\bприбыль\b|\bобретение\b|\bдоход\b", text)
или проще, с
помощью группировки вариантов:
match = re.findall(r"\b(?:прибыль|обретение|доход)\b", text)
Во втором случае
мы используем несохраняющую группировку и для найденных совпадений выполняем
проверку на соответствие границы слова.
Обратите
внимание, проверки не являются частью совпадения строки по шаблону, они лишь
проверяют определенные условия, поэтому сам по себе символ \b в строке text не ищется, а
определяется граница слова в шаблоне, где он записан.
В общем случае,
для регулярных выражений доступны следующие проверки:
|
Символ
|
Описание
|
|
^
|
Начало
текста (с флагом re.MULTILINE – начало
строки)
|
|
$
|
Конец
текста (с флагом re.MULTILINE – позиция
перед символом переноса строки \n)
|
|
\A
|
Начало
текста
|
|
\b
|
Граница
слова (внутри символьных классов [] соответствует символу BACKSPACE)
|
|
\B
|
Граница
не слова (зависим от флага re.ASCII)
|
|
\Z
|
Конец
текста
|
|
A(?=B)
|
Позитивная опережающая проверка (positive
lookahead).
Находит такое текущее совпадение A, за которым обязательно
следует шаблон B.
|
|
A(?!B)
|
Негативная опережающая проверка (negative
lookahead).
Находит такое текущее совпадение A, за которым не следует шаблон B.
|
|
(?<=B)A
|
Позитивная ретроспективная проверка (positive
lookbehind).
Находит такое текущее совпадение A, перед которым обязательно
следует шаблон B.
|
|
(?<!B)A
|
Негативная ретроспективная проверка (negative
lookbehind).
Находит такое текущее совпадение A, перед которым не следует
шаблон B.
|
С простыми
символами, в целом, все понятно, а вот опережающие (lookahead) и
ретроспективные (lookbehind) проверки требуют отдельного рассмотрения. Начнем с
опережающих проверок.
Пусть, например,
из строки вида:
msg = "5 ручек стоит 450 руб."
нужно выделять
целые числа, после которых записано «руб.». Как раз здесь можно воспользоваться
опережающей проверкой со следующим шаблоном:
match = re.findall(r"\d+(?=[ ]*руб[.])", msg)
print(match)
Увидим в консоли
результат:
['450']
Обратите
внимание, что опережающие проверки (?=…) и (?!...) не включаются в итоговый результат,
и мы выделяем только число 450. Также текущая позиция обработки текста будет
находиться на пробеле после числа 450. Поэтому, если после опережающей проверки
в шаблоне выбрать все оставшиеся символы до конца строки:
match = re.findall(r"\d+(?=[ ]*руб[.]).*", msg)
то в итоговом результате
увидим:
['450 руб.']
Этот шаблон
можно расширить на разные слова, записанные после какого-либо числа. Например,
так:
msg = "5 ручек стоит 450 рублей"
match = re.findall(r"\d+(?=[ ]*(?:руб[.]|рубл[яи]|рублей|рублями))", msg)
print(match)
Теперь после
цены можно прописывать разные слова: руб., рубля, рубли, рублей, рублями.
Давайте теперь воспользуемся
противоположной проверкой и для примера выделим все email вне зоны ru:
emails = ["test@example.ser_bal.com", "info@test.ru", "admin@domain.org"]
Упрощенный шаблон
для обработки каждого элемента этого списка можно записать в следующем виде:
for email in emails:
match = re.findall(r"[\w\W]+@[\w\W]+[.](?!ru)", email)
if match:
print(match)
Мы здесь не
проверяем корректность email-адресов, а лишь оставляем те, что не
заканчиваются на «ru». Результатом будет вывод:
['test@example.ser_bal.']
['admin@domain.']
Действительно
остались только нужные email, но без окончаний. Мы уже знаем, как
это можно поправить. После опережающей проверки выберем все оставшиеся символы.
Получим шаблон:
match = re.findall(r"[\w\W]+@[\w\W]+[.](?!ru).*", email)
со следующим
результатом его работы:
['test@example.ser_bal.com']
['admin@domain.org']
Конечно, в
данном конкретном случае, решить эту задачу можно было бы гораздо проще вообще
без регулярных выражений, обычными методами обработки строк языка Python:
for email in emails:
if not email.endswith(".ru"):
print(email)
Этот пример хорошо
показывает, что регулярные выражения не следует применять везде и всюду, а
вначале оценить, насколько их применение в программе оправданно. Здесь приведен
лишь учебный пример для понимания работы опережающей проверки.
Давайте
рассмотрим несколько более сложный пример. Пусть имеется текст, из которого
нужно выделить email-адреса вне зоны «ru»:
text = '''
Напишите Сергею на email: sergey.balakirev@bagoogle.com. Возможно, он ответит вам,
если ваш email guest89@mail.ru! Но для надежности связи лучше укажите
альтернативный admin.test@gmail.com в зоне com.
С наилучшими пожеланиями, ваш верный друг!
'''
Шаблон можно
прописать следующим образом:
match = re.findall(r"\b[a-zA-Z0-9._-]+@[a-zA-Z0-9._-]+[.](?!ru)[a-zA-Z]{2,}\b", text, re.MULTILINE)
print(match)
Здесь мы в
пределах границ слова «\b» выделяем сначала фрагмент до символа «@»,
затем фрагмент после символа «@» до последней точки. И если после нее не
записано «ru», то фрагмент
считается верным и продолжается выделение домена шаблоном [a-zA-Z]{2,}. Получаем
следующий результат выделения email-адресов:
['sergey.balakirev@bagoogle.com', 'admin.test@gmail.com']
Вот уже эту
задачу решить без регулярных выражений было бы куда сложнее, поэтому здесь
применение модуля re вполне оправдано.
Следует отметить,
что длинные конструкции регулярных выражений можно записывать в несколько
строк, сопровождая их комментариями. Например, так:
match = re.findall(r"\b[a-zA-Z0-9._-]+" # выделение фрагмента до символа @
r"@" # символ @
r"[a-zA-Z0-9._-]+" # выделение фрагмента после символа @
r"[.]" # проверка наличия последней точки
r"(?!ru)" # после не должно быть фрагмента "ru"
r"[a-zA-Z]{2,}\b", # выделение доменной зоны
text, re.MULTILINE) # флаг обработки многострочного текста
Или можно
записать, как обычную многострочную строку с указанием дополнительного флага re.VERBOSE:
match = re.findall(r'''\b[a-zA-Z0-9._-]+ # выделение фрагмента до символа @
@ # символ @
[a-zA-Z0-9._-]+ # выделение фрагмента после символа @
[.] # проверка наличия последней точки
(?!ru) # после не должно быть фрагмента "ru"
[a-zA-Z]{2,}\b # выделение доменной зоны
''',
text, re.MULTILINE|re.VERBOSE) # флаг обработки многострочного текста
Флаги регулярных выражений
Вообще
регулярные выражения модуля re поддерживают
следующие флаги:
|
Флаг
|
Описание
|
|
re.A или re.ASCII
|
При
этом флаге проверки \b, \B, \s, \S, \w и \W действуют так, как если бы они
применялись к тексту, содержащему только символы ASCII (по умолчанию
используется Юникод re.U / re.UNICODE и лучше оставаться в этом режиме)
|
|
re.I
или re.IGNORECASE
|
Проверка
без учета регистра символов
|
|
re.M
или
re.MULTILINE
|
Влияет
на проверки ^ и $. Начало ^ считается началом строки (сразу после символа \n или начало
текста). Конец $ считается в позиции перед \n (или конец
строки)
|
|
re.S
или
re.DOTALL
|
При
установке этого флага символ . также включает символ перевода строки \n.
|
|
re.X или
re.VERBOSE
|
Позволяет
включать в регулярные выражения пробелы и комментарии
|
|
re.DEBUG
|
Включает
режим отладки при компиляции регулярного выражения
|
Флаги также
можно указывать и непосредственно внутри выражения, используя синтаксис:
(?flags),
где flags – один или
несколько флагов. Причем, их имена, следующие:
-
a – то же самое,
что и re.ASCII;
-
i
– соответствует
re.IGNORECASE;
-
m
– для
re.MULTILINE;
-
s
– для
re.DOTALL;
-
x
– для
re.VERBOSE.
и записываются в
самом начале, например, так:
text = "Python, python, PYTHON"
match = re.findall(r"(?im)python", text)
print(match)
Мы здесь
включили два флага: re.IGNORECASE и re.MULTILINE. Благодаря
первому, в строке находятся все три совпадения со словом python:
['Python', 'python', 'PYTHON']
Ретроспективные проверки
Далее рассмотрим ретроспективные проверки, которые задаются согласно синтаксису:
(?<=B)A и (?<!B)A
|
Шаблон
|
Описание
|
|
A(?=B)
|
Позитивная опережающая проверка (positive
lookahead).
Находит такое текущее совпадение A, за которым обязательно
следует шаблон B.
|
|
A(?!B)
|
Негативная опережающая проверка (negative
lookahead).
Находит такое текущее совпадение A, за которым не следует шаблон B.
|
|
(?<=B)A
|
Позитивная ретроспективная проверка (positive
lookbehind).
Находит такое текущее совпадение A, перед которым обязательно
следует шаблон B.
|
|
(?<!B)A
|
Негативная ретроспективная проверка (negative
lookbehind).
Находит такое текущее совпадение A, перед которым не следует
шаблон B.
|
Начнем с
простого примера, демонстрирующий общий принцип ретроспективных проверок.
Предположим, нам
необходимо выделить все символы, записанные после префикса «0x» или «0X»:
msg = "Шестнадцатеричное число 0x4d соответствует десятичному числу 77."
Для этого
пропишем следующий шаблон с позитивной ретроспективной проверкой:
match = re.findall(r"(?<=0x|0X)[0-9a-fA-F]+", msg)
print(match)
Увидим в консоли
результат:
['4d']
Обратите
внимание, что ретроспективные проверки (?<=…) и (?<!...), так же как и
опережающие, не включаются в результат, и мы видим только фрагмент «4d». Также следует
учитывать, что перед ретроспективными проверками в регулярных выражениях не
рекомендуется прописывать ничего, кроме самого условия.
Однако сейчас
этот шаблон будет выделять и такие шестнадцатеричные числа:
msg = "Шестнадцатеричное число 0x4d или u0x4D соответствует десятичному числу 77."
У второго числа
префикс не «0x», а «u0x». Тем не менее,
формально, он тоже подпадает под указанный шаблон и выделяется. Было бы хорошо
добавить ограничение и отсеивать варианты, не соответствующие «0x». Один из
вариантов, прописать в ретроспективной проверке границу слова «\b», получим:
match = re.findall(r"(?<=\b0x|\b0X)[0-9a-fA-F]+", msg)
В этом случае
будет выделено только первое значение «4d».
Конечно, есть
соблазн записать «\b» перед ретроспективной проверкой:
match = re.findall(r"\b(?<=0x|0X)[0-9a-fA-F]+", msg)
Но, во-первых,
перед ней лучше ничего никогда не прописывать, и всегда ставить в начале шаблона.
А, во-вторых, такая конструкция все равно не будет работать. Потому что сначала
будет искаться граница слова «\b», за которой сразу следует один из
символов [0-9a-fA-F]. В нашем
случае под такой шаблон не подпадает ни один результат, поэтому на выходе
увидим пустой список:
[]
А вот прежний
вариант:
match = re.findall(r"(?<=\b0x|\b0X)[0-9a-fA-F]+", msg)
работает так.
Сначала находится один из символов [0-9a-fA-F], а затем,
относительно этого первого найденного совпадения, строка просматривается в
обратном (ретроспективном) направлении с проверкой наличия префикса «\b0x» или «\b0X».
Таким образом,
при использовании ретроспективных проверок следует руководствоваться простым
правилом: их следует прописывать в самом начале регулярного выражения.
Негативная ретроспективная проверка
Давайте теперь
рассмотрим работу негативной ретроспективной проверки. Предположим, что нам
нужно выделить все положительные числа из следующей строки:
msg = "-127, 4563, +1046 -114, from -20 to +200"
Запишем для
этого такое регулярное выражение:
match = re.findall(r"(?<![-])\d+\b", msg)
Получим
результат:
['27', '4563',
'1046', '14', '0', '200']
Очевидно, это
несколько не то, что мы бы хотели получить. Откуда взялось первое число 27? Да,
это остаток числа -127. Но почему он был выделен? На самом деле все очень
просто. Метасимвол «\d» позволяет находить любую цифру. Первая
цифра в строке msg – это 1. Перед ней стоит знак «-». Включается
ретроспективная проверка на отсутствие этого знака «-» и общий шаблон регулярного
выражения считается неподходящим. Поэтому берется следующая цифра 2. Перед ней
стоит цифра 1. Ретроспективная проверка относительно двойки определяет
отсутствие символа «-» и выражение считается верным. Соответственно, далее
выбираются все цифры после 2. Получаем число 27. И так для всех остальных
чисел.
Множественные ретроспективные проверки
В данном случае
поправить шаблон регулярного выражения очень просто. Нужно в ретроспективной
негативной проверке указать отсутствие не только символа «-», но и любой цифры:
match = re.findall(r"(?<![-\d])\d+", msg)
Тогда получим
ожидаемый результат:
['4563', '1046',
'200']
Или же можно записать
эту проверку в виде двух ретроспекций:
match = re.findall(r"(?<!-)(?<!\d)\d+", msg)
Это будет полный
аналог предыдущего случая. То есть, записывая подряд идущие ретроспективные
проверки, сначала проверяется первая, а затем, с той же начальной позиции –
вторая. Если обе оказываются истинными, то выделяется соответствующее число из
текста.
Этот же прием
можно использовать и с опережающими проверками, записывая их друг за другом.
Чтобы
ретроспективные проверки работали корректно и предсказуемо, на них во многих ЯП
наложено ограничение фиксированной длины проверяемого фрагмента. Это значит,
что в шаблонах таких проверок недопустимо использовать метасимволы «+*».
Например, следующая запись шаблона для выделения дробной части вещественного
числа приведет к ошибке:
match = re.findall(r"(?<=\b\d+)[.]\d+", msg) # re.error: look-behind requires fixed-width pattern
Как вариант,
можно воспользоваться более продвинутой библиотекой regex регулярных
выражений, но она не входит в состав стандартной языка Python. А чтобы решить
поставленную задачу средствами библиотеки re, можно
прописать следующий несложный шаблон:
msg = "-127.5, 4563.87, +1046.34 -114.0, from -20 to +200"
match = re.findall(r"\b[-+]?(?:\d+[.])(\d+)\b", msg)
Получим
результат:
['5', '87',
'34', '0']
Примеры шаблона с двумя типами проверок
Давайте для
примера воспользуемся сразу двумя типами проверок: опережающими и
ретроспективными, для выделения целых положительных чисел из следующей строки:
msg = "-127.5, 4563, +1046 -114.0, from -20 to +20.0"
Шаблон определим
таким:
match = re.findall(r"(?<![-.\d])\d+(?![.]\d+)(?=\b)", msg)
Мы здесь
проверяем, чтобы вначале числа не было знака минус или точки или цифры, а после
числа не было точки с набором цифр и заканчивалось число границей слова. Получаем
следующий результат:
['4563', '1046']
Попробуйте
самостоятельно прописать аналогичный шаблон без использования опережающих и
ретроспективных проверок.
Дополнительные примеры использования различных типов проверок
Давайте
рассмотрим еще несколько примеров использования различных типов проверок в
регулярных выражениях.
Выделение содержимого тегов script
Предположим,
имеется следующий многострочный текст HTML-страницы:
text = """<!DOCTYPE html>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=windows-1251">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Уроки по Python</title>
</head>
<body>
<script type="text/javascript">
let o = document.getElementById('id_div');
console.log(obj);
</script>
Next script</p>
<script>
let var_mask = 1000;
</script>
</body>
</html>"""
Нам нужно выделить
из него содержимое всех встречаемых тегов script. Регулярное
выражение для решения этой задачи можно записать различными способами:
match = re.findall(r"^<script.*>([\w\W]+?)(?=</script>)", text, re.MULTILINE)
match = re.findall(r"^<script.*>([\w\W]+?)</script>", text, re.MULTILINE)
Здесь сначала
проверяется, что тег script записан вначале строки (флаг MULTILINE указывает,
чтобы проверка «^» соответствовала началу каждой строки). Далее, проверяется
запись тега script: он может быть
с параметрами, а может идти и без параметров. Затем, выбираются все символы
(любые), пока не встретится закрывающий тег script. В результате,
получим:
["\nlet o = document.getElementById('id_div');\nconsole.log(obj);\n", '\nlet var_mask = 1000;\n']
Как видите,
вариантов построения регулярных выражений для решения одной и той же задачи
может быть множество.
Также в
приведенном примере может возникнуть вопрос: почему мы используем именно такой
символьный класс [\w\W] для выбора всех символов? Почему бы не
использовать точку, которая соответствует любому символу. Дело в том, что точка
соответствует любому символу, но не символу перевода строки \n. Поэтому вот
такое выражение:
match = re.findall(r"^<script.*?>(.+?)</script>", text, re.MULTILINE)
даст пустую
коллекцию, т.к. символ \n встречается сразу же после тега script.
Выделение числовых данных из определенных тегов
Следующим
примером изучим некоторые особенности применения ретроспективных проверок.
Пусть имеется многострочный HTML-документ с набором цен по различным обучающим
курсам:
text = """<body>
<ul>
<li>Цены по курсам</li>
<li><a href="python">500</a></li>
<li><a href="python_oop">3500</a></li>
<li><a href="python_stl">3000-5000</a></li>
<li>800</li>
</ul>
</body>"""
И ставится
задача выделения одиночных целых чисел, заключенных внутри тегов, исключая тег <li>. Шаблон
регулярного выражения для решения этой задачи можно записать в таком виде:
match = re.findall(r"(?<!<li>)(?<=>)\d+(?=<)", text, re.IGNORECASE)
Здесь вначале
записаны сразу две ретроспективные проверки на отсутствие тега «<li>», затем сразу
за закрывающейся угловой скобкой должно идти одно целое число, а после него
стоять символ «<». Получим результат с выделенными одиночными числами из
тегов, исключая тег «<li>»:
['500', '3500']
Вот так в целом
реализуются ретроспективные и опережающие проверки модуля re на Python. И, в целом, мы
с вами охватили весь материал по основам построения регулярных выражений.
Используя эти знания, можно создавать правила для обработки строк для решения
самых разных задач. Но как это применять, используя методы модуля re, мы с вами
подробно поговорим на следующих занятиях.