Практикум: регулярные выражения для email, телефонов и дат
Собираем шаблоны, которые вы будете писать чаще всего: адреса почты, российские телефоны в трёх форматах и даты — с нормализацией группами и честными оговорками о границах регулярных выражений.
Редакция Питоники
Десять уроков вы набирали детали: классы и квантификаторы, группы и жадность, методы поиска, замены, якоря, проверки, флаги. Пора играть партию целиком. В этом уроке три задачи, которые вы будете решать в реальных проектах снова и снова: вытащить адреса почты из текста, нормализовать телефоны в едином формате и разобрать даты, написанные по-разному. Каждая задача — это маленькая инженерная история: первая версия шаблона, её слабые места и рабочая версия.
Инструменты все знакомые: группы из урока про группы, fullmatch и findall из урока про методы поиска, re.sub из урока про замену, проверки из урока про lookahead. Нового синтаксиса почти не будет — будет опыт.
Как устроено регулярное выражение для email?
Начнём с извлечения — поиска адресов в свободном тексте. Форма записи простая: имя ящика, собака, домен с хотя бы одной точкой. Каждую часть выражаем классом и помним, что точка и плюс в имени ящика — легальные символы.
import re
page = "Поддержка: help@pythonika.ru, реклама: ads@example.com. Ошибка: not@@mail"
found = re.findall(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+", page)
print(found)
['help@pythonika.ru', 'ads@example.com']
Разбор по частям. [\w.+-]+ — имя ящика: буквы, цифры, подчёркивание, точка, плюс, дефис; в наборе точка не спецсимвол, экранировать не надо. Затем @[\w-]+ — домен до точки. Хитрость в хвосте: (?:\.[\w-]+)+ — невладеющая группа из урока про группы, «точка и кусок домена», повторённая один и больше раз. Она же решает проблему хвостовой точки: в «ads@example.com.» запятая после адреса остаётся в тексте, а не липнет к находке.
import re
EMAIL = re.compile(r"[\w.+-]+@[\w-]+(?:\.[\w-]+)+")
for address in ["anna@pythonika.ru", "anna..two@example.com", "@example.com", "anna@", "anna@localhost"]:
print(address, "->", "принят" if EMAIL.fullmatch(address) else "ошибка")
anna@pythonika.ru -> принят anna..two@example.com -> принят @example.com -> ошибка anna@ -> ошибка anna@localhost -> ошибка
Смотрите на вторую строку: адрес с двумя точками подряд прошёл проверку — формально он синтаксически возможен, хотя сервисы такие не регистрируют. Это не баг, это граница метода.
Где этот шаблон живёт в настоящих проектах? В логах заявок, в подписях под письмами, в выгрузках из CRM, где адреса свалины вперемешку с телефонами. Во всех этих местах задача — не доказать валидность, а вытащить кандидатов: findall работает списком, а дальше сомнительные адреса отсеивает уже обычный код. Обратная сторона: не применяйте шаблон к полям формы как фильтр «всё, что нашлось, — значит верно», извлечение прощает ровно потому, что ничего не гарантирует.
Как составить регулярку для российских телефонов?
Телефон — идеальный материал для тренировки групп с необязательными частями. Один и тот же номер люди пишут как +7 (912) 345-67-89, как 8 912 345 67 89 и как 89310020030. Структура при этом неизменна: страна (+7 или 8), код оператора из трёх цифр, три цифры, две, две. Разделители — пробелы, дефисы, скобки — необязательны.
import re
mess = "Колл-центр: 8 (912) 345-67-89, +7 921 555-12-34 и 89310020030"
PHONE = re.compile(r"(?:\+7|8)[\s-]?\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{2}[\s-]?\d{2}")
print(PHONE.findall(mess))
['8 (912) 345-67-89', '+7 921 555-12-34', '89310020030']
Каждый разделитель в шаблоне — класс из одного-двух символов с квантификатором ? из урока про квантификаторы: [\s-]? — пробел или дефис, есть или нет; \(? и \)? — скобки, есть или нет. Именно сочетание «обязательная структура плюс необязательные украшения» ловит все три формата одним шаблоном. Для извлечения этого достаточно; для чистки данных нужен следующий шаг.
Отдельно скажу про «телефонные регулярки из интернета» — полотна на сотню символов, претендующие на все страны мира. Они иллюстрируют скорее пределы подхода, чем пользу: форматов записи слишком много, и универсальный шаблон либо ломается о реальность, либо пропускает мусор. Инженерно честная позиция — узкий шаблон под данные вашей задачи: если проект российский, три формата выше покрывают почти всё, а остальные случаи попадают в счётчик «не разобралось» и разбираются руками. Тот же принцип, что и со счётчиком пропущенных строк в парсере логов: знать, что не съел шаблон, полезнее, чем съесть всё подряд.
import re
mess = "8 (912) 345-67-89, +7 921 555-12-34, 89310020030"
PHONE = re.compile(r"(\+7|8)\s*\(?(\d{3})\)?[\s-]?(\d{3})[\s-]?(\d{2})[\s-]?(\d{2})")
def normalize(m):
return "+7-{}-{}-{}-{}".format(m[2], m[3], m[4], m[5])
print(PHONE.sub(normalize, mess))
+7-912-345-67-89, +7-921-555-12-34, +7-931-002-00-30
Тот же шаблон, но скобки стали владеющими группами, и re.sub из урока про замену собирает номер заново через функцию-замену: группа 1 (страна) даже не используется — «8» и «+7» оба превращаются в канонический «+7», а код оператора и цифры склеиваются дефисами. Это и есть типовой пайплайн аналитика: нашёл — разобрал по группам — собрал в эталонный вид. Один нюанс на вырост: если номера могут прилипать к другим цифрам (в смс-выгрузках бывает и не такое), добавьте шаблону хвост (?!\d) — запрет лишней цифры сразу после номера, тогда склеенный мусор не будет разрезан пополам. Страховка спереди ставится так же, проверкой (?<!\d), которую вы уже видели в уроке про проверки.
Как валидировать даты регулярным выражением?
Даты добавляют новое умение: ограничение диапазона внутри шаблона. Наивный шаблон \d{2}\.\d{2}\.\d{4} пропустит и тринадцатый месяц, и сороковое число. Правильные классы строятся из перечисления диапазонов: месяц — (0[1-9]|1[0-2]), день — (0[1-9]|[12]\d|3[01]). Добавим три формата: ISO 2026-09-20, русский 20.09.2026 и прописью «20 сентября 2026».
import re
ISO = re.compile(r"(\d{4})-(0[1-9]|1[0-2])-(0[1-9]|[12]\d|3[01])")
RU = re.compile(r"(0[1-9]|[12]\d|3[01])\.(0[1-9]|1[0-2])\.(\d{4})")
WORD = re.compile(
r"(\d{1,2}) ((?:январ|феврал|март|апрел|ма[йя]|июн|июл|август|сентябр|октябр|ноябр|декабр)\w*) (\d{4})"
)
for value in ["2026-09-20", "2026-13-01", "20.09.2026", "40.09.2026", "20 сентября 2026", "1 мая 1945"]:
ok = ISO.fullmatch(value) or RU.fullmatch(value) or WORD.fullmatch(value)
print(value, "->", "формат верный" if ok else "формат неверный")
2026-09-20 -> формат верный 2026-13-01 -> формат неверный 20.09.2026 -> формат верный 40.09.2026 -> формат неверный 20 сентября 2026 -> формат верный 1 мая 1945 -> формат верный
Разберём трюки. Шаблон месяца (0[1-9]|1[0-2]) читается «нуль и цифра от 1 до 9, или единица и от 0 до 2» — тринадцатый месяц не складывается ни из одной ветки. Месяцы словом выражены основами: «сентябр» покроет и «сентября», и «сентябрь», потому что хвост дописывает \w*; пара ма[йя] ловит «май» и «мая». Приём со основами — общий способ справиться с русской морфологией: вместо перечисления всех падежей матчит неизменная часть слова, а изменчивый хвост добирает \w*. А вот засада рядом: «40.09.2026» отвергнута — но только из-за первой цифры дня. Класс [12]\d пропускает числа 10–29, 3[01] — 30 и 31, и сороковое число не подходит ни одной ветке.
Переформатирование из русского стиля в ISO — одна замена со ссылками на группы: год и месяц меняются местами.
import re
docs = "Договор от 20.09.2026, акт от 01.10.2026, счёт от 15.12.2026"
print(re.sub(r"(\d{2})\.(\d{2})\.(\d{4})", r"\3-\2-\1", docs))
Договор от 2026-09-20, акт от 2026-10-01, счёт от 2026-12-15
Ссылки \3, \2, \1 в replacement переставляют группы — приём из урока про замену. Сортировка по такому полю становится честной: строки ISO сравниваются как текст и при этом упорядочены по времени. Один нюанс: шаблон дня \d{2} примет и «3.09.2026» с одной цифрой — если в данных встречаются даты без ведущего нуля, пишите \d{1,2} и при переформатировании дополняйте нулём методом .zfill(2) уже в Python.
import re
from datetime import datetime
candidate = "31.02.2026"
if re.fullmatch(r"(0[1-9]|[12]\d|3[01])\.(0[1-9]|1[0-2])\.\d{4}", candidate):
try:
datetime.strptime(candidate, "%d.%m.%Y")
print("настоящая дата")
except ValueError:
print("формат верный, но даты не существует")
формат верный, но даты не существует
Почему готовые шаблоны ломаются на реальных текстах?
Последний навык практикума — диагностика. Типовая жалоба: «шаблон из интернета съедает лишнее». В девяти случаях из десяти виновата жадность из урока про жадные и ленивые квантификаторы: жадный .* ползёт до последнего совпадения с образцом, а не до ближайшего.
import re
text = 'Он сказал "привет" и ушёл, а потом крикнул "пока"'
print(re.findall(r'".*"', text))
print(re.findall(r'".*?"', text))
['"привет" и ушёл, а потом крикнул "пока"'] ['"привет"', '"пока"']
Жадный .* дотащил от первой кавычки до последней — между ними оказались и слова, и вторая цитата. Ленивая версия .*? останавливается у ближайшей закрывающей кавычки. Ещё один надёжный вариант — убрать универсальную точку совсем: "[^"]+" — «всё, кроме кавычки», и возврат просто не нужен.
И последнее правило практикума: шаблон тестируют на своих данных, а не на своей уверенности. Заведите рядом с шаблоном два коротких списка — строка, которая обязана найтись, и строка, которая обязана провалиться, — и гоняйте оба после каждой правки. Пара «принят/отклонён» из блоков выше — это и есть минимальный тест, просто без pytest. После урока про регулярки он стоит пять минут, а ловит опечатки, на которые уходит вечер отладки на живых данных.
Что дальше
Вы прошли полный цикл: выбор структуры шаблона, первая версия, разбор границ применимости, рабочая версия. Email вы теперь пишете с пониманием, телефон — с нормализацией, дату — с проверкой месяца и дня. Осталось собрать всё в программу настоящего проекта: финальный урок раздела — парсер логов сервера с именованными группами, статистикой и разговором о катастрофическом бэктрекинге. А если вы чистите данные для анализа, тот же приём «нашёл — нормализовал» вас ждёт в очистке данных в Pandas, где строковые методы принимают регулярки напрямую.
Хороший шаблон — это не самый умный шаблон, а самый честный в вопросе, чего он не знает.
Сначала предскажи ответ в голове — это главный навык программиста.
import re
print(re.sub(r"(\d{2})\.(\d{2})\.(\d{4})", r"\2/\1", "от 20.09.2026"))
import re
print(re.findall(r"\d+\.\d+", "версия 3.12 и число 5"))
import re
print(re.findall(r'".*?"', '"а" и "б"'))
1. Что проверяет шаблон [\w.+-]+@[\w-]+(?:\.[\w-]+)+ в адресе почты?
2. Зачем в шаблоне телефона классы вроде [\s-]? с вопросиком?
3. Какой класс валидирует месяц 01–12?
4. Почему шаблон ".*" для поиска цитат съедает лишнее и как чинить?
5. Шаблон дня (0[1-9]|[12]\d|3[01]) пропускает «31.02.2026». Почему?
В редакторе строка с телефонами в трёх форматах. Найдите все номера шаблоном и замените их на канонический формат +7-XXX-XXX-XX-XX через re.sub с функцией. Выведите получившуюся строку.
Какая регулярка для email считается правильной?
Та, что соответствует вашей задаче. Для форм и парсинга хватает [\w.+-]+@[\w-]+(?:\.[\w-]+)+ с проверкой через re.fullmatch: она отсеивает очевидные опечатки вроде отсутствующей собаки и не отторгает легальные адреса. Стандарт RFC 5322 описывает конструкции, которые не переварит ни один практичный шаблон, поэтому индустрия проверяет email не регуляркой, а письмом со ссылкой подтверждения.
Как найти в тексте российский номер телефона в любом формате?
Шаблон (?:\+7|8)[\s-]?\(?\d{3}\)?[\s-]?\d{3}[\s-]?\d{2}[\s-]?\d{2} описывает обязательную структуру (страна, код оператора, три-две-две цифры) и необязательные разделители: пробелы, дефисы, скобки. Он находит «+7 (912) 345-67-89», «8 912 345 67 89» и «89310020030» одним проходом. Для приведения к единому виду замените невладеющие группы владеющими и соберите номер заново через re.sub с функцией.
Можно ли регуляркой проверить, что дата существует?
Только частично: шаблоны вроде месяца (0[1-9]|1[0-2]) и дня (0[1-9]|[12]\d|3[01]) отсекают 13-й месяц и 40-е число, но пропустят 31 февраля — регулярки не знают календаря. Правильная связка: формат проверяет re.fullmatch, существование даты — datetime.strptime, который для «31.02.2026» бросает ValueError.
Почему регулярка из интернета находит не то в моём тексте?
Чаще всего — из-за жадного квантификатора .*: он тянется до последнего совпадения с образцом, а не до ближайшего. Замените на ленивое .*? или исключите разделитель из класса: "[^"]+" вместо ".*". Вторая причина — шаблон валидации используют для извлечения: якоря ^ и $ нужны полю формы, но бессмысленны в потоке текста, а без якорей найдётся валидный кусок внутри любой мусорной строки.
Понравился урок? Сошлитесь на него
«Регулярное выражение проверяет форму записи адреса, а не его существование — настоящая проверка email это письмо со ссылкой.»
Скопируйте готовую ссылку в формате HTML, Markdown или чистый адрес и вставьте в статью на Habr, VC, Telegram-канал или свой блог — так о проекте узнают новые читатели.
Что читать дальше
re · Урок 4
Жадные и ленивые квантификаторы: почему regex съедает лишнее
Жадный .* тянет от первого совпадения до последнего и съедает полстраницы HTML. Ленивый .*? останавливается у ближайшего ограничителя: два знака разницы — совсем другой результат.
re · Урок 10
Флаги в регулярных выражениях: re.IGNORECASE, MULTILINE, DOTALL, VERBOSE
Четыре флага, которые меняют поведение целого движка: поиск в любом регистре, якоря на каждой строке, точка с переводом строки и шаблоны с комментариями вместо «полотна».
Pandas · Урок 4
Очистка данных в Pandas: пропуски NaN, дубликаты и типы
Настоящая грязная выгрузка из CRM: пропуски NaN, дубликаты и цены-строки. Чистим её isna, dropna, fillna, drop_duplicates и astype — шаг за шагом.
Похожие уроки по темам
Подобраны автоматически по пересечению тем и ключевых слов.
openpyxl · Урок 15
Выпадающие списки в Excel через openpyxl: DataValidation
DataValidation добавляет в ячейки выпадающий список: бариста выбирают напиток из меню, а не придумывают свои варианты — разбор ошибок ввода и списков с другого листа.
openpyxl data validation списокopenpyxl валидация данных
BeautifulSoup / Scrapy · Урок 4
Извлечение данных: text, get_text, атрибуты и навигация по дереву
Найти элемент — полдела: данные лежат в тексте и атрибутах. Разбираем text против string, безопасное чтение атрибутов и ходьбу по дереву от родителя к соседям.
beautifulsoup извлечение текстаизвлечение данных с сайта python
re · Урок 1
Регулярные выражения Python с нуля: первый шаблон и модуль re
Первый шаблон, который находит любую дату в тексте, а не одну конкретную: import re, re.search, raw-строки и объект Match — всё, чтобы перестать бояться регулярки.
регулярные выражения pythonregex python