Python 网络数据采集(二):抓取所有网页
作者:高玉涵
时间:2022.5.22 08:35
博客:blog.youkuaiyun.com/cg_i
不知前方水深浅。
如有必要,移劝到另一个网页重复这个过程
在互联网上进行自动数据采集这件事情和互联网存在的时间差不多一样长。虽然网络数据采集并不是新术语,但是多年以来,这件事情常见的称谓是网页抓屏(screen scraping)、数据挖掘(data mining)、网络收割(Web harvesting)或其它类似的版本。今天大众好像更倾向于用“网络数据采集”,因此我使用这个术语用于文章标题,不过有时会把网络数据采集程序称为网络爬虫(Web crawler)。之所以叫网络爬虫是因为它可以沿着网络爬行。它们的本质就是一种递归方式。 为了找到 URL 链接,它必须首先要获取网页内容,检查这个页面的内容,再寻找另一个 URL,然后获取 URL 对应的网页内容,不断循环这一过程。
上一篇,例子只是处理单个页面(https://free.kuaidaili.com/free/inha/),只能算是人为简化的例子。从这里开始,我将处理一些现实问题,需要用爬虫遍历多个页面。
不过要注意的是:你可以这样重复采集网页,但并不意味着你一直都应该这么做。当你需要的所有数据都在一个页面上时,前面例子中的爬虫就足以解决问题了。使用网络爬虫的时候,你必须非常谨慎地考虑需要消耗多少网络流量,还要尽力思考能不能让采集目标的服务器负载更低一些。
网络数据采集一般有两种方式:
第一种,就是俗称的“爬虫”程序,这种方式就是模拟人操作浏览器的方式,在网站上一个链接,一个链接的去采集数据,这样会增大网站负载。而且,这种方式获取的数据也都非常的杂乱,需要花很多精力去整理,所以真正分析大数据的时候,工程师不到万不得已,是不会采用这种方式的。
第二种,就是目前我采用的方式,花时间分析网站格式,编写的程序采用更长、更具体选择标签、速度更快的方式。
2. 正则表达式
计算机科学里曾经有个笑语:“如果你有一个问题打算用正则表达式(regular expression)来解决,那么就是两个问题了。“
当你费尽心思写一堆没必要又复杂的查找和过滤函数,其实你真正需要的就是一行正则表达式。之所以叫正则表达式,是因为它们可以识别正则字符串(reqular string);也就是说,它们可以这么定义:“如果你给我的字符串符合规则,我就返回它”,或者是“如果字符串不符合规则,我就忽略它”。这在要求快速浏览大文档,以查找像电话号码和邮箱地址之类的字符串时是非常方便的。
正则表达式在实际中的一个经典应用是识别邮箱地址。虽然不同邮箱服务器的邮箱地址的具体规则不尽相同,但是我们还是可以创建几条能用规则。每条规则对应的表达式如下表第 2 列所示。
规则 | 正则表达式 |
---|---|
1. 邮箱地址的第一部分至少包括一种内容:大写字母、小写字母、数字 0~9、点(.)、加号(+)或下划线(_) | [A-Za-z0-9._+]+:这个正则表达式简写非常智慧。例如,它用“A-Z”表示“任意 A~Z 的大写字母”。把所有可能的序列和符号放在中括号(不是小括号)里表示“括号中的符号里任何一个”。要注意后面的加号,它表示“这些符号都可以出现多次,具至少出现 1 次” |
2. 之后,邮箱地址会包含一个 @ 符号 | @:这个符号很直接。@ 符号必须出现在中间位置,有具仅有 1 次 |
3. 在符号 @ 之后,邮箱地址还必须至少包含一个大写或小写字母 | [A-Za-z]+:可能只在域名的前半部分、符号 @ 后面用字母。而且,至少有一个字母 |
4. 之后跟一个点号(.) | \.:在域名前必须有一个点号(.) |
5. 最后邮箱地址用 com、org、edu、net 结尾(实际上,顶级域名有很多种可能,但是作为示例演示这四个后缀够用了)。 | (com|org|edu|net):这样列出了邮箱地址中可能出现在点号之后的字母序列 |
把上面的规则连接起来,就获得了完整的正则表达式:
[A-Za-z0-9\._+]+@[A-Za-z]+\.(com|org|edu|net)
当我们动手开始写正则表达式的时候,最好先用一个步骤列表描述出你的目标字符串结构。还要注意一些细节的处理。比如,当你识别电话号码的时候,会考虑国家代码和分机号吗?
表 2-1 用简单的说明和例子举了正则表达式的一些常用符号。 这个列表并不是全部符号,别外可能在不同编程语言中会遇到一些变化。但是,这 12 个符号是 Python 的正则表达式中最常用的,可以用来查找和收集绝大多数数据类型。
表 2-1:正则表达式常用符号
符号 | 含义 | 例子 | 匹配结果 |
---|---|---|---|
* | 匹配前面的字符、子表达式或括号里的字符 0 次或多次 | a*b | aaaaaaaa, aaabbbbb, bbbbbbbb |
+ | 匹配前面的字符、子表达式或括号里的字符至少 1 次 | a+b+ | aaaaaaab, aaabbbbb, abbbbbbbb |
[] | 匹配任意一个字符(相当于“任先一个”) | [A-Z]* | APPLE, CAPITALS, QWERTY |
() | 表达式编组(在正则表达式的规则里编组会优先运行) | (a*b)* | aaabaab, abaaab, ababaaaaab |
{m,n} | 匹配前面的字符、子表达式或括号里的字符 m 到 n 次(包含 m 或 n) | a{2,3}b{2,3} | aabbb, aaabbb, aabb |
[^] | 匹配任意一个不在中括号里的字符 | [^A-Z]* | apple, lowercase, qwerty |
| | 匹配任意一个由竖线分割的字符、子表达式(注意是竖线,不是大写字线 I) | b(a|i|e)d | bad,bid,bed |
. | 匹配任意单个字符(包括符号、数字和空格等) | b.d | bad,bzd,b$d,b d |
^ | 指字符串开始位置的字符或子表达式 | ^a | apple, asdf, a |
\ | 转义字符(把有特殊含义的字符转换成字面形式) | \.|\\ | .|\ |
$ | 经常用在正则表达式的末尾,表示“从字符串的末端匹配”。如果不用它,每个正则表达式实际都带“.*“模式,只会从字符串开头进行匹配。这个符号可以看成是 ^ 符号的反义词。 | [A-Z]*[a-z]*$ | ABCabc, zzzyx, Bob |
?! | “不包含”。这个奇怪的组合通常放在字符或正则表达式前面,表示字符不能出现在目标字符串里。这个符号比较难用,字符通常会在字符串的不同部分出现。如果要在整个字符串中全部排除某个字符,就加上 ^ 和 $ 符号 | ^((?![A-Z]).)*$ | no-caps-here, $ymb01s a4ef!ne |
2.1 正则表达式和 BeautifulSoup
如果你觉得前面介绍的正则表达式内容与本节主题有点儿脱节,那么这里就把它们连接起来。在抓取网页的时候,BeautifulSoup 和正则表达式总是配合使用的。其实,大多数支持字符串参数(比如,find(id=“aTagIdHere”))都可以用正则表达式实现。
注意观察网页底部的跳转页面链接——它们的源代码形式如下:
<a href="/free/inha/1/" class="active">1</a>
如果我们想抓取所有<a>
标签的 URL 链接,非常直接的做法就是用 findAll(“a”) 抓取所有链接,对吗?那些明显“多余”的链接,比如,文档中心、帮助和支持等。总之,你不能仅用<a>
标签指向的网页来查找代理信息。
而且网页的布局也可能会变化,或者,因为某些原因,我们不想通过标签在网页中的位置来查找。那么当你想抓取随机分布在网站里的某个元素或数据时,就会出现问题。例如,一些网页的最上面可能有一张商品图片,但是在另一些网页上没有。
解决这类问题的办法,就是直接定位那些标签来查找信息。在本例中,我们直接通文件路径来查找:
from urllib.request import urlopen
from bs4 import BeautifulSoup
import re
html = urlopen('https://free.kuaidaili.com/free/inha/')
bsObj = BeautifulSoup(html.read(), 'html.parser')
for link in bsObj.findAll('a', href=re.compile('(/free/inha/)[0-9]+')):
print(link['href'])
这段代码会打印出跳转含有代理信息页面的相对路径,都是以 /free/inha 开头,以数字结尾,其结果如下所示:
/free/inha/1/
/free/inha/2/
/free/inha/3/
/free/inha/4/
/free/inha/5/
/free/inha/4619/
/free/inha/4620/
正则表达式可以作为 BeautifulSoup 语句的任意一个参数,让你的目标元素查找工作极具灵活性。
2.2 获取属性
到目前为止,我已经介绍过如何获取和过滤标签,以及获取标签里的内容。但是,在网络数据采集时你经常不需要查找标签的内容,而是需要查找标签属性。比如标签 <a>
指向的 URL 链接包含在 href 属性中,这时获取标签属性就变得非常有用了。
对于一个标签对象,可以用下面的代码获取它的全部属性:
myTag.attrs
要注意这行代码返回的是一个 Python 字典对象,可以获取和操作这些属性。比如要 href 属性指定的超链接目标 URL 位置,可以用下面这行代码:
myTag.attrs['href']
2.3 遍历单域名下所有页面
上个例子中,如果你观察生成的一列链接,都是有规律以数字递增方式来区分页面,虽只获得了 7 条页面链接,幸运的是,未尾给出了最大页数,通过下面代码获取它。
links = bsObj.find('div', {'id':'listnav'}).findAll('a',
href=re.compile('(/free/inha/)[0-9]+'))
pageCount = int(links[-1].get_text())
print(pageCount)
输出结果:
4620
2.4 让标签的选择更具体
这里有一个技巧,在项目启动的时候,一定要花时间去比较“我要的链接”和“其它链接”的差异,如果你仔细观察那些指向跳转页面(不是指向其它内容页面)的链接,会发现它们都有三个共同点:
- 它们都在 id 是 listnav 的 div 标签里
- URL 链接都以 /free/inha/
- 都以数字结尾
执行下面代码:
find('div', {'id':'listnav'}).findAll('a', href=re.compile('(/free/inha/)[0-9]+'))
输出结果:
/free/inha/1/
/free/inha/2/
/free/inha/3/
/free/inha/4/
/free/inha/5/
/free/inha/4619/
/free/inha/4620/
结果与先前的例子输出并无二异,这可能会让你感到迷惑。放着简便方式不用?我已不止一次说过,如无特别需求,请尽量“让标签的选择更具体”,你可以尝试去掉第一个 find 语句,再运行一遍程序,不出意外,输出的结果依然相同。但,你可以明显感觉到程序变”慢“了(相对于)。页面里 <a>
标签越多,这种感觉会更加明显。所以让标签的选择更具体,除能提升程序的可靠性,还会提升采集速度(大部分情况下)。
2.5 完整代码
'''
作者:高玉涵
时间:2022.5.27 13:12
说明:爬虫第二版
'''
import socket
import re
from urllib.request import urlopen
from urllib.error import HTTPError
from bs4 import BeautifulSoup
def getTable(bsObj):
'''
获取表格
:param bs 对象
:return 返回表格对象
'''
try:
table = bsObj.table
except AttributeError as e:
return None
return table
def getAgentData(table):
# 抓取到代理数据
agent_data = []
# 获取表头
theads = getThead(table)
try:
# 获取所有行
rows = table.findAll('tr')
except AttributeError as e:
print("TR 标签未找到!")
return None
else:
for row in rows:
# 存放代理信息
agent = {}
for t in theads:
# 逐行查找与列对应的数据
text = row.find('td', {'data-title':t})
if text is not None:
agent.setdefault(t, text.get_text())
if len(agent) != 0:
agent_data.append(agent)
return agent_data
def getThead(table):
# 存放获取的表头值
theads = []
try:
# 遍历表格头子标签
for h in table.thead.tr.children:
# 提取标签内的值去掉前后空格
text = h.get_text().replace(" ","")
# 忽略不可见的换行符
if text != '\n':
theads.append(text)
except AttributeError as e:
print("TR 标签未找到!")
return None
else:
return theads
def getUrl(url):
'''
获取 URL
:param url 地址
:return 返回 bs 对象
'''
try:
html = urlopen(url)
except HTTPError as e:
return None
except socket.error as e:
print('socket')
return None
try:
bsObj = BeautifulSoup(html.read(), 'html.parser')
except AttributeError as e:
return None
return bsObj
def getPageCount(bsObj):
'''
获取底部跳转页面数
'''
try:
links = bsObj.find('div', {'id':'listnav'}).findAll('a',
href=re.compile('(/free/inha/)[0-9]+'))
pageCount = int(links[-1].get_text())
except AttributeError:
return None
except ValueError:
return None
return pageCount
if __name__ == '__main__':
# 免费代理
url = 'https://free.kuaidaili.com/free/inha/'
bsObj = getUrl(url)
if bsObj == None:
print(f"访问:{url} 失败。")
exit(1)
pageCount = getPageCount(bsObj)
if pageCount == None:
print("获取页面数失败。")
exit(1)
print(f"目标:{url} 共发现:{pageCount} 个页面。")
user_choice = input('是否继续(y/n):')
if user_choice not in('y','Y'):
exit(0)
retry = 0 # 重试次数,避免进入死循环
while(True):
if retry >= 3:
exit(1)
try:
retry += 1
user_count = input('请输入需要爬取的页数(默认:3)')
if user_count == "":
user_count = 3
break
elif int(user_count) > pageCount:
print("范围超过最大值!")
continue
except ValueError:
continue
agent_list = [] # 代理列表
'''
网页索引从 1 开始,
user_count + 1,是为了符号人类习惯,不然会存在差 1
range(...,到结束值,但不包括结束值)
'''
for i in range(1, user_count + 1):
bsObj = getUrl(url + str(i))
if bsObj == None:
print(f"访问:{url} 失败。")
exit(1)
table = getTable(bsObj)
if table == None:
print(f"table 标签未发现。")
exit(1)
print(f"采集第 {i} 页,共 {user_count - i} 个页面等待处理,", end="")
agents = getAgentData(table)
agent_list.extend(agents)
print(f"成功采集到 {len(agent_list)} 条数据")
输出结果:
python craw_table2.py
目标:https://free.kuaidaili.com/free/inha/ 共发现:4624 个页面。
是否继续(y/n):y
请输入需要爬取的页数(默认:3)
采集第 1 页,共 2 个页面等待处理,成功采集到 15 条数据
采集第 2 页,共 1 个页面等待处理,成功采集到 30 条数据
采集第 3 页,共 0 个页面等待处理,成功采集到 45 条数据
3. 下一节,通过互联网采集
我们后面要建立的网络爬虫将顺着链接从一个页面跳到另一个页面,它们不再只在网站内部,而是跟着外链跳转,这将是一个新的挑战。相比单个域名采集,互联网采集要难得多——不同网站的布局迥然不同。这就意味着我们必须在要寻找的信息以及查找方式上都极具灵活。