☰
Python爬取百度地图小区边界数据:从POI抓取到GeoJSON入库
2026/10/5 8:57:42 网站建设 项目流程

“把全城所有小区的边界导成一份 GeoJSON,能不能干?”这是半个月前一位做城市更新调研的朋友发给我的需求。后来又有几个做房产数据的人问了类似的问题,我干脆把这套流程完整记录了一遍。整个过程下来,最深的体会是:小区边界爬取的难点从来不是“爬”,而是“边界”这两个字。

百度地图开放平台能直接拿到的,是 POI 点、地址、行政区边界,但小区围栏这种多边形轮廓,官方接口基本不会直接给。所以你只能分两步走:先把小区的位置点弄全,再去想办法把轮廓多边形抠出来。这篇就围绕这个主线展开,内容包括开放 API 的用法、网页版抓包思路、坐标转换、清洗入库,以及我把一个城市跑下来之后踩过的坑。

1. 先搞清楚你要的“小区边界”是哪种边界

很多新手会把话里的“边界”理解成百度地图页面上那条圈出来的蓝色线条,但真实工作流里,至少有三种不同级别的数据,千万别混为一谈。

1.1 点、面、行政区划是三种东西

第一种是 POI 点,也就是小区门口或中心点的一个坐标,通常包含名称、地址、经纬度、唯一标识 uid。百度地图 open API 里的 place 检索返回的就是这类,只能知道“这里有个小区”,不知道小区占多大地方。

第二种是行政区划边界,比如某个区、某个街道的边界多边形,这是官方对外开放的,有明确接口,结果是一串经纬度点围成的面。

第三种才是真正让人头疼的“小区围合轮廓”,也就是南门到北门、围墙到围墙,在百度地图渲染层里那个不规则的多边形。这类数据多数要从百度地图网页版的渲染接口里“抠”,接口不公开,而且参数经常带签名,需要靠抓包动态分析。

清楚你要哪一种,后面的技术选型完全不同。官方 API 只能覆盖前两种情况,第三种必须有爬虫思路介入。

1.2 百度地图各层接口到底开放了什么

我把这次实践中用到的数据来源整理成了一张表,方便你判断边界数据从哪里下手。

数据类型来源返回内容公开程度
小区 POI 点place/v2/search名称、地址、中心点坐标、uid公开,有配额
POI 详情place/v2/detail电话、标签、更多属性公开,有配额
行政区边界place/v2/area/search区县或街道的多边形点串公开
小区渲染轮廓百度地图网页版内部接口高亮区域的多边形点串不公开,需抓包

看清楚这张表之后,你大概能理解我的策略:用公开接口解决“有哪些小区”,用网页版接口解决“边界长什么样”,最后把两部分按 uid 或名称拼接。

2. 开工前的地基:AK、坐标系和依赖

动手前有几个基础问题必须处理,否则后面会反复返工。

2.1 申请服务端 AK 并配置 IP 白名单

去百度地图开放平台创建应用,类型选择“服务端”,这样会拿到一组 AK 和 SK。SK 的作用是给请求做 sn 签名校验,如果只做低频率的本地小规模抓取,可以先不管签名,直接拿 AK 访问所有公开接口。

但我强烈建议你创建完应用之后,马上把出口 IP 加到 IP 白名单里。原因很简单:百度地图开放 API 按访问来源做校验,如果你在服务器上跑,就把服务器出口 IP 加进去;如果在自己电脑上跑,就填宽带出口 IP。填错或漏填会导致请求返回 403 或认证失败,特别容易在深夜调试的时候把人整崩溃。

还要记住:服务端 AK 不要塞进浏览器端代码里,也不要提交到公开仓库。这种 AK 一旦泄露,对方可以拿你的配额随便刷,轻则接口被限流,重则整个账号被封。

2.2 三个坐标系的事,必须一次说清

国内地图数据的坐标是“带偏移”的。GPS 拿到的原始坐标是 WGS84,经过国测局加密后变成 GCJ02,也就是我们常说的火星坐标;百度在这基础上又二次加密,得到 BD09。

百度地图所有公开接口返回的坐标默认是 BD09,你从网页版抓包拿到的点串大概率也是 BD09。如果你要把数据导入 ArcGIS、QGIS,或者和其他 WGS84 数据源做叠加分析,就必须转换。

转换代码我直接贴在最下面这段里,逻辑不算复杂。BD09 转 GCJ02 是固定公式,GCJ02 转 WGS84 用迭代逼近,精度在绝大多数应用场景够用。

import math 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 * 3000 * math.pi / 180) theta = math.atan2(y, x) - 0.000003 * math.cos(x * 3000 * math.pi / 180) return z * math.cos(theta), z * math.sin(theta) def wgs84_to_gcj02(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) return lng + dlng, lat + dlat 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): lng0, lat0 = lng, lat for _ in range(10): glng, glat = wgs84_to_gcj02(lng0, lat0) lng0 -= glng - lng lat0 -= glat - lat return lng0, lat0

2.3 依赖安装和目录规划

这次实战用 Python,核心依赖是 requests、pandas、shapely、pyproj,如果想把 GeoJSON 直接画出来,可以再装 geopandas。Windows 上 geopandas 安装容易出问题,最简单的方式是直接用 conda 安装,一条命令搞定。

conda install -c conda-forge geopandas shapely pyproj

同时建议把工程目录建清楚,因为爬取过程很可能中断要续跑。我会建 data/、cache/、output/ 三个目录,data 存中间抓取结果,cache 存网页版接口返回的原始 JSON,output 存最终清洗后的 GeoJSON 和 SQLite。

3. 第一步:先把全市小区的“点”抓全

在搞边界以前,要先把目标区域的小区点位抓下来。点位列表越全,后面做边界关联时命中率越高。

3.1 关键词和行政区拆解

百度地图 place/v2/search 接口支持 query 和 region 参数,region 可以填城市名,也可以填区县名。如果你直接填城市名搜“小区”,大概率能搜到一批,但这些结果会被一个“每页 20 条、最多翻页到某个上限”的逻辑卡住。

正确的做法是:一级一级拆。先按区县拆,再按关键词拆。因为城市范围太大时,模糊匹配的结果会被其他类型 POI 稀释,比如“某某小区”里混进“工业园区”和“菜市场”的概率会增大。

我在杭州跑的时候,把上城区、拱墅区、西湖区、滨江区、萧山区、余杭区、临平区、钱塘区这些行政区单独循环,每个区下面再搜“小区”“花园”“家园”“公寓”“雅苑”“名苑”“新村”等关键词。关键词怎么加,取决于你想抓的是商品房小区还是老旧小区,否则很容易漏掉。

3.2 分页、限速和断点续跑

下面的脚本是这次抓 POI 的核心骨架,你根据自己的 AK 和行政区列表改一下就能跑。

import requests import time import json AK = "你的AK" REGIONS = ["滨江区", "西湖区", "拱墅区"] # 按实际需要填 QUERIES = ["小区", "花园", "家园", "公寓", "雅苑", "名苑", "新村"] rows = [] for region in REGIONS: for q in QUERIES: for page in range(0, 20): params = { "query": q, "region": region, "output": "json", "ak": AK, "page_size": 20, "page_num": page, "scope": 2, } try: resp = requests.get("https://api.map.baidu.com/place/v2/search", params=params, timeout=10) data = resp.json() except Exception as e: print("请求异常", region, q, page, e) break if data.get("status") != 0: # 状态码非0,常见是配额超限、参数错误或权限失败 print("返回错误", data.get("status"), data.get("message")) break results = data.get("results", []) if not results: break for item in results: rows.append({ "name": item.get("name"), "uid": item.get("uid"), "address": item.get("address"), "lng": item["location"]["lng"], "lat": item["location"]["lat"], "region": region, "query": q, }) # 个人认证的QPS通常不高,1.2秒是保守间隔 time.sleep(1.2) # 用uid去重,因为不同关键词很容易匹配到同一个小区 unique = {} for row in rows: unique[row["uid"]] = row with open("data/communities.json", "w", encoding="utf-8") as f: json.dump(list(unique.values()), f, ensure_ascii=False, indent=2) print("总数:", len(unique))

这里要注意几个典型情况。如果你请求频率太高,接口会返回配额相关的状态码,这时候要停止并发,加长 sleep,甚至可以完整退避一分钟再继续。还有,网络抓取最怕跑到一半挂了。我把原始结果存到 JSON,而不是追着更新 SQLite,这样重跑时可以用 uid 做增量合并,避免重复请求。

3.3 用 UID 补一些公共属性

place/v2/search 返回的字段已经够用,但如果你想看更多,比如电话、标签、营业时间,可以在拿到 uid 后调用 place/v2/detail。我这里用它主要是为了交叉验证,因为同一个 uid 在 detail 接口里返回的地址和标签更准。

def fetch_detail(uid): params = { "uid": uid, "output": "json", "scope": 2, "ak": AK, } resp = requests.get("https://api.map.baidu.com/place/v2/detail", params=params, timeout=10) return resp.json()

有个容易误踩的点:网上很多资料说scope=2会返回更丰富的小区坐标范围,实际上 place/detail 也不会给你边界多边形。它最多给你一个中心点和一些属性字段。所以别在这里耗时间,重点直接进入下一步。

4. 难点攻坚:小区轮廓多边形从哪拿

到这一步,你已经有一个个小区的中心点位了,可以做热力图、可以做点图,但拿不到“边界”。我在这个环节验证了三套方案,每套的复杂度和效果都不一样。

4.1 方案A:从百度地图网页版内部接口拿轮廓,效率最高但依赖抓包

这是我实际最推荐的方式,尤其是你只想要几个区县小区边界时。思路不是靠读文档,而是用浏览器开发者工具观察百度地图网页版在“画出小区边界”的那一瞬间,请求了哪个接口、返回了什么字段。

具体抓包步骤:

  1. 打开 Chrome 无痕窗口,访问 map.baidu.com,按 F12 打开开发者工具,切到 Network 面板并清空请求记录。
  2. 在搜索框输入一个明确的小区名,比如“融创滨江壹号院”,等搜索结果出来。
  3. 点击搜索结果里的目标小区。地图上通常会高亮显示一个多边形轮廓。
  4. 回到 Network 面板,筛选 XHR 和 Fetch 请求。在搜索框里敲boundary、polygon、geo、points这些关键词,逐个观察响应内容。
  5. 找到返回多边形点串的请求后,右键复制响应内容,先存成 JSON,再写程序解析。

这里要提前给你打预防针:百度地图网页版改版很频繁,我上周抓到的接口参数,下周可能就变了。所以不要在文章里等我贴一个固定 URL 出来,那不是可持续的姿势。正确姿势是抓包实时观察,把请求参数尽量泛化成配置文件。

下面这代代码是一个通用解析示意,核心是“从响应 JSON 里找到 boundary 或 polygon 这类字段”,参数结构以你抓包得到的实际请求为准。

import requests # 这里以实际抓包看到的URL和参数为准,下面只是示意 url = "https://map.baidu.com/" params = { "qt": "s", # 代表search类请求,随版本变化 "wd": "融创滨江壹号院", "c": "179", # 城市代码,随版本变化 "rn": "10", } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", "Referer": "https://map.baidu.com/", } resp = requests.get(url, params=params, headers=headers, timeout=10) data = resp.json() # 根据实际抓包结果调整字段路径 content = data.get("content", []) for item in content: if "boundary" in item: print(item["name"], item["boundary"][:200])

如果响应里的字段名做了混淆,这时候就去 Sources 面板搜“boundary”字符串,找到赋值代码的位置,打上断点,再回到页面点击小区,等断点触发后看局部变量。这个方法比盲猜字段名要可靠得多。

这种方式的风险在于:网页版接口可能是加密的,带动态 token 或签名。遇到这种情况,最简单的是换用浏览器自动化方案,用 Playwright 打开地图,搜索小区,等待高亮出现,然后通过执行 JS 从页面地图对象里把覆盖物或图层里的坐标读出来。虽然慢,但省去逆向签名的功夫。

4.2 方案B:行政边界降级,适合快速出图

如果你只是做展示原型,并不一定要精确到小区围墙,那完全可以用“区县边界 + 内部点位”的方案替代。这个方法能救急,而且不会被网页版接口牵着走。

先用 place/v2/area/search 把某个区的行政边界多边形拉出来,脚本如下:

params = { "keyword": "滨江区", "sub_admin": 0, "output": "json", "ak": AK, } resp = requests.get("https://api.map.baidu.com/place/v2/area/search", params=params, timeout=10) data = resp.json() polygon = data.get("result", {}).get("polygon", "")

这里返回的 polygon 是一长串lng,lat;lng,lat;...字符串,存下来后转成 Shapely Polygon 就行。因为它像行政边界,而不是小区边界,只用来当地图底图或范围约束。

降级方案的思路是:小区 POI 点落进哪个区县的多边形,就给这个小区挂上哪个区县的边界标识。你至少能做到“知道小区在哪一片”,而不是精确知道“小区自己的围墙”。

4.3 方案C:开源地图数据做交叉验证

我在完成边界字段后,经常用 OSM 数据交叉验证。从 Geofabrik 下载对应区域的数据,用 osmium 或 osmfilter 过滤出居民地相关面要素,再把过滤结果跟百度抓回来的边界做空间对比。

这样做有两个好处:第一,如果某个小区百度返回的轮廓明显异常,比如面积几百倍偏差,用 OSM 能快速发现问题;第二,对少量缺失轮廓的小区,可以直接把 OSM 里的地块面拿过来兜底,至少保证数据完整性。

适合自己跑的小块数据,可以直接在 Overpass API 里写查询;全量数据下载推荐 Geofabrik 的 pbf 文件,再用工具转换。过程不复杂,但不展开写了,因为和“百度地图”这个主题有关的重点还在上面。

5. 拿到原始数据之后:坐标转换、清洗和入库

不管是网页版接口直接返回的多边形,还是开源数据兜底的多边形,拿到的原始数据都还不能直接交付。你一定逃不过下面这三个环节。

5.1 把多边形点串解析成能用的对象

网页版接口里的多边形,最常见的格式是lng,lat;lng,lat;...,也可能存在坐标对之间用|隔开的情况,所以要统一处理。

def parse_boundary(raw): raw = raw.replace("|", ";") points = [] parts = raw.split(";") for part in parts: if "," in part: lng, lat = part.split(",") points.append((float(lng), float(lat))) return points

解析出 points 之后,交给 Shapely 构造 Polygon。如果接口返回的是多个环,比如外面一个大圈、里面一个小圈,那还要手动拆环再判断内外关系。大多数小区边界是单环,不会遇到这种复杂情形。

5.2 坐标统一,避免“图层错位”

很多人在最后会忽略坐标系统一。结果就是从百度网页版拿到的 BD09 坐标,和从公开 API 拿到的 WGS84 边界在 GIS 里一叠,全都错开几十米到几百米。

我在第二节给了转换函数,你可以直接在拿到每条边界后调用。转换成 WGS84 再入库,这样以后跟 GPS 采集数据、天地图、其他平台数据做叠加,都能对齐。

from shapely.geometry import Polygon from shapely.ops import transform import pyproj wgs84 = pyproj.CRS("EPSG:4326") bd09 = pyproj.CRS("EPSG:???") # 实际没有标准EPSG,转为自定义再转

注意,百度 BD09 没有公开 EPSG 编号,所以最简单的方式还是用纯 Python 公式转,而不是依赖 pyproj。我在实战里就是直接用 2.2 节那套函数,对每个点循环转换,速度完全可以接受。

5.3 用 Shapely 修复自相交和简化

即使拿到了点串,也不能直接构建 GeoJSON,因为原始坐标点可能非常密,而且可能出现自相交,直接存入数据库会导致各种莫名其妙的报错。

用 Shapely 做两件事:

from shapely.geometry import Polygon from shapely.wkt import dumps poly = Polygon(points) if not poly.is_valid: poly = poly.buffer(0) # 修复自相交问题 poly_simplified = poly.simplify(0.00003, preserve_topology=True)

buffer(0)是 GIS 数据处理里的常见技巧,它能强制修正拓扑错误;simplify则压缩点数量。0.00003这个经纬度容忍值大概相当于几米,在小区尺度下已经够平滑,又能让 GeoJSON 文件体积大幅下降。

5.4 导出 GeoJSON 和 SQLite

最终我一般会把数据导成两份。一份是 GeoJSON,方便丢到 QGIS、Leaflet、Mapbox 里直接画;一份是 SQLite,方便后面做关联和空间查询。

import json from shapely.geometry import mapping features = [] for item in merged_data: features.append({ "type": "Feature", "properties": { "name": item["name"], "uid": item["uid"], "region": item["region"], "source": item["source"], }, "geometry": mapping(item["polygon"]), }) geojson = { "type": "FeatureCollection", "features": features, } with open("output/community_boundaries.geojson", "w", encoding="utf-8") as f: json.dump(geojson, f, ensure_ascii=False, indent=1)

SQLite 就更简单了,直接建一张表存 WKT 字符串就行,普通场景不需要上 PostGIS。等以后数据量真的到几十万条再考虑空间数据库也不迟。

6. 实际跑完一个城市后的经验总结

最后这部分不写教科书,全是我在这轮实战中真实碰到的问题。

6.1 配额和速度,比预期更难受

个人认证的百度地图开放平台,QPS 和日配额都不高。我一开始用 1 秒间隔跑,还能跑动,但一旦把关键词和区县组合全展开,请求量立刻涨上去,很容易在半夜触发配额限制。所以我现在的策略是:先跑一个小区的数据,确认整个流程通了,再扩大规模;跑的过程中做好日志和断点续跑,每次请求结果原样落盘,方便出错后增量重试。

6.2 网页版接口改版,是常态

我的经验是,每周固定时间重新抓包检查一遍。接口参数很可能换,字段名很可能换,但“点击小区后高亮轮廓”这个交互不会消失,所以抓包入口一直还在。如果某天响应里突然多了一段看不懂的编码字段,不要急着硬解,先从浏览器页面里观察渲染效果,对照页面高亮是否和抓包数据一致,再决定是否继续。

6.3 数据质量要靠人工抽检

我最终交付的数据里,只有一部分小区能从百度网页版直接拿到理想轮廓,剩下的要么找不到匹配项,要么边界明显是区域级数据,和真实小区围墙差得很远。这种时候我会优先用 OSM 补,再补不上就干脆标记为“无边界”,不让错误数据混进去。宁可少一个,不能错一个。

6.4 合规底线和可持续性

最后必须提醒一句。抓取网页版接口获取不公开的轮廓数据,是存在合规风险的做法。它只适合你用来做个人学习、内部研究,或者是已经确认有授权的项目;不要拿爬到的数据做成商业接口对外卖,不要高频请求冲击百度地图服务,更不要碰用户隐私相关字段。公开接口的配额,能走官方就尽量走官方,网页版抓包这种手段尽量轻量使用。

如果你想正式做一款产品,建议直接走百度地图开放平台的商业授权或企业配额,咨询官方是否提供地理围栏、边界数据服务。就算最后决定自己爬,也要控制频率、尊重平台规则。这个尺度心里要有数。

我自己现在的流程已经固定下来:先用官方接口抓 POI 点,再用网页版抓包补轮廓,没补上的用 OSM 兜底,最后统一转 WGS84,入库后让人工在 QGIS 里随机抽验一批。整个流程虽然听起来步骤多,但每一步都是前面踩过坑之后沉淀下来的。你要是也想跑,强烈建议先拿一个小县城或者一个区练手,把整个链路打通之后,再往上放大范围。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询