全国学校JSON数据库:Python清洗、坐标转换与地图可视化实战
简介:中国学校地理位置JSON数据库基于百度地图API与全国行政区划数据,通过Python Scrapy爬虫系统采集整理,涵盖大学、中学、小学及分校区共计28610条学校记录,每条包含省市区、详细地址与经纬度坐标,并按学段分层存储。这份资源面向需要学校地理信息的GIS开发人员、教育数据分析师及Python爬虫学习者,可直接用于地图打点、区域统计、教育资源分布分析与可视化展示,也适合离线调用与数据校验。压缩包共二十个文件,体积约1.43MB,核心数据为学校总数与大学明细两组JSON文件,并附仅含省市区字段的精简版本,便于不同场景调用;代码部分包含管道、设置、中间件等爬虫工程文件,以及数据处理脚本和说明文档,可快速了解采集、解析与存储流程。目前已有二百一十六人学习下载,对于希望获取现成学校点位数据或研究百度地图接口爬虫实现方式的读者,是一份轻量实用的参考资源。
1. 拿到这份全国学校 JSON 数据库,先别急着做地图可视化
做区域教育资源分析、选址评估或者 GIS 落图的人,大概率都卡在同一个问题上:学校数据从哪来。手工整理太慢,逐个爬官网又容易被反爬拦死。这份压缩包直接给了你结果——28610 条学校记录、8084 条大学记录,全程 JSON 格式,字段覆盖省市区、详细地址和百度地图坐标,来源是百度地图 Place API 的检索结果。拆完这个包,我最直观的感受是:终于不用自己再写那套拼 URL、翻页、解析 JSON 的采集脚本了。如果你正在用 Python 做学校分布统计、要拿经纬度做地图可视化,或者需要一个相对完整的全国学校名单做数据核验,这份资源开箱就能用。
2. 数据拆解:28610 条记录里有哪些字段,怎么选文件
拆任何一份数据资源,第一件事永远不是跑业务,而是先看清盘子里有什么。这个压缩包放了四个 JSON 主文件、一套areas.json地区字典,外加完整的 Scrapy 采集工程,理解它们的层级关系能帮你省掉大量后期清洗时间。
2.1 四个 JSON 文件怎么选:坐标、省市区和详细地址三档粒度
文件分两组,每组是「全量字段」和「只要省市区」的对照。
| 文件名 | 记录数 | 字段粒度 | 典型用途 |
|---|---|---|---|
| 学校-28610.json | 28610 | 分校区、省市区、详细地址、坐标 | GIS 落图、地址核验、空间分析 |
| 学校-28610-只有省市区.json | 28610 | 分校区、省市区 | 按区域聚合统计、快速筛选 |
| 大学-8084.json | 8084 | 分校区、省市区、详细地址、坐标 | 高校专项分析、校址可视化 |
| 大学-8084-只有省市区.json | 8084 | 分校区、省市区 | 高校数量统计、省市级对比 |
这里的命名关系要特别说一句:从记录数看,大学部分大概率是从全量 28610 里筛出来的专项子集,数量对得上,字段结构也和全量文件保持一致。所以做高校分析时直接读大学-8084.json,不用自己再从全量里按关键词过滤一遍。做 K12 或者其他非高校分析时,就直接把 28610 的全量文件作为底座,自己按业务条件筛选。
选文件的判断标准只有一个:你要不要用「详细地址」和「坐标」。如果要画地图、算距离、做空间关联,只有省市区的文件完全不够用,必须上带坐标的全字段版本;如果只是按省、市、区县统计数量、做榜单排序,那只有省市区版本更轻,加载更快,也少了大量无用的地址干扰。
2.2 从 areas.json 到百度地图 Place API:采集链路上的数据来源
这套数据的源头是百度地图开放平台的 Place API 检索接口,areas.json则是全国省市区三级行政区划清单,爬虫就是拿它当「遍历列表」用的。
采集逻辑大致是这样:先读areas.json拿到所有省份和城市,对每个城市分别发起 Place API 检索,query 参数传「大学」「中学」「小学」「幼儿园」这类关键词,region 参数传当前城市名,然后把每一页的 JSON 结果收集起来,最后合并、补全省市区字段、落盘成最终文件。README.md里也注明了地区列表来自 GitHub 上的 Administrative-divisions-of-China 项目,这个字典本身也是公开数据,只做行政划分,不涉及任何敏感信息。
Place API 的请求形态是标准的 GET 接口:
https://api.map.baidu.com/place/v2/search?query=大学®ion=北京市&output=json&ak=YOUR_AK&page_size=20&page_num=0参数里query是检索关键词,region限定检索区域,ak是你在百度地图开放平台申请的开发密钥,page_size最大只能填 20,page_num从 0 开始翻页。类似这种带ak的拼接逻辑在 README 里写得很清楚,这也是整套采集脚本的核心入口。
需要留意的边界是:Place API 单次查询最多只能翻 20 页,也就是一个关键词在一个区域内最多取回 20 × 20 = 400 条结果。这就是为什么爬虫不能只按「省」跑一遍——像北京、上海这种学校密集的城市,一个省的区域粒度根本拿不全,必须拆到区县级去查,areas.json在这里就是爬虫的「分片索引」。
2.3 拿到文件后的第一件事:打印字段名,别靠猜
这是我反复跟人强调的习惯:任何 JSON 资源到手,先别急着写业务代码,先看一眼真实数据长什么样。不同批次的采集脚本对字段封装方式不一样,有的把坐标平铺成lat/lng,有的嵌套在location对象里,你不能拿上一份数据的字段名硬套这一份。
import json with open("学校-28610.json", "r", encoding="utf-8") as f: data = json.load(f) print(type(data)) # 判断是 list 还是 dict if isinstance(data, dict): # 兼容 {"records": [...]} 这种包一层的情况 records = data.get("records", list(data.values())[0]) else: records = data print("总记录数:", len(records)) print("第一条字段名:", list(records[0].keys())) print(records[0])这段代码的重点是先用type(data)确认顶层结构,因为有的脚本用json.dump直接写数组,有的会包一层字典。list(records[0].keys())的作用是拿到字段名清单,配合print(records[0])看一条完整记录,你才能确定后面 pandas 列名、去重字段、坐标字段该用谁。这一步跑完,再进第 3 章的清洗流程就顺了。
3. Python 加载与清洗:把 JSON 落成本地可用的学校数据库
看明白字段之后,下一步就是把 JSON 变成一个可以随手查询、聚合、导出的本地数据库。这里不推荐直接开数据库软件导数据,28610 条的量级在 pandas 里就是毫秒级的事,先用 DataFrame 做清洗反而是效率最高的路径。
3.1 用 pandas 直接读 JSON:类型判断与快速预览
在 2.3 节已经确认过顶层结构,这里直接把结果交给 pandas:
import json import pandas as pd with open("学校-28610.json", "r", encoding="utf-8") as f: data = json.load(f) if isinstance(data, dict): records = data.get("records", list(data.values())[0]) else: records = data df = pd.DataFrame(records) print("行列数:", df.shape) print("各字段缺失值数量:") print(df.isna().sum())df.shape一眼确认总行数和总列数,和文件名的 28610 对得上,说明文件没被截断。df.isna().sum()输出每个字段的缺失量,这是清洗前最重要的体检指标——如果某个字段缺了上千条,后面的聚合逻辑就要考虑填充或剔除。
如果你的场景是处理超大文件,或者后续要反复查询,我更建议顺手把这份数据转成 SQLite 或 Parquet 再走业务逻辑。但在 28610 这个量级上,pandas 完全够用,不用额外引入数据库组件。Windows 环境下注意读取文件时强制encoding="utf-8",别用默认编码,否则中文大概率出乱码。
3.2 省市区字段清洗与经纬度合法性校验
坐标数据有一个绕不开的校验动作:检查经纬度范围。中国境内经度在 73 到 135 度之间,纬度在 3 到 54 度之间,如果某条记录的坐标落在这个范围之外,说明原始数据大概率有误。
# 兼容嵌套 location 对象的情况,把坐标归一化到顶层字段 def normalize_location(rec): loc = rec.get("location") if isinstance(loc, dict): rec["lng"] = loc.get("lng") rec["lat"] = loc.get("lat") return rec df = pd.DataFrame([normalize_location(r) for r in records]) df["lng"] = pd.to_numeric(df["lng"], errors="coerce") df["lat"] = pd.to_numeric(df["lat"], errors="coerce") # 粗校验:中国境内经纬度范围 bad = df[ (df["lng"] < 73) | (df["lng"] > 135) | (df["lat"] < 3) | (df["lat"] > 54) ] print("坐标越界记录数:", len(bad))pd.to_numeric加errors="coerce"会把非数字值变成 NaN,避免字符串坐标参与计算时报错。这里的范围校验是最粗的一层过滤,能拦住坐标写反、单位错误这类低级的坑。如果越界记录数占总量比例很小,可以直接剔除;如果占比高,就要回到采集环节检查是不是区域参数传错了。
省市区字段同样要做归一化:把空字符串统一替换成 NaN,再检查缺失分布。百度接口返回的city字段时有时无,尤其是直辖市的区县数据,所以比较稳的做法是在聚合时用province为主导维度,缺失的city用province回填。
3.3 去重策略:校区保留还是合并,取决于你的业务口径
这批数据里「分校区」是刻意拆开的,28610 条里同一所学校可能有本部、东校区、附属中学等多条记录。要不要去重,完全看你的分析口径。
# 按 省+市+区+学校名 去重,视为同一个学校实体 df_dedup = df.drop_duplicates(subset=["province", "city", "district", "name"]) print("原始记录数:", len(df), "去重后:", len(df_dedup)) # 按省市区聚合,快速看数据覆盖度 stats = df.groupby(["province", "city", "district"]).size().reset_index(name="school_count") print(stats.sort_values("school_count", ascending=False).head(10))drop_duplicates的subset参数决定了去重键。如果你的目标是「评估某区域有多少个学校实体」,按前四个字段去重是合理口径;如果你的目标是「看学校的地理分布密度」,那保留校区反而更准确,因为分校区在物理空间上是真实存在的位置点。这里没有标准答案,只有口径一致性——同一份报告里,去重规则前后必须统一。
4. 自己跑一遍 Scrapy 采集:从 AK 申请到 pipelines 落盘
如果你只想用现成数据,第 3 章结束就够了。但假如学校名单有变动、或者你想把关键词换成「医院」「图书馆」,就需要自己把采集链路跑起来。这套工程的完整度不错,从scrapy.cfg到dataHandle.py都齐了,我把每个文件的职责拆开说。
4.1 工程结构:universityCollecter 里每个文件负责什么
| 文件/目录 | 职责 |
|---|---|
| scrapy.cfg | Scrapy 工程配置,声明默认项目位置 |
| universityCollecter/ | 项目主包,放爬虫与管道代码 |
| spiders/ | 爬虫主体,定义请求规则和解析函数 |
| items.py | 定义 SchoolItem 字段结构 |
| pipelines.py | 数据处理管道,负责写入 JSON 文件 |
| settings.py | 全局配置:并发、延迟、管道开关、输出路径 |
| middlewares.py | 中间件,一般用于代理、UA、重试扩展 |
| dataHandle.py | 独立清洗脚本,合并分片结果并补全省市区字段 |
| areas.json | 全国省市区三级地区清单,采集的遍历入参 |
整个数据流是:spiders读取areas.json拿到地区列表 → 对每个地区请求百度 Place API → 解析 JSON 结果封装成SchoolItem→ 交给pipelines.py写入文件 → 最后用dataHandle.py做全局清洗合并。middlewares.py在这套工程里基本是预留位,本地跑百度接口不需要代理,保持默认即可。
4.2 items、pipelines、settings 三个文件的关键配置
items.py定义存储结构,字段名要和百度接口返回的 JSON key 对应上:
import scrapy class SchoolItem(scrapy.Item): name = scrapy.Field() # 学校名称 province = scrapy.Field() # 省 city = scrapy.Field() # 市 district = scrapy.Field() # 区县 address = scrapy.Field() # 详细地址 lng = scrapy.Field() # 经度 lat = scrapy.Field() # 纬度pipelines.py是把 Item 落盘的核心,我一般会写成批量缓冲再一次性写入,避免每条都开关文件:
import json class JsonWriterPipeline: def open_spider(self, spider): path = spider.settings.get("OUTPUT_FILE", "schools.json") self.file = open(path, "w", encoding="utf-8") self.records = [] def process_item(self, item, spider): self.records.append(dict(item)) return item def close_spider(self, spider): json.dump(self.records, self.file, ensure_ascii=False, indent=2) self.file.close()open_spider在爬虫启动时执行,process_item每收到一个 Item 就追加到缓冲区,close_spider在结束前统一写盘。ensure_ascii=False是必须的,否则中文全变成\uXXXX转义序列,文件没法直接看。indent=2只是让人读着舒服,对机器无所谓。
settings.py里最影响成败的是并发和延迟两个参数:
BOT_NAME = "universityCollecter" ROBOTSTXT_OBEY = False CONCURRENT_REQUESTS = 4 DOWNLOAD_DELAY = 1.0 ITEM_PIPELINES = { "universityCollecter.pipelines.JsonWriterPipeline": 300, } OUTPUT_FILE = "schools.json"ROBOTSTXT_OBEY对纯 API 接口设 False 是常规操作,因为百度地图开放平台是接口服务,不是网页抓取,不存在 robots 协议约束。CONCURRENT_REQUESTS和DOWNLOAD_DELAY是默契组合——百度开放平台的免费配额对 QPS 限制很严格,并发开大不是更快,而是直接撞上频率限制报错。个人经验是 4 并发配 1 秒延迟起步,跑稳定了再一点点加。
4.3 跑通命令与百度接口状态码处理
启动采集的命令如下,-a参数会把值传给 spider:
scrapy crawl school -a ak=你的百度密钥 -a query=大学百度 Place API 返回体里status字段是关键:status: 0表示成功,非 0 都是异常。日常主要会遇到401(AK 无效)、403(配额耗尽或权限不足)、16(每秒并发超限)。
{ "status": 0, "results": [ { "name": "北京大学", "location": {"lat": 39.994, "lng": 116.306}, "address": "北京市海淀区颐和园路5号" } ], "total": 42 }解析时用total字段判断是否还有下一页:当前已收集数量小于total就继续翻页,直到拿满为止。这套解析逻辑写进 spider 的 parse 方法,配合areas.json逐区域执行,最终输出的原始结果再交给dataHandle.py做字段补全和合并。
5. 避坑指南:坐标偏移、一校多址和 AK 配额,这些坑我都踩过
数据资源本身可能没问题,但导入到你的项目里时,坐标系、去重口径、接口配额这三个方向几乎必出状况。下面按我实际踩过的顺序列出来,每条都是「现象 → 原因 → 解决」的完整链路。
5.1 点位整体偏移:百度 BD-09 坐标和 WGS-84 底图差几百米
现象:把坐标投到 OpenStreetMap 或其他 WGS-84 底图上,学校点位整体偏到马路对面,严重的偏进隔壁小区;投到高德底图上也有小幅度偏移。
原因:百度地图 Place API 返回的是 BD-09 坐标系,这是百度在 GCJ-02 之上又叠加一层偏移的私有坐标系,直接当 WGS-84 用必然偏移。这套坐标系是公开算法,不存在保密问题,但转换必须做。
解决:写一个 BD-09 转 WGS-84 的转换函数,入库前统一处理:
import math def _transform_lng_lat(lng, lat): a = 6378245.0 ee = 0.00669342162296594323 dlat = lat - 35.0 dlng = lng - 105.0 radlat = lat * math.pi / 180.0 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 bd09_to_wgs84(lng, lat): 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) gcj_lng = z * math.cos(theta) + 0.0065 gcj_lat = z * math.sin(theta) + 0.006 return _transform_lng_lat(gcj_lng, gcj_lat)转换思路分两步:先 BD-09 还原到 GCJ-02,再从 GCJ-02 转到 WGS-84。math.pi和math.atan2来自标准库,不需要额外依赖。做地图落图前,先明确你的底图是什么坐标系,再决定是否调用这个函数。
5.2 一校多址:28610 条里藏着大量分校区,直接去重会翻车
现象:按学校名直接去重后记录数骤减很多;或者统计某个区学校数时,发现同一所学校名出现在三四个不同地址。
原因:百度 POI 数据会把本部、分校区、附属学校拆成不同 POI 记录,名称有的带「东校区」「分校区」后缀,有的完全同名但地址不同。这批数据里的「分校区」是刻意保留的维度,不是错误。
解决:在下游分析前先定口径。做「物理点位」分析(如地图撒点、周边辐射)就保留全量 28610;做「学校实体」统计就用(province, city, district, name)四字段去重;如果你只关心高等教育,直接用大学-8084.json,别在全量里自己用关键词筛,容易漏掉「学院」「分校」这类命名变体。
5.3 AK 配额问题:采集到一半 status 变成 401 / 403 / 16
现象:采集前几百条正常,之后日志里连续出现status非 0 的响应,文件里大量缺失记录,甚至整批中断。
原因:百度地图开放平台个人认证账号有每日调用配额和 QPS 上限,Place API 按检索次数扣配额;并发设置过高会直接触发每秒请求数限制(对应返回码 16)。
解决:在settings.py里把CONCURRENT_REQUESTS降到 4 以内、DOWNLOAD_DELAY调到 1 秒以上;把全国采集任务按areas.json切成多个批次跑,每一批之间手动停顿 5 到 10 秒;如果任务量大,准备两三个 AK 轮换使用,但每个 AK 的配额消耗还是会在后台记录,轮换只是缓解单账号 QPS 瓶颈,不是绕开配额的手段。
5.4 Windows 下打开文件乱码、导出 CSV 列错位
现象:Windows 记事本直接打开 JSON 文件中文全乱;用 Excel 打开脚本导出的 CSV 时,中文变成乱码或列全部挤到一列。
原因:脚本写文件时没有指定ensure_ascii=False,中文被写成\uXXXX;或者导 CSV 时用了默认 UTF-8 编码,而 Windows 版 Excel 默认按 ANSI 解析 CSV。
解决:所有读写统一显式指定encoding="utf-8",json.dump加ensure_ascii=False;如果要给 Excel 用的 CSV,保存时用带 BOM 的 UTF-8:
df.to_csv("schools.csv", index=False, encoding="utf-8-sig")utf-8-sig会在文件头部写入 BOM 标记,Excel 打开时才能正确识别为 UTF-8 编码并自动按中文解析。这个细节在 Linux 上无感,在 Windows 上是必踩的坎。
6. 进阶技巧:用这份学校库做区域教育资源密度聚合
数据清洗完、坐标校准后,这份学校库真正的价值在于和业务结合。最后给两个我常用的落地方向:省市级聚合统计和 GeoJSON 导出。
6.1 按省市区聚合:快速看教育资源集中度
city_stats = df.groupby(["province", "city"]).size().reset_index(name="school_count") top = city_stats.sort_values("school_count", ascending=False).head(10) print(top)groupby按省和市两层聚合,直接产出一个「每个城市有多少学校」的统计表。这里注意一个隐藏坑:百度返回的city字段可能为空,尤其是直辖市下属区县的数据,聚合前最好把空city先用province回填,否则直辖市的数据会全部落到NaN分组里。拿到这份统计之后,你可以继续接占比、环比,或者和其他经济数据做相关性分析,都是很顺的路。
6.2 导出 GeoJSON 直接进 GIS 工具
GeoJSON 是 QGIS、kepler.gl、Leaflet 通用的地理数据格式,把学校库转成 GeoJSON 后,就能用可视化工具肉眼检查数据质量——这一步强烈建议做,因为坐标漂移、字段错位这类问题,在表格里很难发现,撒到地图上一眼就能看出来。
features = [] for _, row in df.iterrows(): if pd.isna(row["lng"]) or pd.isna(row["lat"]): continue features.append({ "type": "Feature", "geometry": {"type": "Point", "coordinates": [float(row["lng"]), float(row["lat"])]}, "properties": { "name": row.get("name", ""), "province": row.get("province", ""), "city": row.get("city", ""), "district": row.get("district", "") } }) geojson = {"type": "FeatureCollection", "features": features} with open("schools.geojson", "w", encoding="utf-8") as f: json.dump(geojson, f, ensure_ascii=False)导出前先跳过lng/lat为空的记录,避免生成非法坐标点。如果目标是 WGS-84 底图,记得在导出前对坐标先跑一遍第 5.1 节的bd09_to_wgs84转换,否则点位漂移会在可视化阶段被放大。导出后建议在 kepler.gl 或 QGIS 里随机抽查 20 个点,确认没有「学校落在水里」或「点在海里」的异常。
从那以后,我每次拿到任何一份带坐标的 JSON 数据,都强制走这三步:先打印字段名、再做坐标范围校验、最后导出 GeoJSON 肉眼抽查一批点位。这三步加起来不到十分钟,却省掉了后面好几天返工的后悔药。这份学校库的采集脚本和清洗逻辑都很规范,希望帮到你。
本文还有配套的精品资源,点击获取