
上个月帮一个做本地生活数据的朋友收拾一个烂摊子他用高德的地点搜索接口批量拉咖啡馆脚本跑到第 800 多条开始报DAILY_QUERY_OVER_LIMIT改成慢速重试之后又发现存进库里的经纬度跟地图上标的点差了大概几百米。这两个毛病凑在一起其实特别典型——前者是配额没算清楚后者是坐标系没对齐。所以这篇就围绕调用高德 API 实现地点搜索、并拿到经纬度这一件事把接口选型、Key 申请、参数细节、坐标系转换、限流缓存、报错排查整条链路拆开讲一遍。不管你是第一次碰高德开放平台还是已经跑通过 demo 但一上量就翻车下面这些内容基本都能对上号。1. 先把接口选对地点搜索在高德开放平台里对应哪几个能力绝大多数人第一次做地点搜索脑子里只有我给它一个词它给我一堆点这一个画面。但高德开放平台把这件事拆成了好几个接口名字还都挺像选错了要么返回结果不对要么白烧配额。1.1 五个容易混淆的接口各自解决什么我按输入形态把它们捋一遍这个分类方式比官方文档按字母排序要直观得多接口输入输出典型用途关键字搜索/v3/place/text关键词 城市POI 列表含经纬度搜北京有哪些星巴克周边搜索/v3/place/around中心点经纬度 关键词 半径POI 列表含距离我附近 3 公里的加油站多边形搜索/v3/place/polygon多边形顶点 关键词POI 列表这个园区范围内的所有公司地理编码/v3/geocode/geo结构化地址文本单个经纬度把『杭州市余杭区文一西路 969 号』转成坐标逆地理编码/v3/geocode/regeo经纬度结构化地址这个坐标是哪条街关键区别在一对一还是一对多。地理编码是一个地址对一个点它在高德内部走的是地址库匹配命中率取决于地址书写的规范程度而关键字搜索是一个词对一批 POI走的是 POI 库检索返回的是带name、type、address、location的完整实体。我见过最常见的误用是拿地理编码去批量查连锁店。比如想拿到全国所有海底捞的坐标用地理编码逐条查地址结果稍微不规范的地址就直接返回空而且一条地址消耗一次调用几百个城市查下来配额掉得飞快。这种场景正确的做法是关键字搜索 city循环 citylimittrue。1.2 关键字搜索 vs 周边搜索按输入形态做选择判断标准就一句话你手里先有的是一个地名还是一个坐标如果你手里是坐标比如从用户手机定位拿到的点要反查附近有什么那必须用周边搜索因为关键字搜索没法表达以某点为中心这个约束。周边搜索的radius最大能到 50000 米但实际用下来超过 3000 米结果就开始稀疏因为它是按距离排序后截断的半径拉到 50 公里并不等于能拿回 50 公里内的全部 POI。反过来如果你手里是上海 静安寺 附近的便利店这种复合语义别傻傻地先地理编码拿到静安寺坐标再周边搜索——一次关键字搜索请求keywords便利店city上海citylimittrue再在本地按坐标过滤距离总共只花一次调用而拆成两步要花两次还多了一次误差传递。提示citylimittrue是非常重要的参数。默认情况下高德会做全国范围的相关性排序你搜星巴克、城市写拉萨返回结果里很可能混进成都和西宁的店。加上citylimittrue之后才严格限定在指定城市内。1.3 版本差异v3 和 v5 该用哪个高德目前同时存在 v3 和 v5 两套 POI 搜索接口。v5 的字段设计更干净show_fields控制返回内容默认只给基础字段分页上限也是 25 条一页但 v5 的region参数用的是行政区划编码而不是城市名对新手不友好。v3 的city参数可以直接写北京或010兼容性更好。我的建议是新项目直接上 v5字段更可控返回体积小维护老代码就继续用 v3两者短期内都不会下线。但不要在一个项目里混用因为两者的错误码语义和字段名不一致后期排查会非常痛苦。2. Key 申请与配额账本个人开发者最容易踩的现实问题热搜里那句我是个人使用高德开放平台 api 月配额不够用我大概每个月都能在群里看到几次。问题往往不是配额真的少而是调用方式太浪费。2.1 Web 服务 Key 的申请路径与必须勾选的服务申请流程本身不复杂登录高德开放平台控制台进应用管理创建新应用然后添加 Key。这里有个关键选择——服务平台一定要选Web 服务不是Web 端(JS API)也不是Android/iOS 平台。这个选择错了会直接导致调用报USERKEY_PLAT_NOMATCH因为你用服务端 HTTP 请求去调一个为浏览器 JS 准备的 Key平台类型对不上。同理如果你的脚本跑在云服务器上还要注意INVALID_USER_IP这个错误——Web 服务 Key 虽然不像某些平台那样强制绑定 IP但如果你在控制台设置了 IP 白名单服务器出口 IP 变了就会立刻失败。创建完 Key 之后去服务管理里确认你需要的服务是启用状态。地点搜索属于搜索服务地理编码属于地理编码/逆地理编码服务这些在免费额度下都是默认开通的但如果你的账号是新注册的偶尔会遇到某些服务需要手动开启。2.2 配额、QPS、计费三条互相独立的约束这是最多人搞混的地方。高德对一次调用的限制其实是三个维度任何一个超标都会失败但报错码完全不同日调用量每天的总次数上限超了报DAILY_QUERY_OVER_LIMIT。并发 QPS每秒请求数上限超了报CUQPS_HAS_EXCEEDED_THE_LIMIT。注意这是并发不是每秒总数你一次性开 50 个线程同时打过去即使总请求数很小也会触发。个人/企业认证等级个人开发者的免费配额明显低于企业认证账号。如果你的项目是正经商用走企业认证能拿到的配额和 QPS 都高一个量级。把这三个维度分开看很多配额不够的困惑就解释得通了有的人日调用量还剩一大半但脚本一跑就报 QPS 超限因为他用了线程池并发有的人单线程稳稳地跑却半天就把日配额跑完了因为他没有做缓存同样的查询重复请求了几十次。注意配额是按Key 接口维度计算的。关键字搜索、周边搜索、地理编码各有各的日限额不会互相挤占。所以如果某个接口配额吃紧评估一下能不能把一部分查询改走另一个接口有时能缓解。2.3 把调用量算清楚一个能落地的估算表上线之前先算账比事后看账单靠谱。假设你要做全国主要城市 若干关键词的 POI 采集变量取值示例说明城市数30省会 计划单列市关键词数12每个品类一个词每组合翻页数3每页 25 条最多拿 75 条理论调用量30 × 12 × 3 1080每天一轮如果每天跑一轮一个月就是 32400 次。这个量级对个人配额来说确实紧张所以必须做三件事只翻真正需要的页多数关键词第 1 页就够用、结果落库去重同一个 POI 在不同关键词下会重复出现、加缓存层同一参数组合在缓存有效期内直接读本地。后面第 5 节会把这三件事的实现写出来。3. 实测跑通关键字搜索从一次请求到结构化 POI 列表先把最小可用的请求跑通再谈工程化。3.1 最小可用请求与参数逐项说明v3 关键字搜索的核心参数如下curl https://restapi.amap.com/v3/place/text?key你的KEYkeywords咖啡馆city杭州citylimittrueoffset25page1extensionsall各参数的含义和取值要点keyWeb 服务类型的 Key。keywords搜索词。支持多关键词用|分隔比如咖啡馆|书店但实测下来多关键词会稀释相关性不如分两次请求。city城市名、城市中文、adcode 或 citycode 都行写杭州或0571或330100都可以。citylimittrue时严格限定城市强烈建议开。offset每页条数最大 25超过会被截断或报参数错误。page页码从 1 开始。注意 v3 的翻页深度有限制翻太深会返回空数组。extensionsall会返回biz_ext评分、营业时间等和indoor_map但响应体积会大好几倍。只取坐标的话用base就够。返回的 JSON 大致长这样{ status: 1, info: OK, count: 412, pois: [ { id: B0FFH0XXXX, name: 某某咖啡(文一西路店), type: 餐饮服务;咖啡厅;星巴克, location: 120.026,30.279, address: 文一西路969号, cityname: 杭州市, adname: 余杭区, adcode: 330110, tel: 0571-8888XXXX } ] }3.2 返回 JSON 里真正有用的字段pois数组里字段挺多但实际项目里高频用到的就这几个idPOI 唯一 ID做去重的主键比用名称去重靠谱得多。name名称注意有些店名带后缀和分店信息做模糊匹配时要去掉空白和全半角差异。location经度,纬度格式的字符串这是本篇文章要拿的核心数据需要 split 后转 float。type分号分隔的分类路径比如餐饮服务;咖啡厅;星巴克。做品类归类时取第一段或第二段。adcode行政区划编码用来关联你自己的城市表很方便。address地址文本通常是门牌级别但也会出现某某路附近这种模糊描述。有个坑要提前说status字段是字符串1而不是数字1count也是字符串。很多人在 Python 里写if resp[status] 1永远不成立然后以为接口挂了。直接转 int 或者用字符串比较别偷懒。3.3 分页、citylimit 与 types 分类码的组合用法types参数值得单独讲。它是高德的 POI 分类编码用了之后搜索会严格按品类过滤比在关键词里写咖啡馆更准。几个常用的分类码含义分类码含义050000餐饮服务110000风景名胜060000购物服务120000商务住宅070000生活服务150000交通设施服务090000医疗保健服务170000公司企业100000住宿服务190000地名地址信息用法是types050000keywords咖啡两者是与的关系。实测中纯用keywords搜索会混进一些名字里带咖啡但不卖咖啡的店比如咖啡器具专卖加上types之后干净很多。翻页策略上我的经验是最多翻 3 页。一是翻深了相关性会明显下降二是同一关键词在不同城市翻 10 页拿回的 POI 里重复率很高性价比极低。如果某个品类确实 POI 很多比如全国的加油站更合理的做法是按区县拆分城市参数每个区县各翻 1 到 2 页覆盖面比单城市翻 10 页好得多。3.4 封装成一个能复用的 Python 客户端直接上代码这个版本做了参数校验、超时控制和错误归类import time import requests class AmapClient: BASE https://restapi.amap.com/v3/place/text def __init__(self, key, timeout8): self.key key self.timeout timeout self.session requests.Session() def search(self, keywords, city, page1, offset25, typesNone, extensionsbase, citylimitTrue): params { key: self.key, keywords: keywords, city: city, citylimit: str(citylimit).lower(), offset: min(offset, 25), # 硬性上限防止参数越界 page: page, extensions: extensions, } if types: params[types] types for attempt in range(4): try: r self.session.get(self.BASE, paramsparams, timeoutself.timeout) data r.json() except (requests.RequestException, ValueError) as e: # 网络层问题指数退避后重试 time.sleep(2 ** attempt) continue if str(data.get(status)) 1: return data.get(pois, []) info data.get(info, UNKNOWN) if info in (CUQPS_HAS_EXCEEDED_THE_LIMIT, DAILY_QUERY_OVER_LIMIT): time.sleep(2 ** attempt) # 限流类退避重试 continue # 参数类、Key 类错误重试没意义直接抛出 raise RuntimeError(fAmap error: {info}) raise RuntimeError(重试次数用尽)这段代码里有三个刻意的设计值得说明一下第一offset做了min(offset, 25)的硬约束。高德对offset的上限是 25但如果你传了 50有些情况下它不会报错而是默默按 25 返回。这意味着你以为自己在每页拿 50 条实际只拿到 25 条翻页逻辑全乱。在客户端层卡死这个上限比在业务层到处判断要省心。第二错误分了可重试和不可重试两类。限流和网络抖动是暂时的退避重试有用而INVALID_PARAMS、INVALID_USER_KEY这类错误你重试一百次结果都一样只会浪费时间和配额。把它们直接抛出让上层知道该去改配置。第三用Session复用连接。批量调用时 TCP 握手开销不小尤其你的脚本跑在异地服务器上单次请求的 TLS 握手可能要 200ms 以上。用 Session 之后实测整体耗时能降三成左右。4. 坐标系是最大的坑GPS 经纬度为什么不能直接喂给高德如果你的坐标来源是手机 GPS、无人机、GPS 手持设备、或者从其他国家坐标系的数据集里拿的那么直接拿去调高德接口或者直接把高德返回的坐标存进你的 GPS 系统里都会错。4.1 WGS-84、GCJ-02、BD-09 三者的关系这是三个不同的坐标参考系WGS-84全球卫星定位系统原始输出的坐标系GPS 设备、大部分开源地理数据、国际标准地图用的都是它。GCJ-02国内地图服务普遍使用的加密坐标系高德、腾讯用的都是这一套腾讯在此基础上还有自己的偏移。BD-09百度在 GCJ-02 基础上再做一次偏移得到的坐标系只有百度地图用。三者之间的偏差在国内大部分地区是几十米到几百米不等具体数值随地理位置变化。所以差了几百米不是你计算错了是坐标系没对齐。判断自己的数据属于哪一套有个很实用的办法拿一个你熟悉的明显地标把坐标丢进高德地图的坐标拾取工具里Web 端 JS API 的示例页面就有看红点是不是精确落在目标建筑上。如果偏了就是在用 WGS-84 或 BD-09如果精确命中就是 GCJ-02。4.2 纯 Python 转换实现WGS-84 转 GCJ-02 的算法是公开的核心是一组基于椭球参数的偏移计算加上一次反解。这里给一份可以直接用的实现import math A 6378245.0 # 长半轴 EE 0.00669342162296594323 # 偏心率平方 def _out_of_china(lng, lat): # 粗略判断是否在国内范围之外 return not (73.66 lng 135.05 and 3.86 lat 53.55) def _transform_lat(lng, lat): ret (-100.0 2.0 * lng 3.0 * lat 0.2 * lat * lat 0.1 * lng * lat 0.2 * math.sqrt(abs(lng))) ret (20.0 * math.sin(6.0 * lng * math.pi) 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0 ret (20.0 * math.sin(lat * math.pi) 40.0 * math.sin(lat / 3.0 * math.pi)) * 2.0 / 3.0 ret (160.0 * math.sin(lat / 12.0 * math.pi) 320 * math.sin(lat * math.pi / 30.0)) * 2.0 / 3.0 return ret def _transform_lng(lng, lat): ret (300.0 lng 2.0 * lat 0.1 * lng * lng 0.1 * lng * lat 0.1 * math.sqrt(abs(lng))) ret (20.0 * math.sin(6.0 * lng * math.pi) 20.0 * math.sin(2.0 * lng * math.pi)) * 2.0 / 3.0 ret (20.0 * math.sin(lng * math.pi) 40.0 * math.sin(lng / 3.0 * math.pi)) * 2.0 / 3.0 ret (150.0 * math.sin(lng / 12.0 * math.pi) 300.0 * math.sin(lng / 30.0 * math.pi)) * 2.0 / 3.0 return ret def wgs84_to_gcj02(lng, lat): GPS 原始坐标 - 高德/腾讯可直接使用的坐标 if _out_of_china(lng, lat): return lng, lat 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反方向的gcj02_to_wgs84用加偏移再减回去的迭代近似法就能做到米级精度思路是先假设目标点加上一个偏移量得到近似 GCJ-02再算这个近似点和真实 GCJ-02 的差反复迭代两次即可。BD-09 的转换则是在 GCJ-02 之上再做一次简单的极坐标偏移。4.3 什么时候必须转什么时候千万别转这一条是踩过坑才明白的值得单独强调场景是否需要转换GPS 设备坐标 - 调高德接口必须转WGS-84 转 GCJ-02高德返回的坐标 - 存进 GPS 系统必须转GCJ-02 转 WGS-84高德返回的坐标 - 再调高德接口不要转转了反而错高德返回的坐标 - 存进自己的数据库看数据库的坐标系定义建议统一存 GCJ-02 并打标记百度返回的坐标 - 调高德接口必须转BD-09 先转 GCJ-02最容易翻车的是第三条。我见过一个项目开发者在写入数据库时统一转了一遍读取时又转了一遍结果坐标偏出去一公里多排查了整整两天。转换只应该发生一次而且必须发生在坐标系边界上。建议在数据库表里加一个coord_system字段明确记录每条坐标属于哪套系统这个字段以后会救你的命。5. 批量任务下的限流、缓存与重试让配额花在刀刃上单次调用跑通之后真正的问题才来怎么在配额限制下把几万条数据采完。5.1 QPS 限流的令牌桶实现前面说了CUQPS_HAS_EXCEEDED_THE_LIMIT是并发超限报的所以控制手段不是跑慢点而是控制同时在飞的请求数。最简单的做法是单线程加固定间隔但这样太浪费——你的 QPS 配额可能允许 3单线程只能用到 1。一个轻量的令牌桶import threading import time class TokenBucket: def __init__(self, rate, capacity): self.rate rate # 每秒补充的令牌数 self.capacity capacity self.tokens capacity self.lock threading.Lock() self.last time.monotonic() def acquire(self): while True: with self.lock: now time.monotonic() # 按流逝时间补充令牌封顶到容量 self.tokens min( self.capacity, self.tokens (now - self.last) * self.rate ) self.last now if self.tokens 1: self.tokens - 1 return wait (1 - self.tokens) / self.rate time.sleep(wait)用的时候把rate设成你配额 QPS 的 70% 左右留出余量。比如免费配额 QPS 是 3就把rate设成 2。为什么不打满因为限流判断是在服务端做的服务端的计数窗口和你本地的窗口有几十毫秒的相位差本地卡在 3 上服务端很可能在窗口切换的瞬间看到 4直接拒绝。留三成余量是最省心的做法。5.2 用 SQLite 做结果缓存的策略缓存是省配额最有效的手段没有之一。我的做法是用 SQLite 建两张表CREATE TABLE poi ( poi_id TEXT PRIMARY KEY, name TEXT, lng REAL, lat REAL, poi_type TEXT, address TEXT, adcode TEXT, coord_sys TEXT DEFAULT gcj02, updated_at INTEGER ); CREATE TABLE query_log ( query_key TEXT PRIMARY KEY, -- md5(keywordscitypagetypes) fetched_at INTEGER, poi_count INTEGER );两层缓存的意义不同。POI 表做的是内容级去重——同一个店铺在不同关键词、不同页码范围下会反复出现用poi_id作为主键做 upsert可以让你的最终数据集干净很多。我实测过一次30 城 × 12 关键词 × 3 页的采集总调用 1080 次返回约 2 万条记录去重后实际只有 7300 多个独立 POI重复率超过六成。query_log 表做的是请求级缓存——记录每个参数组合最后一次拉取的时间。如果你需要定期刷新数据把刷新周期设成 7 天或 30 天周期内的重复请求全部命中缓存。POI 数据的变化没那么快一家咖啡店不会一周换个位置30 天的缓存周期对绝大多数场景都够用。5.3 重试与错误码分级处理不是所有错误都值得重试。我的分级方式是这样立即重试网络超时、连接重置。这类占失败总数的大头退避重试基本都能救回来。退避后重试CUQPS_HAS_EXCEEDED_THE_LIMIT。等 2 秒、4 秒、8 秒一般第二次就过了。暂停任务DAILY_QUERY_OVER_LIMIT。这时候重试毫无意义应该把当前进度存盘、记录断点等第二天配额刷新后从断点续跑。这个逻辑一定要写否则脚本会空转到天亮。直接失败INVALID_PARAMS、INVALID_USER_KEY、USERKEY_PLAT_NOMATCH。都是配置问题人工改。提示断点续跑的实现关键是以任务为单位记录状态而不是以请求为单位。把任务拆成(city, keywords)这样的组合每个组合跑完就更新一次进度表。这样即使中断重启后也只需要跳过已完成的组合不用精确到某一次请求。6. 报错排查链路一次从 INVALID_USER_KEY 到 DAILY_QUERY_OVER_LIMIT 的完整定位说个真实经历这个排查过程挺有代表性。6.1 现场脚本昨天还能跑今天全红朋友的脚本昨天跑了一晚上都正常第二天早上继续跑一上来就连续报错日志里刷了几百行INVALID_USER_KEY。他的第一反应是 Key 被封了跑去控制台看Key 状态正常服务也都启用着。6.2 逐层排查与验证我让他按这个顺序查第一步确认 Key 本身有没有被改动。检查.env文件和代码里读 Key 的地方。一查发现他昨晚为了清理日志手滑把.env里 Key 后面的一串字符删掉了几个——因为那个 Key 末尾刚好有几个看起来像乱码的字符。这一步的教训是永远不要手动复制粘贴 Key用环境变量注入并且启动时做一次长度校验。高德的 Key 是固定长度的十六进制串长度不对就直接在启动阶段报错退出别等到调用的时候才发现。第二步确认报错的是哪个接口。修完 Key 之后脚本跑起来了但跑了 800 多条又停了。这次报的是DAILY_QUERY_OVER_LIMIT。他一脸疑惑我今天才跑了 800 次啊。第三步去控制台看实际用量。控制台的用量统计里显示当天这个 Key 的搜索服务调用量是 9000 多次。谜底揭开了他的脚本有个 bug在for循环里对同一个城市重复调用了很多次因为他的城市列表里有重复项而且没去重。800 次是这一次运行的量前面还有几轮断断续续的运行加起来就超了。第四步加固。我们最后加了三道防线城市列表去重后落盘、每次启动打印预估调用量并要求确认、单日累计调用量达到配额的 80% 时主动暂停并告警。6.3 高频错误码对照表整理一份常用的出问题时直接查错误码含义处理方式INVALID_USER_KEYKey 无效或格式错误检查 Key 是否完整、是否有多余空格USERKEY_PLAT_NOMATCHKey 平台类型不匹配确认申请的是 Web 服务类型INVALID_USER_IP请求 IP 不在白名单检查控制台 IP 白名单设置INVALID_PARAMS参数不合法检查offset是否超 25、经纬度格式是否正确CUQPS_HAS_EXCEEDED_THE_LIMIT并发超限加令牌桶限流DAILY_QUERY_OVER_LIMIT日配额用尽断点续跑等次日刷新SERVICE_NOT_AVAILABLE服务未开通控制台服务管理里启用对应服务INVALID_USER_SCODE签名校验失败检查是否开启了数字签名但没带签名参数排查时有个通用技巧把出错的那一次请求原封不动地用curl重放一遍。因为你的客户端代码可能对参数做了什么处理编码、截断、默认值填充用 curl 重放能排除掉客户端自身的问题。这个动作花不了一分钟但能省掉很多是不是接口改了的无效猜测。7. 拿到经纬度之后几个真正有用的下游用法坐标到手只是开始真正产生价值的是怎么用它。7.1 距离计算与批量去重拿到一批 POI 之后最常见的需求是找最近的几个或者合并距离 50 米以内的重复点。两个坐标点之间的距离可以用 Haversine 公式算import math def haversine(lng1, lat1, lng2, lat2): 返回两点间距离单位米 r 6371000.0 p1, p2 math.radians(lat1), math.radians(lat2) dp math.radians(lat2 - lat1) dl math.radians(lng2 - lng1) a (math.sin(dp / 2) ** 2 math.cos(p1) * math.cos(p2) * math.sin(dl / 2) ** 2) return 2 * r * math.asin(math.sqrt(a))去重的思路是先按名称做一次精确匹配去掉空格和括号内容剩下的用坐标做二次校验。名称相同且距离小于 50 米的基本可以判定是同一个 POI。为什么是 50 米因为同一个店铺在 POI 库里可能存在多条记录分别对应主点和入口点这两条记录的坐标差通常在一二十米内留 50 米的余量比较稳。7.2 逆地理编码补全地址关键字搜索返回的address字段有时候是空的尤其是小型店铺。这时候可以用逆地理编码补上url https://restapi.amap.com/v3/geocode/regeo params { key: KEY, location: f{lng},{lat}, # 注意顺序经度在前 extensions: base, radius: 200, }两个要点location的顺序是经度,纬度写反了会返回奇怪的结果或者直接报错radius是搜索半径默认 1000 米设小一点比如 200能拿到更贴近的点位地址而不是街道中心。逆地理编码是按次计费的所以别对所有 POI 都做只对address为空的那些做补全。7.3 地面高程这件事高德不提供怎么补热搜里有人问输入某点经纬度查找地面高程。明确说一句高德的 POI 和地理编码接口都不提供高程数据它是做地图和位置服务的高程属于地形数据范畴。如果你确实需要某点的地面高程可行的路子有几条使用公开的 DEM 数据集本地计算。全球范围的数字高程模型有多个公开数据集可以下载精度从 30 米到 90 米不等。下载对应区域的瓦片用 Python 的栅格处理库读取按经纬度索引到具体像元就能拿到高程值。这条路的好处是零调用成本、离线可用代价是要处理投影转换因为 DEM 数据通常用的是投影坐标系需要先把经纬度转成投影坐标才能索引到正确的像元。调用专门的高程查询服务。有些地理数据平台提供按点查询高程的接口格式和 POI 搜索类似返回一个高程值。选这类服务时注意看它的数据源和精度说明30 米精度的数据和 5 米精度的数据在地势平坦的地方差别不大在山区可能差出几十米。从已有的地形数据中插值。如果你手里已经有一批带高程的采样点可以按距离做反距离加权插值估算未知点的值适合采样点比较密集的场景。需要提醒的是不管用哪种方式高程数据的坐标系同样要注意。DEM 数据的经纬度如果是 WGS-84而你的点位是 GCJ-02索引出来的像元可能偏出去几百米在山地地形下高程能差出上百米。这个坑和前面说的坐标转换是同一类问题。我在实际做这类数据采集的时候最大的体会是接口调用本身是最简单的一环真正花时间的是配额规划、坐标系对齐和错误处理这三件事。一份能跑通的 demo 代码通常在半小时内就能写出来但要让它稳定地跑完几万条数据、并且存下来的坐标是可信的往往要反复调试两三天。所以别嫌前面的准备工作啰嗦把 Key 的服务类型确认清楚、把坐标系的边界在哪想明白、把配额账先算一遍后面的坑能少踩一大半。