爬蟲系列-正則表達式

我們常常總是說在處理字符串一類數據的時候,總會提到一個正則表達式,但每次別人提到是又愛又恨的感受,它雖然是一個萬能的辦法,但是它相比其他幾個,麻煩很多,一般都不會用它,但它一般都是我們最後的殺手鐗,我們在寫爬蟲的時候也少不它。

工具介紹

首先提供一個在線測試正則表達式的網站,點開這個鏈接你就可以進去看,然後最近設計一個匹配的文本,然後就可以得出合理的結果。比如:
在這裏插入圖片描述
這個網站對於剛學習的小白是很有用的。我們可以直接點右邊的,然後它就會自動提供一個正則表達式,可以與你寫的正則表達式進行對比。

常用匹配規則

\w    匹配字母、數字及下劃線
\W    匹配不是字母、數字及下劃線的字符
\s    匹配任意空白字符,等價於[\t\n\r\f]
\S    匹配任意非空字符
\d    匹配任意數字,等價於[0-9]
\D    匹配任意非數字的字符
\A    匹配字符串開頭
\Z    匹配字符串結尾,如果存在換行,只匹配到換行前的結束字符串
\z    匹配字符串結尾,如果存在換行,同時還會匹配換行符
\G    匹配最後匹配完成的位置
\n    匹配一個換行符
\t    匹配一個製表符
^     匹配一行字符串的開頭
$     匹配一行字符串的結尾
.     匹配任意字符,除了換行符,當re.DOTALL標記被指定時,則可以匹配包括換行符的任意字符
[...] 用來表示一組字符,單獨列出,比如[amk]匹配a、m或k
[^...]不在[]中的字符,比如[^abc]匹配除了a、b、c之外的字符
*     匹配0個或多個表達式
+     匹配1個或多個表達式
?     匹配0個或1個前面的正則表達式定義的片段,非貪婪方式
{n}   精確匹配n個前面的表達式
{n, m}匹配n到m次由前面正則表達式定義的片段,貪婪方式
a|b   匹配a或b
( )   匹配括號內的表達式,也表示一個組

這些匹配規則主要分爲五大類:
1.普通字符
普通字符包括沒有顯式指定爲元字符的所有可打印和不可打印字符。這包括所有大寫和小寫字母、所有數字、所有標點符號和一些其他符號。
2.非打印字符
非打印字符也可以是正則表達式的組成部分。
3.特殊字符
所謂特殊字符,就是一些有特殊含義的字符,如上面說的 runoo*b 中的 ,簡單的說就是表示任何字符串的意思。如果要查找字符串中的 * 符號,則需要對 * 進行轉義,即在其前加一個 : runo*ob 匹配 runoob。許多元字符要求在試圖匹配它們時特別對待。若要匹配這些特殊字符,必須首先使字符"轉義",即,將反斜槓字符\ 放在它們前面。
4.限定符
限定符用來指定正則表達式的一個給定組件必須要出現多少次才能滿足匹配。有 * 或 + 或 ? 或 {n} 或 {n,} 或 {n,m} 共6種。
5.定位符
定位符使您能夠將正則表達式固定到行首或行尾。它們還使您能夠創建這樣的正則表達式,這些正則表達式出現在一個單詞內、在一個單詞的開頭或者一個單詞的結尾。
定位符用來描述字符串或單詞的邊界,^ 和 $ 分別指字符串的開始與結束,\b 描述單詞的前或後邊界,\B 表示非單詞邊界。
還有其他幾種一般不是怎麼常用,具體每一類的介紹可以去菜鳥教程看一下。
下面我們來介紹一下具體的的匹配方法:

1.match()

這裏首先介紹第一個常用的匹配方法——match(),向它傳入要匹配的字符串以及正則表達式,就可以檢測這個正則表達式是否匹配字符串。
match()方法會嘗試從字符串的起始位置匹配正則表達式,如果匹配,就返回匹配成功的結果;如果不匹配,就返回None。示例如下:

import re#調用正則表達式這個模塊
content = 'Hello 1234567 World_This1'
print(len(content))
result = re.match('^Hello\s\d{7}\s\w{5}', content)#在原字符串中匹配字符
print(result)#匹配成功的話就返回匹配的結果,否則返回None
print(result.group())#輸出成功匹配的內容
print(result.span())#輸出匹配的範圍

運行結果是:

25
<_sre.SRE_Match object; span=(0, 19), match='Hello 1234567 World'>
Hello 1234567 World
(0, 19)

我們可以看到成功匹配了可以看到結果是SRE_Match對象,這證明成功匹配。該對象有兩個方法:group()方法可以輸出匹配到的內容,結果是Hello 1234567 World,這恰好是正則表達式規則所匹配的內容;span()方法可以輸出匹配的範圍,結果是(0, 19),這就是匹配到的結果字符串在原字符串中的位置範圍。
下面我們來分析一下剛纔寫的正則表達式:

^Hello\s\d{7}\s\w{5}

其中^是匹配字符串的開頭,也就是以Hello開頭;然後\s匹配空白字符,用來匹配目標字符串的空格;\d匹配數字,\d{7}表示匹配七個數字,也可以用七個\d來表示,我們只是匹配了一部分,我們可以加一點匹配全部:

^Hello\s\d{7}\s\w{11}

上面這個例子我們實現了對整個字符串的匹配,但有的時候我們往往需要匹配不是整個字符串,而是其中的需要的某個信息,比如從一段文字中提取電話號碼等內容。我們稱爲匹配目標。

1.匹配目標

這裏可以使用()括號將想提取的子字符串括起來。()實際上標記了一個子表達式的開始和結束位置,被標記的每個子表達式會依次對應每一個分組,調用group()方法傳入分組的索引即可獲取提取的結果。
這裏我舉一個例子,給一段文本,我們從其中提取手機號:

import re#調用正則表達式這個模塊
content = '李華的手機號是18971737678'
result = re.match('^李華的手機號是(\d+)', content)#在原字符串中匹配字符
print(result)#匹配成功的話就返回匹配的結果,否則返回None
print(result.group(1))#輸出成功匹配的內容
print(result.span())#輸出匹配的範圍

運行結果是:

<_sre.SRE_Match object; span=(0, 18), match='李華的手機號是18971737678'>
18971737678
(0, 18)

這裏我們想把手機號提取出來,可以用括號將數字部分的正則表達式括起來,然後調用group(1)獲取匹配的結果。這裏用的是group(1),它與group()有所不同,後者會輸出完整的匹配結果,而前者會輸出第一個被()包圍的匹配結果。假如正則表達式後面還有()包括的內容,那麼可以依次用group(2)、group(3)等來獲取。

2.通用匹配

剛纔我們寫的第一個實例正則表達式其實比較複雜,出現空白字符我們就寫\s匹配,出現數字我們就用\d匹配,這樣的工作量非常大。其實完全沒必要這麼做,因爲還有一個萬能匹配可以用,那就是.(點星)。其中.(點)可以匹配任意字符(除換行符),(星)代表匹配前面的字符無限次,所以它們組合在一起就可以匹配任意字符了。有了它,我們就不用挨個字符地匹配了。

import re
content = 'Hello 123 4567 World_This is a Regex Demo'
result = re.match('^Hello.*Demo$', content)
print(result.group())
print(result.span())

運行結果是:

Hello 123 4567 World_This is a Regex Demo
(0, 41)

這裏我們將.*代替中間那部分,最後加一個結尾字符串就行了。可以看到,group()方法輸出了匹配的全部字符串,也就是說我們寫的正則表達式匹配到了目標字符串的全部內容;span()方法輸出(0, 41),這是整個字符串的長度。我們以後可以用這樣的方法來簡化。

3. 貪婪與非貪婪

使用上面的通用匹配.* 時,但可能匹配會出現問題,比如我們想偷個懶,除了中間的數值,我們希望數字前後都用 .*來省略,這樣來簡化。

import re
content = 'Hello 1234567 World_This is a Regex Demo'
result = re.match('^He.*(\d+).*Demo$', content)
print(result.group(1))
print(result.span())

運行的結果是:

7
(0, 41)

這樣我們發現只得到一個數字7?這個就涉及到貪婪與非貪婪的問題了。
在貪婪匹配下,.* 會匹配儘可能多的字符。正則表達式中.*後面是\d+,也就是至少一個數字,並沒有指定具體多少個數字,因此,.*就儘可能匹配多的字符,這裏就把123456匹配了,給\d+留下一個可滿足條件的數字7,最後得到的內容就只有數字7了。
但這樣會給我們帶來了很多的問題,比如說匹配內容會少了,就像上面一樣,這時候我們就需要用到非貪婪匹配了。非貪婪的寫法是 . *?(這裏其實是連在一起的,考慮到markdown語法緣故),下面我們用非貪婪的寫法試試:

import re
content = 'Hello 1234567 World_This is a Regex Demo'
result = re.match('^He.*?(\d+).*Demo$', content)
print(result.group(1))
print(result.span())

運行結果是:

1234567
(0, 40)

我們發現這樣就可以成功獲取到1234567這幾個數字,婪匹配是儘可能匹配多的字符,非貪婪匹配就是儘可能匹配少的字符。當. ?匹配到Hello後面的空白字符時,再往後的字符就是數字了,而\d+恰好可以匹配,那麼這裏.?就不再進行匹配,交給\d+去匹配後面的數字。所以這樣. *?匹配了儘可能少的字符,\d+的結果就是1234567了。
所以我們在做匹配的時候,字符串中間儘量使用非貪婪匹配,也可以用. *?來表示代替. *,以免出現匹配結果缺失的情況。
但這裏需要注意,如果匹配的結果在字符串結尾,. *?就可能匹配儘可能少的字符。

import re
content = 'https://www.baidu.com/newspaper'
result1 = re.match('http.*?com/(.*?)', content)
result2 = re.match('http.*?com/(.*)', content)
print('result1', result1.group(1))
print('result2', result2.group(1))

運行結果是:

result1 
result2 newspaper

可以觀察到,.*?沒有匹配到任何結果,而.*則儘量匹配多的內容,成功得到了匹配結果。

4.修飾符

正則表達式可以包含一些可選標誌修飾符來控制匹配的模式。修飾符被指定爲一個可選的標誌。我們用實例來看一下:

import re

content = '''Hello 1234567 World_This
is a Regex Demo
'''
result = re.match('^He.*?(\d+).*?Demo$', content)
print(result.group(1))

結果報錯:

AttributeError                            Traceback (most recent call last)
<ipython-input-14-8aafafca5161> in <module>()
      5 '''
      6 result = re.match('^He.*?(\d+).*?Demo$', content)
----> 7 print(result.group(1))

AttributeError: 'NoneType' object has no attribute 'group'

這個是因爲這是因爲.匹配的是除換行符之外的任意字符,當遇到換行符時,.*?就不能匹配了,所以導致匹配失敗。這裏只需加一個修飾符re.S,即可修正這個錯誤:

result = re.match('^He.*?(\d+).*?Demo$', content, re.S)
print(result.group(1))

這樣我們我就又能匹配到相應的數字

1234567

re.S這個修飾符的作用是使.匹配包括換行符在內的所有字符。這個re.S在網頁匹配中經常用到。因爲HTML節點經常會有換行,加上它,就可以匹配節點與節點之間的換行了。
還有一些常用的修飾符:

re.I   使匹配對大小寫不敏感
re.L   做本地化識別(locale-aware)匹配
re.M   多行匹配,影響^和$
re.S   使.匹配包括換行在內的所有字符
re.U   根據Unicode字符集解析字符。這個標誌影響\w、\W、 \b和\B
re.X   該標誌通過給予你更靈活的格式以便你將正則表達式寫得更易於理解

在網頁中匹配常用re.S和re.I

5.轉義匹配

我們知道正則表達式定義了許多匹配模式,如.匹配除換行符以外的任意字符,但是如果目標字符串裏面就包含.,我們需要用到轉義字符了,示例如下:

import re
content = '(百度)www.baidu.com'
result = re.match('\(百度\)www\.baidu\.com', content)
print(result)

運行結果如下:

<_sre.SRE_Match object; span=(0, 17), match='(百度)www.baidu.com'>

可以看到我們用到了原字符串,這些後面用的也比較多。

search()

match()方法是從字符串的開頭開始匹配的,一旦開頭不匹配,那麼整個匹配就失敗了。我們看下面的例子:

import re
content = '(百度22)www.baidu.com'
result = re.match('\(22\)www\.baidu\.com', content)
print(result)

運行結果是:

None

match()方法在使用時需要考慮到開頭的內容,這在做匹配時並不方便。它更適合用來檢測某個字符串是否符合某個正則表達式的規則。

這裏就有另外一個方法search(),它在匹配時會掃描整個字符串,然後返回第一個成功匹配的結果。也就是說,正則表達式可以是字符串的一部分,在匹配時,search()方法會依次掃描字符串,直到找到第一個符合規則的字符串,然後返回匹配內容,如果搜索完了還沒有找到,就返回None。

import re
content = '(百度22)www.baidu.com'
result = re.search('22\)www\.baidu\.com', content)
print(result)

運行結果爲:

<_sre.SRE_Match object; span=(3, 19), match='22)www.baidu.com'>

match()在匹配的時候要考慮開頭的內容,這樣匹配很不方便他適合檢測某個字符串是否符合某個正則表達式的規則。
這裏就有另外一個方法search(),它在匹配時會掃描整個字符串,然後返回第一個成功匹配的結果。也就是說,正則表達式可以是字符串的一部分,在匹配時,search()方法會依次掃描字符串,直到找到第一個符合規則的字符串,然後返回匹配內容,如果搜索完了還沒有找到,就返回None。
爲了我們更加熟悉它的用法,我們還可以看幾個實例來研究它的用法。
我參考別人的一段待匹配的HTML文本,接下來寫幾個正則表達式實例來實現相應信息的提取:

html = '''<div id="songs-list">
    <h2 class="title">經典老歌</h2>
    <p class="introduction">
        經典老歌列表
    </p>
    <ul id="list" class="list-group">
        <li data-view="2">一路上有你</li>
        <li data-view="7">
            <a href="/2.mp3" singer="任賢齊">滄海一聲笑</a>
        </li>
        <li data-view="4" class="active">
            <a href="/3.mp3" singer="齊秦">往事隨風</a>
        </li>
        <li data-view="6"><a href="/4.mp3" singer="beyond">光輝歲月</a></li>
        <li data-view="5"><a href="/5.mp3" singer="陳慧琳">記事本</a></li>
        <li data-view="5">
            <a href="/6.mp3" singer="鄧麗君"><i class="fa fa-user"></i>但願人長久</a>
        </li>
    </ul>
</div>'''

這是一個網頁的部分HTML文本,ul節點裏有許多li節點,其中li節點中有的包含a節點,有的不包含a節點,a節點還有一些相應的屬性——超鏈接和歌手名。
我們希望提取class爲active的li節點內部的超鏈接包含的歌手名和歌名,此時需要提取第三個li節點下a節點的singer屬性和文本。
此時正則表達式可以以li開頭,然後尋找一個標誌符active,中間的部分可以用.?來匹配。接下來,要提取singer這個屬性值,所以還需要寫入singer="(.?)",這裏需要提取的部分用小括號括起來,以便用group()方法提取出來,它的兩側邊界是雙引號。然後還需要匹配a節點的文本,其中它的左邊界是>,右邊界是。然後目標內容依然用(.*?)來匹配,所以最後的正則表達式就變成了:

<li.*?active.*?singer="(.*?)">(.*?)</a>

我們再調用search()方法,在整個html找到符合正則表達式的第一個內容返回,然後這個文本中換行,我們需要設置修飾符爲re.S模式。下面我們具體看看怎麼寫:

result = re.search('<li.*?active.*?singer="(.*?)">(.*?)</a>', html, re.S)
if result:
    print(result.group(1), result.group(2))

運行結果爲:

齊秦 往事隨風

如果正則表達式不加active(也就是匹配不帶class爲active的節點內容),那會怎樣呢?我們將正則表達式中的active去掉,代碼改寫如下:

result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html, re.S)
if result:
    print(result.group(1), result.group(2))

運行結果爲:

任賢齊 滄海一聲笑

由於search()方法會返回第一個符合條件的匹配目標,所以結果發生改變。去掉active匹配到節點變成第二個,後面後面的就不會匹配。在上面匹配我們都用道re.S,下面我們不加這個看看會怎麼樣:

result = re.search('<li.*?singer="(.*?)">(.*?)</a>', html)
if result:
    print(result.group(1), result.group(2))

匹配結果:

beyond 光輝歲月

可以看到,結果變成了第四個li節點的內容。這是因爲第二個和第三個li節點都包含了換行符,去掉re.S之後,.*?已經不能匹配換行符,所以正則表達式不會匹配到第二個和第三個li節點,而第四個li節點中不包含換行符,所以成功匹配。

由於絕大部分的HTML文本都包含了換行符,所以儘量都需要加上re.S修飾符,以免出現匹配不到的問題。

findall()

前面的search()用法可以幫我們匹配正則表達式第一個內容,但如果想匹配符合正則表達式的所有內容,那麼該怎麼辦呢?這個時候我們需要用到findall()方法了。如果匹配成功,則會會返回列表類型的數據,所以我們需要來遍歷獲取每組內容。還是上面的HTML文本,我們匹配符合要求的歌曲鏈接,歌手,以及歌名

import re
result = re.findall('<li.*?href="(.*?)" singer="(.*?)">(.*?)</a>', html, re.S)
print(result)

返回的結果是:

[('/2.mp3', '任賢齊', '滄海一聲笑'), ('/3.mp3', '齊秦', '往事隨風'), ('/4.mp3', 'beyond', '光輝歲月'), ('/5.mp3', '陳慧琳', '記事本'), ('/6.mp3', '鄧麗君', '但願人長久')]

也可以這樣寫

results = re.findall('<li.*?href="(.*?)".*?singer="(.*?)">(.*?)</a>', html, re.S)
print(results)
print(type(results))
for result in results:
    print(result)
    print(result[0], result[1], result[2])

這樣我們可以看看每一塊各自的信息

[('/2.mp3', '任賢齊', '滄海一聲笑'), ('/3.mp3', '齊秦', '往事隨風'), ('/4.mp3', 'beyond', '光輝歲月'), ('/5.mp3', '陳慧琳', '記事本'), ('/6.mp3', '鄧麗君', '但願人長久')]
<class 'list'>
('/2.mp3', '任賢齊', '滄海一聲笑')
/2.mp3 任賢齊 滄海一聲笑
('/3.mp3', '齊秦', '往事隨風')
/3.mp3 齊秦 往事隨風
('/4.mp3', 'beyond', '光輝歲月')
/4.mp3 beyond 光輝歲月
('/5.mp3', '陳慧琳', '記事本')
/5.mp3 陳慧琳 記事本
('/6.mp3', '鄧麗君', '但願人長久')
/6.mp3 鄧麗君 但願人長久

可以看到,返回的列表中的每個元素都是元組類型,我們用對應的索引依次取出即可。

如果只是獲取第一個內容,可以用search()方法。當需要提取多個內容時,可以用findall()方法。

sub()

除了使用正則表達式提取信息外,有時候還需要藉助它來替換字符串,對其中的內容進行修改,比如我們想去掉一個字符串中所有數字,但用replace太麻煩了,我們可以借用sub()。比如這個實例:

import re
test_text ='euier23eerr23344eefdsdhssajakhsjs'
result = re.sub('\d+','',test_text)
print(result)

運行結果是:

euiereerreefdsdhssajakhsjs

這裏只需要給第一個參數傳入\d+來匹配所有的數字,第二個參數爲替換成的字符串(如果去掉該參數的話,可以賦值爲空),第三個參數是原字符串。
sub有的時候簡化HTML,我們可以將不需要的部分替換掉,然後再來提取就簡單很多,還是最開始那一段HTML文本,但這次我們只需要歌名。

html = re.sub('<a.*?>|</a>', '', html)
results = re.findall('<li.*?>(.*?)</li>', html, re.S)
for result in results:
    print(result.strip())

最後的結果是:

一路上有你
滄海一聲笑
往事隨風
光輝歲月
記事本
但願人長久

通過這個實例,我們可以發現利用sub先替換,然後再正則表達式匹配,相對簡單很多。

compile()

其實這個函數作用就是將正則字符串編譯成正則表達式對象,以便在後面的匹配中複用。示例代碼如下:

import re
content1 = '2016-12-15 12:00'
content2 = '2016-12-17 12:55'
content3 = '2016-12-22 13:21'
pattern = re.compile('\d{2}:\d{2}')
result1 = re.sub(pattern, '', content1)
result2 = re.sub(pattern, '', content2)
result3 = re.sub(pattern, '', content3)
print(result1, result2, result3)

運行結果:

2016-12-15  2016-12-17  2016-12-22 

這裏有3個日期,我們想分別將3個日期中的時間去掉,這時可以藉助sub()方法。該方法的第一個參數是正則表達式,但是這裏沒有必要重複寫3個同樣的正則表達式,此時可以藉助compile()方法將正則表達式編譯成一個正則表達式對象,以便複用。
compile()還可以傳入修飾符,例如re.S等修飾符,這樣在search()、findall()等方法中就不需要額外傳了。所以,compile()方法可以說是給正則表達式做了一層封裝,以便我們更好地複用。
正則表達式基礎內容主要是這些了,想要了解更多可以去網上找對應的教程,下篇博客我會將講解具體的爬取案例。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章