地图POI采集自动化:网格切分与增量更新实战解析

📅 发布时间:2026/10/21 13:20:49
地图POI采集自动化:网格切分与增量更新实战解析
lingbot-map 这个名字拆开看就是 ling bot map。Ling 可以理解成“聆听”或“灵敏”bot 是机器人map 是地图。合起来它就是一个专门跟地图数据打交道的自动化采集机器人。我最初做这个项目是因为要维护一套本地生活服务的门店信息库需要持续获取区域内的商户 POI兴趣点数据比如店名、地址、电话、类别、经纬度还得定期更新确保数据不是“死”的。手动一个个搜、一个个复制粘贴显然不现实于是就有了 lingbot-map一个把地图 POI 采集、清洗、去重、入库、增量更新串成自动化流程的爬虫服务。这个项目能解决什么问题往小了说它能帮你把某个城市某个商圈内的餐饮店、便利店、维修店等全量捞出来存成结构化表格或数据库往大了说它是做选址分析、竞品监控、线下零售网络规划、本地内容聚合的基础数据底座。适合谁参考如果你是做数据开发的、搞GIS分析的、或者接手了类似“给我一份区域商家清单”的需求这篇复盘里的思路和代码可以直接抄作业如果只是好奇地图数据怎么自动化获取也能通过它理解整个技术链路。1. 项目定位与整体架构拆解1.1 先想清楚这到底是个采集器还是个数据管道最开始我把这个项目想简单了觉得写个爬虫循环请求地图接口拿数据就行。跑了两天后发现不对劲采集回来的数据有大量重复、字段格式五花八门、部分坐标偏移、更新周期没法统一管理。如果只把它当成“爬虫”那它就是一次性脚本跑完就扔。但如果把它当成“数据管道”设计上就要考虑来源、清洗、存储、调度四个环节后续维护才会轻松。所以 lingbot-map 最终长这样输入层行政区划代码列表 关键词列表如“餐饮”“便利店”“维修”采集层按区域网格划分构造查询请求拿到原始 POI 列表处理层字段标准化、去重、坐标格式转换、类别归一化存储层MySQL 保存明细数据Redis 缓存已处理的任务快照避免重复采调度层定时任务按周/月触发增量更新对比新老数据状态整个链路下来已经不是“写个爬虫”而是一套有状态的数据维护系统。这个认知转变是这个项目最关键的起点。1.2 为什么选择“网格切分 关键词组合”而不是直接行政区下拉地图服务的 POI 搜索接口一般都有数量上限比如单次请求最多返回 20 或 25 条超出部分拿不到。城市级别的数据动辄几十万条直接按行政区查根本调不全。所以必须把查询范围切小把每个小格子里的数据量控制在接口上限之内。实际做法是用 geohash 把城市范围切成 6 级或 7 级格子每个格子边界约 1.2 公里到 4.9 公里具体看纬度。对每个格子依次发起关键词检索记录格子和检索词的组合状态这样一个格子查漏了还能补查完成了就标记 done下次不再重复跑。有人问“为什么不直接用多边形区域检索”因为不少地图服务的区域查询对形状和大小限制更严格用互不重叠的网格反而最容易控制。这里要合理补充一句网格粒度选取需要根据目标城市的面积和 POI 密度试跑。我的经验是先跑一个中等面积行政区统计返回总量如果单格结果经常顶到上限就把 geohash 精度调高一级如果大多数格子结果很少就降一级避免请求次数白涨。1.3 命名规范与服务边界“lingbot-map”里 bot 的含义是它要像个机器人一样自主跑、自主停、自主记账。所以项目里设计了任务状态机核心包括这些状态PENDING待执行RUNNING执行中RETRY_WAIT等待重试FINISHED成功完成PARTIAL_DONE部分完成查了一部分还剩一部分有了这套状态服务重启、接口限额、数据回滚都变得可控。否则脚本一旦中途挂了你都不知道自己采到哪一格、哪些数据已经入库第二次再跑直接重复一大堆。2. 技术选型解析坐标系、存储与任务调度2.1 坐标系的坑能早踩就早踩地图数据里最容易被忽视但也最容易出错的是坐标系。地图服务商返回的坐标基本都是 GCJ-02 坐标系俗称“火星坐标系”是经过偏移处理的。而如果你要跟 GPS 原始坐标WGS-84或其他来源的数据做匹配必须做转换否则同一家店的两份数据在图上能差好几百米。我一开始没有做任何坐标转换结果采集回来的数据跟线上地图一对比点位全飘。后来在入库前加了一层转换模块# gcj02_to_wgs84 近似反向转换 import math def gcj02_to_wgs84(lng, lat): a 6378245.0 ee 0.00669342162296594323 dlat transform_lat(lng - 105.0, lat - 35.0) dlng transform_lng(lng - 105.0, lat - 35.0) radlat lat / 180.0 * math.pi 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) wgs_lat lat - dlat wgs_lng lng - dlng return wgs_lng, wgs_lat这段代码网上很容易找到但真正要踩的坑是转换后一定要回查验证。拿同一个地址用地图服务和纯 GPS 采集设备分别测点位误差在 5 米内才是正常。另外如果只做城市级展示不跨数据源匹配不转换也行但如果后续接地理分析、路径规划、跨平台对标必须统一坐标系。2.2 存储选型明细表和任务表分开采集结果我用 MySQL 存因为后续可能按类别统计、按商圈聚合SQL 天然方便。建了两张核心表一张存 POI 明细一张存采集任务状态。明细表字段包括CREATE TABLE poi_detail ( id BIGINT PRIMARY KEY AUTO_INCREMENT, poi_id VARCHAR(64) NOT NULL COMMENT 来源POI唯一标识, name VARCHAR(255) NOT NULL COMMENT 商户名称, category VARCHAR(64) COMMENT 一级分类, sub_category VARCHAR(64) COMMENT 二级分类, address VARCHAR(512) COMMENT 地址, tel VARCHAR(64) COMMENT 联系电话, lng_wgs84 DECIMAL(10,6) COMMENT WGS-84经度, lat_wgs84 DECIMAL(10,6) COMMENT WGS-84纬度, lng_gcj02 DECIMAL(10,6) COMMENT GCJ-02经度, lat_gcj02 DECIMAL(10,6) COMMENT GCJ-02纬度, source VARCHAR(32) COMMENT 数据来源, raw_json JSON COMMENT 原始返回JSON留底, created_at DATETIME, updated_at DATETIME, UNIQUE KEY uk_poi_source (poi_id, source) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;任务表记录每个网格、每个关键词、每次执行的完成情况字段包括任务 ID、行政区划、geohash 格子、关键词、状态、请求次数、最后执行时间。有了这两张表后面做增量更新才有依据不是每次重新全量抓而是只重跑没完成的任务和周期过期的任务。关于为什么不用 Elasticsearch 或对象存储POI 数据平铺用 MySQL 足够ES 适合后续做地理搜索但调试成本高对象存储适合存原始响应 JSON 备份不适合频繁条件查询。先把简单的做好量级上来再拆也不迟。2.3 调度器设计定时触发 失败补偿调度器我一开始用操作系统的 crontab 直接跑 Python 脚本后来发现任务一多日志散乱、重试逻辑难以维护。后来改成框架内置的调度用一个常驻进程每 5 分钟扫一次任务表把到期或超时的任务捞出来执行。这轮改动的核心收益是任务的暂停、续跑、手动重发都在同一套接口里完成而不是靠“重新执行脚本”。举例来说某天第三方接口升级导致部分请求失败我可以筛选状态为RETRY_WAIT的任务批量重置而不是删表重来。3. 核心采集流程实战从网格切分到入库去重3.1 行政区划数据底座要采集一个城市先得知道这个城市包含哪些区县、边界是怎样的。我使用公开的行政区划数据包含精确边界坐标然后拿这些边界和 geohash 网格做相交判断。城市中心的网格完整保留城市边界上的网格做裁剪或直接跳过避免大量无效请求打到城外的农田湖泊上。具体实现上先用 shapely 库判断网格多边形与行政区边界的包含关系from shapely.geometry import Polygon, Point def grid_intersects_region(grid_polygon, region_polygon): return grid_polygon.intersects(region_polygon)不直接拿全部网格跑有两个原因一是网格太大时城市边界处容易包进太多外部区域白白消耗请求配额二是后续统计网格覆盖率时包含关系不清会导致数值失真影响你对“全量采集是否完成”的判断。3.2 构造查询关键词 区域 分页POI 检索服务的标准请求参数通常包括关键词、区域范围矩形或圆形、页码。构造请求的时候有三个关键点关键词壳不要一次性把所有商家类型都搜完比如只搜一个“餐饮”可能只能覆盖部分子类最好拆成“火锅”“面馆”“咖啡”“快餐”等子词分别检索再合并。这样单次请求数量上限的利用率更高数据类别也更细分。矩形或圆形范围网格是方形的用矩形范围最直接。圆形会形成大量重叠和空隙反而不利于按网格统计覆盖率。分页深度控制地图接口的分页上限往往不是无限的翻到十几页就可能强制终止。所以网格大小要设计得让结果不超过分页上限。如果发现某个格子一直翻到底还有数据就标记为PARTIAL_DONE并在后续用更细的网格重切。3.3 构造请求的代码骨架import requests import time def fetch_poi(keyword, grid_bounds, page): params { keyword: keyword, bounds: f{grid_bounds[min_lat]},{grid_bounds[min_lng]},{grid_bounds[max_lat]},{grid_bounds[max_lng]}, page: page, page_size: 20, output: json } resp requests.get( https://openapi.example.com/poi/search, paramsparams, headers{Authorization: Bearer your_token}, timeout10 ) if resp.status_code 200: return resp.json() elif resp.status_code 429: time.sleep(30) # 触达频率限制慢速重试 return fetch_poi(keyword, grid_bounds, page) else: resp.raise_for_status()提示示例里的请求域名和 token 只是演示用法。实际开发时务必先去对应服务商开放平台阅读服务条款和配额说明把自己的账号密钥放到配置中心或环境变量里不要硬编码在代码中。3.4 清洗与去重同一家店不要出现三次采集过程中同一家店可能通过不同关键词反复出现。比如“某食堂”既会命中“餐饮”也会命中“食堂”或“美食”。处理去重的重点不是比较门店名称而是比较poi_id或来源ID。同一来源的 POI ID 不会变但如果不同来源的数据混采比如地图A、地图B都采需要建立内部的唯一键。我用的规则是取来源代码 经纬度四舍五入到小数点后3位 店名前缀拼接哈希作为业务指纹。如果该指纹已存在就把原来的记录标记为“在多关键词下重复”仅保留首次入库的数据作为主记录并更新这条记录最新出现的日期。这样后续统计“这个月新增多少店”时不会把重复采集算成多倍。3.5 写入与留底永远保留原始响应我在poi_detail表里留了raw_json字段这个设计很多人不理解觉得浪费存储。但它对排查问题太重要了当发现某条数据的解析逻辑和接口返回结构对不上时直接捞原始报文看比 debug 半天强。而且接口升级后解析规则可能需要调整有留底才能重放历史数据。另外一个隐藏收益是原始 JSON 里有不少额外字段是自定义解析时故意没用到的比如评分、评论数、营业状态。后来做“店铺是否在营业”的分析时原始报文直接派上了用场不用重新采集。4. 增量更新机制与数据保鲜策略4.1 全量首采和增量更新的分工项目首跑当然需要全量采集但频率绝不能高。城市级数据全量跑一次会消耗大量请求配额我一般只在季度级别做一次全量巡检。日常的数据维护走增量更新只更新过去几天活跃的、地址变动的、电话变更的 POI。增量更新实现依赖两张表poi_detail记录当前全量数据poi_history记录每次采集到的版本和变化标记。对比两次采集结果门店消失就标记为“疑似关闭”门店新增就标记为“新增”地址电话变就标记为“变更但保留旧值”。这样每次增量更新完成后不仅能更新数据还能产出一份“变化日报”本周新增了多少商家、多少商家更换了电话、多少商家疑似停止营业。这个变化数据对运营分析非常有价值比单纯的数据快照有用得多。4.2 任务过期重跑的判断逻辑一个网格在首采时完整跑完不代表它彻底完成了。周边数据变化、关键词覆盖不足、接口升级都可能导致陈旧。我的调度器里设置了一个数据新鲜度指标对FINISHED状态的网格设置过期时间比如 7 天或 30 天。网格超过过期时间后状态自动重置为PENDING。但不是所有网格同时重置而是分批扫描、错峰执行避免服务瞬时压力太大。调度轮次下发时还会参考最近 24 小时内的接口配额消耗动态调整本轮待执行网格数量。原则很简单宁可少跑不能把配额打满然后被限流。4.3 失败重试和任务幂等增量更新最怕的就是同一批任务被重复执行。所以任务表里每个网格 关键词组合都有主键每次执行前先检查这个组合的最新状态def should_execute(task): if task.status FINISHED and task.finish_time task.expire_time: return False if task.status RUNNING and task.updated_at now - 5 * 60: return False return True对RUNNING状态加时间判断是为了处理进程被杀死的场景任务跑了 3 分钟就挂了状态还停留在 RUNNING5 分钟后可以重新接管如果任务真的还在跑由于入库都走INSERT ... ON DUPLICATE KEY UPDATE重复执行也只是幂等覆盖不会产生脏数据。5. 常见问题排查实录与避坑指南5.1 问题一接口频繁返回“请求过于频繁”这是我的第一个大坑。因为网格数很多单线程循环请求每分钟请求量很容易超过配额上限。第一反应是加time.sleep(1)但依然偶发限流。后来仔细看了服务商的频率控制文档发现它是按每秒请求数和每小时请求总额双重限制的光睡 1 秒不解决问题。真正的解决方式是分级限速每个请求前检查令牌桶每秒最多发 5 个请求每完成 100 个请求就额外停顿 20 秒把小时级别的配额也控制住。实测下来稳定很多跑几十万次请求不再出现大规模返 429。5.2 问题二个别网格 POI 数据量异常大有的商圈比如市中心核心地段一个 1 公里的格子可能就有数千家商户。正常接口翻页翻不到底。这时候如果强行把网格调小又会导致周边格子碎片化严重影响后续增量维护。我的处理方式是“动态递归四分法”如果某个格子第一页就返回满页数量说明数据密集立即对该格子做四等分每个子格重新查询如果子格还是满页再次分割。直到某个格子的返回数量远低于满页值以此确认完整采集。这样既能精细采集又不至于整个城市切成无数小格。5.3 问题三电话号码和地址字段缺失严重地图数据的质量依赖于商户登记的信息很多小店根本没有填电话或者地址只写到“某某路”。这没办法靠爬虫解决。我的对策是不因为缺字段就丢弃整个 POI保留记录但打标data_incomplete。后续做应用时如果只需要地图展示缺失字段也能用如果做电话外呼过滤掉data_incomplete的即可。还有个小技巧是交叉补全同一个 POI 在多个关键词或多次采集记录中可能某次返回了电话而另一次没返回。用统一的 poi_id 合并记录时优先填满缺失字段。5.4 问题四任务处理到一半服务重启服务重启后所有内存队列都会丢但任务表在 MySQL 中不会丢。重启后调度器扫描任务表时会发现很多跑了很久但仍处于 RUNNING 状态的任务。我加了一个启动自检逻辑启动时把所有 RUNNING 且更新时间超过 10 分钟的任务恢复为PENDING然后重新调度。这个“乐观锁 超时抢占”的思路是通用的不管任务是被 kill 掉还是服务器宕机恢复流程都一样。初期没做这个手动改数据库改到怀疑人生做完之后整个系统的稳定性上了一个台阶。5.5 速查表高频问题与处理策略现象原因处理方案请求返回 429请求频率超过接口配额升级令牌桶限速增加批量停顿返回总量远低于预计网格过大翻页被截断开启四等分递归分割直到非满页入库后数据大量重复跨关键词检索命中同一 POI使用 poi_id 来源 时间戳拼接业务指纹POI 坐标与地图标注不符坐标系转换缺失或写反入库前统一转换保留 GCJ-02 与 WGS-84 双份增量更新后“新增”数量暴增去重键设计不合理老数据被误判为新数据检查主键和业务指纹是否稳定服务重启后重复采集任务状态未恢复启动时恢复超时 RUNNING 为 PENDING6. 项目复盘经验与后续扩展方向这个项目跑了大半年最大的体会是地图数据采集真正难的从来不是“发请求拿数据”而是数据怎么组织、状态怎么维护、异常怎么恢复。如果当初只写一个脚本把数据随便堆在 CSV 里那它现在早就废了。正是因为一步步把坐标转换、网格切分、任务状态、增量更新做完这个数据底座后来才能支撑起多个分析需求。比如后续我在它之上加过一层简单的可视化展示按类别统计热力分布、按商圈对比门店密度。数据还是那些数据但使用价值完全不一样。最后分享一个小经验。如果你想给这个项目加新功能优先做“变化数据输出”比如每周生成一份“新增 / 关闭 / 信息变更”的商家清单。这类数据是最稀缺的也是运营和决策最愿意看的。数据维护类项目做到后期比的不是谁能拿到更多数据而是谁的数据更新、更准、更能反映真实世界的变化。