开头先交代一下背景:我是个常年跟地图数据打交道的爬虫工程师,年初接了个物业数据整理的需求,甲方开口就要全市几千个小区的边界数据,还要能导入GIS系统里看。我翻遍百度地图开放平台的文档,地点检索只给中心点坐标,行政区划接口也只到区县一级,压根没有“小区边界”这么细的能力。后来折腾了两天,绕了个弯子,从百度地图前端项目里把那层边界数据直接“抠”了出来,最后导出了标准的矢量文件。这篇博文就是想把这套“百度地图小区边界爬取”的完整思路、代码和坑记录下来,给同样被边界数据卡住的朋友一条可复现的路。
项目本身说简单也不简单,难点不在HTTP请求,而在“边界数据藏在哪”“返回的boundary怎么解析”“坐标怎么转换”这三件事。适合谁看呢?做智慧城市、物业可视化、房产分析、地图数据整理,或者纯粹想学怎么从Web应用里提取结构化数据的人,这篇应该都能用到。全程我用Python跑通,代码量不大,依赖也就requests一个库,剩下全是思路。
1. 先搞清楚边界数据藏在哪:官方API到底给不给小区边界
搞爬虫最忌讳一上来就写代码,连数据源在哪都没想明白。我先花时间把百度地图开放平台的能力盘了一遍,结论就是:官方对外开放的接口,确实没有一个能直接返回小区的多边形边界。
1.1 官方能力盘点:地点检索、行政区划、地理编码
百度地图开放平台的Web服务API大致分几类:地点检索(Place API)、行政区划区域检索、地理编码、路线规划、逆地理编码等。跟“小区边界”沾边的只有两个:
- 地点检索:按关键词在某个城市内搜索POI,能返回小区名称、地址、经纬度,但那个经纬度是POI的展示中心点。小区可能很大,一个点没法表示边界轮廓,甲方要的是能落到GIS里算面积的多边形。
- 行政区划区域检索:网上有人介绍过,用它可以查省、市、区县、乡镇的边界数据,返回地理范围和多边形坐标。但试过就知道,它的粒度最多到乡镇街道,再往下到小区、楼盘这种“非行政单位”基本拿不到。
所以可以断言:官方API在“小区边界”这个粒度上处于盲区。不是说没有这个数据——百度地图前端在搜索小区、渲染小区轮廓时明明有边界线在页面上,只是这个能力没有走对外开放的Web服务API拿,而是由前端调用了一套“对应用开放”的内部数据接口。
1.2 三个常见的“弯道方案”为什么都不靠谱
知道官方没有之后,很多人第一反应是换路子,我总结三个最常见的坑:
第一,去爬地图瓦片。就是把网页上显示的地图切成几万张小图片下载下来,再想办法矢量化。做过几次这种活,零件便宜人力贵,图片拼接、坐标对齐、边缘提取每一步都容易翻车,而且最终拿到的还是栅格图,不是坐标串,离“边界”差得远。
第二,拿中心点画圆代替边界。确实能出图,但小区轮廓千奇百怪,一个圆或者一个矩形完全不能用,面积计算误差太大,甲方一眼就能看出来是糊弄事。
第三,买第三方商业数据。数据商的边界数据一方面是贵,另一方面更新未必及时,一个小项目就为几百个小区掏几万块钱不划算。
最终我发现,网页端搜索小区时,地图上会“框”出一个轮廓,这个轮廓位置的数据就是我要的东西。于是方案定了:构造请求,从百度地图前端正在使用的数据接口里把边界坐标串拉出来。这一步是整个项目最关键的分水岭。
2. 技术选型与前置知识:请求接口而不是渲染页面
确定数据源在网页前端后,还有一个选择要做:是模拟浏览器点来点去,还是直接HTTP请求后端接口。我的答案是后者。
2.1 为什么用 Python+requests,而不是Selenium
凡是搞过抓取的人都懂,Selenium这类浏览器自动化方案“能做所有事,但做任何事都慢”。启动浏览器、加载地图JS、等待瓦片渲染,一个小区可能要卡好几秒,爬几千个小区得跑到天荒地老。而且地图页面动不动就触发轨迹检测、滑块验证,反而更容易被封。
真正正确的做法是打开浏览器开发者工具(F12),切到Network面板,筛选XHR请求,然后在页面里搜索一个小区名。观察几次请求,会发现有个别请求特别“眼熟”:它返回的JSON里包含一个叫boundary或者类似的字段,值是一长串用分号隔开的经纬度。这意味着后台根本不用计算,它把边界数据已经算好了,只是序列化成字符串发给前端。
你要做的事情很简单:用Python模拟这个请求,拿到同样的JSON,自己解析。省去了页面渲染的性能损耗,也绕开了操作浏览器的复杂性。技术上就是requests一次GET请求,几毫秒完成一个小区,比Selenium快几个数量级。
2.2 坐标系与坐标转换的基础
搞地图数据躲不开坐标系问题,这个不提前说清楚,后面解析完发现边界飘到海里别怪我没提醒。百度地图对外输出的大部分坐标不是标准GPS坐标,而是“百度坐标系”(简称BD09)。它是在火星坐标系(GCJ02)基础上又做了一次二次加密偏移,跟你在GPS设备里拿到的WGS84坐标相比,往往偏移几十米到几百米。
所以爬下来的边界坐标直接用,在大部分地图或GIS软件里都会发现边界整体往东北方向偏出去一截。如果要跟天地图、GPS采集数据、谷歌地球数据叠加,就必须做坐标转换。转换算法本身是公开的,我后面会给完整Python实现:BD09先转GCJ02,GCJ02再迭代转WGS84。这个步骤不能省,也别想着让GIS软件自动纠偏,很多软件不认BD09。
2.3 环境准备与数据落地的格式约定
这次项目只需要Python3环境,核心依赖就一个requests,解析JSON用标准库json就够了。我习惯再用os、time、random这几个标准库做文件写入和频率控制,不需要装重型框架。
落地格式建议直接写GeoJSON。为什么不是CSV?小区边界本质是面数据,CSV只能存点或者存一长串文本,后续导入QGIS、ArcGIS、Leaflet都麻烦。GeoJSON是Web GIS的事实标准,一个文件里能同时存名称、城市、地址、边界坐标系,免转换直接拖进地图工具。另外再落一份CSV用来快速预览名称和经纬度范围,算是工作习惯。
3. 核心实现:定位小区、拉取边界、解析导出
这章是实操重头戏,我会按三步讲清楚整个流程,最后给一份能直接改改就用的完整脚本。每一步我都会解释为什么这么写,免得你复制了代码但不会应对变化。
3.1 第一步:用地点检索确定小区精确位置
虽然前面吐槽过地点检索只给中心点,但中心点并不是没用——它是后续请求边界数据的前置参数。你要先确定这个小区在地图库里的唯一标识,通常就是经纬度坐标或POI id。
我们可以调用官方对外开放的地点检索接口,这一步合法且稳定,目的是拿到目标小区的地图坐标和名称标准化形式。请求URL是:
https://api.map.baidu.com/place/v2/search核心参数如下:
- query:要搜的小区名,比如“中粮海景壹号”
- tag:固定为“小区”,能过滤掉同名餐饮、写字楼
- region:城市名,比如“上海”,或者城市adcode
- output:json
- ak:你在百度地图开放平台申请的密钥
- scope:2,返回更详细的POI信息
- page_size:10,同一城市同名小区可能有多个,多拿几条备选
Python代码很简单:
import requests AK = "你的百度地图开放平台AK" def search_place(city, keyword): url = "https://api.map.baidu.com/place/v2/search" params = { "query": keyword, "tag": "小区", "region": city, "output": "json", "ak": AK, "page_size": 10, "scope": 2, } resp = requests.get(url, params=params, timeout=10) data = resp.json() if data.get("status") != 0: raise RuntimeError(f"地点检索失败: {data.get('message')}") return data.get("results", [])返回的results数组里,每个元素有name、location.lng、location.lat。选取规则我一般这样:优先选名称完全匹配且location附近有小区边界的那个;如果多个同名小区,按城市区划判断,比如用户只关心浦东南路那个。确定了中心点之后,把lng和lat记下来,下一步要把它拿出来当参数。
3.2 第二步:构造边界请求并处理返回结构
中心点有了,接下来是边界数据。这部分我建议你打开百度地图网页版,在开发者工具的Network面板里过滤“boundary”关键字,搜索一个小区名,观察返回数据里包含边界坐标的那个请求。不同时期的前端项目使用的接口域名、参数名都会变,但交互模式几乎一致——GET请求,返回JSON,JSON里有边界坐标串。
我不建议直接把某个固定URL硬编码进脚本,因为这类接口一旦变动,你的代码马上失效。正确做法是把抓包看到的URL和参数配置化。演示代码里我用一个占位URL,你把它替换成自己抓包得到的实际地址即可:
BOUNDARY_API = "https://your-boundary-api-url.example.com/getboundary" def get_boundary(lng, lat, name): params = { "location": f"{lng},{lat}", "name": name, "ak": AK, "output": "json", } resp = requests.get(BOUNDARY_API, params=params, timeout=15) data = resp.json() return data需要注意返回结构。我见过几种典型形态,封装函数时要兼容:
- 最外层是JSON对象,边界字段名可能是boundary、bounds、boundaryData
- boundary字段值可能直接是一串"经度,纬度"用分号连接
- 也可能在不同JSON层里嵌套了多条边界,比如复杂小区由多个地块组成
- 个别接口返回的boundary带前缀控制信息,例如编码名称或颜色配置,需要用分隔符切开后再解析经纬度
所以拿到响应后别急着split,先把JSON打印出来看结构,这一步永远是第一步。
3.3 第三步:把边界字符串解析成可用坐标
核心解析逻辑是这套流程里最需要写健壮的地方。boundary的典型字符串长这样:
121.491,31.230;121.492,31.231;121.493,31.230;121.492,31.229分号分隔一个点,逗号分隔经纬度。但复杂情况还包含多环、多地块,有些返回里会用竖线“|”分隔多个闭合环,有些会在坐标串前面带“color=xxx;”之类的前缀。我的解析函数写成这样:
def parse_boundary(boundary_str): if not boundary_str: return [] # 1. 去掉可能存在的控制前缀:找到第一个纯经纬度分号组合的起点 import re match = re.search(r'(\d+\.\d+,\d+\.\d+)', boundary_str) if match: boundary_str = boundary_str[match.start():] # 2. 按竖线拆分成多个环(如果没有竖线,只有一个环) rings = [] for part in boundary_str.split('|'): part = part.strip().strip(';') if not part: continue coords = [] for point in part.split(';'): point = point.strip() if not point: continue lng, lat = point.split(',') coords.append([float(lng), float(lat)]) if len(coords) >= 3: # 少于3个点无法构成面,直接丢弃 rings.append(coords) return rings这段解析有几个细节值得说:正则定位纯坐标起点,是为了防前缀;竖线拆环是为了防多地块小区;长度小于3的环直接丢,是为了防脏数据污染。每次地图接口返回的boundary我都建议用这个函数过一遍,你能省掉后续一大半数据清洗时间。
3.4 完整脚本:一条命令跑完
把上面三段逻辑串起来,再加一个GeoJSON导出函数,就是一套完整脚本。GeoJSON的Polygon结构是一个三维数组:外环是由若干坐标点构成的闭合数组,坐标顺序遵循“右旋规则”,即外环应该是逆时针方向,不过多数GIS软件对方向不敏感,后续可以统一纠正。
import json import time import random import requests AK = "你的百度地图开放平台AK" BOUNDARY_API = "https://your-boundary-api-url.example.com/getboundary" def search_place(city, keyword): url = "https://api.map.baidu.com/place/v2/search" params = { "query": keyword, "tag": "小区", "region": city, "output": "json", "ak": AK, "page_size": 10, "scope": 2, } resp = requests.get(url, params=params, timeout=10) data = resp.json() if data.get("status") != 0: raise RuntimeError(f"地点检索失败: {data.get('message')}") return data.get("results", []) def get_boundary(lng, lat, name): params = { "location": f"{lng},{lat}", "name": name, "ak": AK, "output": "json", } resp = requests.get(BOUNDARY_API, params=params, timeout=15) return resp.json() def parse_boundary(boundary_str): import re if not boundary_str: return [] match = re.search(r'(\d+\.\d+,\d+\.\d+)', boundary_str) if match: boundary_str = boundary_str[match.start():] rings = [] for part in boundary_str.split('|'): part = part.strip().strip(';') if not part: continue coords = [] for point in part.split(';'): point = point.strip() if not point: continue lng, lat = point.split(',') coords.append([float(lng), float(lat)]) if len(coords) >= 3: rings.append(coords) return rings def build_geojson(features): return { "type": "FeatureCollection", "features": features, } def main(): city = "上海" keyword = "中粮海景壹号" results = search_place(city, keyword) if not results: print("未找到该小区") return result = results[0] lng = result["location"]["lng"] lat = result["location"]["lat"] name = result["name"] data = get_boundary(lng, lat, name) boundary_str = data.get("boundary", "") rings = parse_boundary(boundary_str) if not rings: print("未解析到边界") return feature = { "type": "Feature", "properties": { "name": name, "city": city, "center": [lng, lat], }, "geometry": { "type": "Polygon", "coordinates": rings, }, } geojson = build_geojson([feature]) with open(f"{name}.geojson", "w", encoding="utf-8") as f: json.dump(geojson, f, ensure_ascii=False, indent=2) print(f"已导出: {name}.geojson,共 {len(rings)} 个环") time.sleep(random.uniform(0.5, 1.5)) if __name__ == "__main__": main()这个脚本已经能跑通单个小区的边界导出。规模化处理的时候,把所有小区名放在一个列表里,外部套一层循环,每次循环之间睡眠0.5到1.5秒,避免请求频率过高。批量过程中建议每次导出后把结果追加写进一个汇总GeoJSON,避免程序中断丢进度。
4. 数据验证与清洗:坐标转换和可视化检查
爬完数据不等于项目交付,我见太多人卡在“爬到了坐标但不知道坐标准不准”这一步。边界数据落地后,一定要做验证和清洗,否则给你老板看的时候边界偏出一公里,场面会很尴尬。
4.1 导出GeoJSON并修复常见脏数据
解析出来的环在写文件前,建议做几项清洗:
- 闭合检查:多边形首尾两点应该相同,如果解析回来的数据首尾不一致,要手动把第一个点追加到末尾,否则很多GIS软件会报拓扑错误。
- 自相交检查:部分复杂小区的boundary可能包含洞(内部有湖水、绿地),这类数据在简单Polygon结构里表达不了,建议先按闭合环导出,后续在QGIS里人工处理。
- 精度控制:boundary字符串里经纬度可能给到六位甚至更多小数,浮点数本身没影响,但文件体积会偏大。统一保留六位小数,大概是0.1米精度,完全足够,还能减小文件体积。
GeoJSON文件内部建议显式声明坐标系信息:
"crs": { "type": "name", "properties": { "name": "urn:ogc:def:crs:EPSG::4547" } }但说实话,很多在线工具不认这个字段,更多时候是坐标转换后在文件命名或属性里备注“WGS84”或“BD09”字样。如果你不转坐标,务必在属性表里加一个coord_type字段并填BD09,省得过两个月自己都不记得这份数据底细。
4.2 坐标转换:BD09到WGS84
如果边界数据要跟GPS采集的数据或者其他标准地图叠加,就需要坐标转换。这里给出一个经过项目验证的Python实现思路,分为两步:BD09先转GCJ02,再把GCJ02转成WGS84。网上流传的wandergis/coordTransform_py库就是这个思路,我这里把它拆开讲,方便你按需调整。
import math x_pi = 3.14159265358979324 * 3000.0 / 180.0 a = 6378245.0 ee = 0.00669342162296594323 def bd09_to_gcj02(lng, lat): x = lng - 0.0065 y = lat - 0.006 z = math.sqrt(x * x + y * y) - 0.00002 * math.sin(y * x_pi) theta = math.atan2(y, x) - 0.000003 * math.cos(x * x_pi) gcj_lng = z * math.cos(theta) gcj_lat = z * math.sin(theta) return [gcj_lng, gcj_lat] 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 gcj02_to_wgs84(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) mglat = lat + dlat mglng = lng + dlng return [lng * 2 - mglng, lat * 2 - mglat] def bd09_to_wgs84(lng, lat): gcj = bd09_to_gcj02(lng, lat) return gcj02_to_wgs84(gcj[0], gcj[1])转换时遍历坐标串里所有点,逐点转换,最后重新封装成环。注意经纬度顺序,前面函数返回的数组我按[lng, lat]习惯写,别搞混了。
4.3 用QGIS等工具检查边界是否贴合
清洗和转换完成后,一定要做可视化检查。最省事的办法是把GeoJSON拖进QGIS,再叠加官方底图或者天地图卫星图,肉眼观察边界是否贴合小区真实轮廓。
我实际操作中遇到过三种情况:
- 完全贴合:说明坐标本来就准,直接交付。
- 整体偏移但形状不变:说明坐标系没转对,检查是不是BD09直接当WGS84用了。解决办法是走一遍坐标转换,或者反过来从WGS84转GCJ02。
- 部分小区缺边界或边界粗糙:这是数据源本身的问题,可能是接口返回的是简化的边界(顶点数量少,形状失真),也可能是小区搜索落到了错误的POI上。此时要回到第一步,调整query关键词,用“楼盘名”“小区名+期数”重新试。
还有些隐藏技巧:输出GeoJSON后可以用在线工具转成KML,直接在谷歌地球里打开,跟卫星影像叠加看。这个检查方法简单粗暴,非GIS工程师也能一眼看出边界对不对。
5. 实战中绕不开的坑:频率控制、接口变更与合规
做地图数据抓取,真正浪费时间的往往不是主流程,而是各种边界情况。按我这边的经验整理一份避坑清单。
5.1 高频问题速查表
| 问题现象 | 可能原因 | 处理方式 |
|---|---|---|
| 地点检索返回空数组 | region城市名不标准,或确实查无此小区 | 用城市adcode替代region,去掉“小区”后缀重试 |
| 地点检索报“APP AK校验失败” | AK没有开通Place API服务,或配额耗尽 | 登录百度地图开放平台,给应用勾选对应服务;查配额余量 |
| 边界接口返回空字符串 | 该小区名匹配不到多边形数据 | 换搜索关键词,加“楼盘”或“花园”等后缀,手工验证 |
| boundary带了一堆“color=xxx”前缀 | 接口返回了样式控制信息 | 用正则把坐标起始点提取出来再解析 |
| 爬了几百个后被限流 | 请求频率过快,IP被临时封禁 | 降低QPS到1以内,随机睡眠,必要时用代理池(注意合规) |
| 坐标整体偏移几百米 | 重灾区:把BD09坐标直接当WGS84用 | 必须走BD09→GCJ02→WGS84两段转换,不能省 |
这张表基本覆盖了我踩过的所有坑。第一条最容易忽略:地点检索的region参数如果传“上海市”,有时反而返回空,传adcode“021”反而稳,建议两种都试。
5.2 请求频率、UA和重试策略
地图服务接口并不欢迎被打爆。即使你的AK合法,高频请求也会触发限制。我自己的策略是:单线程,每请求之间sleep 0.5到1.5秒随机值,这样一分钟最多几十次,既不会让自己陷入反爬泥潭,也照顾到了服务器的压力。
请求头建议加一个常见的浏览器User-Agent,有些网关对裸Python请求识别得更严格。示例:
headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36" } resp = requests.get(url, params=params, headers=headers, timeout=15)重试策略也有讲究。对状态码5xx或者超时,指数退避重试三次;对4xx(参数错误、鉴权失败)不要重试,重试只会浪费配额。代码里判断一下status字段,不是网络错误就不动。
5.3 接口变化与合规使用的边界
最后说点实在的。这类边界接口并不是百度地图官方声明中“鼓励”的采集方式,它随时可能改版、加签名、下线。所以我前面才啰嗦地让你抓包确认URL,而不是给你一个所谓“永久有效”的接口。脚本要写成配置驱动的形式,接口变了只改配置,不用重写逻辑。从工程上来讲,这比什么花哨的重试都重要。
数据合规方面同样要放在心上。采集范围建议只限公开可见的小区轮廓信息,用于正当的工程分析、学术研究或者自己业务系统的底图制作,不要拿去批量售卖、恶意爬取全量,也不要存太多超出需求的字段。控制采集频率,尊重网站正常运行秩序,这是爬虫从业者的基本底线。我在项目交付时还会在数据库建表注释里写明“坐标来源:百度地图公开前端接口,采集日期2025年”,既方便溯源,也是留存合规证据。
整体项目做完,我最深的体会是:地图边界爬取的技术门槛其实不高,真正值钱的是对数据结构、坐标系和接口背后的理解。整个过程踩过不少坑,但把方法固化下来之后,再遇到类似的“官网没有、前端有”的数据需求,就都能举一反三地应对了。下次再有人问我怎么爬那个什么什么地图的小区边界,我大概会把这篇文章甩过去,然后补一句:先开F12抓包,别在网上找过期的代码。