Python 自动化指南(繁琐工作自动化)第二版:七、使用正则表达式的模式匹配

原文:https://automatetheboringstuff.com/2e/chapter7/

Python 自动化指南(繁琐工作自动化)第二版:七、使用正则表达式的模式匹配_第1张图片

您可能熟悉通过按下CTRL+F并输入您要查找的单词来搜索文本。正则表达式更进了一步:它们允许你指定文本的模式来搜索。您可能不知道某个企业的确切电话号码,但如果您住在美国或加拿大,您会知道它是三位数字,后跟一个连字符,然后是四位数字(还可以选择以三位数字的区号开头)。这就是你,作为一个人类,看到一个电话号码是怎么知道的:415-555-1234是一个电话号码,但 4155551234 不是。

我们每天还会识别各种其他文本模式:电子邮件地址中间有@符号,美国社会保障号码有九位数字和两个连字符,网站 URL 通常有句点和正斜杠,新闻标题使用标题大小写,社交媒体标签以#开头且不包含空格,等等。

正则表达式是有帮助的,但是很少有非程序员知道它们,即使大多数现代的文本编辑器和文字处理器,如 Microsoft Word 或 OpenOffice,都有查找和查找替换功能,可以基于正则表达式进行搜索。正则表达式不仅对软件用户来说,而且对程序员来说,都是巨大的省时工具。事实上,科技作家科利·多克托罗认为,我们应该在编程之前就教授正则表达式:

了解正则表达式可能意味着用 3 个步骤解决问题和用 3000 个步骤解决问题的区别。当你是一个书呆子的时候,你会忘记你通过几个按键解决的问题可能会花费其他人几天乏味的、容易出错的工作来完成 [1]。

在这一章中,你将首先编写一个程序,在不使用正则表达式的情况下找到文本模式,然后看看如何使用正则表达式使代码不那么臃肿。我将向您展示正则表达式的基本匹配,然后介绍一些更强大的特性,比如字符串替换和创建您自己的字符类。最后,在这一章的最后,你将编写一个程序,可以自动从文本块中提取电话号码和电子邮件地址。

不使用正则表达式查找文本模式

假设您想在一个字符串中查找一个美国电话号码。如果你是美国人,你应该知道这个模式:三个数字,一个连字符,三个数字,一个连字符,和四个数字。举个例子:415-555-4242

让我们使用一个名为isPhoneNumber()的函数来检查一个字符串是否匹配这个模式,返回TrueFalse。打开一个新的文件编辑器选项卡,并输入以下代码;然后将文件保存为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 Moshi moshi a phone number?')
print(isPhoneNumber('Moshi moshi'))

运行该程序时,输出如下所示:

Is 415-555-4242 a phone number?
True
Is Moshi moshi a phone number?
False

isPhoneNumber()函数的代码会进行几次检查,看看text中的字符串是否是有效的电话号码。如果这些检查中有任何一项失败,该函数将返回False。首先,代码检查字符串是否正好是 12 个字符 ➊。然后检查区号(即text中的前三个字符)是否仅由数字字符 ➋ 组成。函数的其余部分检查字符串是否遵循电话号码的模式:号码必须在区号 ➌ 后有第一个连字符,再有三个数字字符 ➍,然后是另一个连字符 ➎,最后是四个数字 ➏。如果程序执行设法通过了所有检查,它返回True➐。

用参数'415-555-4242'调用isPhoneNumber()将返回True。用'Moshi moshi'调用isPhoneNumber()会返回False;第一个测试失败了,因为'Moshi moshi'不是 12 个字符长。

如果您想在一个更大的字符串中查找电话号码,您必须添加更多的代码来查找电话号码模式。用以下内容替换isPhoneNumber.py中的最后四个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循环的每次迭代中,来自message的 12 个字符的新块被分配给变量chunk➊。比如第一次迭代,i0chunk被赋值message[0:12](也就是字符串'Call me at 4')。在下一次迭代中,i1chunk被赋值为message[1:13](字符串'all me at 41')。换句话说,在for循环的每次迭代中,chunk采用以下值:

  • 'Call me at 4'
  • 'all me at 41'
  • 'll me at 415'
  • 'l me at 415-'
  • …诸如此类。

您将chunk传递给isPhoneNumber()以查看它是否与电话号码模式 ➋ 匹配,如果匹配,则打印大块内容。

继续循环通过message,最终chunk中的 12 个字符将是一个电话号码。该循环遍历整个字符串,测试每个 12 个字符的部分,并打印它找到的满足isPhoneNumber()的任何chunk。一旦我们完成了message,我们打印Done

虽然在这个例子中message中的字符串很短,但它可能有几百万个字符长,程序仍然会在不到一秒的时间内运行。使用正则表达式查找电话号码的类似程序也可以在不到一秒钟的时间内运行,但是正则表达式使得编写这些程序更快。

使用正则表达式查找文本模式

以前的电话号码查找程序可以工作,但是它使用大量代码来做一些有限的事情:isPhoneNumber()函数有 17 行,但是只能找到一种电话号码模式。格式为415.555.4242(415) 555-4242的电话号码呢?如果电话号码有分机,比如415-555-4242 x99,会怎么样?isPhoneNumber()函数将无法验证它们。您可以为这些额外的模式添加更多的代码,但是有一种更简单的方法。

正则表达式,简称为正则表达式,是对文本模式的描述。例如,正则表达式中的\d代表一个数字字符,即从 0 到 9 的任何一个数字。Python 使用正则\d\d\d-\d\d\d-\d\d\d\d来匹配与前面的isPhoneNumber()函数相同的文本模式:一个由三个数字、一个连字符、另外三个数字、另一个连字符和四个数字组成的字符串。任何其他字符串都不会匹配\d\d\d-\d\d\d-\d\d\d\d正则表达式。

但是正则表达式可以复杂得多。例如,在一个模式后的大括号({3})中添加一个3就像说“匹配这个模式三次”。所以略短的正则表达式\d{3}-\d{3}-\d{4}也匹配正确的电话号码格式。

创建正则对象

Python 中所有的正则函数都在re模块中。在交互式 Shell 中输入以下内容以导入该模块:

>>> import re

本章中的大多数例子都需要re模块,所以记得在你写的任何脚本的开头或者重启 Mu 的任何时候导入它。否则,你会得到一个NameError: name 're' undefined的错误信息。

将表示正则表达式的字符串值传递给re.compile()会返回一个Regex模式对象(或者简单地说,一个Regex对象)。

要创建一个匹配电话号码模式的Regex对象,请在交互式 Shell 中输入以下内容。(请记住,\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对象的search()方法在传递给它的字符串中搜索正则表达式的匹配项。如果在字符串中没有找到正则表达式模式,search()方法将返回None。如果发现模式,则search()方法返回一个Match对象,该对象有一个group()方法,将从搜索的字符串中返回实际匹配的文本。(我很快会解释组。)例如,在交互式 Shell 中输入以下内容:

>>> 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中。然后我们调用phoneNumRegex上的search(),并向search()传递我们在搜索过程中想要匹配的字符串。搜索的结果存储在变量mo中。在这个例子中,我们知道我们的模式将在字符串中找到,所以我们知道将返回一个Match对象。知道了mo包含一个Match对象而不是空值None,我们可以调用mo上的group()来返回匹配。在我们的print()函数调用中编写mo.group()显示整个匹配,415-555-4242

正则表达式匹配的回顾

虽然在 Python 中使用正则表达式有几个步骤,但每个步骤都相当简单。

  1. import re导入正则模块。
  2. re.compile()函数创建一个Regex对象。(记得使用原始字符串。)
  3. 将您想要搜索的字符串传递到Regex对象的search()方法中。这将返回一个Match对象。
  4. 调用Match对象的group()方法来返回实际匹配文本的字符串。

虽然我鼓励您将示例代码输入到交互式 Shell 中,但是您也应该使用基于 Web 的正则表达式测试器,它可以向您展示正则表达式是如何准确匹配您输入的一段文本的。我推荐pythex.org的测试人员。

使用正则表达式的更多模式匹配

现在您已经知道了使用 Python 创建和查找正则表达式对象的基本步骤,您已经准备好尝试一些更强大的模式匹配功能了。

用括号分组

假设您想将区号与电话号码的其余部分分开。添加括号将在正则(\d\d\d)-(\d\d\d-\d\d\d\d)中创建分组。然后,您可以使用group() match 对象方法从一个组中获取匹配的文本。

正则表达式字符串中的第一组括号将是分组1。第二组将是组2。通过将整数12传递给group()匹配对象方法,可以获取匹配文本的不同部分。向group()方法传递0或什么都不传递将返回整个匹配的文本。在交互式 Shell 中输入以下内容:

>>> 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()返回多个值的元组,您可以使用多重赋值技巧将每个值赋给一个单独的变量,如前面的areaCode, mainNumber = mo.groups()行所示。

括号在正则表达式中有特殊的含义,但是如果需要在文本中匹配一个括号,该怎么办呢?例如,也许您试图匹配的电话号码在括号中设置了区号。在这种情况下,需要用反斜杠对()字符进行转义。在交互式 Shell 中输入以下内容:

>>> 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

错误消息告诉您在r'(\(Parentheses\)'字符串的索引0处有一个左括号,它缺少相应的右括号。

用管道匹配多个分组

这个|字符被称为管道。您可以在任何想要匹配众多表达式之一的地方使用它。例如,正则表达式r'Batman|Tina Fey'将匹配'Batman''Tina Fey'

当蝙蝠侠和蒂娜·菲都出现在搜索字符串中时,匹配文本的第一次出现将作为Match对象返回。在交互式 Shell 中输入以下内容:

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

你可以用第 171 页中讨论的findall()方法找到所有的匹配事件。

作为正则表达式的一部分,您还可以使用管道来匹配几种模式中的一种。例如,假设您想要匹配任意字符串'Batman''Batmobile''Batcopter''Batbat'。由于所有这些字符串都以Bat开头,如果您可以只指定一次前缀就好了。这可以用括号来完成。在交互式 Shell 中输入以下内容:

>>> 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'内的部分匹配文本。通过使用管道字符和分组括号,您可以指定希望正则表达式匹配的几种替代模式。

如果需要匹配一个实际的管道字符,用反斜杠对其进行转义,比如\|

问号与可选匹配

有时,有一种模式,您只想随意匹配。也就是说,无论该文本是否存在,正则表达式都应该找到一个匹配。?字符将它前面的组标记为模式的可选部分。例如,在交互式 Shell 中输入以下内容:

>>> 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是一个可选组。正则表达式将匹配没有实例或只有一个实例的文本。这就是正则表达式同时匹配'Batwoman''Batman'的原因。

使用前面的电话号码示例,您可以让正则表达式查找有或没有区号的电话号码。在交互式 Shell 中输入以下内容:

>>> 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'

你可以认为?是在说,“匹配这个问号前面的零个或一个组”。

如果需要匹配一个实际的问号字符,用\?转义。

使用星号匹配零个或多个

*(称为星号乘号)表示“匹配零个或更多”——星号前面的组可以在文本中出现任意次。可以完全没有,也可以一遍遍重复。让我们再来看看蝙蝠侠的例子。

>>> 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'

对于'Batman',正则表达式的(wo)*部分匹配字符串中wo的零个实例;对于'Batwoman'(wo)*匹配wo的一个实例;而对于'Batwowowowoman'(wo)*匹配wo的四个实例。

如果需要匹配一个实际的星号字符,可以在正则表达式中的星号前面加上反斜杠\*

使用加号匹配一个或多个

*表示“匹配零个或多个”,而+(加号)表示“匹配一个或多个”与星号不同,星号不要求其组出现在匹配的字符串中,加号前面的组必须至少出现一次。它不是可选的。在交互式 Shell 中输入以下内容,并与上一节中的星形正则表达式进行比较:

>>> 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,}将匹配(Ha)组的三个或更多实例,而(Ha){,5}将匹配零到五个实例。大括号有助于缩短正则表达式。这两个正则表达式匹配相同的模式:

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

这两个正则表达式也匹配相同的模式:

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

在交互式 Shell 中输入以下内容:

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

这里,(Ha){3}'HaHaHa'匹配,但与'Ha'不匹配。由于与'Ha'不匹配,search()返回None

贪婪和非贪婪匹配

既然(Ha){3,5}可以匹配字符串'HaHaHaHaHa'中的三个、四个或五个Ha实例,您可能想知道为什么在前面的大括号示例中Match对象对group()的调用返回'HaHaHaHaHa'而不是更短的可能性。毕竟'HaHaHa''HaHaHaHa'也是正则表达式(Ha){3,5}的有效匹配。

默认情况下,Python 的正则表达式是贪婪的,这意味着在不明确的情况下,它们将匹配最长的字符串。大括号的非贪婪(也称为惰性)版本匹配尽可能最短的字符串,右大括号后面跟一个问号。

在交互式 Shell 中输入以下内容,注意搜索相同字符串的大括号的贪婪形式和非贪婪形式之间的区别:

>>> 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对象,请在交互式 Shell 中输入以下内容:*

>>> 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对象,而是返回一个字符串列表。列表中的每个字符串都是匹配正则表达式的一段搜索文本。在交互式 Shell 中输入以下内容:

>>> phoneNumRegex = re.compile(r'\d\d\d-\d\d\d-\d\d\d\d') # has no groups
>>> phoneNumRegex.findall('Cell: 415-555-9999 Work: 212-555-0000')
['415-555-9999', '212-555-0000']

如果正则表达式中有分组,那么findall()将返回元组列表。每个元组代表一个找到的匹配,它的项是正则表达式中每个组的匹配字符串。要查看findall()的运行情况,请在交互式 Shell 中输入以下内容(注意,现在正在编译的正则表达式在括号中有组):

>>> phoneNumRegex = re.compile(r'(\d\d\d)-(\d\d\d)-(\d\d\d\d)') # has groups
>>> 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可以代表任何数字。也就是说,\d是正则表达式(0|1|2|3|4|5|6|7|8|9)的简写。这样的速记字符类还有很多,如表 7-1 所示。

表 7-1: 常用字符类的速记代码

速记字符类 代表
\d 从 0 到 9 的任何数字。
\D 任何不是从 0 到 9 的数字的字符。
\w 任何字母、数字或下划线字符。(把这个想象成匹配“单词”字符。)
\W 任何不是字母、数字或下划线字符的字符。
\s 任何空格、制表符或换行符。(把这个想象成匹配“空白”字符。)
\S 任何不是空格、制表符或换行符的字符。

字符类有利于缩短正则表达式。字符类[0-5]将只匹配数字05;这比敲(0|1|2|3|4|5)短多了。注意,虽然\d匹配数字,而\w匹配数字、字母和下划线,但是没有只匹配字母的速记字符类。(尽管您可以使用[a-zA-Z]字符类,如下所述。)

例如,在交互式 Shell 中输入以下内容:

>>> 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]将匹配任何元音字母,包括小写和大写。在交互式 Shell 中输入以下内容:

>>> 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.]将匹配数字05和一个句点。你不需要把它写成[0-5\.]

通过在字符类的左括号后面放置一个脱字符(^),可以创建负字符类。一个负字符类将匹配字符类中所有不是它们的字符。例如,在交互式 Shell 中输入以下内容:

>>> 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'开头的字符串。在交互式 Shell 中输入以下内容:

>>> 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 的数字字符结尾的字符串。在交互式 Shell 中输入以下内容:

>>> 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+$'正则表达式字符串匹配以一个或多个数字字符开头和结尾的字符串。在交互式 Shell 中输入以下内容:

>>> 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

上一个交互式 Shell 示例中的最后两个search()调用演示了如果使用了^$,整个字符串必须如何匹配正则表达式。

我总是混淆这两个符号的意思,所以我用助记符“胡萝卜花费美元”来提醒自己,插入符号在前面,美元符号在最后。

通配符

正则表达式中的.(点号)字符被称为通配符,将匹配除换行符之外的任何字符。例如,在交互式 Shell 中输入以下内容:

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

请记住,点字符将只匹配一个字符,这就是为什么上例中文本flat的匹配只匹配lat。要匹配一个实际的点,用反斜杠\.对该点进行转义。

用点号匹配一切东西

有时候你会想匹配一切和任何东西。例如,假设您想要匹配字符串'First Name:',后跟任意和所有文本,再跟'Last Name:',然后再跟任何内容。你可以用圆点星(.*)来代表“任何事情”请记住,点字符意味着“除换行符之外的任何单个字符”,而星号字符意味着“前面的零个或多个字符”

在交互式 Shell 中输入以下内容:

>>> 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'

点星使用贪婪模式:它总是试图匹配尽可能多的文本。要以一种非贪婪的方式匹配任何和所有文本,使用点、星和问号(.*?)。和大括号一样,问号告诉 Python 以非贪婪的方式进行匹配。

在交互式 Shell 中输入以下内容,查看贪婪版本和非贪婪版本之间的区别:

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

这两个正则表达式大致翻译为“匹配一个开尖括号,后跟任何内容,再跟一个闭尖括号”。但是字符串' for dinner.>'对于右尖括号有两个可能的匹配。在非贪婪版本的正则表达式中,Python 匹配最短的可能字符串:''。在贪婪版本中,Python 匹配最长的可能字符串:' for dinner.>'

使用点号匹配换行符

除了换行符之外,点星号可以匹配任何内容。通过将re.DOTALL作为第二个参数传递给re.compile(),您可以让点字符匹配所有的字符,包括换行符。

在交互式 Shell 中输入以下内容:

>>> 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.'

regex noNewlineRegex没有将re.DOTALL传递给创建它的re.compile()调用,它将匹配到第一个换行符为止的所有内容,而将re.DOTALL传递给re.compile()匹配所有内容。这就是为什么newlineRegex.search()调用匹配整个字符串,包括它的换行符。

正则符号回顾

本章介绍了很多符号,所以这里快速回顾一下您所学的基本正则表达式语法:

  • ?匹配零个或一个前面的组。
  • *匹配零个或多个前面的组。
  • +匹配一个或多个前面的组。
  • {n}完全匹配n个前一组。
  • {n,}匹配n或更多个前一组。
  • {,m}匹配 0 到m个前一组。
  • {n,m}匹配至少n,最多m个前一组。
  • {n,m}?*?+?执行前一组的非贪婪匹配。
  • ^spam表示字符串必须以spam开头。
  • spam$表示字符串必须以spam结尾。
  • .匹配除换行符以外的任何字符。
  • \d\w\s分别匹配一个数字、单词或空格字符。
  • \D\W\S分别匹配除数字、单词或空格字符之外的任何内容。
  • [abc]匹配括号之间的任意字符(如abc )。
  • [^abc]匹配不在括号内的任何字符。

不区分大小写的匹配

通常,正则表达式会根据您指定的大小写来匹配文本。例如,以下正则表达式匹配完全不同的字符串:

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

但是有时你只关心字母的匹配,而不关心它们是大写还是小写。为了使你的正则表达式不区分大小写,你可以将re.IGNORECASEre.I作为第二个参数传递给re.compile()。在交互式 Shell 中输入以下内容:

>>> 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()方法返回一个应用了替换的字符串。

例如,在交互式 Shell 中输入以下内容:

>>> 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.'

有时,您可能需要使用匹配的文本本身作为替换的一部分。在sub()的第一个参数中,您可以键入\1\2\3等,表示“在替换中输入组123等的文本”。

例如,假设您想通过只显示特工姓名的首字母来审查他们的姓名。为此,您可以使用正则表达式Agent (\w)\w*并将r'\1****'作为第一个参数传递给sub()。该字符串中的\1将被组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()函数忽略正则表达式字符串中的空白和注释来减轻这种情况。这种“详细模式”可以通过将变量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}\))?            # area code
    (\s|-|\.)?                    # separator
    \d{3}                         # first 3 digits
    (\s|-|\.)                     # separator
    \d{4}                         # last 4 digits
    (\s*(ext|x|ext.)\s*\d{2,5})?  # extension
    )''', re.VERBOSE)

注意前面的例子是如何使用三重引号语法(''')来创建一个多行字符串的,这样您就可以将正则表达式定义分散到许多行中,使其更加清晰易读。

正则表达式字符串中的注释规则与常规 Python 代码相同:忽略符号#和其后到行尾的所有内容。此外,正则表达式的多行字符串中的多余空格不被视为要匹配的文本模式的一部分。这使您可以组织正则表达式,以便更容易阅读。

结合re.IGNORECASEre.DOTALLre.VERBOSE

如果您想使用re.VERBOSE在正则表达式中写注释,但又想使用re.IGNORECASE忽略大小写,该怎么办?不幸的是,re.compile()函数只接受一个值作为它的第二个参数。您可以通过使用管道字符(|)组合re.IGNORECASEre.DOTALLre.VERBOSE变量来绕过这一限制,这在本文中称为按位或操作符。

因此,如果您想要一个不区分大小写的正则表达式并且包含换行符来匹配点字符,您可以像这样构成您的re.compile()调用:

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

在第二个参数中包含所有三个选项将如下所示:

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

这种语法有点过时,源于 Python 的早期版本。位运算的细节超出了本书的范围,但是查看参考资料中的可以获得更多信息。还可以为第二个参数传递其他选项;它们并不常见,但是您也可以在参考资料中读到更多关于它们的内容。

项目:电话号码和电子邮件地址提取器

假设您有一项无聊的任务,要在一个很长的网页或文档中找到每个电话号码和电子邮件地址。如果您手动滚动页面,可能会搜索很长时间。但是如果你有一个程序可以在你的剪贴板中搜索电话号码和电子邮件地址,你可以简单地按下CTRL- A 来选择所有的文本,按下CTRL- C 来把它复制到剪贴板,然后运行你的程序。它可以用找到的电话号码和电子邮件地址替换剪贴板上的文本。

每当你着手一个新项目时,很容易就会一头扎进编写代码的工作中。但更多的时候,最好是退一步,考虑大局。我建议首先为你的程序需要做什么起草一个高层次的计划。现在还不要考虑实际的代码——你可以以后再担心。现在,坚持广泛的中风。

例如,您的电话和电子邮件地址提取器需要执行以下操作:

  1. 从剪贴板上获取文本。
  2. 在文本中查找所有电话号码和电子邮件地址。
  3. 将它们粘贴到剪贴板上。

现在你可以开始考虑这在代码中是如何工作的了。该代码需要执行以下操作:

  1. 使用pyperclip模块复制和粘贴字符串。
  2. 创建两个正则表达式,一个用于匹配电话号码,另一个用于匹配电子邮件地址。
  3. 查找两个正则表达式的所有匹配,而不仅仅是第一个匹配。
  4. 将匹配的字符串格式化成一个字符串进行粘贴。
  5. 如果在文本中没有找到匹配项,则显示某种消息。

这个列表就像是这个项目的路线图。在编写代码时,您可以分别关注这些步骤。每一步都相当容易管理,并且用你已经知道如何用 Python 做的事情来表达。

第一步:为电话号码创建一个正则表达式

首先,您必须创建一个正则表达式来搜索电话号码。创建一个新文件,输入以下内容,并将其保存为phoneAndEmail.py:

#! python3
# phoneAndEmail.py - Finds phone numbers and email addresses on the clipboard.
import pyperclip, re
phoneRegex = re.compile(r'''(
    (\d{3}|\(\d{3}\))?                # area code
    (\s|-|\.)?                        # separator
    (\d{3})                           # first 3 digits
    (\s|-|\.)                         # separator
    (\d{4})                           # last 4 digits
    (\s*(ext|x|ext.)\s*(\d{2,5}))?    # extension
    )''', re.VERBOSE)
# TODO: Create email regex.
# TODO: Find matches in clipboard text.
# TODO: Copy results to the clipboard.

TODO注释只是程序的骨架。它们将在您编写实际代码时被替换。

电话号码以可选的区号开始,所以区号组后面有一个问号。由于区号可以只有三个数字(即,\d{3}括号内的三个数字(即,\(\d{3}\)),所以应该有一个管道连接这些部分。您可以将正则表达式注释# Area code添加到多行字符串的这一部分,以帮助您记住(\d{3}|\(\d{3}\))?应该匹配什么。

电话号码分隔符可以是空格(\s)、连字符(-)或句点(.),因此这些部分也应该用管道连接起来。正则表达式接下来的几个部分很简单:三位数,后面是另一个分隔符,再后面是四位数。最后一部分是可选的扩展名,由任意数量的空格组成,后跟extxext.,再后跟 2 到 5 个数字。

很容易与包含带括号( )和转义括号\( \)的组的正则表达式混淆。如果您得到一个“缺失的”、未终止的子模式”错误消息,请记得仔细检查您使用的是不是正确的子模式。

第二步:为电子邮件地址创建一个正则表达式

您还需要一个可以匹配电子邮件地址的正则表达式。让您的程序看起来像下面这样:

#! python3
# phoneAndEmail.py - Finds phone numbers and email addresses on the clipboard.
import pyperclip, re
phoneRegex = re.compile(r'''(
--snip--
# Create email regex.
emailRegex = re.compile(r'''(
     [a-zA-Z0-9._%+-]+      # username # ➊
     @                      # @ symbol # ➋
     [a-zA-Z0-9.-]+         # domain name # ➌
    (\.[a-zA-Z]{2,4})       # dot-something
    )''', re.VERBOSE)
# TODO: Find matches in clipboard text.
# TODO: Copy results to the clipboard.

电子邮件地址 ➊ 的用户名部分是一个或多个字符,可以是以下任意字符:小写和大写字母、数字、点号、下划线、百分号、加号或连字符。你可以把所有这些放入一个字符类:[a-zA-Z0-9._%+-]

域名和用户名由一个@符号 ➋ 分隔。域名 ➌ 有一个稍微宽松的字符类,只有字母、数字、句点和连字符:[a-zA-Z0-9.-]。最后将是.com部分(技术上称为顶级域名),它实际上可以是任何点。这是两到四个字符。

电子邮件地址的格式有很多奇怪的规则。这个正则表达式不会匹配每一个可能的有效电子邮件地址,但它会匹配您遇到的几乎任何典型的电子邮件地址。

第三步:查找剪贴板中所有匹配的文本

既然您已经为电话号码和电子邮件地址指定了正则表达式,那么您可以让 Python 的re模块来完成查找剪贴板上所有匹配项的艰苦工作。pyperclip.paste()函数将获得剪贴板上文本的字符串值,findall()正则方法将返回元组列表。

让您的程序看起来像下面这样:

   #! python3
   # phoneAndEmail.py - Finds phone numbers and email addresses on the clipboard.
   import pyperclip, re
   phoneRegex = re.compile(r'''(
   --snip--
   # Find matches in clipboard text.
   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: Copy results to the clipboard.

每个匹配有一个元组,每个元组包含正则表达式中每个组的字符串。记住,组0匹配整个正则表达式,所以元组中索引0处的组是您感兴趣的组。

正如你在 ➊ 看到的,你将把匹配存储在一个名为matches的列表变量中。它从一个空列表开始,有几个for循环。对于电子邮件地址,您附加每个匹配的组0➌。对于匹配的电话号码,您不希望只是添加分组0。当程序检测几种格式的电话号码时,您希望附加的电话号码是单一的标准格式。phoneNum变量包含从匹配文本 ➋ 的组1358构建的字符串。(这些组是区号、前三位数字、后四位数字和分机。)

第四步:将匹配项添加到剪贴板的字符串中

现在您已经将电子邮件地址和电话号码作为字符串列表放在了matches中,您希望将它们放在剪贴板上。pyperclip.copy()函数只接受单个字符串值,而不是字符串列表,所以您在matches上调用join()方法。

为了更容易看到程序正在运行,让我们将您找到的任何匹配打印到终端上。如果没有找到电话号码或电子邮件地址,程序应该告诉用户这一点。

让您的程序看起来像下面这样:

#! python3
# phoneAndEmail.py - Finds phone numbers and email addresses on the clipboard.
--snip--
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.')

运行程序

举个例子,在打开你的网页浏览器到无淀粉按触点页面,按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()方法代替它们)有许多不同的潜在应用。例如,您可以:

  • 查找以http://https://开头的网址。
  • 通过将不同日期格式(如3/14/201903-14-20192015/3/19)中的日期替换为单一标准格式的日期,来清理这些日期。
  • 删除敏感信息,如社会保险号或信用卡号。
  • 查找常见的错别字,如单词之间有多个空格,不小心不小心重复的单词,或句末有多个感叹号。那些很讨厌!!

总结

虽然计算机可以快速搜索文本,但必须准确地告诉它要搜索什么。正则表达式允许您指定要查找的字符模式,而不是确切的文本本身。事实上,一些文字处理和电子表格应用提供了查找和替换功能,允许您使用正则表达式进行搜索。

Python 自带的re模块允许您编译Regex对象。这些对象有几种方法:search()查找单个匹配,findall()查找所有匹配的实例,sub()对文本进行查找并替换。

你可以在的官方 Python 文档中找到更多。另一个有用的资源是www.regular-expressions.info的教程网站。

练习题

  1. 创建Regex对象的函数是什么?

  2. 为什么创建Regex对象时经常使用原始字符串?

  3. search()方法返回什么?

  4. 如何从一个Match对象中获得与模式匹配的实际字符串?

  5. 在从r'(\d\d\d)-(\d\d\d-\d\d\d\d)'创建的正则中,0组包含了什么?集团1?组2

  6. 括号和句点在正则表达式语法中有特定的含义。如何指定希望正则表达式匹配实际的括号和句点字符?

  7. findall()方法返回字符串列表或字符串元组列表。是什么让它返回一个或另一个?

  8. 正则表达式中的|字符表示什么?

  9. 正则表达式中的?字符表示哪两件事?

  10. 正则表达式中的+*字符有什么区别?

  11. 正则表达式中的{3}{3,5}有什么区别?

  12. 正则表达式中的\d\w\s速记字符类表示什么?

  13. 正则表达式中的\D\W\S速记字符类表示什么?

  14. .*.*?有什么区别?

  15. 匹配所有数字和小写字母的字符类语法是什么?

  16. 如何使正则表达式不区分大小写?

  17. .字符一般匹配什么?如果re.DOTALL作为第二个参数传递给re.compile(),它匹配什么?

  18. 如果numRegex = re.compile(r'\d+')numRegex.sub('X', '12 drummers, 11 pipers, five rings, 3 hens')会返回什么?

  19. re.VERBOSE作为第二个参数传递给re.compile()允许您做什么?

  20. 如何编写一个正则表达式来匹配每三位数用逗号分隔的数字?它必须符合以下条件:

    • '42'
    • '1,234'
    • '6,368,745'

    但不包括以下内容:

    • '12,34,567'(逗号之间只有两位数)
    • '1234'(缺少逗号)
  21. 如何编写一个正则表达式来匹配一个姓Watanabe的人的全名?你可以假设它前面的名字总是一个以大写字母开头的单词。正则表达式必须匹配以下内容:

    • 'Haruto Watanabe'
    • 'Alice Watanabe'
    • 'RoboCop Watanabe'

    但不包括以下内容:

    • 'haruto Watanabe'(名字不大写)
    • 'Mr. Watanabe'(前面的单词有非字母字符的地方)
    • 'Watanabe'(没有名字)
    • 'Haruto watanabe'Watanabe不大写的地方)
  22. 如何编写一个正则表达式来匹配第一个单词是AliceBobCarol的句子?第二个字要么是eatspets,要么是throws;第三个字是applescats,或者baseballs;句子以句号结尾?这个正则表达式应该不区分大小写。它必须符合以下条件:

    • 'Alice eats apples.'
    • 'Bob pets cats.'
    • 'Carol throws baseballs.'
    • 'Alice throws Apples.'
    • 'BOB EATS CATS.'

    但不是以下:

    • 'RoboCop eats apples.'
    • 'ALICE THROWS FOOTBALLS.'
    • 'Carol eats 7 cats.'

实践项目

为了练习,编写程序来完成以下任务。

日期检测

编写一个正则表达式来检测DD/MM/YYYY格式的日期。假设日的范围是 01 到 31,月的范围是 01 到 12,年的范围是 1000 到 2999。请注意,如果日或月是一位数,它将有一个前导零。

正则表达式不必检测每个月或闰年的正确日期;它将接受不存在的日期,如 2020 年 2 月 31 日或 2021 年 4 月 31 日。然后将这些字符串存储到名为monthdayyear的变量中,并编写额外的代码来检测日期是否有效。四月、六月、九月和十一月有 30 天,二月有 28 天,其余月份有 31 天。闰年二月有 29 天。闰年每年都能被 4 整除,除了能被 100 整除的年份,除非那一年也能被 400 整除。请注意,这种计算方式使得无法创建一个大小合理的正则表达式来检测有效日期。

强密码检测

编写一个使用正则表达式的函数,以确保传递给它的密码字符串是强的。强密码被定义为长度至少为八个字符,包含大写和小写字符,并且至少有一位数字。您可能需要针对多个正则表达式模式测试字符串,以验证其强度。

正则版的strip()方法

编写一个函数,它接受一个字符串,并做与strip()字符串方法相同的事情。如果除了要去除的字符串之外没有传递其他参数,那么将从字符串的开头和结尾删除空白字符。否则,函数的第二个参数中指定的字符将从字符串中删除。

你可能感兴趣的:(正则表达式,python,自动化)