1. 程式人生 > >爬蟲入門系列(五):正則表示式完全指南(上)

爬蟲入門系列(五):正則表示式完全指南(上)

爬蟲入門系列目錄:

正則表示式處理文字有如疾風掃秋葉,絕大部分程式語言都內建支援正則表示式,它應用在諸如表單驗證、文字提取、替換等場景。爬蟲系統更是離不開正則表示式,用好正則表示式往往能收到事半功倍的效果。

介紹正則表示式前,先來看一個問題,下面這段文字來自豆瓣的某個網頁連結,我對內容進行了縮減。問:如何提取文字中所有郵箱地址呢?

html = """
        <style>
            .qrcode-app{
                display: block;
                background: url
(/pics/qrcode_app4@2x.png) no-repeat; } </style> <div class="reply-doc content"> <p class="">34613453@qq.com,謝謝了</p> <p class="">30604259@qq.com麻煩樓主</p> </div> <p class="">490010464@163.com<br
/>謝謝</p> """

如果你還沒接觸過正則表示式,我想對此會是一籌莫展,不用正則,似乎想不到一種更好的方式來處理,不過,我們暫且放下這個問題,待學習完正則表示式之後再來考慮如何解決。

字串的表現形式

Python 字串有幾種表現形式,以u開頭的字串稱為Unicode字串,它不在本文討論範圍內,此外,你應該還看到過這兩種寫法:

>>> foo = "hello"
>>> bar = r"hello"

前者是常規字串,後者 r 開頭的是原始字串,兩者有什麼區別?因為在上面的例子中,它們都是由普通文字字元組成的串,在這裡沒什麼區別,下面可以證明

>>> foo is bar
True
>>> foo == bar
True

但是,如果字串中包括有特殊字元,會是什麼情況呢?再來看一個例子:

>>> foo = "\n"
>>> bar = r"\n"

>>> foo, len(foo)
('\n', 1)
>>> bar, len(bar)
('\\n', 2)
>>> foo == bar
False
>>>

"\n" 是一個轉義字元,它在 ASCII 中表示換行符。而 r"\n" 是一個原始字串,原始字串不對特殊字元進行轉義,它就是你看到的字面意思,由 "\" 和 "n" 兩個字元組成的字串。

定義原始字串可以用小寫r或者大寫R開頭,比如 r"\b" 或者 R"\b" 都是允許的。在 Python 中,正則表示式一般用原始字串的形式來定義,為什麼呢?

舉例來說,對於字元 "\b" 來說,它在 ASCII 中是有特殊意義的,表示退格鍵,而在正則表示式中,它是一個特殊的元字元,用於匹配一個單詞的邊界,為了能讓正則編譯器正確地表達它的意義就需要用原始字串,當然也可以使用反斜槓 "\" 對常規定義的字串進行轉義

>>> foo = "\\b"
>>> bar = r"\b"
>>> foo == bar
True

正則基本介紹

正則表示式由普通文字字元和特殊字元(元字元)兩種字元組成。元字元在正則表示式中具有特殊意義,它讓正則表示式具有更豐富的表達能力。例如,正則表示式 r"a.d"中 ,字元 'a' 和 'd' 是普通字元,'.' 是元字元,. 可以指代任意字元,它能匹配 'a1d'、'a2d'、'acd' ,它的匹配流程是:

re

Python 內建模組 re 是專門用於處理正則表示式的模組。

>>> rex = r"a.d"   # 正則表示式文字
>>> original_str = "and"  # 原始文字
>>> pattern = re.compile(rex)  # 正則表示式物件
>>> m = pattern.match(original_str)  # 匹配物件
>>> m 
<_sre.SRE_Match object at 0x101c85b28>

# 等價於
>>> re.match(r"a.d", "and")
<_sre.SRE_Match object at 0x10a15dcc8>

如果原文字字串與正則表示式匹配,那麼就會返回一個 Match 物件,當不匹配時,match 方法返回的 None,通過判斷m是否為None可進行表單驗證。

接下來,我們需要學習更多元字元。

基本元字元

  • .:匹配除換行符以外的任意一個字元,例如:"a.c" 可以完全匹配 "abc",也可以匹配 "abcef" 中的 "abc"
  • \: 轉義字元,使特殊字元具有本來的意義,例如: 1\.2 可以匹配 1.2
  • [...]:匹配方括號中的任意一個字元,例如:a[bcd]e 可以匹配 abe、ace、ade,它還支援範圍操作,比如:a到z可表示為 "a-z",0到9可表示為 "0-9",注意,在 "[]" 中的特殊字元不再有特殊意義,就是它字面的意義,例如:[.*]就是匹配 . 或者 *
  • [^...],字符集取反,表示只要不是括號中出現的字元都可以匹配,例如:a[^bcd]e 可匹配 aee、afe等
>>> re.match(r"a.c", "abc").group()
'abc'
>>> re.match(r"a.c", "abcef").group()
'abc'
>>> re.match(r"1\.2", "1.2").group()
'1.2'
>>> re.match(r"a[0-9]b", "a2b").group()
'a2b'
>>> re.match(r"a[0-9]b", "a5b11").group()
'a5b'
>>> re.match(r"a[.*?]b", "a.b").group()
'a.b'
>>> re.match(r"abc[^\w]", "abc!123").group()
'abc!

group 方法返回原字串(abcef)中與正則表示式相匹配的那部分子字串(abc),提前是要匹配成功 match 方法才會返回 Match 物件,進而才有group方法。

預設元字元

  • \w 匹配任意一個單詞字元,包括數字和下劃線,它等價於 [A-Za-z0-9_],例如 a\wc 可以匹配 abc、acc
  • \W 匹配任意一個非單詞字元,與 \w 操作相反,它等價於 [^A-Za-z0-9_],例如: a\Wc 可匹配 a!c
  • \s 匹配任意一個空白字元,空格、回車等都是空白字元,例如:a\sc 可以配 a\nc,這裡的 \n表示回車
  • \S 匹配任意一個非空白字元
  • \d 匹配任意一個數字,它等價於[0-9],例如:a\dc 可匹配 a1c、a2c ...
  • \D 匹配任意一個非數字

邊界匹配

邊界匹配相關的符號專門用於修飾字元。

  • ^ 匹配字元的開頭,在字串的前面,例如:^abc 表示匹配 a開頭,後面緊隨bc的字串,它可以匹配 abc
  • $ 匹配字元的結尾,在字串的末尾位置,例如: hello$
>>> re.match(r"^abc","abc").group()
'abc'
>>> re.match(r"^abc$","abc").group()
'abc'

重複匹配

前面的元字元都是針對單個字元來匹配的,如果希望匹配的字元重複出現,比如匹配身份證號碼,長度18位,那麼就需要用到重複匹配的元字元

  • * 重複匹配零次或者更多次
  • ? 重複匹配零次或者一次
  • + 重複匹配1次或者多次
  • {n} 重複匹配n次
  • {n,} 重複匹配至少n次
  • {n, m} 重複匹配n到m次
# 簡單匹配身份證號碼,前面17位是數字,最後一位可以是數字或者字母X
>>> re.match(r"\d{17}[\dX]", "42350119900101153X").group()
'42350119900101153X'

# 匹配5到12的QQ號碼
>>> re.match(r"\d{5,12}$", "4235011990").group()
'4235011990'

邏輯分支

匹配一個固定電話號碼,不同地區規則不一樣,有的地方區號是3位,電話是8位,有的地方區號是4位,電話為7位,區號與號碼之間用 - 隔開,如果應對這樣的需求呢?這時你需要用到邏輯分支條件字元 |,它把表示式分為左右兩部分,先嚐試匹配左邊部分,如果匹配成功就不再匹配後面部分了,這是邏輯 "或" 的關係

# abc|cde 可以匹配abc 或者 cde,但優先匹配abc
>>> re.match(r"aa(abc|cde)","aaabccde").group()
'aaabc'

0\d{2}-\d{8}|0\d{3}-\d{7} 表示式以0開頭,既可以匹配3位區號8位號碼,也可以匹配4位區號7位號碼

>>> re.match(r"0\d{2}-\d{8}|0\d{3}-\d{7}", "0755-4348767").group()
'0755-4348767'
>>> re.match(r"0\d{2}-\d{8}|0\d{3}-\d{7}", "010-34827637").group()
'010-34827637'

分組

前面介紹的匹配規則都是針對單個字元而言的,如果想要重複匹配多個字元怎麼辦,答案是,用子表示式(也叫分組)來表示,分組用小括號"()"表示,例如 (abc){2} 表示匹配abc兩次, 匹配一個IP地址時,可以使用 (\d{1,3}\.){3}\d{1,3},因為IP是由4組陣列3個點組成的,所有,前面3組數字和3個點可以作為一個分組重複3次,最後一部分是一個1到3個數字組成的字串。如:192.168.0.1。

關於分組,group 方法可用於提取匹配的字串分組,預設它會把整個表示式的匹配結果當做第0個分組,就是不帶引數的 group() 或者是 group(0),第一組括號中的分組用group(1)獲取,以此類推

>>> m = re.match(r"(\d+)(\w+)", "123abc")
#分組0,匹配整個正則表示式
>>> m.group()
'123abc'
#等價
>>> m.group(0)
'123abc'
# 分組1,匹配第一對括號
>>> m.group(1)
'123'
# 分組2,匹配第二對括號
>>> m.group(2)
'abc'
>>>

通過分組,我們可以從字串中提取出想要的資訊。另外,分組還可以通過指定名字的方式獲取。

# 第一個分組的名字是number
# 第二個分組的名字是char
>>> m = re.match(r"(?P<number>\d+)(?P<char>\w+)", "123abc")
>>> m.group("number")
'123'
# 等價
>>> m.group(1)
'123'

貪婪與非貪婪

預設情況下,正則表示式重複匹配時,在使整個表示式能得到匹配的前提下儘可能匹配多的字元,我們稱之為貪婪模式,是一種貪得無厭的模式。例如: r"a.*b" 表示匹配 a 開頭 b 結尾,中間可以是任意多個字元的字串,如果用它來匹配 aaabcb,那麼它會匹配整個字串。

>>> re.match(r"a.*b", "aaabcb").group()
'aaabcb'

有時,我們希望儘可能少的匹配,怎麼辦?只需要在量詞後面加一個問號" ?",在保證匹配的情況下儘可能少的匹配,比如剛才的例子,我們只希望匹配 aaab,那麼只需要修改正則表示式為 r"a.*?b"

>>> re.match(r"a.*?b", "aaabcb").group()
'aaab'
>>>

非貪婪模式在爬蟲應用中使用非常頻繁。比如之前在公眾號「Python之禪」曾寫過一篇爬取網站並將其轉換為PDF檔案的場景,在網頁上涉及img標籤元素是相對路徑的情況,我們需要把它替換成絕對路徑

>>> html = '<img src="/images/category.png"><img src="/images/js_framework.png">'

# 非貪婪模式就匹配的兩個img標籤
# 你可以改成貪婪模式看看可以匹配幾個
>>> rex = r'<img.*?src="(.*?)">'
>>> re.findall(rex, html)
['/images/category.png', '/images/js_framework.png']
>>>
>>> def fun(match):
...     img_tag = match.group()
...     src = match.group(1)
...     full_src = "http://foofish.net" + src
...     new_img_tag = img_tag.replace(src, full_src)
...     return new_img_tag
...
>>> re.sub(rex, fun, html)
<img src="http://foofish.net/images/category.png"><img src="http://foofish.net/images/js_framework.png">

sub 函式可以接受一個函式作為替換目標物件,函式返回值用來替換正則表示式匹配的部分,在這裡,我把整個img標籤定義為一個正則表示式 r''group() 返回的值是 <img src="/images/category.png">,而 group(1) 的返回值是 /images/category.png,最後,我用 replace 方法把相對路徑替換成絕對路徑。

到此,你應該對正則表示式有了初步的瞭解,現在我想你應該能解決文章開篇提的問題了。

正則表示式的基本介紹也到這裡告一段落,雖然程式碼示例中用了re模組中的很多方法,但我還沒正式介紹該模組,考慮到文章篇幅,我把這部分放在下篇,下篇將對re的常用方法進行介紹。


關注公眾號「Python之禪」(id:vttalk)獲取最新文章 python之禪