日期正则表达式

一、先上干货

这玩意是直接可以用的,可以捕获的日期模式可以

1
(?<![0-9])(?:(?:(?:(?:[13579][26]|[2468][048]|0?[48])00|(?:\d{2})?(?:[13579][26]|[2468][048]|0[48]))[-/_.\\]?0?2[-/_.\\]?29)|(?:0?2[-/_.\\]?29[-/_.\\]?(?:(?:[13579][26]|[2468][048]|0?[48])00|(?:\d{2})?(?:[13579][26]|[2468][048]|0[48])))|(?:(?:(?:1[789]|2[01])(?:\d{2})|[06789]\d)[-/_.\\]?(?:(?:1[02]?|0?[3578])[-/_.\\]?(?:[12][0-9]?|3[01]?|0?[4-9])|(?:0?[469]|11)[-/_.\\]?(?:[12][0-9]?|30?|0?[4-9])|0?2[-/_.\\]?(?:[1][0-9]|2[0-8]|0?[3-9])))|(?:(?:(?:1[02]?|0?[3578])[-/_.\\]?(?:[12][0-9]?|3[01]?|0?[4-9])|(?:0?[469]|11)[-/_.\\]?(?:[12][0-9]?|30?|0?[4-9])|0?2[-/_.\\]?(?:[1][0-9]|2[0-8]|0?[3-9]))[-/_.\\]?(?:(?:1[789]|2[01])(?:\d{2})|[06789]\d)))(?![0-9])

二、拆分讲解

接下来如果需要对一些要求定制或者该表达式不符合使用预期的话,咱们再往下看,分块的拆分讲解该表达式。

2.1 闰年的2月29日

首先先处理闰年中二月的最后一天,这个是特殊情况单独考虑。

2.1.1 闰年月日

1
0?229

在日期匹配的正则表达式中,尤为需要注意的一点就是2月29日这个日期,因为这个日期不是每年都有,因此在进行日期匹配的时候要把闰年的2月29日单独拿出来匹配,剩下的日期就可以不区分平年闰年了。
这里的问号是正则表达式中的特殊量词符号,表示0可以出现或不出现,即匹配[229,0229]

2.1.2 闰年年份

1
(\d{1,2})?([13579][26]|[2468][048]|0[48])

这个正则表达式匹配所有可以整除四但不能整除一百的年份。首先看后面括号中,是一个多选结构,可以匹配 [ ‘12’, ‘16’, ‘20’, ‘24’, ‘28’, ‘32’, ‘36’, ‘40’, ‘44’, ‘48’, ‘52’, ‘56’, ‘60’, ‘64’, ‘68’, ‘72’, ‘76’, ‘80’, ‘84’, ‘88’, ‘92’, ‘96’, ‘04’, ‘08’ ] 这些数字串。然后前面的\d{1,2}表示任意一或两位数字,由于|优先级比较低,所以要先将后面的多选结构加上括号。问号在这里依然是作为可选量词使用。

注意到这里后面的括号并不是包括了所有可以被4整除的两位数字,因为没有处理00的情况,这是因为如果闰年的年份的后两位数字是00,那么这个年份需要可以整除400才算做闰年。因此这类的年份需要单独考虑,好在这类情况和前面的情况极为类似。

但是这个表达式匹配的是所有四位的日期,甚至连5678都会认为是一个年份,但是在实践中很难遇到这个年份,所以可以对\d{1,2}进行改动,如果想要的是1000到3000的年份,可以将\d{2}改为[12]?\d,也就是说匹配四位数字时要求第一位数字如果存在的话必须是1或者2,如果希望只匹配四位数的年份,就将问号去掉就可以。

1
([13579][26]|[2468][048]|0?[48])00

这个正则表达式匹配的数字串包括:[‘400’, ‘800’, ‘1200’, ‘1600’, ‘2000’, ‘2400’, ‘2800’, ‘3200’, ‘3600’, ‘4000’, ‘4400’, ‘4800’, ‘5200’, ‘5600’, ‘6000’, ‘6400’, ‘6800’, ‘7200’, ‘7600’, ‘8000’, ‘8400’, ‘8800’, ‘9200’, ‘9600’]。

需要注意的有几点,一是这里匹配的年份是不包括0000年的。然后和上面的例子一样,如果在使用中不需要这么大的范围可以自己调节多选结构中允许的范围。比如如果需要的是1000到3000年之前的年份,就改为(1[26]|2[048])00,那么这个表达式可以匹配的就是[‘1200’, ‘1600’, ‘2000’, ‘2400’, ‘2800’]。

两个闰年年份的表达式放到一个多选结构( | )中就得到完整的闰年年份的正则表达式。

1
(([13579][26]|[2468][048]|0?[48])00|(\d{2})?([13579][26]|[2468][048]|0[48]))

2.1.3 分隔符

1
[-/_.\\]?

由于文本中日期串的格式可能存在分隔符,比如2017年的元旦,可以表示为20170101或者是2017-01-01,因此我们还得考虑可能出现的分隔符,这里我所考虑的分隔符是有 -/_.\,在python中在字符串前面加上字母“r”表示使用原始字符串,因此我们之需要对反斜杠进行转义。

2.1.4 年月日整合版

首先是直接把前面的年份和月日起连接到一起,并且在可能的连接处加上可选的连接符。

1
(([13579][26]|[2468][048]|0?[48])00|(\d{2})?([13579][26]|[2468][048]|0[48]))[-/_.\\]?0?2[-/_.\\]?29

此时这个表达式已经开始变长了,而且过一会还会更长,这样的会这个正则表达式的可维护性会很低,那么可以使用注释模式来写这个正则表达式。

1
2
3
4
5
6
7
8
9
( # year
([13579][26]|[2468][048]|0?[48])00 # Leap Year 1:year%4==0
| # Multiselect keyword
(\d{2})?([13579][26]|[2468][048]|0[48]) # Leap Year 2:year%400==0
)
[-/_.\\]? # seperator
0?2 # February
[-/_.\\]? # seperator
29 # 29th

用这种方式去写正则表达式在改动表达式的时候会简单很多,这种模式下会忽略空白字符,支持多行并且把#后面的解释成注释,在python中要使用re.VERBOSE或re.X来启用详细模式。如下:

1
2
3
4
import re
s = r"""...""" # ...表示上面那一大堆
match = re.compile(s, re.X)
match.search("18840229") # <_sre.SRE_Match object; span=(0, 8), match='18840229'>

上面这个表达式其实有一点问题,就是在上面的年月之间以及月日之间都有分隔符,通常来说这两处分隔符要么同时存在,要么同时不存在,但是上面没有体现出这种约束,这块如何改进就交给各位读者考虑吧……(我有点懒得想了T^T)

2.1.5 改进

对于日期可能有很多出现的模式比如yyyymmdd、mmddyyyy或者ddmmyyyy这样的活我们可以用多选模式将这两种组合起来,比如要可以匹配yyyymmdd和mmdddyyyy的话:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
(?:								# selection of yyyymmdd and mmddyyyy
(?: # yyyymmdd
(?: # selection of two year formation
(?:[13579][26]|[2468][048]|0?[48])00 # Leap Year option 1
|
(?:\d{2})?(?:[13579][26]|[2468][048]|0[48]) # Leap Year option 2
)
[-/_.\\]? # seperator
0?2 # February
[-/_.\\]? # seperator
29 # 29th
)
|
(?: # mmddyyyy
0?2 # February
[-/_.\\]? # seperator
29 # 29th
[-/_.\\]? # seperator
(?: # selection of yyyymmdd and mmddyyyy
(?:[13579][26]|[2468][048]|0?[48])00 # Leap Year option 1
|
(?:\d{2})?(?:[13579][26]|[2468][048]|0[48]) # Leap Year option 2
)
)
)

在上面的表达式中每个左括号的后面都加上了?:,这个表示将该括号的内容标记为非捕获分组,就是说正常情况下括号内被匹配的内容(而不是表达式)会被正则引擎捕获并暂存以供引用,所有这些捕获暂存会消耗性能,如果不需要暂存可以用?:将括号标记为非捕获分组,这种括号只有限定表达式作用范围的作用,而不会作为分组使用。

其次,为了进一步提高可读性,在多行模式的基础上如果出现嵌套结构,使用缩进与注释是不错的选择。

最后,我们发现上面开始出现了多次重复,这种重复是代码“坏味道”的表现,根据DRY原则,我们要对其进行抽象。简单的方式就是对每一小块都分别用变量保存,然后在对其进行组合。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
leapyear_mod_4 = r"""(?:[13579][26]|[2468][048]|0?[48])00"""
leapyear_mod_400 = r"""(?:\d{1,2})?(?:[13579][26]|[2468][048]|0[48])"""
seperator = r"""[-/_.\\]?"""
leap_date = r"""
(?: # selection of yyyymmdd and mmddyyyy
(?: # yyyymmdd
(?:{0}|{1}) # selection of two year formation
{2}0?2 # February
{2}29 # 29th
)
|
(?: # mmddyyyy
0?2{2} # February
29{2} # 29th
(?:{0}|{1}) # selection of two year formation
)
)
""".format(leapyear_mod_4, leapyear_mod_400, seperator)
re.search(leapdate, "19840229", re.X)

这样处理之后,可读性以及可维护性都会提高。

2.2 普通日期

2.2.1 普通年份

1
(?!00)(?:\d{2})?\d{2}

这个表达式如果不看最前面的(?!00)就很容易理解,是匹配任意2位或者4位的表达式。但是我们前面说过,没有公元零年,所以不希望出现00或者0000,另一方面假如年份以两位数字的形式出现,我们认为这两位是19xx年,比如0098MMDD这种数字,98更有可能指的是1998年,而前面的00我们不希望将其划分到正则表达式中,因此当年份是4位时不以00开头,当年份是两位是不能是00。

(?!...)是正则表达式中的 否定顺序环视 ,它的功能不是匹配字符,而是类似于^或者$的功能去匹配位置(?!*str*)匹配在该位置右侧不是str的位置。所以(?!00)的功能就是匹配当前位置右侧不是00的位置,实际可以理解为是一种检查功能,通过它检测我们右侧的表达式匹配到的是否是00,如果是则匹配失败。

2.2.2 大月日期

1
(?:1[02]|0?[13578])[-/_.\\]?(?:[12][0-9]|3[01]|0?[1-9])

大月口诀是“一三五七八十腊,三十一天永不差”(貌似不同的地方有不同的版本),不同的月份天数不一样,虽然20190431乍一看是一个日期,但是实际上却不可能有这一天。

这个表达式没有什么特殊的地方,可以匹配所谓的“一三五七八十腊”。但是也要格外注意一些细节问题,就是如果一个字符串可以被多选结构中的多个分支匹配会出现什么问题。比如只看月份的部分:

1
2
3
4
>>> re.search(r"1[02]|0?[13578]","12")
<_sre.SRE_Match object; span=(0, 2), match='12'>
>>> re.search(r"0?[13578]|1[02]","12")
<_sre.SRE_Match object; span=(0, 1), match='1'>

会发现同样的模式只不过把多选结构改变顺序的话结果会不一样,在多选结构的多个分支多可以完成匹配时,没有标准的解决方法,但是很多语言中的实现都是优先选择左侧的分支。另外,应该尽量避免这种多个分支有交叠的情况,因为这样会大大的增加回溯的计算量。如果像我们这样多选分支中有分支A可以匹配分支B的一部分的情况,就进行表达式的优化合并或者将分支B放在前面。上面的表达式中日期部分也有这种分体,所以进行优化,优化后:

1
(?:1[02]?|0?[13578])

同理匹配所有的日期。

1
(?:[12][0-9]?|3[01]?|0?[1-9])

然后将其使用连接符连接就得到了本小节最开始的表达式。仔细观察的话发现该表达式还可以优化,因为字符串“1”可以被分支结构的第一部分或最后一部分捕获,不过我还是懒的处理了……

2.2.3 小月日期

1
(?:0?[469]|11)[-/_.\\]?(?:[12][0-9]?|30?|0?[1-9])

除去大月剩下的月份是2、4、6、9、11,然后2月稍后单独考虑,所以月份的正则表达式就是(0?[469]|11),然后是日期的表示,同样避免多个分支的重叠功能。注意|的优先级很低,所以在分支结构中0?[469]是作为一个整体的分支。

2.2.4 非闰年的二月份

1
0?2[-/_.\\]?(?:1[0-9]|2[0-8]|0?[1-9])

无须赘述。

2.2.5 整合版本

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
seperator = r"""[-/_.\\]?"""
feb_date = r"""0?2{}(?:1[0-9]|2[0-8]|0?[1-9])""".format(seperator)
small_date = r"""(?:0?[469]|11){}(?:[12][0-9]?|30?|0?[1-9])""".format(seperator)
big_date = r"""(?:1[02]|0?[13578]){}(?:[12][0-9]|3[01]|0?[1-9])""".format(seperator)
normal_year = r"""(?!00)(?:\d{2})?\d{2}"""

normal_date = r"""
(?: # selection of yyyymmdd and mmddyyyy
(?: # yyyymmdd
{0}{4} # year + seperator
(?: # selection of bigdate / smalldate / febdate
{2}|{3}|{1}
)
)
|
(?: # mmddyyyy
(?: # selection of bigdate / smalldate / febdate
{3}|{2}|{1}
)
{4}{0} # seperator + year
)
)
""".format(normal_year, feb_date, big_date, small_date, seperator)

re.search(norma_date, "19940330", re.X)

但是这样会出现一个问题,用1994年3月30日举例,如果是(yyyymmdd|mmddyyyy)这种结构,可以正确匹配19940330但是却会对于03301994只匹配到033019,也就是被前面的分支捕获并理解为0330年1月9日;然而如果是(mmddyyyy\|yyyymmdd)这种多选结构,它会正确匹配03301994,但是错误地将19940330匹配为199403(9403年1月9日),这种不是前面的一个分支可以匹配另一个分支的一部分的问题,而是两者产生了不包含的重叠。原因在于普通年份的正则表达式的匹配能力太强了,像9403这种数字都可以被匹配成年份,然而结合人们的生活经验,一般年份的出现都有“局部性”。我们因此对年份进行限制,平年年份只能匹配19xx,18xx,17xx,20xx,21xx或者简写形式的[06789]x,这个不重要,只是一种假设的可能性,可以根据需要更改,因此,年份的表达式更新为:

1
(?:(?:1[789]|2[01])(?:\d{2})|[06-9]\d)

然后是需要对上面的normal_year变量进行修改即可。但是其实这样修改之后依然会把“03301994”识别出“03301”是日期,并解释为03年3月01日。因此这个问题比较好的方式是使用最长匹配,检测几种不同的年月日排序方法中哪个最长。另外还可以优化的地方在于,通常是否补零要保持一致,3月1日要么表示为“3-1”要么表述为“03-01”,这个问题和上面的连接符问题是一样的。

2.3 超长整合

首先刚刚我们对普通年份的范围进行了限制,因此对闰年的范围也进行相对应的调整,调整之后的闰年可以匹配17xx年到21xx年范围内的闰年,简写形式可以满足以[06789]开头的两位数字:

1
(?:1[789]|2[01])?([79][26]|[68][048]|0[48])

所以最终的超级整合版本是:

1
date_regex = r"""(:?{0}|{1})""".format(leap_date, normal_date)

三、总结

然后假设想要在一串数字文本混合的文字中提取日期字符串,就可以用上述正则表达式,比如在身份证号中提取日期表达式(当然身份证号这种确定格式的可以直接根据位数提取,正则表达式更适用于不知道预先的格式的情况)。但是如果不希望在一串不可能是日期的串中提取出日期,比如下面这个串:“Tom19940330male13124214651”。假设我们获取到了这种格式的文本,如果直接用python的re包中的findall方法可能会得到不期望的结果:

1
2
3
>>> re.findall(date_regex, "Tom19940330male13124214651", re.X)

<<< ['19940330', '1242146']

这种情况是将后面的电话号码错误的解释成了日期,我们如果不希望从一个数字串中拆分出子串进行解释,可以在前后加入环视,检测前后都是非数字,那么就是我们在文章最开始给出的可以直接用的版本。当然,现在有很多json格式的文本,像例子中这种各个field混合在一起的形式可能不会遇到了,但是灵活强大的RegEx依旧是文本处理的一把尖刀。

1
2
3
>>> re.findall(r"(?<![0-9])(?:(?:(?:(?:[13579][26]|[2468][048]|0?[48])00|(?:\d{2})?(?:[13579][26]|[2468][048]|0[48]))[-/_.\\]?0?2[-/_.\\]?29)|(?:0?2[-/_.\\]?29[-/_.\\]?(?:(?:[13579][26]|[2468][048]|0?[48])00|(?:\d{2})?(?:[13579][26]|[2468][048]|0[48])))|(?:(?:(?:1[789]|2[01])(?:\d{2})|[06789]\d)[-/_.\\]?(?:(?:1[02]?|0?[3578])[-/_.\\]?(?:[12][0-9]?|3[01]?|0?[4-9])|(?:0?[469]|11)[-/_.\\]?(?:[12][0-9]?|30?|0?[4-9])|0?2[-/_.\\]?(?:[1][0-9]|2[0-8]|0?[3-9])))|(?:(?:(?:1[02]?|0?[3578])[-/_.\\]?(?:[12][0-9]?|3[01]?|0?[4-9])|(?:0?[469]|11)[-/_.\\]?(?:[12][0-9]?|30?|0?[4-9])|0?2[-/_.\\]?(?:[1][0-9]|2[0-8]|0?[3-9]))[-/_.\\]?(?:(?:1[789]|2[01])(?:\d{2})|[06789]\d)))(?![0-9])", "Tom19940330male13124214651")

<<< ['19940330']

四、注释

注释:
[1]: 特殊量词符号还有和+,表示{0,},即可以出现任意次数,+表示{1,},既出现至少一次。而{m,n}是一般量词,表示出现最少m次最多n次,m、n都可以省略,表示其对应的最大值或者最小值不受限制,但是m即使省略也表示最少为0,由于有的语言中的正则表达式支持问题,推荐不要省略m。
[2]: 目前使用的历法是用的西方的格里高利历,没有公元0年,只有公元元年,即公元一年(按现在的四位数字纪年应记为0001年),它的前一年是公元前一年,公元元年恰好是西汉最后一位皇帝汉平帝元始元年。
[3]: 括号在正则表达式中可以归纳为三种作用,分组,多选和引用,分组是指将括号内的内容看做一个整体操作,多选是指在括号内被竖线分开的各个选项中选择其一,引用是指将括号中子表达式匹配到的文本缓存起来,供分组后反向引用。
[4]: 环视共有四种。包括肯定顺序环视(?=…)、否定顺序环视(?!…)、肯定逆序环视(?<=…)和否定逆序环视(?<!…)。
[5]: 贪婪与懒惰模式:在量词后面添加后缀修饰问号就可以将磨人的贪婪模式修改为懒惰模式。比如:

1
2
3
import re
print(re.findall("a0{3,5}","a00000"))
print(re.findall("a0{3,5}?","a00000"))

但是有的时候要考虑正则匹配的原理,贪婪模式使用的时候不要想当然,比如下面的案例:

1
2
3
import re
print(re.findall("0{3,5}a","000000a"))
print(re.findall("0{3,5}?a","000000a"))

执行出来会发现两者并没有区别,贪婪模式并没有只匹配到尽可能少的3个0,究其原因还是要思考匹配原理。

贪婪模式下,会一股脑的用量词吞掉尽可能多的匹配字符串,知道吞不下去,查看正则模式的下一个字符,如果匹配失败,会将量词吞掉的串吐出来一个再尝试匹配。懒惰模式是量词正常情况下不吃串,后续失败后匹配模式回溯到量词,量词勉为其难的吃一个,然后继续后续匹配。这部分是自动机的原理。那么看上面的python代码,即使贪婪模式下,匹配的时候,会先吃掉3个0,然后匹配a失败,然后回溯给量词,量词再尝试吃掉4个5个0,但是后面都匹配不到a。没办法,正则模式回溯空了,被匹配字符串从第二个字符开始匹配,还是优先尝试3个0,失败,但是这次的时候,5个0的时候会匹配成功,于是就返回了。因此我想到的不成熟的改进方法是使用分组,前面使用一个尽可能多的捕获a模式,后面接一个分组,那么得到的a就尽可能少了。

其实我还考虑使用环视功能,但是环视匹配字符串不允许使用量词,必须定长,所以行不通。

1
print(re.search("0*(0{3,5}a)","000000a").group(1))

参考

  1. 余晟《正则指引》
本站总访问量