快递网点抓取杂记

引言

应求开发一个简单的爬虫,用于从快递 100 抓取快递网点的分布,输入省份+城市+区县,然后是快递公司名称,抓取其对应的快递网点的信息(包括名称、地址、电话、坐标等)。说起来,开发这样一个简单的爬虫其实没什么难度,也没什么技术含量(没有时间要求、不需要分布式抓取、没什么反反爬策略加持等),不过这期间也遇到一些比较有趣的问题,特此记录下。

另外就是第一次接触了 pyecharts 这个数据可视化工具,用起来还挺方便的。样式配置好,还是会带来很好的视觉冲击的!当然,关键是利用这种工具有助于增加对抓取数据的理解(看具体需要的分析维度了)。

整个过程还是挺有趣的,虽然定位某些元素的时候很麻烦,写 XPath 表达式也比较枯燥(不要指望浏览器自动生成的 XPath,通常都不够通用)。但是完成整个任务后,还是有些收获的。下面开始吧~

确定方案

尝试 API 直接获取

快递 100 对外公开的 API 文档在此,但是并没有开放网点查询的接口,故无法使用。直接通过 API 查询是最省心的方式,但是目前这条路行不通

尝试分析网页请求,间接使用 API 请求

网点查询的主页,可以根据选择的城市和快递公司,页面内容自动切换。此时,还是尝试分析其 API 请求,因为这个通常是抓取网站最简单的方式。

打开 Chrome 浏览器的调试模式,通过点击页面上的筛选项,查看网络请求,确定请求了什么接口,传递的参数是什么,返回的参数是什么,从而能够模拟其请求方式来获取数据。接下来演示的是查询上海地区的某快递公司的网点。

截图 1:请求方式为 POST,URL 为 www.kuaidi100.com/network/searchapi.do

截图 2:以表单的形式,提交了请求的参数,也就是页面点击的筛选项。

截图 3:当完成请求后,可以看到其返回结果为 JSON 结构,其中的 netList 正是想要找的网点数据。

尝试分析网页结构,采用传统的方式抓取

一般在进行网站数据抓取时,能通过其 API 获取,就尽可能去利用。使用 API 请求数据的好处有这么几点:

  1. 省去了分析网页 HTML 结构的时间,可以直接解析接口返回的数据,提取想要的信息并存储即可;
  2. API 请求方式最为简单可靠灵活,且能适应较高的抓取频率;
  3. 相对于分析网页 HTML 结构,提取信息的方式,API 抓取通常不用像担心前端页面频繁改版而导致爬虫程序也要即使更新的困境,方便维护。

不过只是通过 API 来抓取,感觉不是很有趣,在 Selenium 的帮助下,以可视化的方式抓取页面貌似更有趣点。这里的思路如下:

  1. 安装 Selenium + Chrome Web Driver,通过操纵浏览器模拟人类访问页面的方式来抓取数据;
  2. 编写爬虫程序,控制浏览器打开网点查询的首页:https://www.kuaidi100.com/network/
  3. 接收输入的城市和快递公司名称作为参数,然后操纵浏览器点击下拉框,选择城市;然后操纵浏览器点击指定的快递公司;
  4. 接下来开始解析页面结构,提取快递网点数据转换为 JSON 格式(地理位置坐标通过百度地图 API 获得),存储在指定路径下;然后不断控制浏览器翻页,完成剩余页面解析,至此对应的网点信息抓取完成。

准备开发环境

  1. Python 3.7 环境;
  2. selenium-python,其使用文档参考此处
  3. chromedriver 下载,并解压得到可执行文件;
  4. Anaconda 环境安装,并且需要保证 Jupter Notebook 能够正常工作;
  5. 依赖的重要 Python 包安装:
    • pyecharts
    • echarts-countries-pypkg
    • echarts-china-provinces-pypkg
    • echarts-china-cities-pypkg
    • echarts-china-counties-pypkg
    • echarts-china-misc-pypkg
    • requests
  6. 记得安装 Chrome 浏览器。

页面关键元素定位

根据上述抓取思路,需要做的是定位一些关键元素,并且能够从 HTML 中抽取出需要的信息。页面元素定位,需要了解下 XPath 语法,摘取想要的元素。

小技巧,可以在 Chrome 控制台通过 $x('xpath-expression') 验证选择器是否正常:

  • 省份选择下拉框元素 id 为 provinceSelect

  • 定位省份位置(拷贝其 XPATH)

小结

其它关键元素确定方式类似,主要以 XPath 的方式查找。得到最终需要的 XPath 表达式如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
class XPathExpression:
# 省份下拉框 Tab
PROVINCE_TAB = '//*[@id="provinces"]/div/ul/li[2]'
# 具体某个省份
PROVINCE_ITEM = '//*[contains(@class, "place province") and contains(text(), "{}")]'
# 具体某个城市
CITY_ITEM = '//*[contains(@class, "place city") and contains(text(), "{}")]'
# 具体某个区县
COUNTY_ITEM = '//*[contains(@class, "place county") and contains(text(), "{}")]'
# 右侧查询按钮
QUERY_BUTTON = '//*[contains(@class, "btn-query")]'
# 快递公司选择
PROVIDER_ITEM = '//ul[@id="companyCount"]/li/*[contains(text(), "{}")]'
# 下一页
NEXT_PAGE_BUTTON = '//*[contains(@class, "page-down-active")]'
# 页面中的快递公司信息条目
QUERY_ITEMS = '//*[@id="queryResult"]/dl'

写代码

main 函数是关键入口,它会调用爬虫类搜索指定位置指定公司的快递网点信息,并将返回的数据存储到指定的路径下,使用 json line 格式(即每一行都是 json 格式字符串)存储。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
def main(province, city, provider='全部'):
spider = DeliveryNetworkSpider(
driver_path=os.path.join(PROJECT_DIR, 'dep/mac/chromedriver'))

try:
results = spider.search(province=province, city=city, provider=provider)
except Exception as err:
print(err)
else:
for result in results:
print(result)
store_result(os.path.join(PROJECT_DIR, 'results', f'{province}-{city}-{provider}.jl'), result)

finally:
spider.close()

核心爬虫类

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
139
140
141
142
143
144
145
146
147
148
149
150
151
class DeliveryNetworkSpider(object):
"""基于快递 100 的快递网点查询爬虫
"""

def __init__(self, driver_path):
"""
初始化快递网点爬虫,关键参数是要指定 webdriver 路径,否则
无法控制浏览器进行模拟查询。当前仅支持 Chrome 浏览器。

:param driver_path: 默认是 Mac 版本所在的路径,如果是 win
版本,需要自行修改路径。
"""
# Windows 下需要替换成对应的 chromedriver,可以在 dep 目录下新建 win
# 目录,将 windows 版本的放到目录下,并修改 mac->win
self._driver = webdriver.Chrome(executable_path=driver_path)
self._driver.implicitly_wait(10) # 隐式等待元素出现

def close(self):
try:
self._driver.close()
except:
pass

def search(self, province, city, county='暂不选择', provider='全部'):
"""执行网点查询的核心方法。

:param province: 省份(一定要是快递 100 上有的名字)
:param city: 城市(一定要是快递 100 上有的名字)
:param county: 区县,默认为全部区县
:param provider: 快递服务商名字,不传则查询所有快递公司
"""
self._open_home_page()
self._select_location(province, city, county)
self._select_provider(provider)
while True:
yield from self._parse_page(province, city)
if self._has_next_page():
time.sleep(.5) # 防止翻页太快被抓到
self._visit_next_page()
else:
break

def _open_home_page(self):
self._driver.get('https://www.kuaidi100.com/network/')

def _select_location(self, province, city, county):
# 找到输入下拉框位置
elem = self._driver.find_element_by_id('provinceSelect')
self.__highlight(elem)
elem.click()

# 定位选择省份 Tab
elem = self._driver.find_element_by_xpath(XPathExpression.PROVINCE_TAB)
self.__highlight(elem)
elem.click()

# 接下来选择省份
elem = self._driver.find_element_by_xpath(
XPathExpression.PROVINCE_ITEM.format(province))
self.__highlight(elem)
elem.click()

# 紧接着选择城市
elem = self._driver.find_element_by_xpath(
XPathExpression.CITY_ITEM.format(city))
self.__highlight(elem)
elem.click()

# 选择所有区域
elem = self._driver.find_element_by_xpath(
XPathExpression.COUNTY_ITEM.format(county))
self.__highlight(elem)
elem.click()

# 定位到查询按钮,并点击查询跳转页面
elem = self._driver.find_element_by_xpath(XPathExpression.QUERY_BUTTON)
self.__highlight(elem)
elem.click()

def _select_provider(self, provider):
elem = self._driver.find_element_by_xpath(
XPathExpression.PROVIDER_ITEM.format(provider))
self.__highlight(elem)
elem.click()

def _has_next_page(self):
try:
self._driver.find_element_by_xpath(XPathExpression.NEXT_PAGE_BUTTON)
except NoSuchElementException:
return False
else:
return True

def _visit_next_page(self):
elem = self._driver.find_element_by_xpath(XPathExpression.NEXT_PAGE_BUTTON)
self.__highlight(elem)
elem.click()

def __highlight(self, elem):
"""高亮操作的元素"""
self._driver.execute_script(
"arguments[0].setAttribute('style',arguments[1]);",
elem,
"outline:2px solid red;")
time.sleep(.2)

def _parse_page(self, province, city):
try:
elements = self._driver.find_elements_by_xpath(XPathExpression.QUERY_ITEMS)
except NoSuchElementException:
return []
else:
for el in elements:
yield self._extract(el.text, province, city)

@staticmethod
def _extract(text, province, city):
item = dict(
province=province,
city=city,
name='', # 名称
address='', # 地址
contact_phone='', # 联系电话
pickup_phone='', # 取件电话
check_phone='', # 查件电话
complaint_phone='', # 投诉电话
location=dict(
lng=0.0, # 经度
lat=0.0, # 纬度
)
)
if not text:
return item

# 提取出纯文本后换行解析
lines = [l.strip() for l in text.splitlines() if l.strip()]

def __(k):
r = [l for l in lines if l.startswith(k)]
return r[0].replace(k, '') if r else ''

item['name'] = lines[0]
item['address'] = __('公司地址:')
item['contact_phone'] = __('联系电话:')
item['pickup_phone'] = __('取件电话:')
item['check_phone'] = __('查件电话:')
item['complaint_phone'] = __('投诉电话:')
item['location'] = get_location(
item['address'], city=u"{}{}".format(province, city))

return item

坐标查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
BASE_API = 'http://api.map.baidu.com'
LOCATION_API = '/geocoding/v3/?address={}&city={}&output=json&ak={}'

# 加缓存,避免重复查询
@filecache(YEAR)
def get_location(addr, city=''):
"""
文档参考:http://lbsyun.baidu.com/index.php?title=webapi/guide/webservice-geocoding
"""
if not addr:
return dict(lng=0.0, lat=0.0)

url = _get_query_url(LOCATION_API.format(addr, city, AK))
r = requests.get(url)
result = r.json()
assert result['status'] == 0, u"获取经纬度信息失败!请求:{},返回结果:{}".format(url, result)
return result['result']['location']

def _get_query_url(query_str):
query_str = query_str.replace('#', '') # 去除特殊字符
# 对 query_str 进行转码,safe 内的保留字符不转换
qs = quote(query_str, safe="/:=&?#+!$,;'@()*[]")
sn = hashlib.md5(quote_plus(qs + SK).encode('utf8')).hexdigest()
return BASE_API + query_str + f'&sn={sn}'

控制抓取数据

比如,下面就是抓取中通快递在北京各个地区的网点。启动后,会通过 Selenuium 控制 Chrome 浏览器访问查询网站,并点击对应的元素,完成数据的抓取。

1
main(province='北京', city='北京', provider='中通')

整个工作过程演示参见 百度网盘(提取码: kaun)。抓取到的数据示例如下:

绘制简单的热点地图

这里就需要使用关键的 pyecharts 了,具体怎么安装和配置就不多说了,它有非常完善的中文文档,以及一些 Demo 可以学习。以下就是利用上述抓到的数据,做个简单的演示,看看这些快递网点的具体分布热点是怎么样的。

我们需要切换到爬虫目录下,并启动 Jupter Notebook:

1
2
cd path/to/kuaidi100/src
jupter notebook # 启动服务

紧接着,选择 src/heatmap.ipynb 文件打开:

菜单栏 Cell->Run All 执行所有代码,可以看到简单的热点地图如下所示:

总结

至此,整个折腾的过程就完结咯。主要时间花费在确定爬重的方案,以及使用灵活的方式定位元素上。在实际测试中,也遇到一些小问题,并做了部分优化:

  1. 比如某些情况下元素 click 会失败,这时为了保证爬虫的健壮性,需要做异常捕获;页面加载未完成时,可能无法查找到指定元素,导致爬虫程序挂了,这里就需要配置 Selenium 隐式等待 10s。
  2. 为了方便观察 Selenium 正在操纵的元素,这里借助了 driver.execute_script() 的方式给选中的元素添加红色边框。
  3. 另外,考虑到爬取频率过快,可能导致触发反爬策略,这里简单做了延迟等待(time.sleep())。

整体而言,写得比较简单,所以这里也就简单记录下。完整的代码仓库参见:kuaidi100-spider

参考

  1. pyecharts 地理图标文档
  2. pyecharts 在地图上根据经纬度和量值,画出散点图/热力图
  3. 百度地图文档
  4. XPath 语法
0%