尧图精选

用Python调用高德API批量获取POI数据完整指南

🕒 发布时间:2026/10/2 1:22:28 📁 来源:尧图网络
之前帮朋友做门店选址朋友问我“杭州到底有多少家咖啡店”我一开始真的打开高德网页版一个个搜、一页页翻复制店名、地址、电话到 Excel弄了 200 条差点崩溃。后来我用 Python 调高德 API 把整座城市的咖啡、便利店、奶茶店 POI 全部拉了下来存成表格再做分析整个过程从手动半天变成脚本几分钟。今天这篇就把完整流程和代码分享出来包括申请 Key 的坑、接口参数、批量抓取、去重和坐标转换。先说清楚一个容易混淆的点标题里的 POI 是 Point of Interest即“兴趣点”——在地图场景下指咖啡店、便利店、加油站这些具体地点不是 Java 圈里常说的 Apache POI 那个操作 Word/Excel 的类库。搜教程时别搜错方向这两种完全不同。文章适合这几类人看刚开始接触接口爬取、想批量获取地理数据做分析、或者工作中临时需要 POI 清单的开发者。不需要很深的基础Python 能跑、requests 装得上就行。1. 为什么宁可调官方接口也不去爬网页版地图1.1 网页手动复制和爬虫的极限地图网页版本身是给人类浏览用的不是给程序批量取数的。你在网页上搜索“咖啡”一页大概显示 20 个点想看更多就得翻页页面还有滚动懒加载、反爬检测、参数加密前端接口的返回结构也经常变。就算你强行写爬虫去模拟请求维护成本也非常高而且严格来讲突破了平台对网页的使用限制风险完全没有必要承担。我最初手动复制 200 家店花了接近两个小时复制粘贴时还得自己清理格式中间漏掉几条也发现不了。这种工作方式放在几千条数据上根本不可行。1.2 官方 Web 服务 API 的逻辑完全不同高德开放平台提供了专门的 Web 服务 API其中/v3/place/text就是 POI 搜索接口。它的工作方式是你发一个 HTTP GET 请求服务端返回结构化的 JSON里面有名称、地址、经纬度、所属城市、分类等标准字段。一次请求最多拿到 25 条数据但程序连续发几十个请求就能在几分钟内拿回上千个有效点。用官方接口最大的好处是数据规范、字段完整、返回稳定而且只要你按配额和频率使用完全不涉及绕过验证的事情。做数据分析和商业分析时这套数据是能拿得上台面的。1.3 和浏览器里那个 JS API 不是一回事很多人第一次去高德开放平台时容易懵控制台里既有“Web 服务”又有“Web 端(JS API)”。批量获取 POI 用的是前者也就是 Web 服务 API这是服务端程序直接调 HTTP 接口JS API 是给网页里嵌入地图用的Key 类型和应用场景完全不同。申请 Key 时平台要你选服务类型一定选“Web 服务”选错的话后面积分代码会发现请求根本过不了鉴权。顺带说一句国内有类似能力的还有百度地图 API 和腾讯位置服务 API两者也都很成熟。我选择高德主要是因为它的 POI 类型编码体系比较完整城市行政区域数据也比较干净实际抓下来之后清洗成本更低。2. 申请 Key 时最容易翻车的几个细节2.1 创建应用和 Key 的完整步骤在高德开放平台申请 Key 的流程不复杂但有几个位置很容易填错我拆开来说。打开高德开放平台用支付宝或手机号注册账号然后完成实名认证。个人开发者实名认证即可不需要企业资质。进入控制台找到“应用管理”点击“创建新应用”。应用名称随便填比如“POI采集脚本”。创建完成后在应用下添加 Key服务平台务必选择“Web服务”。添加成功后你会得到一个形如 32 位字母数字组合的 Key 字符串这就是代码里要用的 key 参数。整个过程大概五分钟真正容易出问题的不是申请本身而是下一步的配额理解和白名单配置。2.2 配额到底怎么回事为什么有人说“收费坑人”热搜里经常看到“高德地图api收费坑人”“月配额不够用”这些抱怨的真实原因大多数不是高德抠门而是请求次数设计得太浪费。高德 Web 服务 API 对个人开发者和企业开发者有不同额度的免费配额个人认证的 Key 日调用量上限通常不高基本够做小规模验证和项目 Demo如果要做城市级全量抓取可能一天就把额度用完。超额之后要么等第二天重置要么付费购买资源包。很多人在不了解配额机制的情况下上来就全量抓取全国数据自然觉得“坑”。这里有一个实际经验抓取前一定先算请求量。POI 搜索接口每次请求顶多返回 25 条如果某个关键词在城市里有 5000 条数据按 25 条一页算也要 200 次请求按默认 10 条一页就是 500 次。同样是抓一份数据请求次数能差 2.5 倍而配额消耗也差 2.5 倍。看懂 offset 参数并把页大小拉满是所有配额问题的第一解法。有人会问能不能申请多个 Key 绕开配额限制我不建议这么干。平台对一人多 Key、异常调用是有风控的与其惦记着绕过限制不如把请求设计得精细一点。城市级小规模分析合理优化后配额完全够用。2.3 IP 白名单报错的排查方法高德 Web 服务 Key 在创建时可以设置 IP 白名单。如果你在本地测试但设了白名单而本机出口 IP 不在名单里请求会返回鉴权类错误常见的错误文本是USERKEY_PLAT_NOMATCH。我的建议是初期测试阶段先不设白名单或者把当前出口 IP 加进去。因为很多人家里的宽带、公司网络出口 IP 是动态的今天加的白名单明天可能就失效。等脚本部署到固定公网 IP 的服务器上时再设置白名单反而更安全。还有一个很常见的低级错误把 Key 复制时多复制了空格或者把Web服务和Web端(JS API)的 Key 混用这类问题返回的错误一般是INVALID_USER_KEY。排查时先打印一下实际发送的请求 URL肉眼检查 key 参数是否和官网一致比盯着错误码猜更快。我调试时习惯写一个固定的检查逻辑任何请求返回后先看status字段如果status不是1直接把info字段打出来。高德的info会直接告诉你是 Key 错误、配额超限还是参数非法不用背错误码。3. 看懂 /v3/place/text 的请求参数和返回结构3.1 核心参数逐个说清楚接口地址是https://restapi.amap.com/v3/place/text这是一个 GET 请求全部参数通过 URL 传递。我用到的参数和推荐配置如下参数是否必填说明建议key必填高德 Web 服务 Key控制台申请keywords可选查询关键字如“咖啡”与 types 二者至少给一个types可选POI 类型编码如 050000 表示餐饮服务过滤更精确时使用city可选查询城市支持城市名或 adcode建议使用 adcode如杭州是 330100citylimit可选是否限制在当前城市true/false传 true 避免跨城市返回offset可选每页记录数默认 10最大 25传 25 减少请求次数page可选页码默认 1翻页时递增extensions可选base 或 all传 all 拿到更多字段output可选JSON 或 XML传 JSON需要特别说明的是keywords和types的关系。它们不是二选一的必须项可以单独用也可以组合用。组合时返回的结果是两者的交集比如keywords麦当劳types050000拿到的是餐饮类的麦当劳而不是全部叫“麦当劳”的 POI。这个特性在数据清洗时很好用。城市参数city我强烈建议用 adcode 而不是城市名。中国有同名城市比如“鼓楼区”在南京、福州、徐州都有直接用区名会混数据。高德开放平台有行政区查询接口可以提前拉一份省市区的 adcode 对应表存成本地文件抓取时按 adcode 传入。3.2 count、page、offset 三者的关系以及 1000 条上限这是整个 POI 抓取里最重要的一个机制。响应体里有一个count字段表示该查询条件下的总记录数。但高德这个接口并不是count是多少就一定能翻页翻到多少它最多只返回前 1000 条记录。结合offset最大 25 来看翻页上限就是 40 页。如果count超过 1000你必须换个思路把查询拆细而不是继续翻页。怎么拆我在第五章详细说。我见过有人写循环时不判断count只判断当前页有没有返回数据结果pois为空就 break。这个方法在总数小于 1000 时没问题但一旦超过 1000逻辑会自然停止在第 40 页容易让人误以为数据抓全了。正确做法是同时判断当前页有没有数据、已抓数量是否达到min(count, 1000)、是否超过最大页数。3.3 返回 JSON 里哪些字段是真正能用的一个典型的返回结构长这样{ status: 1, count: 540, pois: [ { id: B0FFH5XXXX, name: 星巴克咖啡(某某店), location: 120.153576,30.287459, address: 某某路 100 号, pname: 浙江省, cityname: 杭州市, adname: 西湖区, type: 餐饮服务;咖啡厅;咖啡厅, typecode: 050700, tel: 0571-88888888, business_area: 黄龙 } ] }location字段是经纬度字符串格式是“经度,纬度”注意顺序是经度在前纬度在后和很多地方习惯的“纬度,经度”相反。如果你直接把字符串按逗号切开存进数据库一定先确认列名别写反。id是 POI 在高德体系里的唯一标识这个字段非常关键后面去重全靠它。type和typecode是分类信息typecode是六位数字编码type是人可读的层级描述两者配合可以做非常精细的过滤。business_area是商圈信息做商业选址时这个字段的参考价值甚至比行政区域更高。4. 完整代码实现4.1 单页请求函数把“发请求—校验—解析”封装干净我先把最小单元写好也就是请求一页 POI 数据的函数。单独拆函数的原因很简单后续要做翻页、多关键词、多城市如果全写在主流程里会非常乱。import requests import time KEY 你申请的高德Web服务Key BASE_URL https://restapi.amap.com/v3/place/text def fetch_poi_page(keywords, city, page1, offset25, typesNone): params { key: KEY, keywords: keywords, city: city, citylimit: true, offset: offset, page: page, extensions: all, output: JSON, } if types: params[types] types resp requests.get(BASE_URL, paramsparams, timeout10) resp.raise_for_status() data resp.json() if data[status] ! 1: raise RuntimeError(f高德API返回错误: {data.get(info)}) return data这里有几个细节timeout10是必须的。requests 默认不设超时一旦网络抖动脚本可能卡死在一个请求上。citylimittrue传的是字符串true不是 Python 的布尔值True因为最终要拼到 URL 里变成citylimittrue。实际上 requests 会把布尔值处理成True但部分接口对大小写敏感写成字符串最稳妥。types参数是可选过滤项传了才放进params避免传空字符串干扰接口判断。4.2 翻页聚合函数自动判断什么时候该停单页函数能跑之后写一个聚合函数把多页结果拼成一个列表。def fetch_all_pois(keywords, city, typesNone, max_pages40): all_pois [] seen_ids set() total None for page in range(1, max_pages 1): data fetch_poi_page(keywords, city, pagepage, typestypes) pois data.get(pois) or [] if page 1: total int(data.get(count, 0)) print(f[{keywords} - {city}] 总记录数: {total}) if not pois: break for poi in pois: poi_id poi.get(id) if poi_id and poi_id not in seen_ids: seen_ids.add(poi_id) all_pois.append(poi) # 已抓数量达到接口返回的总数或达到接口上限直接停 if total is not None and len(all_pois) min(total, 1000): print(f[{keywords} - {city}] 已抓取 {len(all_pois)} 条达到上限停止翻页) break if page % 10 0: print(f[{keywords} - {city}] 已翻到第 {page} 页累计 {len(all_pois)} 条) time.sleep(0.3) return all_pois注意这里我在聚合时就做了去重。虽然正常情况下同一关键词同一城市翻页不会重复返回 id但多关键词调度时会交叉重复去重逻辑放在最底层能省不少后续功夫。翻页循环里还有个容易被忽略的问题高德返回的count是字符串不是整数第一次处理时没做int()转换直接拿字符串和整数比较结果永远不相等循环就会一直跑到第 40 页。所以第一页拿到count后立刻转成 int。4.3 多关键词、多城市批量调度并保存 CSV实际项目里往往不是抓一个词而是“杭州的咖啡店”“杭州的便利店”“上海的咖啡店”交叉抓取。主调度逻辑写成这样import csv CITY_ADCODE { 杭州市: 330100, 上海市: 310000, 南京市: 320100, } KEYWORDS [咖啡, 便利店, 奶茶] def save_to_csv(all_pois, filenamepoi_data.csv): with open(filename, w, newline, encodingutf-8-sig) as f: writer csv.writer(f) writer.writerow([poi_id, name, lng, lat, address, tel, type, typecode, pname, cityname, adname, business_area]) for poi in all_pois: loc poi.get(location, ) lng, lat loc.split(,) if , in loc else (, ) writer.writerow([ poi.get(id, ), poi.get(name, ), lng, lat, poi.get(address, ), poi.get(tel, ), poi.get(type, ), poi.get(typecode, ), poi.get(pname, ), poi.get(cityname, ), poi.get(adname, ), poi.get(business_area, ), ]) def main(): all_data [] for city_name, adcode in CITY_ADCODE.items(): for keyword in KEYWORDS: pois fetch_all_pois(keywordskeyword, cityadcode) print(f{city_name} - {keyword} 抓取完成: {len(pois)} 条) all_data.extend(pois) # 全部结果按 POI id 去重 final_seen set() unique_pois [] for poi in all_data: poi_id poi.get(id) if poi_id and poi_id not in final_seen: final_seen.add(poi_id) unique_pois.append(poi) print(f去重后总数据量: {len(unique_pois)}) save_to_csv(unique_pois) print(数据已保存到 poi_data.csv) if __name__ __main__: main()保存 CSV 时用utf-8-sig编码而不是utf-8这是 Excel 直接打开 CSV 不乱码的关键。用默认 utf-8 写入Excel 里中文全是乱码我早期在这里浪费过不少时间。两层循环加time.sleep(0.3)之后单个关键词单城市的请求间隔被拉长整体频率稳稳控制在合理范围。抓 3 个城市 3 个关键词总请求量大概在 100 次上下跑完两分钟左右。4.4 多线程加速版本和它的适用边界如果你觉得串行太慢可以用ThreadPoolExecutor做并发但这里的坑比提升更明显。高德接口对单个 Key 的并发和频率有限制并发太高会触发频率超限返回info类似“请求过于频繁”的提示然后整批请求全废。我自己测试下来4 到 8 个线程加每线程内部 0.2 秒休眠是一个相对稳定的组合。代码可以这样改from concurrent.futures import ThreadPoolExecutor, as_completed def worker(task): city_name, keyword task pois fetch_all_pois(keywordskeyword, cityCITY_ADCODE[city_name]) return city_name, keyword, pois with ThreadPoolExecutor(max_workers4) as executor: tasks [(city, kw) for city in CITY_ADCODE for kw in KEYWORDS] futures [executor.submit(worker, t) for t in tasks] for future in as_completed(futures): city, kw, pois future.result() print(f{city} - {kw}: {len(pois)} 条)但说实话个人开发者配额本来就不高串行 25 条每页的方式反而最不容易触发限流。多线程省下来的时间通常不足以抵消配额风险带来的重试成本。如果你只是城市级数据优先用串行版。4.5 请求失败自动重试的兜底逻辑网络请求不可能 100% 成功高德偶尔也会因服务波动返回非 200 状态码。我习惯给单页请求包一层重试逻辑最多重试 3 次每次等待时间递增。def fetch_poi_page_with_retry(keywords, city, page1, offset25, retries3, typesNone): for attempt in range(retries): try: return fetch_poi_page(keywords, city, pagepage, offsetoffset, typestypes) except Exception as e: print(f第 {page} 页请求失败第 {attempt 1} 次重试: {e}) time.sleep(2 * (attempt 1)) raise RuntimeError(f第 {page} 页多次重试后仍失败: {keywords} - {city})把fetch_poi_page换成fetch_poi_page_with_retry主流程基本不用动。重试间隔用线性递增而不是固定值是为了避免所有重试请求同时挤在一起再次触发热点限流。5. 跑完数据之后一定要处理的几个问题5.1 去重为什么明明按关键词分开抓还是有一堆重复多关键词批量抓取后重复几乎是必然的。举例来说“咖啡”和“咖啡馆”本身是同义词查询高德可能对两个关键词返回大量相同的 POI类型上咖啡店属于餐饮服务你的关键词列表里如果同时有“咖啡”和“餐饮”交集范围会更大。去重只用 POI 的id字段不要用“名称地址”拼接因为连锁店名称一样但门店不同地址在两家地图数据源里可能有微小格式差异拼接法去重会误杀。我在代码里的做法是全程维护一个seen_ids集合见一个 id 记一个 id只有第一次见到才保留。5.2 坐标偏移GCJ-02 坐标系和地图底图的关系高德返回的经纬度是 GCJ-02 坐标系俗称火星坐标系。这是国内地图通用的一套加密偏移坐标。如果你把数据直接叠加到使用 WGS-84 坐标的底图上比如 GPS 设备采集的数据、某些国际地图服务会发现位置整体偏移几百米这在城市里放到街道级别就非常明显。如果你要用高德自己的 JS API 展示那完全不用转换直接用原始坐标。但如果后续要导出到支持 WGS-84 的工具里分析需要做一次坐标转换。下面是一个公开的近似转换函数适合日常数据分析import math def _transform_lat(x, y): ret -100.0 2.0 * x 3.0 * y 0.2 * y * y 0.1 * x * y 0.2 * math.sqrt(abs(x)) ret (20.0 * math.sin(6.0 * x * math.pi) 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 ret (20.0 * math.sin(y * math.pi) 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0 ret (160.0 * math.sin(y / 12.0 * math.pi) 320 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0 return ret def _transform_lng(x, y): ret 300.0 x 2.0 * y 0.1 * x * x 0.1 * x * y 0.1 * math.sqrt(abs(x)) ret (20.0 * math.sin(6.0 * x * math.pi) 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 ret (20.0 * math.sin(x * math.pi) 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0 ret (150.0 * math.sin(x / 12.0 * math.pi) 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0 return ret def gcj02_to_wgs84(lng, lat): a 6378245.0 ee 0.006693421622965943 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) mglat lat dlat mglng lng dlng return lng * 2 - mglng, lat * 2 - mglat这是公开的近似反算方法精度对大多数商业分析足够如果要做车道级、米级的应用还是建议用专业的地图纠偏服务。写 CSV 时如果明确知道自己要用 WGS-84就在落盘前调用这个函数把经度和纬度都转一遍。5.3 超过 1000 条数据时正确拆分查询姿势前面反复提到接口最多返回 1000 条那真实需求里超过 1000 条怎么办有两条拆分路线我一般组合使用第一按行政区拆分。把城市拆到区县级别甚至街道级别。比如抓杭州“咖啡”超过 1000 条就分别以“330106”西湖区、“330105”拱墅区等 adcode 去查询。高德的行政区域数据比较干净各区县的 adcode 可以通过行政区查询接口提前拉取。第二按 types 细化拆分。一级分类太大就用二级、三级分类。高德 POI 类型编码是分层的比如 050000 是大餐饮050100 是中餐厅050700 是咖啡厅。如果“餐饮”这个大类超过 1000 条就按中餐厅、咖啡厅、西餐厅等子类型分别抓抓完再合并去重。拆分之后还要注意按行政区分抓取会有边界重叠问题同一个 POI 可能又出现在西湖区又出现在拱墅区但这是因为高德行政区划边界和 POI 归属判断存在冗余不是你的代码写错了。最终合并时依然用id去重整个流程就不会有重复项。5.4 配额优化和把原始数据留好的习惯最后分享几个我固定使用的实操习惯。第一每次抓新关键词之前先只请求第一页拿到count估算总请求数。如果预计超过配额就不要无脑往下跑而是停下来调整拆分策略。按一页 25 条计算1000 条实际只需要 40 次请求这在你动手之前就能算出来。第二offset一定传 25。这是最简单却最容易被忽略的配额优化。默认 10 条意味着 1000 条数据要 100 次请求改成 25 只要 40 次直接省掉 60% 的配额消耗。第三第一次抓取时把原始 JSON 完整保存一份不要只存清洗后的 CSV。因为后续你很可能发现少存了某个字段、坐标需要转换、分类需要重筛如果原始 JSON 还在一切都可以本地重来不用再次消耗接口配额。我通常是按关键词和城市分组把每个响应保存在单独的 JSON 文件里文件名形如杭州_咖啡_page_01.json。第四存 CSV 后第一步不是画图而是先做去重和坐标转换。不要等可视化时发现店的位置全部偏到河对岸才回头排查先验证再分析能省很大力气。我把这套流程固定成了自己的标准操作先跑一个关键词估量数据量确认在千条以内再放开批量调度抓完数据第一件事是去重和坐标转换第二件事才是做热力图和分布分析。这样折腾完杭州的咖啡店分布图才真正能看。希望这篇也能帮你少走点弯路。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →