小记在中经网的一次爬虫踩坑经历

最近几天花了点时间写了一个爬虫,过程不太复杂,但是对我这样的爬虫小白来说还是花了点时间的。没有涉及到太多技术,收拾的拦路小妖怪倒是不少,下面记录一下具体的实现过程。

Photo by Jonatan Pie on unsplash

Photo by Jonatan Pie on unsplash

我的需求是抓取历年中国地方政府工作报告全文,简单 Google 一下发现没有现成的数据集,或许再花点时间也可以搜索到或者找别人可以要到,为了多写点代码,最终还是决定通过自己动手写爬虫抓取下来。经过搜索,中国经济网整理了从 2003 年到 2017 各省、直辖市、自治区的政府工作报告数据,目标网址是: http://district.ce.cn/zg/201702/26/t20170226_20529710.shtml,粗略统计约 700 多篇,不算很多,对数据感兴趣的读者可以访问链接下载使用:https://pan.baidu.com/s/1clUF2Q 密码:uxg1

中国经济网首页

中国经济网首页

在代码中用到的库有 urllib 、BeautifulSoup 、pandas 等,urllib 负责从 URL 地址中请求得到 HTML 标签数据,urllib2 是 python 自带的模块,不需要下载,urllib2 在 python3.x 中被改为 urllib.request。BeautifulSoup 负责解析 HTML 数据,剩下的工作就是编写几个小功能函数了,最后再将这些函数组装起来。

1. 实现思路

说一下具体的实现思路,这里我们先将 http://district.ce.cn/zg/201702/26/t20170226_20529710.shtml 称为父网址(father_url)好了,这个网址列出了从 2003 年 到 2017 年地方政府报告合集的链接。首先从父网址解析得到历年报告合集页面的 URL 地址,如“2017 年汇编”的 URL 地址是 http://district.ce.cn/zg/201702/26/t20170226_20528713.shtml,我们称之为子网址(child_url_year);然后,再从子网址中解析得到某个年份地方政府报告的 URL 地址(child_url_city)。当我们得到了所有报告的标题(report_title)和链接(report_url),接下来的工作就很简单了,只需要从一个一个的链接中提取出正文即可。

3 个层级 URL

3 个层级 URL

2. 遇到的几个坑

但是在实现过程中还是遇到了几个小问题需要解决:

2.1 一般报告不止一页,如何知道总页数?

刚遇到这个问题,我准备打算做个简单粗暴的遍历去实现,一旦解析 404 后直接跳出,后来一想效率太低并且也不太好看。然后在网页源代码中找到这么一段,报告的页数统计是用 js 实现的,也就是动态的,所以 HTML 代码中解析不到页数统计的数据,而是隐藏在一段被注释 js 的代码中。比如,下面这段代码中,页面总数 4 就在 createPageHTML(4, 0, "t20170207_20021665", "shtml") 里。

需要把 4 这个参数取出来

需要把 4 这个参数取出来

1
2
3
4
5
6
7
8
9
10
11
12
<!--
function createPageHTML(_nPageCount, _nCurrIndex, _sPageName, _sPageExt){
if(_nPageCount == null || _nPageCount<=1){
return;
}

//中间代码省略

}//函数结束符
//WCM 置标
createPageHTML(4, 0, "t20170207_20021665", "shtml");
//-->

在这里,我用了一个正则表达式把参数取了出来

1
2
3
tmp_html = standard_html.find_all('script', language='JavaScript')
tmp_str = re.search(r'createPageHTML\((\d+)', str(tmp_html))
page_sum = tmp_str.group(1)

2.2 爬取速度变慢

编写爬虫中,考虑到数据量并不大,一开始并没有设置请求的代理头、也没有为 urllib 设置超时,所以在抓取数据的时候,爬虫程序经常超时或者 HTTP Error 502。幸好伪装浏览器解决了这个问题,否则得要按照前辈说的上代理池了。

1
2
3
headers = {'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64)AppleWebKit/537.36 (KHTML, like Gecko) Chrome/63.0.3239.84 Safari/537.36'}
req = urllib.request.Request(url=url, headers=headers)
html = urllib.request.urlopen(req, timeout=60).read()

关于如何伪装浏览器见:https://zhidao.baidu.com/question/2117242032496816307.html

2.3 爬取速度过于频繁

这里我采取了一种比较笨的办法,每访问一次网站就间隔几秒或者随机间隔几秒,经过这个设置后,网站终于没有限制抓取了。这应该不是长久之计,毕竟效率太低了。

2.4 两次面临 HTML 解析器选择

之前写爬虫时用得最多的 HTML 解析器是 html.parser,后来在使用的过程中发现,html.parser 的容错率太低了,很多好端端网页的源代码抓下来一解析就变乱码了,比如这个网址:http://district.ce.cn/zg/201602/04/t20160204_8740940.shtml。开始,我认为是编码的问题,但是后来马上又否定了,中经网所有的网页都是采用 gb2312 的编码,为什么其他网址解析正常,唯独偏偏这个网址就出问题?

网页源代码的头部信息

网页源代码的头部信息

苦苦思索无果后,我在一个爬虫交流群里提出了这个问题,热心的群友亲自在本地给我测试了一下这个网址,一切正常。为什么?比较代码之后才发现,是 HTML 解析器的缘故,把 html.parser 换成了 lxml,岁月静好。

网页源代码解析乱码

网页源代码解析乱码

解析得到的乱码变量

解析得到的乱码变量

第二次面临的选择则是在获取报告正文总页数上,解析又出现了错误,总页面数解析不到,再次把 lxml 换成 html5.lib,岁月静好。根据这轮踩坑,如果求稳的话,以后优先选择 html5.lib 作为第一解析器。

关于解析器的选择,见 BeautifulSoup 文档中的比较:安装解析器

2.5 URL 地址或标题非法导致文件写入不成功

爬下来的数据往往不是你想象中的那么干净,数据永远是脏的,比如报告的链接地址和标题就有各种奇奇怪怪的情形,比如标题中混入了 \xa0、不是报告的 URL 或者有标点符号,又或者 URL 地址不合法导致无法访问。所以,这里采取的解决办法是在抓取的时候过滤掉这些地址

1
2
3
if re.match(r'^https?:/{2}\w.+$', province_report_url) and 
re.match(r'[^?!(\d) | ^?!(()]', report_title) and
len(report_title) > 6:

2.6 异常处理

异常处理非常重要!!!

在异常处理这块踩了好多坑,你一定不想看到程序跑了一个小时候突然因为一粒“老鼠屎”而挂了吧?记住,进行字典处理、文件 IO 的时候一定一定要考虑异常,火星人,地球是很危险的,一不小心会挂。

3. 实现代码

其他的后续再补充,下面是代码,欢迎指正!

3.1 过滤年度报告的标题及地址

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
#!/usr/bin/python
#-*- coding:utf-8 -*-


"""
@author: Li Bin
@date: 2017-12-13
"""


import configparser as ConfigParser
import csv
import logging
import re
import time
import urllib.request
from bs4 import BeautifulSoup


config = ConfigParser.ConfigParser()
config.read('config.ini')
local_reports_collect_url = config['url']['collect_url']
file_path = config['file_path']['url_data_file']


def get_standard_html(url):
"""获取网页的标准 HTML 文本
:return:
"""
headers = {'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/63.0.3239.84 Safari/537.36'}
req = urllib.request.Request(url=url, headers=headers)
try:
html = urllib.request.urlopen(req, timeout=60).read()
standard_html = BeautifulSoup(html, 'html5lib')
except Exception as e: # 如果抓取失败则退出,并令 standard_html 为 None
logging.info(url + "抓取超时......")
standard_html = None
pass
return standard_html


def filter_report_collect_url(standard_html):
"""从 standard_html 中过滤得到历年地方政府报告合集页面的 URL
:return: url_dict,
[{'year': "2017年汇编", 'url': 'http://district.ce.cn/zg/201702/26/t20170226_20528713.shtml'}]
"""
url_dict = []
raw_html_text = standard_html.find_all('div', class_='TRS_Editor')
html_text = raw_html_text[0].find_all('a')
for text in html_text:
temp_dict = {
'year': text.get_text(),
'url': text.get('href')
}
url_dict.append(temp_dict)
return url_dict


def filter_local_gov_report_url(url):
"""地方政府报告合集页面的省市报告的 URL
:param url:
:return:
[{'report_title': '北京市政府工作报告 (2017年1月14日 蔡奇)',
'report_url': 'http://district.ce.cn/newarea/roll/201702/07/t20170207_20015641.shtml'}]
"""
local_gov_report_url_dict = []
standard_html = get_standard_html(url)
raw_html_text = standard_html.find_all('div', class_='TRS_Editor')
"""个别网页的链接在 div 标签的 content 类中"""
if len(raw_html_text) > 0:
html_text = raw_html_text[0].find_all('a')
for text in html_text:
temp_dict = {
'report_title': (text.get_text()).replace('\xa0', '').replace('资料:', ''), # 1. 替换 \xa0,防止写入 csv 错误
'report_url': text.get('href') # 2. 替换“资料:”,文件名中有冒号无法写入
}
local_gov_report_url_dict.append(temp_dict)
else:
raw_html_text = standard_html.find_all('div', class_='content')
html_text = raw_html_text[0].find_all('a')
for text in html_text:
temp_dict = {
'report_title': (text.get_text()).replace('\xa0', '').replace('资料:', ''),
'report_url': text.get('href')
}
local_gov_report_url_dict.append(temp_dict)
return local_gov_report_url_dict


def get_annual_report_url_list(url_dict):
"""
获取所有的省市报告地址
:param url_dict:
:return:
"""
annual_report_url_list = [] # 年度省(直辖市)报告 URL 列表:2003-2017 年所有省市报告的地址
for item in url_dict:
report_url = item.get('url') # report_url 是某年报告汇编合集的地址
local_gov_report_url = filter_local_gov_report_url(report_url)
annual_report_url_list.append(local_gov_report_url)
print(report_url)
time.sleep(2) # 为了防止 ip 被封,每抓完一年延迟两秒
return annual_report_url_list


def write_data_to_file(url_list):
with open(file_path, 'w', newline='') as f:
fieldnames = ['report_title', 'report_url']
writer = csv.DictWriter(f, fieldnames=fieldnames)
writer.writeheader()
for item in url_list:
for sub_item in item:
report_title = sub_item.get('report_title')
province_report_url = sub_item.get('report_url')
"""
1. 判断 URL 地址是否合法
2. 判断标题是否数字或者以中文括号‘(’开头,如果是,则不是报告地址
3. 标题长度至少大于 6:“政府工作报告”
"""
if re.match(r'^https?:/{2}\w.+$', province_report_url) and \
re.match(r'[^?!(\d) | ^?!(()]', report_title) and \
len(report_title) > 6:
writer.writerow(sub_item)
f.close()


if __name__ == '__main__':
standard_html = get_standard_html(local_reports_collect_url)
url_dict = filter_report_collect_url(standard_html)
annual_report_url_list = get_annual_report_url_list(url_dict)
write_data_to_file(annual_report_url_list)

3.2 遍历所有的地址并抓取全文

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
#!/usr/bin/python
#-*- coding:utf-8 -*-


"""
@author: Li Bin
@date: 2017-12-17
"""


import configparser as ConfigParser
import logging
import pandas as pd
import random
import re
import time
import urllib.request
from bs4 import BeautifulSoup


config = ConfigParser.ConfigParser()
config.read('config.ini')
local_reports_collect_url = config['url']['collect_url']
file_path = config['file_path']['url_data_file']
root_file_path = config['file_path']['root_directory']


def read_url(annual_report_url_list_file_path):
"""
读取 CSV 文件
:return: DataFrame
"""
df = pd.read_csv(annual_report_url_list_file_path, encoding='gbk')
return df


def get_standard_html(url):
"""获取汇总合集页面的标准 HTML 文本
:return:
"""
headers = {'User-Agent':'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/63.0.3239.84 Safari/537.36'}
req = urllib.request.Request(url=url, headers=headers)
try:
html = urllib.request.urlopen(req, timeout=60).read()
standard_html = BeautifulSoup(html, 'html5lib')
except Exception as e:
logging.info(url + "抓取超时......")
standard_html = None
pass
return standard_html


def get_report_text(url):
"""从 URL 地址获取网页正文部分
:param local_report_url:
:return: text:list,网页正文
"""
standard_html = get_standard_html(url)
if standard_html: # 如果从地址中解析得到了网页源代码则获取正文,否则返回 None
raw_html_text = standard_html.find_all('p')
text = []
for item in raw_html_text:
text.append(item.get_text())
return text
else:
return None


def get_page_sum(url):
"""
获取报告总页数:如“共(n)页”的 n 参数
"""
standard_html = get_standard_html(url)
if standard_html:
try:
tmp_html = standard_html.find_all('script', language='JavaScript')
tmp_str = re.search(r'createPageHTML\((\d+)', str(tmp_html))
page_sum = tmp_str.group(1)
except:
page_sum = 1
return int(page_sum) # 如果 standard_html 为 None,则返回 1,至少抓一页


def generate_page_urls(url, page_sum):
"""
当报告有多个页面时,按照后缀递增的方式生成每个页面的 URL
"""
i = 1
url_list = [url]
try:
while i < page_sum:
page_url = ''.join([url[:-6], '_', str(i), '.shtml'])
url_list.append(page_url)
i += 1
except Exception: # 上面的 while 判断偶尔出现一次错误,暂未找到原因,先忽略
url_list = [url]
return url_list


def crawl_province_report(report_title, province_report_url):
"""抓取报告全文并写入 txt
"""
global file_path
page_sum = get_page_sum(province_report_url)
url_list = generate_page_urls(province_report_url, page_sum)
text = []
for page_url in url_list:
tmp_text = get_report_text(page_url)
text.append(tmp_text)
file_path = root_file_path + str(report_title) + '.txt'
print('正在抓取' + str(report_title) + '......')
time.sleep(random.randint(0, 5))
try:
flatten_text = [item for sublist in text for item in sublist]
report_text_handler = open(file_path, "ab+")
for item in flatten_text:
report_text_handler.write((item + '\r\n').encode('UTF-8'))
print('正在写入文件......')
except Exception:
pass


def get_all_report():
"""
从省市报告地址中提取出网页正文,并写入 TXT 文件
"""
url_list_df = read_url(file_path)
for item in url_list_df.iterrows():
report_title = item[1]['report_title']
province_report_url = item[1]['report_url']
crawl_province_report(report_title, province_report_url)
time.sleep(3) # 间隔 3 秒抓取一次,防止抓取过于频繁


if __name__ == '__main__':
get_all_report()

后记

本次爬虫的数据量很少,总共也就不到 700 篇,真正恼人的是时不时蹦出的小错误。当然,这个爬虫的代码很烂,根本没有考虑效率因素,大规模的爬虫实践肯定不是这种操作,后面有时间再实践实践多线程、多进程、ip 代理池的技术。

推荐阅读

  1. http://wiki.jikexueyuan.com/project/python-crawler-guide/advanced-usage-of-urllib-library.html
  2. python 高度健壮性爬虫的异常和超时问题
  3. http://www.cnblogs.com/ly5201314/archive/2008/09/04/1284139.html
  4. http://caibaojian.com/zhongwen-regexp.html

继续阅读本站其他精彩文章

  1. 机器学习
  2. 编程语言
  3. 技术碎碎念
  4. 读书笔记
觉得还不错?帮我赞助点域名费吧:)
分享到: