
做地理数据的同学应该都遇到过这种需求想统计某个区域里的学校分布或者想给地图上的学校加个轮廓范围结果发现网上流传的所谓“POI数据包”要么是几年前的旧货要么来源不明带着一股灰色气息。说句实在话POI点数据要拿到并不难难点在于两点一是合法合规地拿二是拿到点之后怎么把“边界数据”也一并解决掉。这篇就围绕百度地图、POI、边界数据这三个关键词展开用学校这个场景把整条链路走一遍从申请密钥到最终输出带范围的GeoJSON手把手讲清楚。先说清楚这篇解决什么问题。如果你只是想要“某个点”的信息比如学校名称、地址、电话、经纬度官方API完全够用但如果你要的是“学校占地轮廓”也就是这个学校在地图上的多边形边界那就得在技术上绕几步。我后面会给出三条路子用OpenStreetMap公共数据拼边界、用POI点位聚合近似边界、用行政区划数据做空间归属。三条路线各有适用场景你要是看完能根据自己的数据需求灵活选那这篇就没白写。这篇适合谁参考一个是做数据分析、行业调研、智慧城市相关项目的工程师另一个是刚入门爬虫、想在合规前提下练手的学生开发者。我不建议纯小白从零开始学爬虫直接上这个项目至少你得懂一点Python、知道requests怎么发请求、会看JSON结构不然中间排查问题会比较吃力。1. 先想清楚POI点数据和边界数据到底差在哪1.1 POI是什么边界数据又是什么POI全称Point of Interest也就是兴趣点。百度地图上的学校、餐厅、商场、医院都是一个一个的点每个点带属性信息名称、地址、电话、经纬度、分类标签、评分等。点数据解决的是“哪里有什么”的问题。边界数据是Polygon是一个闭合的面描述的是这个兴趣点在地图上的实际覆盖范围。比如一所大学它有围墙、有校区边界、有建筑群轮廓这些信息用点表达不了必须用面的集合来描述。很多人刚开始做数据项目时分不清这两者的区别结果拿到的“POI数据集”只有点坐标画在图上就是一堆密密麻麻的红点根本看不出学校规模大小、覆盖范围。这其实已经决定了后续很多分析做不了你想算学校周边500米内有多少公交站拿点数据没法算——你得先知道学校边界在哪才能做缓冲区分析。1.2 不同获取方式的合规风险对比这里必须说点实在的。很多人一听到“爬虫”两个字就兴奋觉得拿到数据就是王道但在POI数据这个领域拿法不同风险和成本完全不同。第一种自己写脚本去模拟请求百度地图页面接口绕过官方配额限制高并发抓取。这种方式数据确实能拿到但违规性质很明显突破了访问频率限制抓了超出授权范围的数据而且拿到的数据如果用于商业用途合同和法律风险都很高。业内因为这类操作吃官司的不是没有轻则警告重则面临索赔和刑事责任。我这里不展开讲案例只提醒一句任何一个公开发布的爬虫教程都不会替你承担法律后果。第二种用官方开放的Web服务API。百度地图有正经的开放平台个人开发者申请密钥后可以按配额调用地点检索、逆地理编码、行政区划查询等接口。这种方式拿数据是合规的但拿不到详细的边界轮廓因为官方接口返回的主要是点坐标和属性信息。第三种用开放协作的地理数据源。OpenStreetMapOSM是全球开放的矢量地图数据集学校矢量边界是其中一个很成熟的数据类型。用它的Overpass API按区域查询学校边界在许可协议允许的范围内自由使用。我的方案是把第二种和第三种结合用百度地图POI数据拿现势性好、属性全的点位信息用OSM的公开边界数据做面信息两者做空间匹配和校验最终输出一套“点面兼备”的学校数据集。1.3 我为什么选这条技术路线单用百度地图API有一个硬伤官方地点检索接口不返回多边形边界。你查一个大学返回的是中心点坐标搜索半径也有限制“区域检索”也只返回点列表。单用OSM的边界数据也有问题国内OSM数据的学校覆盖率参差不齐尤其小城市和农村地区的学校边界可能是缺的属性字段也不全。所以我的思路是“以百度为准做点位去重以OSM为主做边界兜底”。点位以百度为主因为国内POI数据的现势性百度做得最好边界以OSM为主实在没有边界就退一步用凹包算法做一个近似范围或者直接放弃边界只保留点位标注。2. 准备工作账号、密钥与本地环境2.1 申请百度地图开放平台密钥进入百度地图开放平台注册登录后创建应用应用类型选择“服务端”这样会生成一个AKAccess Key访问密钥。申请的时候会要求填写IP白名单如果你是在本地跑代码填本机公网IP就行如果不确定IP可以先填0.0.0.0/0但跑通之后建议尽快收紧白名单避免密钥被滥用。创建完应用后进入“配额管理”页面注意看地点检索服务的QPS每秒请求数配额。个人认证的默认配额一般是1到2足够做小规模实验但如果你要大批量抓全城学校1QPS会非常慢后面我会讲怎么用控制策略来解决。2.2 本地Python环境与依赖清单我用的是Python 3.10项目依赖只有四个核心库尽量少引入重量级依赖pip install requests pandas shapely geopandas foliumrequests发HTTP请求pandas做数据处理shapely生成凸包和凹包边界geopandas读写GeoJSON并做空间匹配folium做可视化验证。如果你只是拿点数据不涉及空间分析和矢量文件操作geopandas可以不装换成geojson库直接写GeoJSON就行。2.3 先读懂官方接口的参数结构百度地图地点检索接口的核心是Place API请求URL格式如下https://api.map.baidu.com/place/v2/search? query大学 region北京 outputjson scope2 page_size20 page_num0 ak你的AK几个关键参数解释一下query是关键字可以传学校名称也可以用“小学”“中学”“大学”这类分类词。region是检索区域必须是行政区划名称可以是市也可以是县。scope2表示返回详细信息包括电话、地址、经纬度等比scope1数据量大。page_size最大是20page_num从0开始。注意普通个人开发者拿到的配额下单次请求的page_size最大就20不能改。这意味着一个城市的学校数据如果超过20条就得用分页循环拉取。3. 核心实操分步获取学校POI点数据3.1 按城市行政区划切块请求直接对“北京”全城搜“学校”有两个问题一是一次检索最多返回多少条数据是有上限的超过上限后面的直接拿不到二是分页拉太深的时候结果集不稳定容易出重复或漏数据。我的做法是拆细行政区划按市辖区为最小检索单元。比如北京拆成朝阳区、海淀区、丰台区分别搜“小学”“中学”“大学”再合并去重。这样做的好处是每次检索的数据量小、翻页浅、稳定性高还能顺便统计每个区的学校分布。同一个城市如果拆到区还是超过上限就往下拆到街道。百度地图的行政区划数据里能查到乡镇街道级别的行政编码配合区域检索可以继续细拆。核心请求代码如下import requests import pandas as pd import time def fetch_pois(ak, query, region, max_pages10): base_url https://api.map.baidu.com/place/v2/search results [] for page_num in range(max_pages): params { query: query, region: region, output: json, scope: 2, page_size: 20, page_num: page_num, ak: ak } resp requests.get(base_url, paramsparams, timeout10) data resp.json() if data.get(status) ! 0: print(f{region} - {query} - page {page_num} error: {data}) break pois data.get(results, []) if not pois: break for poi in pois: results.append({ name: poi.get(name), province: poi.get(province), city: poi.get(city), area: poi.get(area), address: poi.get(address), lat: poi[location][lat], lng: poi[location][lng], uid: poi.get(uid), detail_url: poi.get(detail_url), }) page_num 1 time.sleep(0.6) # 控制请求频率, 避免触发限流 return results ak 你的AK all_schools [] for region in [北京市海淀区, 北京市朝阳区, 北京市丰台区]: for query in [小学, 中学, 大学]: data fetch_pois(ak, query, region) print(f{region} {query}: {len(data)} 条) all_schools.extend(data)注意这里我刻意把sleep设在0.6秒配个人的1QPS配额刚好合适。如果你申请的配额是更高的可以把sleep缩短到0.2秒左右但建议也不要太激进给上游服务器留点缓冲。3.2 用圆形检索兜底补漏行政区划检索的最大问题是有些学校虽然在区域内但行政归属的边界标注不一定准确会出现明明在A区却检索不到的情况。另外部分学校名称没有按“小学”“中学”“大学”的字眼出现比如“某某培训机构”“某某实验基地”用分类词就抓不到。我的兜底方案是圆形检索。在区域内按经纬度网格均匀撒点以每个点为圆心、半径1公里到3公里做圆形检索用“学校”作为query。把圆检索结果和区域检索结果合并再按uid去重能显著提高覆盖率。圆形检索的参数和区域检索略有区别def fetch_pois_by_circle(ak, query, lat, lng, radius3000): base_url https://api.map.baidu.com/place/v2/search params { query: query, location: f{lat},{lng}, radius: radius, output: json, scope: 2, page_size: 20, page_num: 0, ak: ak } resp requests.get(base_url, paramsparams, timeout10) data resp.json() return data.get(results, [])用网格式撒点去循环每个圆之间的间距建议设为半径的一半确保覆盖有重叠防止边缘漏掉。3.3 数据清洗与坐标转换合并完区域检索和圆形检索的数据后最重要的两步是去重和坐标转换。去重以uid为唯一键。同一个学校在百度地图里只有一个uid如果圆形检索和区域检索都命中了它uid相同直接保留一条。还有一类重复是不同uid但名字几乎一样比如“北京大学”和“北京大学本部”。这种只能靠名称相似度做二次清洗我一般用difflib的SequenceMatcher相似度超过0.85且坐标距离小于500米的判定为重复人工抽查确认。坐标转换是另一个大坑。百度地图对外输出的坐标系是BD-09这是一种加偏移的加密坐标系直接和其他数据源的坐标混用会导致点位偏移几百米。如果需要和WGS-84或GCJ-02的数据做空间分析必须做转换。BD-09转WGS-84的标准公式可以写成独立函数import math def bd09_to_wgs84(lat, lng): x_pi math.pi * 3000.0 / 180.0 z math.sqrt(lng * lng lat * lat) 0.00002 * math.sin(lat * x_pi) theta math.atan2(lat, lng) 0.000003 * math.cos(lng * x_pi) bd_lng z * math.cos(theta) 0.0065 bd_lat z * math.sin(theta) 0.006 # 再用 GCJ-02 转 WGS-84 的迭代方法 g _gcj02_to_wgs84(bd_lat, bd_lng) return g def _gcj02_to_wgs84(lat, lng): # 这里为了节省篇幅省略标准偏移表, 实际请用完整实现 pass实际工程里建议直接用开源的coord_convert库或者python包geopy里的转换工具手写容易踩精度坑。工程建设上我的经验是点数据在进数据库之前就把坐标统一转好不要等做分析的时候再转否则后面每一环节都会偏移。清洗完的数据直接落地成CSVdf pd.DataFrame(all_schools) df df.drop_duplicates(subsetuid, keepfirst) df.to_csv(schools_poi.csv, indexFalse, encodingutf-8-sig)注意编码用utf-8-sigExcel直接打开时不乱码。4. 边界数据获取的三条实用路径4.1 路径AOpenStreetMap提取真实边界百度地图的POI接口拿不到边界但OpenStreetMap里“学校”是成熟的地理要素类型。用Overpass API按城市和学校类型查能直接返回School对应的多边形边界坐标。Overpass API不用申请密钥只要请求频率合理就能用。查询一个城市内所有学校边界的语句如下[out:json]; area[name北京市][boundaryadministrative]-.city; ( nwr[amenityschool](area.city); nwr[amenitycollege](area.city); nwr[amenityuniversity](area.city); ); out body geom;用Python调用import requests import json overpass_url https://overpass-api.de/api/interpreter query [out:json]; area[name北京市][boundaryadministrative]-.city; ( nwr[amenityschool](area.city); nwr[amenitycollege](area.city); nwr[amenityuniversity](area.city); ); out body geom; resp requests.post(overpass_url, data{data: query}, timeout30) data resp.json()返回的elements数组里有的带nodes经纬度列表是折线有的带geometry数组是多边形边界。处理时优先取geometry因为它直接是闭环坐标数组转成shapely的Polygon最方便。OSM数据的优点是真边界精度高用地理视觉对比时和地图上的学校轮廓基本能对得上。缺点是国内覆盖不完整尤其小城市的初中小学边界缺得厉害。所以OSM适合做“锦上添花”不适合单独依赖。4.2 路径BPOI点位聚合生成近似范围如果OSM里没有某个学校的边界我又确实需要“面”来做地图展示就退而求其次对该学校名下的所有百度POI返回点位做凹包聚合。具体做法是先用“学校名称城市”关键字检索把返回的多个点全部收集起来。一个学校在百度地图上往往不只有一个点比如“清华大学”可能有东南门、西门、图书馆、主楼等多个POI点。把同学校的所有点聚成一簇然后用shapely的concave_hull算法生成一个覆盖所有点的最小凹多边形这个多边形在可视化层面可以作为学校范围的近似表达。from shapely.geometry import MultiPoint from shapely.ops import unary_union points [(-1, 0), (1, 0), (0, 1), (0, -1), (0, 0)] multipoint MultiPoint(points) hull multipoint.concave_hull(ratio0.3) print(hull.wkt)concave_hull的ratio参数控制凹陷程度ratio越大越贴近凸包越小越紧贴点簇。我的经验是0.3到0.5之间效果比较自然太大就退化成凸包了太小会过度收缩导致边界锯齿严重。需要说明的是近似边界不等于真实边界学术上这叫“核密度范围”或“凹包范围”用来做空间可视化可以用来做精确的行政统计不行。比如你用这个边界算占地面积和真实校园面积误差可能很大。4.3 路径C用官方行政区划边界做归属分析有些场景你其实不需要学校本身的范围只需要知道“这个学校位在哪个乡镇街道”或者“这个学校在哪个功能区范围内”。这时候真正需要的边界是行政区划边界而不是学校边界。百度地图开放平台里有行政区划查询接口可以按编码查询省市区乡镇的边界轮廓数据。用这个接口拉下级行政边界再把POI点位做空间匹配import geopandas as gpd from shapely.geometry import Point # 读取行政区划GeoJSON gdf_boundary gpd.read_file(towns.geojson) # POI转GeoDataFrame gdf_pois gpd.GeoDataFrame( df, geometrygpd.points_from_xy(df.lng, df.lat) ) # 空间连接, 给每个POI打上所属乡镇编码 joined gpd.sjoin(gdf_pois, gdf_boundary, howleft, opwithin)这种方式不直接给你“学校边界”但能回答“哪些学校属于哪个街道”这在很多政府类、规划类项目中反而是最常见的需求。别绕弯路去找学校实际边界直接做归属分析效率最高。4.4 质量校验确认边界没跑偏不管用哪条路拿到边界数据落地前必须做视觉和逻辑双重校验。视觉校验用folium最方便import folium m folium.Map(location[39.9, 116.4], zoom_start12) for _, row in gdf_schools.iterrows(): folium.GeoJson(row.geometry, style_functionlambda x: {color: blue}).add_to(m) folium.Marker([row.lat, row.lng], tooltiprow.name).add_to(m) m.save(check_schools.html)打开HTML逐个看边界和点是否匹配。如果某个学校的点落在另一个学校的边界里说明匹配逻辑有误或者该校OSM边界本来就是缺失的。逻辑校验包括三个规则一是点的坐标必须在边界范围内如果点不在面内要么点错要么面错二是边界面积要在合理区间小学面积通常在几千到几万平方米之间如果一根学校边界跑出几十平方公里基本就是行政区划边界混进来了三是相同校区不能被多个边界重复覆盖否则说明查询条件太宽。5. 常见问题与排查技巧我把实际操作里踩过的坑整理成一张速查表按频率排序问题现象出现原因处理办法接口返回status302无效密钥或配额限制检查AK是否创建了服务端应用确认IP白名单是否匹配返回status401校验失败确认AK是否被禁用或QPS超过配额翻页到第10页后数据重复增多深翻页时结果集不稳定拆小检索区域把page_num控制在10以内小区级区域检索返回结果为空区域边界未匹配成功改用上级行政区划或者用圆形检索兜底OSM查询超时Overpass API公共实例负载高减小查询区域拆成按区查询错峰执行圆形检索和区域检索合并后uid大量重复正常现象按uid去重不保留重复项BD-09坐标叠加到WGS-84地图上整体偏移坐标系未转换统一转换后再做空间分析5.1 并发限流怎么处理个人开发者的默认QPS一般在1左右也就是说每秒钟只能发1次请求。如果你需要抓取整个城市的学校数据拆细到区、再分页总请求量很容易上千次按1QPS算就是一千多秒大概20分钟。我的处理方式是先做一次小范围试抓统计总请求量然后计算预估时长。如果超过预期就提配额申请。百度地图开放平台的配额管理页里可以提交提额申请个人认证通常可以申请到5到10的QPS足够应付大多数场景。另外一定不要把AK直接放在浏览器端的代码里。被抓包之后别人可以直接用你的AK刷接口既浪费你的配额还可能连累账号被封。服务端应用必须要把AK放后端前端只转发请求。5.2 关于反爬与合规边界最后说点能避坑的东西。百度地图前端页面的数据接口背后是网页端实时渲染用的它没有授权给外部程序批量调用。你可以理解为它“没有锁门”但不代表你进去搬东西是合法的。如果你用自动化工具实时抓取页面接口、伪造Cookie、绕过风控校验那性质就完全变了。合法获取和不合规抓取之间边界我认为是三句话一是只调用官方公开的、开放授权的接口二是不突破访问频率和数据量限制三是不把数据用于授权范围之外的商业场景。如果你能守住这三条就不会踩到红线上。5.3 数据落库后的扩展玩法拿到POI点和边界数据之后后面能做的事其实很多。比如结合逆地理编码接口给每个学校补全周边500米内的交通站、餐饮店、小区数量做成一个“学校周边便利度评分”。这是POI点数据最常见的商业扩展方向不少做学区房分析、教育选址的平台就是吃这路数据。再比如把多个城市的学校边界合并做城市教育资源分布对比用GeoPandas做叠加分析形成“教育设施覆盖率”指标。这块的前提是你把前面的数据清洗做扎实否则后面分析建在沙地上一碰就塌。我个人在实际操作里最深的体会是爬虫这块方案并不难找难的是克制。很多数据你用非常规手段一天能拉几百万条但你真的需要那么多吗把需求缩小到“某个城市、某个类型、某个时间周期”用官方接口稳稳当当地拿可能就慢十几分钟但心态稳妥得多。后面做数据交付、做集成、做商业化每一环都禁得起查这才是真正的长期主义。