做商圈分析、选址评估或者城市热力研究,最头疼的其实不是模型怎么搭,而是数据怎么来。以前我手动处理过一批商圈POI,先在网页上查,再挨个标行政区,折腾了几天眼睛都快瞎了。后来干脆写了一套Python爬虫,把地图POI抓取、行政区反查、CSV导出、SQLite存储全串起来,形成一条稳定的数据准备链路。这套方案我复用了很多次,今天把它完整拆出来分享,适合做数据运营、商业分析、城市研究,或者单纯想练手爬虫的朋友参考。
整套方案最终能拿到什么?一份干净的结构化CSV和一套可反复查询的SQLite库,里面每条数据都包含POI名称、地址、经纬度、所属行政区、商圈标签。有了这个底子,后面做热力图、商圈对比、客群分析都顺手多了。
1. 商圈热力数据准备的整体设计
1.1 核心需求拆解:POI数据与行政区反查为什么绑定
商圈热力分析的数据源一般分两类。一类是运营商信令或者App定位日志,这种数据精度高但获取门槛极高,个人开发者基本拿不到;另一类就是公开地图POI数据,也就是地图上那些商家、写字楼、小区的兴趣点。POI数据覆盖广、更新快、获取成本低,做商圈边界识别、业态密度分析完全够用。
但POI原始数据有个很麻烦的问题——坐标系是火星坐标,而且返回的JSON里往往只有经纬度,没有直接的行政区名称,或者行政区字段在跨区商圈场景下不准确。举个例子,北京望京和酒仙桥紧挨着,有些POI的地址跨了两个区,单看纬度看不出到底是朝阳还是顺义。这时候就需要行政区反查,把经纬度反向转换成省市区三级行政区划。
为什么行政区反查特别重要?因为商圈热力分析的核心逻辑就是"按区聚合"。你得知道某个品牌的门店在哪个区分布最多、某个商圈的辐射范围跨了几个区,如果没有行政区字段,数据就是一盘散沙。所以POI抓取和行政区反查必须捆绑,抓完坐标后马上反查归属地,一条数据才算完整。
1.2 技术选型思路:从爬虫到存储的整体方案对比
爬虫这块,业界方案很多,我最后选了高德公开Web服务接口加requests库的组合。网上也有人用八爪鱼这类图形化工具下载百度POI,操作确实省事,但有两个问题:一是免费版有导出限制,二是没法做行政区反查的自动化后处理。自己写爬虫的好处是数据链路可控,拿到原始JSON之后想怎么蹂躏都行。
行政区反查我调研过两条技术路线。一条是用geopy库调Nominatim服务,精度高但依赖外网且限流严格,商用或者频繁调用会被封IP;另一条是用gps和行政区边界坐标集合做空间判断,纯本地计算,速度快不依赖外部服务。我最终采用的是第二种思路:内置行政区边界坐标集合,用射线法判断点归属。虽然边界数据精度不如官方权威库,但对商圈热力分析这个场景完全够用,而且可以在代码里演示清晰的空间算法逻辑。
存储这块非常明确,必须SQLite。原因很简单:单文件、零配置、Python内置sqlite3模块直接操作,不需要额外装数据库服务。商圈POI数据量级最多几十万条,SQLite完全扛得住,而且随时可以把库文件发给同事用。CSV导出则作为最终交付格式,方便非技术同事用Excel打开。
1.3 数据流全景:从请求到落库的完整链路
整个数据流长这样:城市名加商圈关键词拼出请求URL,用requests发GET请求,解析返回的JSON拿到POI列表,逐条清洗后做行政区反查,最后写入SQLite和CSV。反查失败的记录单独存到日志表,不中断主流程。
链路看似简单,实际上有几个细节决定成败。第一个是请求参数里有city限制,不传的话全国同名商圈都会返回,数据冗余且污染分析;第二个是翻页参数有单页上限,必须循环翻页直到取完;第三个是城市和行政区的映射关系,反查之后需要和POI自带的adcode字段做交叉验证,不匹配的标记出来人工核对。
2. 核心实现:POI抓取与行政区反查
2.1 请求构造与参数解析:公开地图Web服务接口的调用细节
我以高德开放平台的"关键字搜索"接口为例,这套实现也适用于百度等其他服务。核心请求参数如下表:
| 参数名 | 说明 | 示例值 | 注意点 |
|---|---|---|---|
| key | API密钥,需在高德开放平台申请 | 一串32位字符串 | 免费配额有限,注意控制频率 |
| keywords | 查询关键字,可以是商圈名或POI类型 | 国贸、亦庄、科技园 | 支持模糊匹配,建议加城市限制 |
| city | 城市名称 | 北京 | 不传会搜全国,必传 |
| citylimit | 是否限定当前城市 | true | 防止返回外地的同名POI |
| offset | 每页记录数 | 20或25 | 上限25,传大了会被截断 |
| page | 页码,从1开始 | 1 | 配合count字段控制翻页 |
| extensions | 返回结果详略级别 | base或all | all会返回更多字段但耗流量 |
用Python构造请求的代码极简,但有几个坑必须避开。一是请求头里要带上User-Agent和Referer,有些服务对裸的Python requests请求会返回"请求异常"之类的错误;二是用params字典传参而不是手拼URL字符串,前者会帮你处理URL编码,中文关键词才不会乱码。
import requests import json def fetch_poi(key, keywords, city, page=1): url = "https://restapi.amap.com/v3/place/text" params = { "key": key, "keywords": keywords, "city": city, "citylimit": "true", "offset": "25", "page": str(page), "extensions": "all", } headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36", "Referer": "https://lbs.amap.com/" } resp = requests.get(url, params=params, headers=headers, timeout=10) resp.raise_for_status() return resp.json()2.2 解析JSON的关键字段:经纬度、名称、地址与行政区
接口返回的JSON格式是固定的,最外层有一个status字段表示请求是否成功,count字段表示返回的记录总数,pois数组里装的是具体数据。POI对象里最关键的几个字段如下。
location字段是经纬度,格式是"经度,纬度",注意是经度在前、纬度在后,跟GPS坐标的顺序不一样,踩过坑的人都知道,一旦顺序弄反,那结果直接把你从北京反查到上海。
name是POI名称,address是地址文本。这两个字段都需要做清洗,因为原始数据里偶尔会带前后空格或者换行符。adcode是行政区编码,比如110105是朝阳区,这个编码在后面做行政区交叉验证时很有用。poi的business_area字段是商圈名,有些POI没有这个字段,得用前端字段判断。
def parse_poi(item): location = item.get("location", "") lng, lat = location.split(",") if "," in location else ("", "") return { "name": item.get("name", "").strip(), "address": item.get("address", "").strip(), "lng": float(lng) if lng else None, "lat": float(lat) if lat else None, "adcode": item.get("adcode", ""), "business_area": item.get("business_area", ""), "type": item.get("type", ""), }type字段容易忽略,实际很值得留。它表示POI的类型,比如"餐饮服务;中餐厅;中餐厅",这个字段可以一级级拆开,后面做业态分类时能直接用,不一定要重新去爬。
2.3 行政区反查双方案对比:geopy与内置边界坐标判断
行政区反查是这套方案里技术含量最高的部分,值得单独拉出来说。主流两个方案我都用过,下面做个对比。
方案一是用geopy调Nominatim服务,代码最短,一个reverse函数搞定,但是有两个硬伤。第一是Nominatim在国外,请求延迟高,大量POI跑下来很慢;第二是服务有严格的QPS限制,实测超过每秒1个请求就会被临时封锁IP,几十万条数据要跑好几天。而且评论区关于Nominatim坐标系和行政区域边界不匹配的抱怨很多,尤其在县级边界交界处,经常反查出邻省地名。
方案二是在本地做射线法判断,核心思路是"从点出发朝任意方向画一条射线,穿过多边形边界的次数为奇数则在多边形内"。这个算法的几何原理跑遍全国都不变,关键是边界数据怎么拿。我用了公开的行政区划GeoJSON数据,处理成精简多边形坐标集合,虽然单次加载内存占用略高,但查询是纯内存运算,单条耗时基本在毫秒级。
实际项目中我把两个方案结合:本地射线法做主力判断,边界坐标覆盖不到或匹配歧义时,再用geopy兜底。这种混合策略既保证速度又保证覆盖率,不过给读者演示代码时我建议直接用射线法版本,结构更清晰。
2.4 射线法反查的Python实现与边界数据处理
射线法的Python实现,核心就是一个函数:给定经纬度和一个多边形顶点列表,判断点是否在多边形内。这里要注意,经纬度是球面坐标,但商圈分析的范围很小,直接把经纬度当平面直角坐标处理误差可以接受,不必做投影转换。
def is_point_in_polygon(point, polygon): lng, lat = point n = len(polygon) inside = False j = n - 1 for i in range(n): pi = polygon[i] pj = polygon[j] # 判断射线与线段是否相交 if ((pi[1] > lat) != (pj[1] > lat)) and \ (lng < (pj[0] - pi[0]) * (lat - pi[1]) / (pj[1] - pi[1]) + pi[0]): inside = not inside j = i return inside def reverse_geocode(lng, lat, district_polygons): for adcode, info in district_polygons.items(): if is_point_in_polygon((lng, lat), info["polygon"]): return info["province"], info["city"], info["district"], adcode return None, None, None, None边界数据入库前不要直接用,要学会简化。多边形的顶点冗余度很高,一个区的边界可能有几万个点,但很多点在可视化尺度上完全重叠。我写了个抽稀算法,把顶点间距小于0.0001度的点去掉,轮廓不变但数据量能压缩到原来的十分之一。这样程序启动时加载边界更快,内存占用也更低。
3. 实操过程:商圈POI数据抓取全流程
3.1 商圈围栏设定:如何用关键词加城市限定锁定目标范围
抓POI之前先想清楚要抓什么。商圈热力分析的目标一般有两种,一种是把某个城市所有核心商圈全抓下来做横向对比,另一种是聚焦某个商圈把周边POI全部挖出来做细化分析。
第一种场景,关键词就是商圈名称列表,像"王府井""西单""三里屯",然后把city参数限定为北京,citylimit设为true。第二种场景,关键词可以换成POI类型关键词,比如"购物""餐饮""写字楼",再用中心点加radius做周边搜索。实测下来,第一种用text接口就够,第二种用around接口效果更好。
我做得比较多的是全城市多商圈对比,所以方案的默认逻辑就是跑一遍商圈名称列表。这里有个细节,商圈名称命名各地差异很大,北京叫"XX商圈",很多三四线城市根本没有"商圈"概念,直接搜"步行街""商业广场"反而更精准。建议先跑一批种子关键词,看看返回的count值,再决定要不要扩充词汇。
3.2 翻页与循环抓取:一次性抓干净不留尾巴
接口的默认限制是单页最多返回25条,而一个商圈的POI数量动辄上千,所以翻页逻辑是必须的。常见错误是只翻到第二页就停了,因为第二页返回的count和第一页一样,就误以为数据没变,其实每页数据内容完全不同。正确做法是用total_count变量记录第一个page返回的count字段,然后循环page从1到ceil(total_count / offset),最后对比实际拿到的总数和count是否一致。
def fetch_all_poi(key, keyword, city): all_pois = [] page = 1 total_pois = None while True: data = fetch_poi(key, keyword, city, page) if data.get("status") != "1": break pois = data.get("pois", []) if not pois: break if total_pois is None: total_pois = int(data.get("count", 0)) all_pois.extend(parse_poi(p) for p in pois) if len(all_pois) >= total_pois: break page += 1 time.sleep(random.uniform(0.5, 1.5)) return all_pois注意每翻完一页要sleep一下,随机间隔0.5到1.5秒。这是给自己留的后路,公共接口每秒几百个请求,不限制的话你的key很快就会被风控,轻则当日限流,重则永久封禁。
3.3 数据清洗统一队列:入库前的字段标准化处理
抓下来的数据不能直接入库。原始POI的address字段经常带一堆乱七八糟的东西,比如"北京市朝阳区建国路93号院12号楼底商"这种长地址,要拆出区级和路级信息;有的POI没有business_area字段,商圈名就留空了;有的name字段里有HTML转义符,不处理的话导出的CSV打开就是乱码。
我先统一做一轮标准化,规则如下:字符串字段全部strip,去除首尾空白和换行;name为空直接丢弃该条记录;lng或lat为空则跳过反查直接标记unknown;type字段的前两级保留,作为业态大类和小类。这些清洗规则都在一个函数里完成,这样无论在哪个环节发现脏数据,都走同一条清洗流水线,避免重复逻辑。
3.4 完成数据入库:持久化到SQLite并同步导出CSV
清洗完的POI列表进SQLite,建表语句设计得稍微克制一点,别搞太多冗余字段,够用就行。核心表就一个,加一个任务维度的表记录每次爬取的时间、城市、商圈、总数、成功数和失败数,方便后续对账。
CREATE TABLE IF NOT EXISTS poi_records ( id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, address TEXT, lng REAL, lat REAL, adcode TEXT, province TEXT, city TEXT, district TEXT, business_area TEXT, poi_type TEXT, source_keyword TEXT, created_at TEXT DEFAULT (datetime('now', 'localtime')) ); CREATE TABLE IF NOT EXISTS crawl_tasks ( id INTEGER PRIMARY KEY AUTOINCREMENT, city TEXT, keyword TEXT, total_count INTEGER, success_count INTEGER, fail_count INTEGER, start_time TEXT, end_time TEXT );入库同时生成CSV,用utf-8-sig编码,这个编码会加一个BOM头,Excel打开才不会乱码。CSV字段顺序跟数据库表字段保持一致,表头写成中文,非技术的同事拿到直接就能看明白。
4. 行政区反查的工程落地与性能优化
4.1 反查性能瓶颈在哪:从逐条请求到本地批量判断
最初版本的反查是逐条调geopy,10万条数据要跑到天荒地老,后来做了一次彻底的重构,把反查服务的远程调用彻底换成本地射线判断。本地判断的核心数据是行政区边界,我整理了一份全国省市区三级边界数据,预处理成精简多边形集合,启动时一次性加载到内存。
性能提升非常明显。900个区的边界数据大概占几十MB内存,加载完之后单条POI的反查耗时从网络请求的几百毫秒降低到本地计算的零点几毫秒,10万条数据三分钟内全部跑完,而且不消耗任何外部配额。
加载这块同样可以做并发优化。Python的GIL对纯计算密集型任务不友好,但这里主要是循环遍历匹配,可以用multiprocessing的进程池,把POI列表按区块拆给多个进程并行反查,四核机器实测能跑到3倍多加速。不过要注意进程间数据共享,边界数据在每个子进程里要各自加载一份,不能直接共享大对象。
4.2 边界坐标库的构建与更新策略
边界数据怎么来?我是从公开的行政区划数据源下载GeoJSON文件,然后用Python的json模块解析,把每个区县的Polygon多边形顶点坐标提取出来存储。这里的存储格式可以自己定义,我用的是pickle打包成dict,key是adcode,value包含多边形顶点列表和省市名称。
更新策略也很重要。行政区划每过一两年就会调整,有的区县合并,有的乡镇升格,边界数据如果老了,反查结果会错得离谱。我设计了一个version字段,每次更新边界数据就递增版本号,入库时记录版本信息,这样后续分析时如果发现行政区归属可疑,可以先查版本再判断是不是边界数据过期。
4.3 反查结果的多级校验与异常标记
反查不是跑完就结束,必须做校验。校验的核心是拿反查得到的adcode和POI自带的adcode做对比,两者一致说明结果高度可信;不一致说明这个点位于行政边界附近,或者原数据本身就有问题。我做了个三级标记:一致标OK,不一致标REVIEW,没有原adcode的标UNKNOWN。商圈分析时优先使用OK的数据,对REVIEW数据做人工抽检。这样避免了个别边界点影响整体分析精度。
5. 常见问题与排查技巧实录
5.1 接口返回数据不全或直接报错怎么办
高德接口报错常见错误码有几种。INVALID_USER_KEY说明key配置错了;DAILY_QUERY_OVER_LIMIT说明当日配额用完了;BUSINESS_OVER_LIMIT说明并发超限。应对策略也很简单:KEY配额用完就换绑定其他账号的key,或者等到第二天再跑;并发超限就降低并发、加长sleep时间。
有一种隐蔽情况是接口正常返回status=1,但count远小于预期。这通常是关键词太冷门或者city参数值不对,比如城市名用了"广西"而不是具体的"南宁市",接口可能返回空。排查时先手动在浏览器打开请求URL看返回结果,是参数问题还是数据真没有,一测便知。
5.2 行政区反查结果边界不准确的排查技巧
边界反查失败的高发区就是行政区域交界处。比如北京的朝阳和通州在东五环外那段,边界犬牙交错,一个点画条射线,可能一会儿穿进朝阳一会穿进通州。排查这类问题,我会把边界数据可视化叠到地图上看,确认边界顶点有没有明显缺口。多边形的首尾顶点必须闭合,首尾不相连会导致射线法判断异常。
另一个容易忽略的问题是经纬度坐标系的混用。高德返回的是GCJ-02火星坐标,有些公开边界数据用的是WGS-84原始坐标,两者相差300到500米。不做转换的话,反查结果大概率测偏一个区。解决方法是引入坐标转换工具库,抓到的火星坐标统一转成WGS-84再和边界数据匹配。
5.3 SQLite写入慢或数据库文件损坏的处理
SQLite写入慢一般是两个原因。一是每写一条都commit一次,事务提交很费IO;二是数据量大时没有用批量插入。我的做法是开启显式事务,攒够500条才commit一次,插入速度能提升一个量级。二是索引不要建太多,POI表只在district和business_area上建索引就够了,其他字段靠查询时过滤,索引过多反而拖慢写入。
数据库文件损坏概率不高,但突然断电或者磁盘空间不足时会遇到。事后补救用PRAGMA integrity_check命令做检查,遇到损坏用.recover命令导出能恢复的记录。更稳的做法是定期把SQLite文件备份到另一个磁盘,备份时先从主库拿副本再复制,别直接复制正在写入的库文件。
5.4 Excel打开CSV是乱码的经典问题
这个老生常谈但还是有人踩坑。CSV用普通utf-8编码保存,Excel会用GBK或者系统本地编码打开,中文自然变乱码。解决办法是写文件时指定encoding='utf-8-sig',这个编码会写入BOM标记,Excel就能正确识别UTF-8。代码就一行,非常便宜但省心。
还有人遇到CSV字段里含逗号或者换行符,导致Excel里列错位。这个要注意转义,用csv模块的writer而不是自己用逗号拼接字符串,csv.writer会自动处理引号包裹。用pandas的to_csv函数时注意设置quoting参数。
6. 项目扩展与热力数据进阶方向
6.1 从POI到热力:如何基于这套数据产出商圈热度评分
有了POI和行政区还不够,热力分析需要的是"评分"和"密度"。我给商圈做热度评估时,用这套数据计算了几个衍生指标:单位面积POI数量(密度)、业态多样性指数、头部连锁品牌占比。POI表里的poi_type字段这时候就派上用场了,把"餐饮服务""购物服务""生活服务"等大类聚合统计,得出业态结构。
你可能会问,POI数量真的能代表商圈热度吗?说实话,它刻画的是"供给密度"而不是"人流量",但商业分析中这个指标高度相关。一个有趣的交叉验证方法,是把POI密度和公开的夜间灯光或者菜鸟指数打分做相关分析,你会发现高关联度的区域确实集中在商圈核心区。
6.2 定时调度与增量更新:让数据仓库持续保鲜
商圈数据是会变化的,新店开业、老店关闭,一个月前的POI数据就过期了。我加了个轻量级调度,用系统的cron定时任务每周跑一次增量抓取。增量怎么识别?用SQLite里的唯一索引,比如(name, address, lng, lat)组合去重,新抓到的数据如果在库里已存在就不重复插入,否则作为新记录落库。
这种增量更新方案虽然简单,但已经能覆盖绝大多数场景。如果你有更复杂的去重需求,可以在表里增加一个etl_hash字段,抓取时对关键字段做哈希,插入前先查哈希是否已存在。实测这种方案在十几万条数据上性能没问题。
6.3 联动其他数据源:让商圈分析从文本走向可视化
数据落库之后,我有两个常用的可视化出口。第一个是生成热力图坐标文件,把POI经纬度整理成heatmap格式,直接丢进地图平台上渲染。第二个是把行政区聚合结果输出成GeoJSON,在地图上用色块展示各商圈POI密度。这两个出口都服务于同一个目的:让数据自己在图上说话,而不是堆在表格里。
有了时间和行政区的维度,还能做更多有意思的分析,比如某个新区商圈的POI密度随时间增长情况,或者横向对比几个新城的人口与商业配置缺口。这套爬虫加存储方案,本质上是一个可扩展的数据底座,后续加任何数据源和分析逻辑,都不用推翻重来。
6.4 反爬与合规的边界思考
最后必须聊聊风控和合规。我做这套方案的目的始终是获取公开数据做分析,而不是攻击任何网站或者绕过付费墙。地图开放平台的接口虽然提供了免费配额,但单位时间内请求量过大依然会被平台拒绝。所以整套代码里sleep是故意的,是合理利用规则、不给服务端造成压力的体现。
另外,对于需要登录或者动态加载的数据,我没有建议也没必要破解。做分析用到的数据,优先从公开渠道合规获取,抓取频率合理,存储使用的数据不对外违规传播。这才是爬虫项目能长期稳定运行的基础。有想深入做爬虫的朋友,在合规的前提下多折腾,功力就是这么一调一跑积累出来的。