高德地图爬虫实战:POI采集、API调用与并发限流详解
最近又有一批朋友来问我高德地图爬虫怎么搞。坦白说这项目不算多难但也真不是网上随便抄个requests脚本就能跑通的“xx行爬虫”。高德的数据分散在好几个入口Web服务API、网页端的前端渲染、瓦片图层、移动端离线包不同入口的抓法完全不在一个量级。很多人一上来就requests乱抓抓到一堆HTML发现全是JS渲染然后心态就崩了。这篇文章我按自己实际跑过的项目来复盘先讲高德数据的分层和方案选型再拆解requests请求里的参数细节、限流策略、并发设计最后给一套可以直接落地的POI采集代码外加我踩过的各种坑和排查思路。适合刚起步的数据工程师、准备做城市分析的Python选手也想玩高德Loca组件做可视化的GIS爱好者。1. 先想清楚高德爬虫到底要爬哪一层1.1 三层数据模型API、网页、瓦片做高德爬虫第一个要搞明白的事是你要的数据到底在哪儿。我习惯把高德地图的数据入口拆成三层。第一层是Web服务API。这个返回的是结构化JSON字段非常规整有名称、坐标、地址、电话、类型、行政区划等适合做POI采集、地理编码和路径规划。它数据质量最稳也是最推荐普通人去碰的一层。第二层是网页端数据。打开高德地图网页版里面很多信息是前端渲染出来的比如店面的营业时长、评分、评论数这些字段API里不一定给全只能在网页上抠。代价是你要处理JS渲染、SVG地名、字体反爬这些东西维护成本明显上升。第三层是瓦片数据。瓦片是地图底图的最小单元栅格瓦片就是一张张PNG图片矢量瓦片则是一段段的二进制协议。通常用于自建底图、离线地图、车机导航的数据基础。瓦片下载按xyz编号有固定规律适合离线包场景不适合做POI采集。这三类数据的技术栈差异很大很多人翻车是因为根本没想清楚自己要哪层直接把三个混在一起抓最后什么都抓不干净。1.2 方案选型为什么把官方API放在首位我自己的原则是能用官方API解决的绝对不碰网页爬虫。高德开放平台对个人开发者很友好注册后创建应用就能拿keyWeb服务接口比如地点搜索、地理编码、逆地理编码都是直接可用的。相比解析网页API返回的数据是官方整理好的字段有保证坐标已经处理成GCJ-02火星坐标系省去大量清洗工作。网页爬虫什么时候才需要上我遇到过一种场景客户要某商圈所有餐饮店面的近期评分和评论数但高德POI接口里不返回评分只有网页详情页里有。这种时候才需要配合Playwright或Selenium模拟浏览器打开详情页提取评分、评论摘要。要注意网页端的风控比API明显严格短时间高频访问会触发验证码甚至IP封锁所以我会在网页爬虫前先问自己这几个字段真的没有其他渠道能拿到吗瓦片下载则是另一个目的——离线底图。比如你要给森林防火系统做一张无缝大底图规划区域内所有瓦片拼起来用这时候API帮不上忙必须走瓦片下载。后面4.4小节我会专门讲瓦片URL的规律。1.3 关于API收费与Key安全提前给你打预防针网上搜“高德地图api收费坑人”的人不少但说实话很多坑是自己踩出来的。最常见的情况是个人开发者key直接用在生产环境测试阶段没事上线后日配额很快耗尽系统默认自动切换到了付费流量包月底账单一看傻眼了。还有一种情况是key写在前端代码里被挖出来被别人拿去刷接口配额度直接被打爆。我的建议是三条。第一key一定要放服务端前端要调接口就先请求自己的后端由后端转发key永远不要出服务器。第二在开放平台控制台给key设置IP白名单只允许你的服务器IP调用这是性价比最高的风控手段。第三定期看配额监控设置用量预警配额快用完时主动暂停爬虫而不是让它自动扣费。高德的key权限可以单独关闭服务。比如你只做地点搜索就把逆地理编码、路径规划这些用不到的服务全部关掉减少被恶意刷的风险面。2. 核心细节解析请求、限流与并发设计2.1 requests请求与返回结构先跑通再优化高德Web服务API的请求方式很简单就是一个HTTP GET参数通过query string传递。但有几个细节值得注意。第一用requests的params传参不要自己拼URL。params会自动处理URL编码中文关键词、特殊字符都不会出错而手拼URL很容易在城市名带“市辖区”这类词时翻车。第二高德返回体里有个status字段1代表成功0代表失败。失败时要看infocode字段来判断具体原因。很多新手只判断HTTP状态码200就以为成功了结果中间夹着一堆失败JSON数据清洗时才发现不对。第三如果开通了数字签名params里要加sig参数。签名规则是把除sig外的所有参数按key的字典序排序拼成keyvalue...的字符串末尾拼接安全密钥然后做MD5得到sig。这个不复杂但容易漏。import requests import hashlib import time # 基础参数 params { key: 你的key, keywords: 便利店, city: 杭州, citylimit: true, offset: 25, page: 1, extensions: all } # 如果开通了签名功能追加sig sorted_params sorted(params.items()) raw_string .join(f{k}{v} for k, v in sorted_params) params[sig] hashlib.md5( (raw_string 你的安全密钥).encode(utf-8) ).hexdigest() # 发送请求 resp requests.get( https://restapi.amap.com/v3/place/text, paramsparams, timeout10 ) data resp.json() # 正确姿势先判断业务状态再处理数据 if data.get(status) 1: print(总数:, data.get(count)) for poi in data.get(pois, []): print(poi[name], poi[location], poi[adname]) else: print(失败:, data.get(infocode), data.get(info))注意requests请求一定要设置timeout不然某个IP卡住会让整个爬虫停在那里。建议配合一个重试装饰器遇到超时或5xx错误时自动退避重试重试2~3次后再把异常抛出来记录。2.2 限流与代理IP遇到封禁先从频率找原因高德API层相对来讲没有特别复杂的反爬核心就是配额和并发控制。如果你在返回里看到“CUQPS_HAS_EXCEEDED_THE_LIMIT”或者类似提示意思是QPS超限了这时候第一反应不是去换IP而是把并发降下来。很多做网页爬虫的朋友习惯性先买代理池其实在POI爬虫场景里代理IP真的排不上大用场。API是按key鉴权的你换再多IPkey的配额该不够还是不够。代理IP只在网页端爬虫里能派上用场——用来规避同IP高频访问触发的风控。如果你确实需要代理requests里只需要加一个参数proxies { http: http://user:pass你的代理地址:端口, https: http://user:pass你的代理地址:端口 } resp requests.get(url, paramsparams, headersheaders, proxiesproxies, timeout10)但我的实际经验是免费代理质量很差延迟高、掉线快在你只需要几百个POI的场景下加代理反而是负优化。我自己做城市级POI采集时通常不用代理而是严格控制QPS比如每秒钟只发1~2个请求配合指数退避重试实测下来很少触发风控。一句话总结先降频率后换代理不要本末倒置。2.3 并发设计怎么选协程、线程池还是分布式搜“并发设计到底哪个好”的朋友大概率是被网上各种方案搞晕了。我直接给结论高德POI爬虫是典型的IO密集型任务瓶颈在网络等待CPU几乎不参与计算。协程方案比如asyncioaiohttp理论效率最高一个小脚本就能开几百个并发。但高德服务端有QPS限制并发开大了只会得到一堆限流错误协程的优势根本发挥不出来。所以对大多数场景我推荐用Python自带的concurrent.futures.ThreadPoolExecutor简单、可控出问题还好调试。线程池方案的核心技巧是限速。我会用信号量控制同时进行的请求数量再加一个简单的速率控制器。比如最多同时4个请求每秒钟最多10次调用这样既稳定又不会突破配额上限。import threading import time from concurrent.futures import ThreadPoolExecutor class RateLimiter: def __init__(self, max_calls_per_sec): self.interval 1.0 / max_calls_per_sec self.lock threading.Lock() self.last_time 0.0 def wait(self): with self.lock: now time.time() wait_time self.last_time self.interval - now if wait_time 0: time.sleep(wait_time) self.last_time time.time()分布式爬虫比如ScrapyRedis方案适合城市级甚至全国级的超大批量数据抓取多台机器横向扩展还要处理任务分发、去重、配额拆分一套做下来工程成本不小。爬一个小城市全量POI真不用上分布式。我见过不少团队一上来就搭分布式结果数据还没爬完先在各种中间件上花了两周时间。我的选择逻辑很朴素小规模用for循环中等规模用线程池加信号量遇到跨省、千万级数据量再加分布式。3. 实操记录从零写一个可用的POI爬虫3.1 准备工作创建应用、申请Web服务Key这条路我走过很多次流程其实特别简单打开高德开放平台官网注册账号。进入控制台选择“应用管理”创建一个新应用类型选“出行”。在应用下添加Key服务平台选“Web服务”。生成key后建议立即在安全设置里配置IP白名单把你服务器或本机的出口IP加进去。到“配额管理”里确认你要用的接口有额度个人开发者的免费额度对测试和中小规模项目来说基本够用。申请完不要急着写代码先在控制台里把人机验证、服务开关这些设置过一遍后面能省很多事。3.2 第一版单线程分页抓取POI高德地点搜索接口的部分逻辑是这样的每次请求可以设置offset每页记录数最大25和page页码返回的count是符合条件的总数。实际能拿到的最大页数有限制如果翻了几十页还没到底就说明这个范围的数据量太大需要用行政区划去拆分。单线程版本很好写核心就是一个while循环不断翻页直到当前页返回的POI数量不足一页或者页码超过上限。每页请求之间加0.5秒延时别把自己手写爬虫变成对服务端的攻击。import requests import time import hashlib KEY 你的key SECRET 你的安全密钥 def get_poi(city, keyword, page1): params { key: KEY, keywords: keyword, city: city, citylimit: true, offset: 25, page: page, extensions: all } sorted_params sorted(params.items()) raw .join(f{k}{v} for k, v in sorted_params) params[sig] hashlib.md5((raw SECRET).encode(utf-8)).hexdigest() for attempt in range(3): try: resp requests.get( https://restapi.amap.com/v3/place/text, paramsparams, timeout10 ) data resp.json() if data.get(status) 1: return data elif attempt 2: time.sleep(2 * (attempt 1)) else: print(请求失败:, data.get(info)) return None except requests.exceptions.RequestException: time.sleep(2 * (attempt 1)) return None def crawl_city_poi(city, keyword): all_pois [] page 1 while page 40: data get_poi(city, keyword, page) if not data: break pois data.get(pois, []) all_pois.extend(pois) if len(pois) 25: break page 1 time.sleep(0.5) return all_pois if __name__ __main__: result crawl_city_poi(杭州, 便利店) print(总共抓取:, len(result))这一段代码看起来不多但已经包含了签名、重试、分页三个核心逻辑。先跑通它再考虑优化。3.3 第二版ThreadPoolExecutor并发提速单线程跑一个小城市还行要是跑几十个城市那就有点慢悠悠了。我在第二版里把并发加上去。思路是这样每个城市作为一个独立任务丢进线程池每个任务内部仍然按页码顺序抓取只是不同城市之间并行。把线程池大小设为4~6不会太激进也不容易触发限流。from concurrent.futures import ThreadPoolExecutor, as_completed def crawl_one_city(city): pois crawl_city_poi(city, 便利店) return city, pois cities [杭州, 宁波, 温州, 嘉兴, 湖州, 绍兴, 金华, 衢州, 舟山, 台州, 丽水] results {} with ThreadPoolExecutor(max_workers5) as executor: future_map {executor.submit(crawl_one_city, c): c for c in cities} for future in as_completed(future_map): city, pois future.result() results[city] pois print(f{city} 完成共 {len(pois)} 条)实测下来10个城市跑完的速度比单线程快了4倍左右。这里有个容易被忽略的问题如果每个城市内部也有多个关键词要爬那么把“城市关键词”作为任务粒度更合适否则单个城市内部还是串行的扩展性受限。另外线程数不是开得越多越好。我在某个一起跑项目的朋友那边见过max_workers50的写法结果跑了三分钟就被高德限流了越往后越是各种重试。线程池大小和限速必须一起考虑高德API对单Key的QPS有硬限制超过了就是排队重试吞吐量反而下降。3.4 第三版按adcode细分避免数据遗漏当你爬一个超大目标比如“餐饮”时会碰到一个很尴尬的情况接口返回的count明明有5000多条但每页25条翻到40页就再也翻不动了。这是高德服务端的限制——单个查询条件下最大返回数量约1000条。超过这个数量必须把查询范围拆小。拆分的标准做法是使用adcode也就是行政区划编码。比如杭州下面有上城区、拱墅区、西湖区、滨江区等等每个区都有独立的adcode。把城市级查询改成区县级查询每个区县的数据量基本能控制在1000条以内不会丢数据。def get_districts(city_adcode): params { key: KEY, keywords: None, subdistrict: 2, extensions: base } # 调用行政区划查询接口 url https://restapi.amap.com/v3/config/district resp requests.get(url, params{**params, keywords: city_adcode}, timeout10) data resp.json() districts [] for district in data.get(districts, []): for sub in district.get(districts, []): districts.append((sub[name], sub[adcode])) return districts拿到adcode列表后抓取循环就变成两层外层遍历区县内层翻页抓取。配合并发线程池每个区县一个任务效率更高也不容易丢数据。这是我做全量POI采集时最常用的方式。4. 进阶常见问题与排查实录4.1 接口状态码与业务code对照速查爬高德API最让人头大的就是返回了一堆错误码。我把高频错误整理成一张表排查的时候直接对着看infocode含义常见原因处理建议10001key不正确或过期key写错、被删除检查控制台key重新生成10002用户不是白名单IP白名单限制加IP白名单或关闭白名单10003QPS超限请求太密集降并发、加sleep10021签名错误sig参数没算对按字典序排序后拼密钥再MD510024用户id无效账号异常或欠费检查账号状态10033配额已用完日配额耗尽等次日或升级套餐20000请求参数非法参数类型或值不对仔细检查参数格式实际排查时我先看请求是否进入了“成功”分支再看infocode最后才看异常堆栈。很多人一看到报错就以为是代码问题其实七成是配额和签名的问题。4.2 坐标偏移GCJ-02与WGS84互转有个搜索热词叫“苹果手机位置错误”做LBS应用的朋友大概率遇到过用高德API拿到的坐标放到苹果自带地图上显示位置偏了几百米。这不是手机坏了是坐标系不匹配。高德地图用的是GCJ-02火星坐标系苹果自带地图和大多数海外工具用的是WGS84国际坐标系。两者之间有个非线性偏移需要做转换。import math def gcj02_to_wgs84(lng, lat): a 6378245.0 ee 0.00669342162296594323 dlat _transform_lat(lng - 105.0, lat - 35.0) dlng _transform_lng(lng - 105.0, lat - 35.0) radlat lat / 180.0 * math.pi magic math.sin(radlat) magic 1 - ee * magic * magic sqrtmagic math.sqrt(magic) dlat (dlat * 180.0) / ((a * (1 - ee)) / (magic * sqrtmagic) * math.pi) dlng (dlng * 180.0) / (a / sqrtmagic * math.cos(radlat) * math.pi) return lng - dlng, lat - dlat我的经验是爬下来的数据如果不确定对方要什么坐标系统一存WGS84用的时候再转GCJ-02。这样可以避免数据在不同地图之间来回切的时候出现二次偏移。百度地图还单独用BD-09坐标系在高德数据转百度的场景下要先GCJ-02再BD-09两步不能省。4.3 小程序跳转高德App与Loca组件做爬虫之外的场景很多人也会遇到“微信小程序跳转到高德App”的需求。这东西不复杂但容易卡在协议上。高德提供了URI API用一个跳转链接就能唤起高德App并自动发起导航。https://uri.amap.com/navigation?to终点经度,终点纬度,name目的地modecarcoordinategaode在小程序里用wx.openLocation也能直接拉起地图选点但如果要跳转到高德App做完整导航就得用上面的URL。注意coordinate参数要传gaode如果你手里是WGS84坐标得先转成GCJ-02再拼链接。再聊聊Loca组件。爬下来的POI数据如果只是存个CSV价值不大配一张好的可视化图才是能力的放大。高德Loca是个专门做海量数据可视化的JavaScript库基于AMap JS API运行支持热力图层、蜂巢图、飞线图。用的时候先引AMap再引Locascript srchttps://webapi.amap.com/maps?v2.0key你的key/script script srchttps://cache.amap.com/loca/dist/loca.min.js/script把爬到的POI按经纬度喂进Loca的DataSet几万条点数据渲染成热力图基本不卡。这份数据可视化呈现对我来说才是爬虫项目的终点。4.4 SVG爬虫与瓦片下载的正确姿势网页端高德地图里地名有时是SVG矢量字体或纹理渲染的直接去抓HTML文本你会发现抓不到完整文字这就是SVG爬虫的难点。处理思路是分析页面的字体接口和SVG映射文件把字符id和渲染结果对应起来。这个工程量不小如果目标数据能从API拿到尽量不要走这条路。瓦片下载相对直接。高德栅格瓦片的URL规律网上能找到我自己验证过https://webrd0{1-4}.is.autonavi.com/appmaptile?x{x}y{y}z{z}langzh_cnsize1scale1style7x、y、z分别代表瓦片坐标和缩放级别。下载前先搞清楚你要用的是XYZ还是TMS规则两种规则在y轴方向是相反的。高德这套用的是XYZ规则y从顶部开始。写个小脚本按经纬度范围算出瓦片编号范围然后并发下载下载完用PIL或者gdal拼接成一张大图就能得到一张离线底图。还有朋友会搜“高德红绿灯算法”这类词我说句实话红绿灯倒计时和路况预测是高德基于海量浮动车轨迹、路口信控数据训练出来的结果不是爬虫能拿到现成参数的东西。爬虫能做到的极限是基于历史路况接口做延迟预测分析和红绿灯算法的深度不是一个量级。关于离线包如果你是为了做离线地图与其抓瓦片再自己拼不如直接走官方离线地图下载通道安卓、iOS都有成熟的离线包方案。自己抓瓦片只适合做特定区域的深色底图、行业定制图层这类官方不支持的自定义场景。还有人在搜“高德地图精简版”这类App改造话题那属于另外一条完全不同的路线这里不展开。最后聊点个人体会。高德爬虫这个项目真正花时间的不是写代码而是把“数据边界”想清楚——你到底需要哪一层的数据、覆盖多大范围、目标坐标系是什么这些问题没定下来就急急忙忙写代码后面基本都要返工。还有一个小技巧分享给准备跑全量城市数据的朋友正式跑之前先把所有行政区划的adcode列表导出来按每个区县跑一条测试请求确认“单区县数据量没有超过1000条上限”。这个前置验证能帮你精确估算总请求量和耗时避免爬到一半才发现数据被截断从头再来。