7.6. 案例研究:解析电话号码

到目前为止,您已经专注于匹配整个模式。要么模式匹配,要么不匹配。但正则表达式的功能远不止于此。当正则表达式确实匹配时,您可以挑选出其中的特定部分。您可以找出匹配的位置。

这个例子来自我遇到的另一个现实问题,同样来自以前的工作。问题:解析美国电话号码。客户希望能够以自由格式输入号码(在单个字段中),但希望将区号、局号、号码和可选的分机号分别存储在公司的数据库中。我在网上搜索了许多声称可以做到这一点的正则表达式示例,但没有一个足够宽松。

以下是我需要能够接受的电话号码

种类繁多!在每种情况下,我都需要知道区号是 800,局号是 555,其余的电话号码是 1212。对于有分机号的电话,我需要知道分机号是 1234

让我们逐步开发电话号码解析的解决方案。此示例显示了第一步。

示例 7.10. 查找号码

>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})$') 1
>>> phonePattern.search('800-555-1212').groups()            2
('800', '555', '1212')
>>> phonePattern.search('800-555-1212-1234')                3
>>> 
1 始终从左到右阅读正则表达式。这个表达式匹配字符串的开头,然后是 (\d{3})。什么是 \d{3}?嗯,{3} 表示“匹配正好三个数字”;它是您之前看到的 {n,m} 语法 的一种变体。\d 表示“任何数字”(09)。将其放在括号中表示“匹配正好三个数字,然后将它们作为一个组记住,我以后可以要求”。然后匹配一个文字连字符。然后匹配另一组正好三个数字。然后是另一个文字连字符。然后是另一组正好四个数字。然后匹配字符串的结尾。
2 要访问正则表达式解析器沿途记住的组,请对 search 函数返回的对象使用 groups() 方法。它将返回一个元组,其中包含在正则表达式中定义的组数。在这种情况下,您定义了三个组,一个包含三个数字,一个包含三个数字,一个包含四个数字。
3 此正则表达式不是最终答案,因为它不处理末尾带有分机号的电话号码。为此,您需要扩展正则表达式。

示例 7.11. 查找分机号

>>> phonePattern = re.compile(r'^(\d{3})-(\d{3})-(\d{4})-(\d+)$') 1
>>> phonePattern.search('800-555-1212-1234').groups()             2
('800', '555', '1212', '1234')
>>> phonePattern.search('800 555 1212 1234')                      3
>>> 
>>> phonePattern.search('800-555-1212')                           4
>>> 
1 此正则表达式与前一个几乎相同。和以前一样,您匹配字符串的开头,然后是一个记住的三个数字的组,然后是一个连字符,然后是一个记住的三个数字的组,然后是一个连字符,然后是一个记住的四个数字的组。新增的是,您然后匹配另一个连字符,以及一个由一个或多个数字组成的记住的组,然后是字符串的结尾。
2 由于正则表达式现在定义了四个要记住的组,因此 groups() 方法现在返回一个包含四个元素的元组。
3 不幸的是,此正则表达式也不是最终答案,因为它假设电话号码的不同部分之间用连字符分隔。如果它们用空格、逗号或点分隔怎么办?您需要一个更通用的解决方案来匹配几种不同类型的分隔符。
4 哎呀!此正则表达式不仅没有完成您想要的所有操作,而且实际上是一种倒退,因为现在您无法解析没有分机号的电话号码。这根本不是您想要的;如果分机号存在,您想知道它是什么,但如果它不存在,您仍然想知道主号码的不同部分是什么。

下一个示例显示了用于处理电话号码不同部分之间的分隔符的正则表达式。

示例 7.12. 处理不同的分隔符

>>> phonePattern = re.compile(r'^(\d{3})\D+(\d{3})\D+(\d{4})\D+(\d+)$') 1
>>> phonePattern.search('800 555 1212 1234').groups()                   2
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212-1234').groups()                   3
('800', '555', '1212', '1234')
>>> phonePattern.search('80055512121234')                               4
>>> 
>>> phonePattern.search('800-555-1212')                                 5
>>> 
1 坚持住。您正在匹配字符串的开头,然后是一个由三个数字组成的组,然后是 \D+。那是什么鬼东西?嗯,\D 匹配数字以外的任何字符,而 + 表示“1 个或多个”。所以 \D+ 匹配一个或多个不是数字的字符。这就是您用来代替文字连字符的内容,以尝试匹配不同的分隔符。
2 使用 \D+ 代替 - 意味着您现在可以匹配各部分之间用空格而不是连字符分隔的电话号码。
3 当然,用连字符分隔的电话号码仍然有效。
4 不幸的是,这仍然不是最终答案,因为它假设根本存在分隔符。如果输入的电话号码根本没有空格或连字符怎么办?
4 哎呀!这仍然没有解决需要扩展的问题。现在您有两个问题,但您可以使用相同的技术解决这两个问题。

下一个示例显示了用于处理没有分隔符的电话号码的正则表达式。

示例 7.13. 处理没有分隔符的号码

>>> phonePattern = re.compile(r'^(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$') 1
>>> phonePattern.search('80055512121234').groups()                      2
('800', '555', '1212', '1234')
>>> phonePattern.search('800.555.1212 x1234').groups()                  3
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212').groups()                        4
('800', '555', '1212', '')
>>> phonePattern.search('(800)5551212 x1234')                           5
>>> 
1 您自上一步以来所做的唯一更改是将所有 + 更改为 *。您现在不是在电话号码的各部分之间匹配 \D+,而是匹配 \D*。还记得 + 表示“1 个或多个”吗?嗯,* 表示“零个或多个”。所以现在即使根本没有分隔符,您也应该能够解析电话号码。
2 瞧,它真的有效。为什么?您匹配了字符串的开头,然后是一个记住的三个数字的组(800),然后是零个非数字字符,然后是一个记住的三个数字的组(555),然后是零个非数字字符,然后是一个记住的四个数字的组(1212),然后是零个非数字字符,然后是一个记住的任意数量数字的组(1234),然后是字符串的结尾。
3 其他变体现在也有效:用点代替连字符,以及在分机号之前用空格和 x
4 最后,您已经解决了另一个长期存在的问题:扩展再次成为可选的。如果没有找到分机号,groups() 方法仍然返回一个包含四个元素的元组,但第四个元素只是一个空字符串。
5 我不想成为坏消息的传递者,但您还没有完成。这里有什么问题?区号前有一个额外的字符,但正则表达式假设区号是字符串开头第一个字符。没问题,您可以使用“零个或多个非数字字符”的相同技术跳过区号前的前导字符。

下一个示例显示了如何处理电话号码中的前导字符。

示例 7.14. 处理前导字符

>>> phonePattern = re.compile(r'^\D*(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$') 1
>>> phonePattern.search('(800)5551212 ext. 1234').groups()                 2
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212').groups()                           3
('800', '555', '1212', '')
>>> phonePattern.search('work 1-(800) 555.1212 #1234')                     4
>>> 
1 这与前面的示例相同,只是现在您在第一个记住的组(区号)之前匹配 \D*,即零个或多个非数字字符。请注意,您没有记住这些非数字字符(它们不在括号中)。如果您找到它们,您将跳过它们,并在到达区号时开始记住它。
2 即使在区号前有前导左括号,您也可以成功解析电话号码。(区号后的右括号已经处理完毕;它被视为非数字分隔符,并由第一个记住的组后的 \D* 匹配。)
3 只是一个健全性检查,以确保您没有破坏以前有效的任何内容。由于前导字符完全是可选的,因此这将匹配字符串的开头,然后是零个非数字字符,然后是一个记住的三个数字的组(800),然后是一个非数字字符(连字符),然后是一个记住的三个数字的组(555),然后是一个非数字字符(连字符),然后是一个记住的四个数字的组(1212),然后是零个非数字字符,然后是一个记住的零个数字的组,然后是字符串的结尾。
4 这就是正则表达式让我想要用钝器挖出我的眼睛的地方。为什么这个电话号码不匹配?因为区号前有一个 1,但您假设区号前的所有前导字符都是非数字字符(\D*)。啊。

让我们退后一步。到目前为止,所有正则表达式都从字符串的开头开始匹配。但现在您看到字符串的开头可能有一些您想要忽略的不确定数量的内容。与其尝试将它们全部匹配,以便您可以跳过它们,不如采用不同的方法:根本不要显式匹配字符串的开头。下一个示例中显示了这种方法。

示例 7.15. 电话号码,无论我在哪里找到你

>>> phonePattern = re.compile(r'(\d{3})\D*(\d{3})\D*(\d{4})\D*(\d*)$') 1
>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()        2
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212')                                3
('800', '555', '1212', '')
>>> phonePattern.search('80055512121234')                              4
('800', '555', '1212', '1234')
1 请注意此正则表达式中缺少 ^。您不再匹配字符串的开头。没有什么说您需要用正则表达式匹配整个输入。正则表达式引擎将完成艰苦的工作,找出输入字符串从哪里开始匹配,然后从那里开始。
2 现在,您可以成功解析包含前导字符和前导数字的电话号码,以及电话号码各部分周围任意数量的任何类型的分隔符。
3 健全性检查。这仍然有效。
4 那仍然有效。

看看正则表达式是如何快速失控的?快速浏览一下之前的任何一次迭代。你能说出其中一个和下一个之间的区别吗?

虽然您仍然理解最终答案(而且它就是最终答案;如果您发现了它无法处理的情况,我不想知道),但在您忘记做出选择的原因之前,让我们将其写成一个详细的正则表达式。

例 7.16. 解析电话号码(最终版本)

>>> phonePattern = re.compile(r'''
                # don't match beginning of string, number can start anywhere
    (\d{3})     # area code is 3 digits (e.g. '800')
    \D*         # optional separator is any number of non-digits
    (\d{3})     # trunk is 3 digits (e.g. '555')
    \D*         # optional separator
    (\d{4})     # rest of number is 4 digits (e.g. '1212')
    \D*         # optional separator
    (\d*)       # extension is optional and can be any number of digits
    $           # end of string
    ''', re.VERBOSE)
>>> phonePattern.search('work 1-(800) 555.1212 #1234').groups()        1
('800', '555', '1212', '1234')
>>> phonePattern.search('800-555-1212')                                2
('800', '555', '1212', '')
1 除了分布在多行之外,这与上一步的正则表达式完全相同,因此它解析相同的输入也就不足为奇了。
2 最终健全性检查。是的,这仍然有效。您完成了。

正则表达式的进一步阅读