利用selenium爬取高德地图道路地理数据

最近科研中需要爬取几条已知名字的道路的地理数据,从而建立某一区域内的路口和路段间的拓扑关系。 一开始想到使用 osmnx 库,但是尝试之后发现不能满足我的需求。主要存在几个问题:OSM 的路网很不干净,存在我不想要的支路段、匝道还有多余节点,如果一个个删除很麻烦;节点合并根据距离,不管怎么调整阈值效果都不理想;坐标系是 WGS-84,但我不想频繁转换数据的坐标系。于是尝试从高德地图中获取。

获取道路ID

首先,高德官方的 Web 服务 API 是不提供道路、AOI 边界这些线、面地理坐标信息的,但通过 POI 搜索服务可以查询到道路的 ID。根据对地图请求抓包,我们可以知道高德是从https://ditu.amap.com/detail/get/detail?id=ID这个 url 拿到 ID 对应的 JSON,里面包括我们想要的地理数据。

先获取所有道路的 ID。申请一个 Web 服务的 key 之后,直接请求就可以了:

1
2
3
4
5
6
7
8
9
10
11
12
amap_key = ''    # 高德Web服务的开发者key
types = '190301' # 道路的POI类型编码
city = '杭州'
road_name = '奔竞大道'

search_url = 'https://restapi.amap.com/v3/place/text?key={}&keywords={}&types={}&city={}'.format(amap_key, road_name, types, city)
try:
response = requests.request("GET", search_url, headers=headers)
road_id = json.loads(response.text)['pois'][0]['id']
except requests.ConnectionError as e:
print(f'{road_name}:', e.args)
road_id = None

爬取道路地理数据

拿到 ID 之后,我直接用 requests 库对上面的 url 进行爬取,但第一次访问就直接被反爬发现,会跳出一个带有滑动条的验证页面。不管我怎么修改 header 或是加其他的伪装都没有用。同时,直接用 Chrome 访问这个 url 是正常的,就算跳出验证页面,滑动之后也能拿到数据。参考网上一些经验贴,我决定用 selenium 库模拟浏览器操作来爬取。

selenium和相关配置

装好 selenium 库之后,先要下载 Chrome driver。在这个网址下载对应版本的 driver。如果没有对应版本,可以下载主版本号一致的版本。

我的Chrome版本是 101.0.4951.67,下载 101.0.4951.41 的 driver 可用

之后我们还需要用到 BrowserMob Proxy,它是一个 HTTP 代理服务,利用它我们可以截获 HTTP 请求和响应内容,另外还可以把 Performance data 输出 har 文件。先使用 pip 命令安装pip install browsermob-proxy,再到该项目GitHub上下载 BMP 工具。

BrowserMob Proxy基于 Java 实现,需要预先配置 Java 环境。JDK 版本需要为 11,否则会报错。

都准备好之后,可以用下面的代码启动浏览器。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
from selenium import webdriver
from browsermobproxy import Server
from selenium.webdriver.chrome.options import Options

server = Server('browsermob-proxy-2.1.4\bin\browsermob-proxy.bat')
server.start()
proxy = server.create_proxy()

chrome_options = Options()
chrome_options.add_argument('--proxy-server={0}'.format(proxy.proxy)) # 加代理抓包
chrome_options.add_argument('--ignore-certificate-errors') # 忽略无效证书的问题
chrome_options.add_argument('--disable-blink-features=AutomationControlled') # 防检测

driver = webdriver.Chrome(options=chrome_options)
proxy.new_har("amap", options={'captureHeaders': True, 'captureContent': True})

解决滑动验证

高德的这个滑动验证不是拼图式的,只需要从左滑到右,相对简单,解决思路是分别获取滑块元素和需要滑动的距离,然后利用selenium.webdriver.common.action_chains模块模拟人工操作滑动。通过页面分析可知滑块元素的 id 为nc_1_n1z,滑动槽长度是 300px,因此进行以下操作:

1
2
3
4
5
6
from selenium.webdriver.common.action_chains import ActionChains

button = driver.find_element_by_id("nc_1_n1z") # 滑块元素
action = ActionChains(driver) # 实例化action对象
action.click_and_hold(button).perform() # 执行单击并保持
action.move_by_offset(xoffset=300).perform() # 拖动

然而实际操作中这套方法验证失败,原因是瞬间滑动 300 像素明显是非人类操作。因此需要模拟人工滑动的情况,分步滑动。网上有一些现成的滑动轨迹生成代码,但实测通过率很低,并不好用,我改写了一下,基本 100% 通过验证。

这一环节的另一个问题是,分步滑动后,滑块运动有明显的卡顿,速度很慢,同样会导致验证失败。解决方法是在包安装目录selenium\webdriver\common\actions\下找到pointer_input.py文件的DEFAULT_MOVE_DURATION变量,减小这个默认值(我改到了 20)。

解决滑动验证的完整代码如下:

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
from selenium import webdriver
from browsermobproxy import Server
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.action_chains import ActionChains
from selenium.common.exceptions import NoSuchElementException

def get_track(distance):
track = []
current = 0
v = 3
a = 10
t = 0.2
v_t = math.sqrt(2*a*distance)
while current < distance:
v0 = v
v = v0 + a*t
move = v0 * t + 1 / 2 * a * t * t
current += move
track.append(round(move))
return track

def solve_scroll_bar():
tracks = get_track(300) # 获取滑动轨迹
button = driver.find_element_by_id("nc_1_n1z") # 滑块元素
action = ActionChains(driver) # 实例化action对象
action.click_and_hold(button).perform() # 执行单击并保持
for offset in tracks:
try:
action.move_by_offset(xoffset=offset).perform() # 滑动一步
except:
break

base_url = 'https://ditu.amap.com/detail/get/detail?id='
road_id = 'XXXXXX'
driver.get(base_url + road_id)
time.sleep(random.randint(3,7)) # 停顿,伪装人为操作
try: # 如果出现滑动验证
status = driver.find_element_by_class_name('warnning-text').text
solve_scroll_bar()
except NoSuchElementException: # 不需要滑动验证
print("No scroll bar found")

text = json.loads(driver.find_element_by_tag_name('pre').text)
cord = text['data']['spec']['mining_shape']['shape']
print(cord)

从地图页面爬取

正当我以为大功告成,开始批量爬取的时候,高德的反爬又给了我一击。大概在爬到第 5 个 ID 之后,访问 url 会出现网络拥堵的提示页面,且等待短时间后刷新无法解决,长时间等待后页面会直接被重定向到淘宝网。高德应该是对这个 url 做了访问限制,直接通过 url 爬取走不通了。

另一种方法是从地图页面爬取。模拟人在搜索框输入、点击的过程,路段在地图上显示后,从 har 文件里能够找到请求的坐标。通常情况下,输入路名后,目标道路会显示在联想词框第一位,但部分道路有同名地铁站,优先级更高。所以还需要加入一个模块,逐选项判断候选是否是目标道路。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
input_box = driver.find_element_by_id('searchipt')    #获取搜索框
input_box.clear() #清空搜索框
road_name = '盈丰路'
input_box.send_keys(road_name) #输入关键词
time.sleep(random.randint(2, 5))
select = driver.find_elements_by_class_name("autocomplete-suggestion") #获取联想词框
time.sleep(random.randint(2, 5))

# 检查候选项
for sug in select:
if sug.find_elements_by_class_name('sug_val')[0].text == road_name:
sug.click() # 点击选项

result = proxy.har # har信息
pattern = re.compile('"value":"(.*?)","name":"roadaoi"') # 目标坐标串
links = re.findall(pattern, str(result))
> 如果查询多条道路,只需要在最后获取一次 har 信息,links 中会储存每一次查询的道路坐标

最后把爬到的道路地理数据处理一下,用 GeoPandas 绘制看看效果:

总结

目前网络上关于高德道路爬取的帖子很少,而且之前的教程贴已经过时,当时能用的手段现在基本都不适用了。由于缺少现成的参考,加上高德的反爬措施非常严格,我花费了挺多的时间才完成这个爬虫,过程也是踩了很多坑。包括配置 BMP,还有中间解决滑动验证的部分最后都没有用上,但在过程中也学到了很多知识。

这个道路爬取方法也有很多不足的地方,一是要保证道路名和高德查询到的相吻合,不要出现别名;二是为了防止被检测,爬取效率还比较低。总体上这个方法基本能满足小规模的应用,大规模的道路爬取可能还比较困难。

参考

Python爬虫终极解决方案-以获取高德地图小区边界为例
使用selenium模拟登录解决滑块验证问题
高德道路爬取
selenium解决鼠标拖动滑块慢的问题