之前帮朋友做门店选址,朋友问我“杭州到底有多少家咖啡店”,我一开始真的打开高德网页版一个个搜、一页页翻,复制店名、地址、电话到 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,如杭州是 330100 |
| citylimit | 可选 | 是否限制在当前城市,true/false | 传 true 避免跨城市返回 |
| offset | 可选 | 每页记录数,默认 10,最大 25 | 传 25 减少请求次数 |
| page | 可选 | 页码,默认 1 | 翻页时递增 |
| extensions | 可选 | base 或 all | 传 all 拿到更多字段 |
| output | 可选 | JSON 或 XML | 传 JSON |
需要特别说明的是keywords和types的关系。它们不是二选一的必须项,可以单独用,也可以组合用。组合时返回的结果是两者的交集,比如keywords=麦当劳&types=050000,拿到的是餐饮类的麦当劳,而不是全部叫“麦当劳”的 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, page=1, offset=25, types=None): 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, params=params, timeout=10) resp.raise_for_status() data = resp.json() if data["status"] != "1": raise RuntimeError(f"高德API返回错误: {data.get('info')}") return data这里有几个细节:
timeout=10是必须的。requests 默认不设超时,一旦网络抖动,脚本可能卡死在一个请求上。citylimit=true传的是字符串"true",不是 Python 的布尔值True,因为最终要拼到 URL 里变成citylimit=true。实际上 requests 会把布尔值处理成True,但部分接口对大小写敏感,写成字符串最稳妥。types参数是可选过滤项,传了才放进params,避免传空字符串干扰接口判断。
4.2 翻页聚合函数:自动判断什么时候该停
单页函数能跑之后,写一个聚合函数,把多页结果拼成一个列表。
def fetch_all_pois(keywords, city, types=None, max_pages=40): all_pois = [] seen_ids = set() total = None for page in range(1, max_pages + 1): data = fetch_poi_page(keywords, city, page=page, types=types) 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, filename="poi_data.csv"): with open(filename, "w", newline="", encoding="utf-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(keywords=keyword, city=adcode) 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(keywords=keyword, city=CITY_ADCODE[city_name]) return city_name, keyword, pois with ThreadPoolExecutor(max_workers=4) 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, page=1, offset=25, retries=3, types=None): for attempt in range(retries): try: return fetch_poi_page(keywords, city, page=page, offset=offset, types=types) 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 后第一步不是画图,而是先做去重和坐标转换。不要等可视化时发现店的位置全部偏到河对岸才回头排查,先验证再分析,能省很大力气。
我把这套流程固定成了自己的标准操作:先跑一个关键词估量数据量,确认在千条以内再放开批量调度;抓完数据第一件事是去重和坐标转换,第二件事才是做热力图和分布分析。这样折腾完,杭州的咖啡店分布图才真正能看。希望这篇也能帮你少走点弯路。