1. 为什么我要自己动手做一个 AOI/POI 抓取工具做地理数据分析这行的朋友应该都有体会拿到一份干净的、带边界几何的 AOIArea of Interest兴趣面数据有多难。商业地图 API 要么按调用次数收费要么对返回字段做阉割要么干脆不给你几何边界只丢一个中心点坐标过来。POIPoint of Interest兴趣点稍微好一点但批量拉取的时候配额卡得死死的想跑一个城市的全量餐饮 POI光配额就能把人耗死。我平时做城市商业分析、选址评估、竞品门店分布这类项目AOI 和 POI 是绕不开的基础数据。之前一直靠手工整理加零散脚本拼凑效率低不说数据质量还不稳定。后来把目光转向了 OpenStreetMap以下简称 OSM这个由全球志愿者共同维护的开源地图数据库数据量足够大覆盖也够广关键是它的数据获取接口是开放的不需要申请密钥没有调用配额的概念。但 OSM 的数据获取有个门槛它用的是 Overpass QL 这套查询语言语法跟 SQL 完全不是一回事新手第一次看官方文档基本是一头雾水。而且 Overpass API 的公共实例有速率限制和超时机制查询写得太宽泛直接给你返回 504。我踩了不少坑之后决定把这套流程封装成一个开源工具让不懂 Overpass QL 的人也能一键拿到全国范围的 AOI 和 POI 数据输出标准的 GeoJSON 格式直接丢进 QGIS、ArcGIS 或者用 Python 的 geopandas 读取都行。这个工具解决的核心问题就三个第一把 Overpass QL 的查询逻辑封装成可视化操作或者简单的配置文件不用手写查询语句第二处理公共实例的限流和超时自动分片、重试、断点续传第三把 OSM 原始的节点、路径、关系数据转换成规范的 GeoJSONAOI 输出多边形POI 输出点属性字段做标准化映射。适合谁来参考做城市数据分析的、搞 GIS 的、做商业选址的、写爬虫想找个稳定数据源的以及任何需要批量获取地理边界和兴趣点数据的人。哪怕你之前没接触过 OSM跟着走一遍也能跑通。2. 整体架构设计与技术选型思路2.1 为什么选 OpenStreetMap 而不是商业地图先说数据源的选择。商业地图 API 的优势在于数据更新及时、POI 属性丰富营业时间、电话、评分这些但劣势也很明显AOI 边界基本不开放POI 批量获取有配额而且各家坐标系还不统一拿回来还得做纠偏。OSM 的优势是数据完全开放ODbL 协议允许自由使用和分发AOI 边界在 OSM 里叫 multipolygon 或 relation是完整存储的POI 的标签体系虽然不如商业地图丰富但核心字段名称、类别、地址都有。更重要的是OSM 的数据可以通过 Overpass API 做空间查询比如“给我某个矩形范围内的所有学校”这种查询在商业 API 里要么不支持要么收费。对于做区域分析的人来说这种按空间范围批量拉取的能力才是刚需。当然 OSM 也有短板。国内的数据覆盖度不如商业地图一线城市还行三四线城市和乡镇的 POI 密度明显偏低。AOI 边界方面住宅小区、大学校园、工业园区这些大型面状要素比较完整但小型商业综合体的边界可能缺失。这个在后面的实操环节我会讲怎么判断数据质量。2.2 工具的整体架构整个工具我分成了四层查询构建层负责把用户的输入区域名称、类别、范围翻译成 Overpass QL 查询语句。这一层内置了常见类别的查询模板比如学校、医院、餐厅、住宅小区等用户也可以自定义标签。请求调度层管理对 Overpass API 的请求处理分片、限流、重试、超时。核心逻辑是把大范围查询拆成多个小范围查询避免单次请求数据量过大导致超时。数据解析层把 Overpass API 返回的 JSON 数据解析成几何对象。OSM 的数据模型是节点node、路径way、关系relation三种需要根据标签和成员关系重建几何。输出层把解析后的几何和属性写成 GeoJSON 文件支持按类别分文件、按区域分文件属性字段做标准化映射。技术栈上我选了 Python原因是地理数据处理生态成熟shapely 处理几何、geopandas 做空间操作、requests 发请求都是现成的。没有用异步框架因为 Overpass API 本身有速率限制并发太高反而容易被封用同步加队列控制节奏更稳。2.3 Overpass QL 的封装策略Overpass QL 的语法确实劝退举个例子查某个区域内的所有学校[out:json][timeout:60]; area[name杭州市]-.searchArea; ( node[amenityschool](area.searchArea); way[amenityschool](area.searchArea); relation[amenityschool](area.searchArea); ); out body; ; out skel qt;这段查询的意思是先找到“杭州市”这个区域然后在这个区域内查所有标签为 amenityschool 的节点、路径和关系最后输出完整的几何信息。对于不熟悉的人来说光是理解 area、node、way、relation 这几个概念就要花不少时间。我的封装思路是把区域名称和类别做成配置项用户只需要填“杭州市”和“学校”工具自动生成上面的查询。区域名称的匹配我用的是 OSM 的 Nominatim 服务做地理编码把中文名称转成 OSM 的 area ID。类别方面我整理了一份常见 POI 类别到 OSM 标签的映射表比如“学校”对应 amenityschool“餐厅”对应 amenityrestaurant“住宅小区”对应 landuseresidential 或 placeneighbourhood。这里有个细节要注意OSM 的标签体系是社区约定的同一个事物可能有多种标签方式。比如“大学”可能是 amenityuniversity也可能是 amenitycollege。我在映射表里对每个类别都列了多个候选标签查询时用正则或者多条件 OR 来覆盖。3. 核心细节解析与实操要点3.1 Overpass API 实例的选择与限流应对Overpass API 有多个公共实例主实例是 overpass-api.de还有 kumi.systems、overpass.openstreetmap.ru 等镜像。不同实例的负载和限流策略不一样主实例最稳定但高峰期经常排队镜像实例响应快但偶尔会挂。我的策略是配置多个实例按优先级轮询。请求失败或者超时自动切换到下一个实例。实测下来主实例适合跑大查询镜像实例适合跑小查询和测试。限流方面公共实例一般限制每秒 1-2 个请求单次查询超时 60-180 秒不等。我的调度层做了两件事一是请求间隔控制默认每个请求之间 sleep 1 秒二是查询分片把大范围拆成小范围。比如查全国所有大学直接一个查询肯定超时我按省份拆成 34 个查询每个省份再按城市拆分单次查询的数据量控制在几千条以内。注意不要试图用多线程或者异步并发去压榨公共实例被临时封禁 IP 是小事影响整个社区的使用体验才是大问题。OSM 的公共资源靠大家自觉维护。3.2 OSM 数据模型到 GeoJSON 的转换逻辑OSM 的数据模型跟 GeoJSON 不是一一对应的。OSM 有三种基本元素节点node一个经纬度点对应 GeoJSON 的 Point。路径way一串有序节点的连线可能是开放路径如道路或闭合路径如建筑轮廓闭合路径对应 GeoJSON 的 Polygon。关系relation多个节点、路径或关系的组合用于表示复杂几何如多边形的外环和内环multipolygon对应 GeoJSON 的 MultiPolygon。转换的难点在关系类型。一个 multipolygon 关系里成员路径分“外环outer”和“内环inner”需要正确组装成带孔洞的多边形。如果组装错了几何就会自相交或者孔洞跑到外面去。我的处理逻辑是先用 Overpass 的out body; ; out skel qt;拿到完整的成员信息然后用 shapely 的 polygon 组装。外环按顺序拼接内环作为孔洞。如果外环不闭合自动首尾相连。如果多个外环生成 MultiPolygon。这里有个坑OSM 里有些关系的成员路径方向是乱的直接拼接会导致几何异常。我的做法是用 shapely 的polygonize或者linemerge先做预处理再判断闭合性。实测下来90% 以上的关系能正确转换剩下的 10% 主要是数据本身有问题比如成员缺失这种就记录到日志里跳过。3.3 属性字段的标准化映射OSM 的标签是键值对形式比如name浙江大学、amenityuniversity、addr:city杭州。这些标签直接输出到 GeoJSON 的 properties 里也能用但字段名不统一做分析的时候很麻烦。我做了一层标准化映射把常用字段转成统一的英文名OSM 标签标准字段名说明namename名称name:zhname_zh中文名称amenitycategory类别addr:citycity城市addr:districtdistrict区县addr:streetstreet街道addr:housenumberhousenumber门牌号phonephone电话websitewebsite网址opening_hoursopening_hours营业时间原始标签我也没有丢弃全部保留在osm_tags字段里方便需要的时候回溯。这样既保证了标准化字段的易用性又不丢失原始信息。3.4 坐标系与几何精度处理OSM 的数据是 WGS84 坐标系EPSG:4326GeoJSON 标准也是 WGS84所以不需要做坐标系转换。但如果你要跟国内的商业地图数据做叠加分析就需要转到 GCJ-02 或者 BD-09这个转换我在工具里没有内置因为涉及合规问题建议在 QGIS 里用插件处理。几何精度方面OSM 的节点精度一般在米级对于城市尺度的分析足够用。但如果做建筑级别的分析可能会发现边界不够贴合。这个不是工具能解决的是数据源本身的精度限制。提示如果你的分析需要高精度边界建议用 OSM 数据做初筛再对重点区域用商业地图或实地采集做补充。4. 完整实操流程与关键环节实现4.1 环境准备与依赖安装先说一下环境。Python 3.8 以上就行我用的 3.10。依赖库不多pip install requests shapely geopandas pandasrequests 发 HTTP 请求shapely 处理几何geopandas 做空间数据操作和文件输出pandas 处理属性表。如果你要用 Nominatim 做地理编码requests 就够了不需要额外的库。Nominatim 是 OSM 的地理编码服务可以把“杭州市”这样的地名转成 OSM 的 area ID。但 Nominatim 也有使用政策要求每秒不超过 1 个请求并且必须设置 User-Agent 标识你的应用。我在工具里默认加了 1 秒的请求间隔和自定义 User-Agent。4.2 区域范围获取与查询分片第一步是把目标区域转成 OSM 的 area ID。用 Nominatim 的搜索接口import requests import time def get_area_id(place_name): url https://nominatim.openstreetmap.org/search params { q: place_name, format: json, limit: 1, polygon_geojson: 0 } headers {User-Agent: MyAOITool/1.0} resp requests.get(url, paramsparams, headersheaders) time.sleep(1) data resp.json() if data: return data[0][osm_id], data[0][osm_type] return None, None返回的 osm_id 和 osm_type 用来构建 Overpass 查询里的 area。注意 Nominatim 返回的 area ID 跟 Overpass 里的 area ID 有个转换关系如果 osm_type 是 relationarea ID 3600000000 osm_id如果是 wayarea ID 2400000000 osm_id。这个转换规则是 OSM 内部约定的不转换的话查询会找不到区域。拿到 area ID 之后构建 Overpass 查询。以查学校为例def build_query(area_id, category_tags): tag_filters [] for tag in category_tags: key, value tag.split() tag_filters.append(fnode[{key}{value}](area.searchArea);) tag_filters.append(fway[{key}{value}](area.searchArea);) tag_filters.append(frelation[{key}{value}](area.searchArea);) query f [out:json][timeout:180]; area({area_id})-.searchArea; ( {.join(tag_filters)} ); out body; ; out skel qt; return query这个查询会返回区域内所有匹配的节点、路径和关系以及它们的完整成员信息。out body;输出元素的基本信息和标签;递归输出成员out skel qt;输出成员的几何信息。如果区域太大比如查全国直接一个查询肯定超时。我的分片策略是按行政区划层级拆分全国拆成省省拆成市市拆成区县。每个区县单独查询结果合并。这样单次查询的数据量可控超时概率大大降低。4.3 请求发送与重试机制请求发送这块核心是处理超时和限流。我的实现是一个带重试的请求函数import requests import time OVERPASS_INSTANCES [ https://overpass-api.de/api/interpreter, https://overpass.kumi.systems/api/interpreter, https://overpass.openstreetmap.ru/api/interpreter ] def query_overpass(query, max_retries3): for attempt in range(max_retries): for instance in OVERPASS_INSTANCES: try: resp requests.post( instance, data{data: query}, timeout200 ) if resp.status_code 200: return resp.json() elif resp.status_code 429: time.sleep(10) continue elif resp.status_code 504: time.sleep(5) continue except requests.exceptions.Timeout: continue except requests.exceptions.RequestException: continue time.sleep(5) return None逻辑是每个实例试一遍遇到 429限流等 10 秒遇到 504超时等 5 秒所有实例都失败就等 5 秒进入下一轮重试。最多重试 3 轮。实测下来95% 以上的查询能在前两轮成功。注意如果你的查询数据量特别大建议把 timeout 参数调大同时把查询范围缩小。Overpass 的 timeout 是服务端超时不是客户端超时客户端 timeout 要设得比服务端大。4.4 数据解析与几何重建拿到 Overpass 返回的 JSON 后解析成 GeoJSON。返回的数据结构是elements数组每个元素有typenode/way/relation、id、tags、nodesway 的节点列表、membersrelation 的成员列表。先建索引def parse_elements(data): nodes {} ways {} relations {} for elem in data[elements]: if elem[type] node: nodes[elem[id]] elem elif elem[type] way: ways[elem[id]] elem elif elem[type] relation: relations[elem[id]] elem return nodes, ways, relations然后重建几何。节点直接转 Pointfrom shapely.geometry import Point, LineString, Polygon, MultiPolygon def node_to_geometry(node): return Point(node[lon], node[lat])路径转 LineString 或 Polygondef way_to_geometry(way, nodes): coords [(nodes[nid][lon], nodes[nid][lat]) for nid in way[nodes] if nid in nodes] if len(coords) 2: return None if coords[0] coords[-1] and len(coords) 4: return Polygon(coords) return LineString(coords)关系转 MultiPolygon 是最复杂的。需要区分 outer 和 inner 成员分别拼接def relation_to_geometry(relation, ways, nodes): outer_ways [] inner_ways [] for member in relation[members]: if member[type] ! way: continue way ways.get(member[ref]) if not way: continue coords [(nodes[nid][lon], nodes[nid][lat]) for nid in way[nodes] if nid in nodes] if len(coords) 2: continue if member[role] outer: outer_ways.append(coords) elif member[role] inner: inner_ways.append(coords) # 拼接 outer 和 inner构建多边形 # 这里省略具体的拼接逻辑核心是用 shapely 的 polygonize 或手动拼接 ...实际代码里我用了一个更稳健的方法把所有 outer 和 inner 的线段收集起来用 shapely 的polygonize生成多边形再根据包含关系判断哪些是孔洞。这个方法能处理大部分情况包括成员顺序错乱的问题。4.5 输出 GeoJSON 与字段标准化最后一步是输出。用 geopandas 构建 GeoDataFrame写 GeoJSONimport geopandas as gpd def export_geojson(features, output_path): gdf gpd.GeoDataFrame(features, crsEPSG:4326) gdf.to_file(output_path, driverGeoJSON)features 是一个列表每个元素是{geometry: ..., properties: {...}}。properties 里放标准化后的字段和原始 OSM 标签。输出的时候我按类别分文件比如schools.geojson、restaurants.geojson方便后续按需加载。如果数据量特别大还可以按区域分文件比如hangzhou_schools.geojson。提示GeoJSON 文件超过 100MB 之后很多软件打开会卡。建议按类别或区域拆分单个文件控制在 50MB 以内。5. 常见问题与排查技巧实录5.1 查询返回空结果怎么办这是最常见的问题。可能的原因有几个第一区域名称匹配错了。Nominatim 对中文地名的匹配有时候不准比如“杭州市”可能匹配到“杭州”这个 relation也可能匹配到某个街道。解决办法是在 Nominatim 返回的结果里检查display_name和osm_type确认是你要的区域。如果不对可以加上省份前缀比如“浙江省杭州市”。第二标签写错了。OSM 的标签是大小写敏感的amenityschool和amenitySchool不一样。而且有些类别在 OSM 里用的不是 amenity 键比如“住宅小区”用的是 landuseresidential。解决办法是先用 Overpass 的out tags;查一下目标区域里有什么标签再决定用哪个。第三区域 ID 转换错了。前面说过Nominatim 返回的 osm_id 需要转换成 Overpass 的 area ID。如果忘了转换查询会找不到区域返回空结果。5.2 查询超时怎么优化超时的核心原因是单次查询数据量太大。优化方向有三个一是缩小查询范围。全国拆省省拆市市拆区县。拆到区县级别单次查询的数据量一般不会超过几万条Overpass 能在 60 秒内返回。二是减少输出字段。out body;会输出所有标签如果只需要名称和类别可以用out tags;只输出标签不输出几何。但这样就没法重建几何了适合只需要 POI 点数据的场景。三是用更精确的标签过滤。node[amenityschool]比node[amenity]精确得多返回的数据量也小得多。尽量用键值对过滤不要只用键。5.3 几何重建失败的排查几何重建失败一般表现为多边形自相交、孔洞跑到外面、几何为空。排查步骤先检查原始数据。用 Overpass 的out meta;输出元数据看看关系的成员是否完整。如果成员缺失说明数据本身有问题跳过即可。再检查拼接逻辑。外环和内环的拼接顺序很重要如果外环没有按顺序拼接多边形会扭曲。我的做法是用 shapely 的linemerge先把相邻的线段合并再判断闭合性。最后检查坐标系。OSM 的经纬度是 WGS84如果误用了其他坐标系几何会偏移。确认 GeoDataFrame 的 crs 设置为 EPSG:4326。5.4 常见问题速查表问题现象可能原因解决方法返回空结果区域名称匹配错误检查 Nominatim 返回的 display_name加省份前缀返回空结果标签大小写错误确认 OSM 标签的大小写用 out tags 查实际标签返回空结果area ID 未转换relation 加 3600000000way 加 2400000000查询超时数据量太大按行政区划拆分查询范围查询超时标签过滤太宽泛用键值对精确过滤避免只用键几何自相交成员顺序错乱用 linemerge 预处理或手动排序几何为空成员缺失检查原始数据跳过不完整的关系文件太大打不开单文件数据量过大按类别或区域拆分文件5.5 几个实操中踩过的坑第一个坑Overpass 的area查询对某些区域不生效。比如查“浦东新区”Nominatim 可能返回一个 relation但 Overpass 里这个 relation 没有对应的 area。解决办法是用way或relation的name标签直接过滤而不是用 area。比如way[name浦东新区]但这样只能查到名称完全匹配的覆盖不全。更好的办法是用边界框bbox查询先拿到区域的边界框再用 bbox 过滤。第二个坑OSM 的 POI 数据在国内的覆盖度参差不齐。一线城市的餐饮 POI 可能只有商业地图的十分之一但学校和医院这类公共设施的覆盖度还不错。如果你的项目对 POI 密度要求高建议 OSM 数据做底商业地图做补充。第三个坑GeoJSON 的属性字段里如果有中文某些软件打开会乱码。解决办法是确保文件编码为 UTF-8并且在 GeoJSON 的 properties 里避免使用特殊字符。geopandas 默认输出 UTF-8一般没问题但如果你手动拼接 JSON要注意编码。第四个坑Overpass API 的公共实例在高峰期响应很慢有时候一个查询要等好几分钟。我的做法是把不着急的查询放到晚上跑或者用多个实例轮询。如果你有服务器资源可以自己搭一个 Overpass 实例数据同步一次之后查询速度飞快但维护成本不低适合高频使用的场景。6. 数据质量评估与后续扩展方向6.1 怎么判断拿到的数据能不能用拿到 GeoJSON 之后别急着做分析先做一轮质量检查。我一般看三个指标覆盖率拿目标区域的实际 POI 数量做对比。比如你知道某个区有 200 家餐厅OSM 只返回了 30 家那覆盖率就是 15%这个数据做密度分析就不太靠谱。覆盖率低于 30% 的话建议只用来做辅助参考不要作为主要数据源。几何完整率AOI 数据里有多少是完整的多边形有多少是只有中心点的。我的工具会在输出的时候加一个geometry_type字段标记是 polygon 还是 point。如果 polygon 占比低于 50%说明这个区域的 AOI 边界数据质量一般。属性完整率名称、类别、地址这三个核心字段的填充率。如果名称填充率低于 80%说明很多 POI 是匿名节点用起来价值不大。6.2 后续可以怎么扩展这个工具目前只做了基础的数据抓取和格式转换后续可以扩展的方向不少一是增加更多类别的标签映射。OSM 的标签体系非常庞大我目前只整理了 50 多个常见类别还有很多细分领域可以补充比如不同菜系的餐厅、不同类型的医疗设施等。二是增加增量更新机制。OSM 的数据是持续更新的每次全量拉取效率太低。可以用 Overpass 的adiff查询或者时间戳过滤只拉取最近变更的数据做增量合并。三是增加数据可视化预览。抓完数据直接生成一个简单的 HTML 地图预览不用打开 QGIS 就能看数据分布方便快速判断质量。四是支持更多输出格式。除了 GeoJSON还可以输出 Shapefile、CSV带 WKT 几何、GeoPackage 等适配不同的分析工具。五是增加空间分析功能。比如自动计算 POI 密度、生成热力图、做缓冲区分析等把数据抓取和分析打通减少中间环节。6.3 关于合规使用的几点提醒OSM 的数据是 ODbL 协议允许自由使用、分发和修改但要求署名并以相同协议共享衍生作品。如果你把数据用于商业产品需要在产品里注明数据来源是 OpenStreetMap 贡献者。这个不是可选项是协议要求。另外Nominatim 和 Overpass 的公共实例都有使用政策Nominatim 要求每秒不超过 1 个请求Overpass 要求合理使用不要滥用。我的工具里默认加了请求间隔也是出于这个考虑。如果你要大规模抓取建议自己搭实例或者联系 OSM 社区申请更高的配额。最后抓取到的数据里可能包含个人信息比如某些 POI 的运营者姓名使用的时候要注意脱敏不要直接对外发布包含个人信息的原始数据。我在实际使用这个工具的过程中最大的体会是开源数据的价值不在于它有多完美而在于它给了你一个不依赖商业服务的底线选项。商业 API 随时可能涨价、改政策、关停服务但 OSM 的数据永远在那里只要你愿意花点时间理解它的规则。这个工具做的事情就是把理解规则的成本降到最低让你能把精力放在分析本身而不是数据获取上。