Python爬虫正则表达式抓取糗百笑话

很多人对正则表达式很头痛,其实写正则表达式是有套路的,看完这篇文章保证你会写任何html上的内容抓取的正则。
为确保代码的正确性,此文约定环境为python3,如果在python2运行,可能要做一些调整。

从网上获取整个html

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import urllib.parse
import urllib.request

class QSBK:
def __init__(self):
self.page = 1
# 记录访问的页码
self.user_agent = 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)'

def get_stories(self):
url = "http://www.qiushibaike.com/hot/page/"+str(self.page)
#构建请求的url
req = urllib.request.Request(url)
req.add_header('User-Agent', self.user_agent)
response = urllib.request.urlopen(req)
the_page = response.read().decode("utf-8")
return the_page

if __name__ == '__main__':
qb = QSBK()
# print(qb.get_stories()) 此处会报一个编码错误的error,应该是在html的后面有
# 个别编码不一样的原因,用下面这个可以截取前面的部分,正常看到内容
print(qb.get_stories()[:5000])

编写正则表达式

为了写出正则表达式,我们总结了糗百的段子规律:以<div\sclass="article\sblock\suntagged\smb15"\sid='qiushi_tag开头,然后有好多空行啦,html的 <div> 块啦,然后<div\sclass="content">接着可能有几行空行或者没空行,接着就是我们要的段子,再接着是空行加</div>结束段子。在匹配到开头到段子内容之间有很多内容,我们如果想要用一些正则忽略他们的内容进行匹配就必须用到.*的多行匹配re.S。

这里用到了正则表达式的 re.findall(pattern, string[, flags]) 大家可以先去搜索一下这个东西大概浏览一下,特别注意一下 flags=re.Xflags=re.S 的意义,这里了利用这两个flags能写出很好看很容易理解的正则表达式:

  • re.S:DOTALL
    使 “.” 特殊字符完全匹配任何字符,包括换行;没有这个标志, “.” 匹配除了换行外的任何字符。
  • re.X:VERBOSE
    该标志通过给予你更灵活的格式以便你将正则表达式写得更易于理解。当该标志被指定时,在 RE 字符串中的空白符被忽略,除非该空白符在字符类中或在反斜杠之後;这可以让你更清晰地组织和缩进 RE。它也可以允许你将注释写入 RE,这些注释会被引擎忽略;注释用 “#”号 来标识,不过该符号不能在字符串或反斜杠之後。

抓取到内容后就是编写正则表达式匹配内容了,这里先另外起程序写出正确的正则表达式再加进上面代码里面。

先选用一段html作为范例教大家怎么写正则。

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
26
27
import re
html = '''<div class="article block untagged mb15" id='qiushi_tag_117188488'>

<div class="author clearfix">
<a href="/users/29552012/" target="_blank" rel="nofollow">
<img src="http://pic.qiushibaike.com/system/avtnew/2955/29552012/medium/20150723232021.jpg" alt="invictusmaneo"/>
</a>
<a href="/users/29552012/" target="_blank" title="invictusmaneo">
<h2>invictusmaneo</h2>
</a>
</div>


<div class="content">

昨天在食堂打菜 给人碰了一下 菜汤泼裤子上了 当时愁的 心说油渍多难洗啊 吃完饭 看了眼裤子 连个印子都没了 菜里根本就没油啊!!! 是我想太多了..

</div>



<div class="stats">
<span class="stats-vote"><i class="number">7152</i> 好笑</span>
<span class="stats-comments">'''

pattern = '''<div class="article block untagged mb15" id='qiushi_tag_117192587'>'''
myItems = re.findall(pattern,html,re.S|re.X)
print(myItems)

1.空格

我们先拿一小段试试能不能匹配,发现匹配不了。匹配不了的时候不用慌,我们把匹配内容再缩小: pattern='''<div''' 发现是能匹配的,而 pattern='''<div class''' 匹配不了,这时候稍微想一下就知道,这个空格出问题了,然后再浏览一下python正则的博文或者直接百度搜索,就知道空格应该用 \s,然后我们把整行的空格全替换掉:

1
pattern = '''<div\sclass="article\sblock\suntagged\smb15"\sid='qiushi_tag_117192587'>'''

运行后,结果能匹配到。然后我们继续研究关于多行匹配的

2.多行

先试试匹配两行连续的。记得先把空格替换成 \s

1
2
pattern = '''<div\sclass="author\sclearfix">
<a\shref="/users/27990908/"\starget="_blank"\srel="nofollow">'''

发现是匹配不了的,这时候就要想了,有可能换行要特殊处理的,搜一下百度,我们试试 \n 代替换行。

1
pattern = '''<div\sclass="author\sclearfix">\n<a\shref="/users/27990908/"\starget="_blank"\srel="nofollow">'''

试了下,结果空的,说明不对,然后再试一下写两行的:

1
2
pattern = '''<div\sclass="author\sclearfix">\n
<a\shref="/users/27990908/"\starget="_blank"\srel="nofollow">'''

结果还是空的,说明这个换行是匹配失败了,因为单独两行的分别去匹配是可以匹配到的,加个换行就不行了。此时再想一想,还有什么地方关于这个\n容易出问题的?很容易想到\可能会出问题。我们再试试\\n:

1
pattern = '''<div\sclass="author\sclearfix">\\n<a\shref="/users/27990908/"\starget="_blank"\srel="nofollow">'''

发现是能匹配到结果的。因为用了re.X,我们再试试换写成两行的:

1
2
pattern = '''<div\sclass="author\sclearfix">\\n
<a\shref="/users/27990908/"\starget="_blank"\srel="nofollow">'''

这个也是能匹配的。证明用了re.X确实对于空格和换行等于没效果的。

3.分组

上面所有匹配到的都是在 pattern 的全部内容,我们最终要的只是笑话的部分,所以必然会有一个筛选的东西。通过大概的搜索浏览 python正则 可以知道括号分组能从匹配结果中只返回分组内容。例如:

1
2
pattern = '''<div\sclass="author\sclearfix">\\n
<a\shref="/users/27990908/"\starget="_blank"\srel="(nofollow)">'''

运行后得到 nofollow。所以我们只要把pattern一直写下去,写到笑话那里,然后把内容用括号括起来就可以得到笑话内容了。另外,因为每段笑话前后结构大体相同,但是还是有细节是不一样的,所以还要处理一些不一样的东西。不然像上面的pattern就只能匹配到一个结果,也就是说如果是笑话也只能匹配到一个。很容易,我们就能观察到时那串数字27990908不一样。此时我们可以用\d+代替,以匹配所有的数字。

4.贪婪、非贪婪匹配

我们继续写开始的pattern:

1
pattern = '''<div\sclass="article\sblock\suntagged\smb15"\sid='qiushi_tag_\d+'>'''

此时,能匹配到开头了,但是接下来有一大段内容,我们不想关注他是什么,我们只想关注到段子那里。我们尝试用 .*\w*之类的写法:

1
pattern = '''<div\sclass="article\sblock\suntagged\smb15"\sid='qiushi_tag_\d+'>.*<div\sclass="content">'''

运行发现,只有一段笑话的话是可以匹配到的,但是整个html有很多段的时候,他直接匹配到最后一个笑话的<div\sclass="content">!为了解决问题,我们搜索一下python正则 尽可能少匹配,很容易得到贪婪非贪婪这两个词:

1
2
Python里数量词默认是贪婪的(在少数语言里也可能是默认非贪婪),总是尝试匹配尽可能多的字符;
非贪婪则相反,总是尝试匹配尽可能少的字符。在"*","?","+","{m,n}"后面加上?,使贪婪变成非贪婪

然后我们得到新的pattern:

1
pattern = '''<div\sclass="article\sblock\suntagged\smb15"\sid='qiushi_tag_\d+'>.*?<div\sclass="content">\\n*(.*?)\\n*</div>'''

此时运行后发现已经能抓到大部分段子了,但是依然存在一些垃圾信息抓多了的。然后观察一下html会发现,这些垃圾信息是属于带图片的段子的文本部分,因为少了图片看起来就很奇怪。所以我们要继续改进pattern,使其不匹配带图片的段子。要想实现这个就必须找出文本跟图片段子的区别。

很容易我们就能发现,在文本段子后面,只有文本的段子是直接就<div class="stats">,然后几行后是有<div class="single-clear"></div> 的,而有图片的段子在到达<div class="stats">之前是多了好几个html标记的,其中一个是<img>标记。我们只要把pattrtn继续写下去,就能只匹配文本的段子了:

1
pattern = '''<div\sclass="article\sblock\suntagged\smb15"\sid='qiushi_tag_\d+'>.*?<div\sclass="content">\\n*([^(?:</div>)]*?)\\n*</div>\\n*<div\sclass="stats">'''

运行发现,垃圾信息依然存在

1
['昨天在食堂打菜 给人碰了一下 菜汤泼裤子上了 当时愁的 心说油渍多难洗啊 吃完饭 看了眼裤子 连个印子都没了 菜里根本就没油啊!!! 是我想太多了..', '12345\n\n</div>\n\n\n\n<div class="thumb">\n\n<a href="/article/117193665" target="_blank">\n<img src="http://pic.qiushibaike.com/system/pictures/11719/117193665/medium/app117193665.jpg" alt="糗事#117193665" />\n</a>', '家里有个两岁的小暖男,大热天他经常问他爸爸:冷吗?然后帮他爸爸把电风扇关了,把被子盖上,每天乐此不疲。']

仔细分析我们的pattern和那个多余的垃圾信息,发现确实是能匹配到的,那个垃圾信息完全符合pattern开头中间结尾的所有内容。关键点就在于在 <div class="content"></div> 之间的才是真正的段子。我们虽然对段子用了(.*?)非贪婪匹配,但是因为加了后面结尾的条件,所以匹配会在满足结尾的条件下去用非贪婪匹配,所以得到了上面的结果。这时候非贪婪就不够用了,我们要自己给他实现非贪婪匹配。既然是段子结尾接着是</div>,那么我们只要限制段子的内容不能是</div>就能让他抓取到正确的段子了。

搜索一下 python正则 否定 就能得到^在python正则里边是否定的意思。但是网上的例子大部分都是只有一个字符的,我们想要实现不是</div>要怎么写呢?答案是 [^(</div>)]* 至于为什么这样,写起来太啰嗦,能理解的理解,不能理解的记住规则就可以了。如果纠结于括号会想到分组,可以用[^(?:</div>)]*,分组括号里面用?:表示这个分组的内容不会被当作返回结果,分组只作为一个整体的作用。

最终,我们得到了能完全匹配到自己想要的段子的正则,然后可以利用re.X的特性,让正则更美观易懂一些:

1
2
3
4
5
6
7
8
pattern = '''<div\sclass="article\sblock\suntagged\smb15"\sid='qiushi_tag_\d+'> # 这里匹配开头
.*? # 这里匹配开头到段子前标记之间的内容,注意要用非贪婪匹配?
<div\sclass="content">\\n* # 这里是段子开始的标记,包括段子前的空行
([^(?:</div>)]*?) # 这里是段子内容,最外边的括号是分组,里边的只是整体作用
\\n*</div> # 这里是段子结束的标记,包括段子后的空行
\\n*
<div\sclass="stats"> # 只匹配不含图片的段子。
'''

最终结果

因为糗事百科网页可能会变化,这段代码在写这篇文章的时候是能得到预期结果的,如果不能得到预期结果,请自行修正。

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
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
import urllib.parse
import urllib.request
import re

class QSBK:
def __init__(self):
self.page = 1
# 记录访问的页码
self.user_agent = 'Mozilla/4.0 (compatible; MSIE 5.5; Windows NT)'
self.stories = []
# 存储段子

def get_stories(self):
url = "http://www.qiushibaike.com/hot/page/"+str(self.page)
#构建请求的url
req = urllib.request.Request(url)
req.add_header('User-Agent', self.user_agent)
response = urllib.request.urlopen(req)
the_page = response.read().decode("utf-8")
# print(the_page)
pattern = '''<div\sclass="article\sblock\suntagged\smb15"\sid='qiushi_tag_\d+'> # 这里匹配开头
.*? # 这里匹配开头到段子前标记之间的内容,注意要用非贪婪匹配?
<div\sclass="content">\\n* # 这里是段子开始的标记,包括段子前的空行
([^(?:</div>)]*?) # 这里是段子内容,最外边的括号是分组,里边的只是整体作用
\\n*</div> # 这里是段子结束的标记,包括段子后的空行
\\n*
<div\sclass="stats"> # 只匹配不含图片的段子。
'''

myItems = re.findall(pattern,the_page,re.S|re.X)
# print(len(myItems))
for item in myItems:
self.stories.append(item)
self.page += 1
print(self.stories)

def haha(self):
if len(self.stories)<2:
self.get_stories()
return self.stories.pop()[1]

if __name__ == '__main__':
qb = QSBK()
print(qb.haha())

只要掌握了上面的1234,基本的网页正则匹配都没什么问题了。

正在加载中……