
做商圈分析、选址评估或者城市热力研究最头疼的其实不是模型怎么搭而是数据怎么来。以前我手动处理过一批商圈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服务接口的调用细节我以高德开放平台的关键字搜索接口为例这套实现也适用于百度等其他服务。核心请求参数如下表参数名说明示例值注意点keyAPI密钥需在高德开放平台申请一串32位字符串免费配额有限注意控制频率keywords查询关键字可以是商圈名或POI类型国贸、亦庄、科技园支持模糊匹配建议加城市限制city城市名称北京不传会搜全国必传citylimit是否限定当前城市true防止返回外地的同名POIoffset每页记录数20或25上限25传大了会被截断page页码从1开始1配合count字段控制翻页extensions返回结果详略级别base或allall会返回更多字段但耗流量用Python构造请求的代码极简但有几个坑必须避开。一是请求头里要带上User-Agent和Referer有些服务对裸的Python requests请求会返回请求异常之类的错误二是用params字典传参而不是手拼URL字符串前者会帮你处理URL编码中文关键词才不会乱码。import requests import json def fetch_poi(key, keywords, city, page1): 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, paramsparams, headersheaders, timeout10) 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为空则跳过反查直接标记unknowntype字段的前两级保留作为业态大类和小类。这些清洗规则都在一个函数里完成这样无论在哪个环节发现脏数据都走同一条清洗流水线避免重复逻辑。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 反查性能瓶颈在哪从逐条请求到本地批量判断最初版本的反查是逐条调geopy10万条数据要跑到天荒地老后来做了一次彻底的重构把反查服务的远程调用彻底换成本地射线判断。本地判断的核心数据是行政区边界我整理了一份全国省市区三级边界数据预处理成精简多边形集合启动时一次性加载到内存。性能提升非常明显。900个区的边界数据大概占几十MB内存加载完之后单条POI的反查耗时从网络请求的几百毫秒降低到本地计算的零点几毫秒10万条数据三分钟内全部跑完而且不消耗任何外部配额。加载这块同样可以做并发优化。Python的GIL对纯计算密集型任务不友好但这里主要是循环遍历匹配可以用multiprocessing的进程池把POI列表按区块拆给多个进程并行反查四核机器实测能跑到3倍多加速。不过要注意进程间数据共享边界数据在每个子进程里要各自加载一份不能直接共享大对象。4.2 边界坐标库的构建与更新策略边界数据怎么来我是从公开的行政区划数据源下载GeoJSON文件然后用Python的json模块解析把每个区县的Polygon多边形顶点坐标提取出来存储。这里的存储格式可以自己定义我用的是pickle打包成dictkey是adcodevalue包含多边形顶点列表和省市名称。更新策略也很重要。行政区划每过一两年就会调整有的区县合并有的乡镇升格边界数据如果老了反查结果会错得离谱。我设计了一个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时间。有一种隐蔽情况是接口正常返回status1但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或者系统本地编码打开中文自然变乱码。解决办法是写文件时指定encodingutf-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是故意的是合理利用规则、不给服务端造成压力的体现。另外对于需要登录或者动态加载的数据我没有建议也没必要破解。做分析用到的数据优先从公开渠道合规获取抓取频率合理存储使用的数据不对外违规传播。这才是爬虫项目能长期稳定运行的基础。有想深入做爬虫的朋友在合规的前提下多折腾功力就是这么一调一跑积累出来的。