做小区边界数据的时候,很多朋友第一反应是“直接去百度地图上把边界抠下来”。真操作起来你会发现,这事儿没有想象中那么简单。百度地图的地图瓦片是图片格式,想要得到矢量边界,就得换一套思路绕过去。我最早是被一个做房产数据分析的项目推到这条路上的,需求很明确:把目标城市所有住宅小区的轮廓坐标落进数据库,供后续做空间关联和可视化。折腾了几个晚上,最终跑通了一条还算稳定的链路,今天把完整过程拆开讲,包括方案怎么选、接口怎么调、坐标怎么处理、数据怎么验证,以及我踩过的那些坑。
这篇内容适合已经会用Python发HTTP请求、想拿真实地图数据做分析的开发者,也适合刚接触地理数据爬取、对百度地图开放平台不太熟的新手。核心思路不复杂:用百度地图Web服务API的Place检索接口拿小区POI锚点,再用JS API的Boundary接口或者前端页面内部的数据接口拿边界轮廓,最后统一做坐标纠偏、数据清洗和格式转换。整个过程里,最容易翻车的地方往往不是代码,而是对官方API的能力边界理解不到位。
1. 需求拆解与总体思路
1.1 “爬小区边界”到底在爬什么
先明确一个概念:百度地图上的“小区边界”,在数据层面上不是一个整体文件,而是分层存在的。第一层是POI点数据,代表小区入口或中心点的经纬度坐标,附带名称、地址、类型等属性;第二层是面数据,也就是边界的闭合多边形,由一串有序坐标点组成。POI数据通过官方API就能拿到,但面数据并没有一个公开的、一次性下载全量的接口。
很多人一开始会想到用爬虫直接抓百度地图Web页面的XHR请求,比如拖动地图时network面板里出现的那一堆带boundary字样的JSON。这个思路理论上可行,但实际做起来有门槛:接口带签名参数、有访问频率限制、返回的数据结构会随版本变化。另一个常见思路是用Selenium模拟操作去画边界,但性能太差,跑几百个小区就让人崩溃。
我最终采用的方案是“官方API为主、前端接口为辅”的组合拳。先用Place检索API把小区POI捞下来,拿到每个小区的名称和中心点坐标;再以中心点为线索,去请求边界相关的数据接口。这个顺序很重要,因为大部分边界接口都需要先用名称或坐标定位到具体小区,才能返回对应的围栏坐标。
1.2 三种主流方案的优劣对比
在我动手之前,花了点时间把网上能找到的方式都过了一遍,列个对比表供你参考:
| 方案 | 数据来源 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|---|
| Web服务API(Place检索) | 官方 | 稳定、有配额、返回结构化数据 | 只有POI点,没有边界多边形 | 批量位置筛查、作为锚点数据 |
| JS API Boundary接口 | 官方 | 能拿到部分边界轮廓 | 主要是行政区划边界,小区级支持有限 | 城市、区县边界获取 |
| 前端页面XHR接口 | 非官方 | 数据全、直接有边界坐标 | 接口会变、有风控、需解析签名 | 小批量、研究性质 |
| 第三方数据商 | 商业 | 开箱即用、数据完整 | 收费、更新周期不确定 | 生产环境、预算充足 |
我个人的建议是:如果是正式项目、要长期更新数据,直接考虑采购合规的商业数据;如果是个人研究、原型验证,或者数据量在几千条量级以内,官方API加前端接口的组合足够用。下面我讲的实操,就是基于这个组合展开的。
1.3 为什么不能直接依赖HTML解析
这里要专门说一句,因为后台经常有人问“为什么不用BeautifulSoup去解析百度地图的HTML页面”。原因是百度地图的前端页面是重度异步渲染的,页面里的地图数据是通过一堆加密的JavaScript动态加载的,HTML源码里几乎没有POI和边界信息。即便你用Selenium渲染完整页面,拿到的也只是canvas画出来的像素,不是结构化坐标数据。
所以,“爬”的落点不是HTML解析,而是数据接口。这一点想通了,后面就顺了。用requests直接请求接口、处理JSON响应,比模拟浏览器操作要轻量得多,也更容易控制频率和出错重试。
2. 关键技术方案选型
2.1 官方Place检索API怎么用
百度地图开放平台的Place检索API,是拿POI数据的正道。它支持区域检索、圆形区域检索和矩形区域检索三种方式。对于“小区边界爬取”这个需求,我推荐用区域检索,按城市和关键词“小区”来拉取POI列表。
接口地址是:
https://api.map.baidu.com/place/v2/search关键参数包括:
query:检索关键词,填“小区”region:行政区划名称,比如“北京市”output:返回格式,填jsonak:你在百度地图开放平台申请的密钥scope:填2,这样可以返回更详细的POI信息,包括uid和locationpage_size:每页返回数量,最大是20page_num:页码,从0开始
需要注意,普通开发者账号的单日配额有限,而且每个请求返回的total并不是全量数据量,API存在一定的“最多返回前几百条”的隐性限制。这个问题我放在后面“常见问题”里细讲,这里先按下不表。
我拿到的返回结构大概是这样的:
{ "status": 0, "results": [ { "name": "某某花园", "location": { "lat": 39.908823, "lng": 116.397470 }, "uid": "e1f2c9d8a0b1c2d3e4f5", "area": "朝阳区", "detail_info": { "tag": "住宅区;小区" } } ] }这里的lat和lng是百度坐标系(BD-09)下的坐标,不是标准经纬度,后面要转换,先记住这一点。
2.2 边界数据从哪里来
拿到POI锚点后,下一步就是拿边界。我试过三条路:
第一条是直接用JS API里的Boundary方法。但实测下来,这个接口对行政区划边界支持得很好,对小区级别的支持有限,很多小区根本查不到对应边界。原因是百度并没有把小区边界做成一个公开的行政边界数据源开放出来。
第二条是抓百度地图Web版页面内部使用的接口。在新版百度地图的页面里,搜索某个小区后,地图上会画出一个围栏,这个围栏的坐标就是从某个内部接口返回的。这个接口的URL和参数会随前端版本变化,而且返回的数据里带一些校验字段。这条路能走通,但需要花时间逆向分析,风险是接口随时可能调整。
第三条是我最后用的主力方案:通过百度地图拾取坐标系统页面的关联接口来获取。这个页面本身是百度官方提供给开发者用的工具,用来查询某个地点对应的经纬度和行政区划信息。在查询小区名称后,页面会调用一个内部的geocoder接口,返回的JSON里包含了地点详情和经纬度,但依然没有完整的边界多边形。
说到这里,我得坦白一个现实:百度地图官方渠道,并没有一个完全公开的小区边界矢量数据接口。我最终的落地方案,是把POI锚点拿到后,结合高德地图的行政区划接口和第三方开放数据(比如某些城市开放平台发布的住宅小区轮廓数据)做补充,再用百度地图的坐标拾取器做交叉验证。这样虽然不能保证每个小区都有精确的围栏,但锚点准确性、属性完整度都能达到分析需求。
2.3 坐标系的坑:BD-09与GCJ-02
百度地图的坐标系是BD-09,高德地图是GCJ-02,两者的坐标值会有一个偏移。这个偏移足够让你在地图上展示时错位几十米到几百米不等。如果你后面要做跨平台的数据对比,或者想用高德地图做可视化,就必须做坐标转换。
百度官方提供了一个坐标转换API,可以把百度坐标转成其他坐标系:
https://api.map.baidu.com/geoconv/v1/?coords=116.397470,39.908823&from=5&to=3&ak=你的AK这里from=5代表源坐标系是百度坐标,to=3代表目标坐标系是GCJ-02(高德坐标)。
实际使用中,我也试过在本地用算法做转换,网上有很多公开的坐标偏移算法代码。但官方API的精度更好,而且不容易因为算法版本差异出现批次性偏移。建议优先用API转换,如果请求量太大,再用本地算法兜底。
3. 实操:完整流程与代码实现
3.1 环境准备与密钥申请
动手之前先把环境备齐。我用的Python 3.9,依赖库只有requests和json,后面做数据保存时用geopandas写GeoJSON,但这一步不是必须的,单机小规模用普通JSON文件足够。
百度地图开放平台的密钥申请流程不复杂:
- 打开百度地图开放平台官网,注册并登录账号。
- 进入控制台,创建一个“服务端”类型的应用。
- 应用名称随意填,IP白名单留空表示不限制。
- 创建完成后,页面会给你一个
AK(访问密钥),这就是后面所有接口请求都需要带的参数。
提示:如果你在浏览器里看到请求返回
APP Referer校验失败之类的错误,多半是因为把应用类型选成了“浏览器端”。做服务端爬取一定要选“服务端”。
3.2 批量获取小区POI锚点
先写一个最基础的请求函数,用来翻页拉取某个城市的小区POI数据。
import requests import json import time AK = "你的AK" def search_community(city, page_num): url = "https://api.map.baidu.com/place/v2/search" params = { "query": "小区", "region": city, "output": "json", "scope": "2", "page_size": 20, "page_num": page_num, "ak": AK } resp = requests.get(url, params=params, timeout=10) data = resp.json() return data请求返回后,把results里的POI字段抽出来,保存成列表。这里有个细节:百度Place API对同一关键词的搜索,最多返回几百条POI,不能像想象中那样把整个城市所有小区都拉完。针对这个问题,我的折中策略是把城市切成多个检索区域,用矩形区域检索去分块拉取。
矩形区域检索的用法是换上bounds参数,比如:
bounds=39.80,116.20;40.10,116.50表示经纬度范围左下角和右上角的坐标,用分号分隔。然后在循环里按固定步长滑动这个矩形窗口,把整个城市覆盖一遍。这样能拿到比单纯区域检索多不少的数据。
def search_by_bounds(bounds_str, page_num): url = "https://api.map.baidu.com/place/v2/search" params = { "query": "小区", "bounds": bounds_str, "output": "json", "scope": "2", "page_size": 20, "page_num": page_num, "ak": AK } resp = requests.get(url, params=params, timeout=10) return resp.json()这里要注意:矩形检索模式下,region参数不能同时使用。另外,分割矩形时步长不要太大,我建议每个窗口覆盖大约0.1度乘以0.1度的范围,这样既能控制POI密度,又不会让单个窗口返回超过20条的POI把数据冲掉。
3.3 爬取边界轮廓的推荐路径
拿到POI锚点后,就进入最核心的环节:为每个小区匹配边界轮廓。这里我尝试过多条路径,最终稳定可复现的方案是:
方案A:通过百度页面接口获取指定小区边界
在百度地图网页版中搜索一个小区后,界面会调用的接口大概形如:
https://map.baidu.com/?qt=ext&uid=你的小区UID&ak=你的AK这里的uid是Place检索结果里POI自带的那一串字符。这个接口的返回值里,有时会包含content字段,底下有geo之类的边界信息。但实测下来,这个接口的返回结构和可用性非常不稳定,有的小区有,有的小区没有,而且字段经常改。
方案B:用高德地图API补边界
如果目标城市的小区边界以高德数据为主,那可以考虑用高德的行政区划查询接口。高德的接口对小区的支持比百度好一些:
https://restapi.amap.com/v3/geocode/geo?address=小区名&city=城市名&key=你的高德Key返回的经纬度是GCJ-02坐标,可以作为辅助锚点。但高德同样没有直接暴露小区多边形边界的接口。
方案C:结合开源边界数据做兜底
真正能让批量数据落地的方式,是把上面拿到的锚点坐标和网络上已有的开源小区轮廓数据做匹配。比如很多城市开放数据平台会发布住宅小区的矢量轮廓,格式通常是GeoJSON或Shapefile。把POI锚点与这些数据按名称和空间位置做JOIN,能匹配上的就保留轮廓,匹配不上的就只保留锚点。
跑完整个流程后,我的数据表基本是这样的结构:
| 字段 | 示例 | 说明 |
|---|---|---|
| name | 某某花园 | 小区名称 |
| bd_lat | 39.908823 | 百度坐标纬度 |
| bd_lng | 116.397470 | 百度坐标经度 |
| gcj_lat | 39.903824 | 高德坐标纬度 |
| gcj_lng | 116.391234 | 高德坐标经度 |
| boundary | [[116.3912,39.9038],...] | 多边形坐标(可为空) |
| uid | e1f2c9d8a0b1 | 百度POI唯一标识 |
3.4 批量坐标转换
拿到百度坐标后,批量转换我用的是官方geoconv接口,一次最多转换100个坐标点。写个简单的批量处理函数:
def batch_convert(coords_list): result = [] for i in range(0, len(coords_list), 100): batch = coords_list[i:i+100] coords_str = ";".join([f"{lng},{lat}" for lat, lng in batch]) url = "https://api.map.baidu.com/geoconv/v1/" params = { "coords": coords_str, "from": 5, "to": 3, "ak": AK } resp = requests.get(url, params=params, timeout=10) data = resp.json() if data.get("status") == 0: for item in data["result"]: result.append((item["y"], item["x"])) time.sleep(0.3) return result注意我代码里的sleep(0.3),这是必须的。虽然单个请求很轻,但如果不限速,阿克很快就会触发并发限制,然后整个IP段都会被临时封掉。爬数据这件事,稳比快重要。
3.5 输出GeoJSON
最后一步,把数据结构化成GeoJSON,方便在QGIS、Mapbox或者任意地理可视化工具里打开。
import json geojson = { "type": "FeatureCollection", "features": [] } for item in data_list: feature = { "type": "Feature", "properties": { "name": item["name"], "source": "baidu" }, "geometry": { "type": "Point", "coordinates": [item["gcj_lng"], item["gcj_lat"]] } } geojson["features"].append(feature) with open("communities.geojson", "w", encoding="utf-8") as f: json.dump(geojson, f, ensure_ascii=False, indent=2)如果你拿到了边界轮廓,把Point换成Polygon即可:
"geometry": { "type": "Polygon", "coordinates": [[[lng1, lat1], [lng2, lat2], ...]] }4. 数据验证与常见问题
4.1 坐标偏移导致的划区错位
我第一次把爬下来的POI数据叠加到高德底图上时,发现点位全部往东南方向偏移了几百米。排查完后确认,就是坐标系不一致的问题。这个问题的典型表现是:百度坐标在高德地图上显示时,点位会偏向东北方向,而高德坐标在百度地图上会偏向西南方向。解决办法就是上面提到的geoconv转换,一定要做,不要省。
4.2 接口请求频率限制
百度地图开放平台的默认并发限制是每秒不超过10次,超过后接口会返回APP并发超限。我连续踩了两次之后,在代码里加了一个限速器,也就是统一在每次请求之间sleep 0.2到0.5秒,同时增加了请求失败指数退避重试逻辑。
import time import random def safe_request(url, params, retries=3): for i in range(retries): try: resp = requests.get(url, params=params, timeout=10) data = resp.json() if data.get("status") == 0: return data elif data.get("status") == 302: # IP被临时限制,等待较长时间再重试 time.sleep(60) else: time.sleep(1) except Exception as e: print(e) time.sleep(2 * (i + 1)) return None这个函数看着简单,但在整个流程里帮我节省了大量时间。尤其是status=302这个是百度专门用来限流的返回码,遇到它就别再疯狂重试了,直接等。
4.3 Place API返回结果不全
前面说过,Place API对同一个关键词检索有隐性结果截断。实际表现是:无论你怎么翻页,总POI数超过一定数量后页数就不变了。这个问题没有完美的绕过方案,但用矩形分块可以极大缓解。分块时注意两个坑:
- 每个矩形块返回的POI数量若达到20条上限,说明这个块太小了,可以适当扩大范围再跑一次。
- 若某个矩形块返回的POI数量为0,先别急着跳过,有可能是这个块内确实没有POI,也有可能是接口在你请求时临时抽风。稳妥起见可以重试一次。
4.4 边界轮廓经常缺数据
对于边界缺失,我的处理原则是:不硬凑。有些小区在百度地图上本身就没有独立的围栏数据,比如一些老式筒子楼,或者新建未交付的楼盘。对于这类情况,保留锚点和属性信息,边界字段留空即可。硬凑一个正方形轮廓出来的做法,做出来的地图一眼假,分析结果也没有意义。
如果项目真的需要完整边界,建议转向开源的OpenStreetMap数据。从OSM上可以拿到部分住宅区面数据,但覆盖率和更新时效跟百度地图比差不少,适合做补充,不适合做主力数据源。
4.5 数据清洗与去重
爬完数据后,去重是必备步骤。同一个小区可能被多个矩形窗口重复采集到,这时候需要按uid去重。如果换用了不同接口导致uid缺失,就用“名称+坐标距离”双重条件去重:先按名称拼音聚簇,再计算簇内坐标两两距离,距离小于100米的视为重复。
from math import radians, cos, sin, asin, sqrt def haversine(lng1, lat1, lng2, lat2): lng1, lat1, lng2, lat2 = map(radians, [lng1, lat1, lng2, lat2]) dlng = lng2 - lng1 dlat = lat2 - lat1 a = sin(dlat/2)**2 + cos(lat1) * cos(lat2) * sin(dlng/2)**2 return 2 * 6371 * asin(sqrt(a))这是最常用的距离计算公式,我在去重时会配合它做一个简单的循环判断,把距离过近的POI合并掉。
4.6 数据可视化结果异常
处理完的数据用QGIS打开后,如果发现点位分布非常奇怪,比如大量POI聚集在同一条街上,那大概率是矩形检索窗口设计得有问题。我遇到过一种情况:某个窗口把城市里的主干道整个圈了进去,结果拉回来的POI全是沿街商铺,而不是小区。解决方法是增加POI标签过滤,只保留detail_info.tag里包含“住宅区”或“小区”的记录。
5. 从爬虫到数据服务的实用心得
5.1 先想清楚数据要拿来干什么
爬数据的过程很有意思,但我现在回头看,最关键的决策其实是在动手之前做的——你到底要拿这些数据干嘛。如果只是想在地图上点几个点看分布,那POI锚点就够用了;如果是想做覆盖范围分析、计算每个小区到地铁站的距离,那没有边界也能做(用中心点代替);如果是要做楼盘轮廓对比、拿多边形做空间拓扑运算,那就必须找到可靠的边界数据源,这决定了你的技术方案完全不同。
很多人一上来就盯着“边界”两个字,忽略了业务本身对精度的要求。比如我之前做的项目,实际上用中心点坐标加一个预设半径就能满足需求,根本不需要去死磕边界轮廓,白白浪费了几天时间。这个教训还挺深刻的。
5.2 数据更新的节奏把握
地图POI数据是会变化的,新楼盘入市、旧小区改造、物业更名,都会影响数据质量。我做数据同步时采用“全量周更、增量日更”的策略:每周用Place API把所有目标城市全量扫一遍,保证基础数据不丢;每天对重点关注的高频区域做一次小范围增量更新。这样既控制了API配额消耗,又能保证关键数据的新鲜度。
增量更新的做法不复杂,就是拿已有的POI名称列表和新爬到的数据做比对,凡是新出现的名称或者UID就标记为新增,凡是在旧数据里有但新数据里消失的就标记为下架。
5.3 不要忽视数据脱敏与合规
最后提醒一下,爬到的数据里包含小区名称和精确坐标,这类数据属于敏感地理信息。如果只是本地研究用途,问题不大;但如果你要把数据发布出来、做展示、或者提供给第三方使用,一定要谨慎。我个人的做法是:对外展示时只保留到城市或区县级别的聚合数据,不给到具体小区坐标,也不做单点精确查询的接口。合规这根弦,比技术实现更重要,别等出了事再后悔。
6. 代码封装:做一个可复用的爬取工具
6.1 完整工具类结构
如果只是跑一次性的脚本,上面那些零散的函数足够用了。但要长期维护数据更新,我建议把代码封装成一个工具类,把API调用、数据存储、日志记录都统一起来。下面是一个简化版的类结构:
class BaiduCommunityCrawler: def __init__(self, ak): self.ak = ak self.session = requests.Session() self.base_url = "https://api.map.baidu.com" def search_communities(self, region, page_num=0, page_size=20): pass def convert_coords(self, coords_list): pass def get_community_detail(self, uid): pass def save_to_geojson(self, file_path): pass6.2 增加持久化存储
数据量小的时候存在JSON文件里方便,但数据量一旦上万条,建议上SQLite。SQLite不需要额外部署服务,Python标准库自带,非常适合这种单机爬虫场景。
import sqlite3 conn = sqlite3.connect("communities.db") cursor = conn.cursor() cursor.execute(""" CREATE TABLE IF NOT EXISTS communities ( id INTEGER PRIMARY KEY AUTOINCREMENT, uid TEXT UNIQUE, name TEXT, bd_lat REAL, bd_lng REAL, gcj_lat REAL, gcj_lng REAL, boundary TEXT, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ) """) conn.commit()数据库表建好后,每次爬取开始先查一遍已有数据,把已经存在的UID过滤掉,这样能省下大量重复请求的配额。
6.3 断点续爬的实现
爬虫最怕跑了一半崩了。为了应对中断,我在代码里加了断点续爬的逻辑:每处理完一个矩形窗口,就把进度写入一个单独的progress.json文件里,下次启动时读取这个文件,跳过已经完成的窗口。这样即使程序因为网络波动、断电等原因中断,也不需要从头再来。
import os def save_progress(completed_windows): with open("progress.json", "w", encoding="utf-8") as f: json.dump({"completed": completed_windows}, f) def load_progress(): if os.path.exists("progress.json"): with open("progress.json", "r", encoding="utf-8") as f: return json.load(f).get("completed", []) return []6.4 多线程是否必要
我的答案是:个人项目完全没必要。百度地图API的并发限制摆在那里,多线程不能帮你突破配额,反而容易触发封禁。与其花时间优化并发,不如把单线程跑稳、做好重试和断点续爬。如果你的数据量大到单线程跑不完,更合适的方案是申请更高的配额,或者换用商业数据源,而不是在爬虫层面无限压榨接口。
7. 边界数据缺失时应考虑的替代方向
7.1 城市开放数据平台
国内不少城市的数据开放平台会公布住宅小区的基础信息,部分城市甚至直接提供GeoJSON格式的小区边界数据。这类数据是政府发布的,权威性和准确性都不错,但问题是格式不统一、更新不及时,而且不是所有城市都有。爬取百度地图的同时,可以顺手把目标城市的开放数据平台过一遍,能补多少算多少。
7.2 OpenStreetMap的building数据
OSM上有一部分住宅区(residential area)的面数据,虽然在国内覆盖有限,但在一些城市的新区、开发区,OSM的数据反而比商业地图更新更勤。通过Overpass API可以按区域条件查询,返回的JSON直接就是经纬度坐标。
7.3 技术之外的替代思路
如果最终拿不到精确边界,还有一个“曲线救国”的思路:用小区POI锚点做缓冲区分析。具体做法是以POI为中心、按小区规模生成一个圆形或者椭圆的缓冲区,作为边界的近似替代。对大多数统计分析场景,这种近似已经够用。如果再讲究一点,可以把缓冲区半径和小区属性做关联,比如户数多的小区半径大一点,户数少的小区半径小一点。
这个方法虽然学术上不够严谨,但做出来的可视化效果和空间分析结果,在业务层面是说得通的。
8. 最后的一些经验提醒
8.1 不要把接口文档当唯一参考
百度地图开放平台的文档更新速度跟不上接口的实际变更速度。文档里写的参数,实际调用时可能会多出几个默认字段;文档里没写的限制,实际跑到某个量级就会冒出来。我的习惯是每次调用前把resp.url打出来看一眼,确认最终发出去的请求长什么样,很多莫名其妙的错误都是参数没有被正确拼接导致的。
8.2 一定要做好日志记录
写日志不是给机器看的,是给你自己看的。我踩过的最大一个坑是数据爬到一半报错,但错误信息被吞掉了,导致我花了一个小时排查才知道是AK过期了。从那以后,我每个关键步骤都会加一行print或者logging,把当前页码、窗口范围、返回状态码记录下来。数据出问题时,翻日志比猜原因高效太多了。
8.3 这个事深入下去还能做什么
说实话,百度地图小区边界爬取这件事,技术难点不算高,真正有价值的是后面那一层:拿到坐标之后,你能不能结合其他数据做出有用的分析。比如把小区POI和房价数据进行空间关联,分析不同区域的小区密度与均价的关系;或者把小区边界和公交站点数据叠加,计算每个小区的公共交通覆盖率;甚至可以把多个时间点的POI数据做对比,观察城市扩张方向和新区开发节奏。
数据本身不会说话,但你整理好、分析好、可视化好,它就能讲出不少有意思的故事。