先开门见山说一句:POI 数据这活儿,网上说的“爬取攻略”一抓一大把,但真正能落地、能更新、能追溯历史版本、还能让你对比出“这个月某区多了几家新店”的完整方案,却很少有人系统讲清楚。我这次结合自己做城市POI数据采集与历史版本管理的项目经验,把从渠道选择、权限申请、抓取策略,到增量比对、数据清洗的完整链路复盘一遍。特别是“最新POI”和“历史POI列表”这两个痛点,很多搞商业分析、选址调研、区域趋势观察的朋友都卡在这里,希望这篇能帮你一次打通。
1. 先想清楚:你拿POI到底要干什么
1.1 真实需求比“有多少条数据”更重要
很多人一上来就问“哪里能下全量POI”,但做过几个项目就会明白,POI的价值取决于你拿它来回答什么问题。
我见过三种最常见的需求场景,后续的技术选型完全不一样:
- 商业选址与竞品监测:关心的是某类门店(咖啡、便利店、药店)在目标城市的新增速度和分布密度。这时候你要的是全量类目数据 + 历史快照对比,而且需要按天或按周粒度持续采集。
- 城市发展评估:关注新增住宅区、学校、医院、商场等公共设施,判断一个新区是否在“成熟”。这种场景更看重月度或季度趋势,对日更要求不高,但要求类目尽量完整。
- 业务系统需要:比如给App做“附近店铺”功能,那你要的是当前最新的POI坐标和状态,历史数据反而次要,重点在更新频率和接口稳定性。
搞清楚用途之后,再决定你选哪种数据渠道、用多快的频率抓、历史版本要不要保留。否则很容易出现“下了一整份全国POI、结果压根用不上”的尴尬局面。
1.2 渠道盘点:高德关键词搜索、周边POI、开放平台到底怎么选
既然标题聚焦“高德”,那就先把高德体系内的渠道摸清楚。高德对外提供三类和POI相关的通行方式:
| 渠道 | 输出形式 | 适合场景 | 门槛 |
|---|---|---|---|
| 高德地图网页端搜索 | 页面渲染结果,无结构化批量输出 | 临时查几个点 | 无 |
| 高德开放平台Web服务API | 结构化JSON,单次最多25条 | 小批量、准实时业务查询 | 需注册开发者账号并申请Key |
| 高德开放平台周边搜索API | 以某坐标为中心返回周边POI | 商圈分析、栅格化全量抓取 | 需配额管理,调用量受限 |
如果你要的是“城市维度全量POI”,单靠关键词搜索接口一条条查类目是不现实的——配额限制摆在那里。业内常规做法是用周边搜索按栅格扫城市:把城市切成网格,逐个格子去请求中心点周边POI。这套方式抓下来的数据覆盖面才像一个“全量”的样子。
要是你只有一个普通开发者Key,单日调用量是有上限的。所以量化一下自己的需求规模很重要。比如有客户要求某二线城市全量POI,光覆盖市区加郊区就需要将近几万个格子,按每格最多返回周边数据来算,这个量可不是一两天能跑完的。这时候要么申请个人实名认证去提升配额,要么做调度排队慢慢跑。我把调度和配额相关的心得放在后面“实操”部分细说。
1.3 关于“历史POI列表”的普遍误解
历史数据不是“下载一份存着就完事”。很多人刚开始是简单地把每天爬到的全量数据存起来,等到要对比的时候才发现问题一大堆:坐标偏移、类目字段变化、同一家店在不同批次里名称写法不一样、数据重复。所以历史POI的管理,本质上是如何把不同批次的数据纳入同一套标准中来比对。
这个项目里我最早踩的坑就是把每天的原始结果直接落库,结果某天接口返回的排序变了,同一家店在一个批次里排第3、下个批次里排第10,导致重复入库。后来加了去重逻辑和唯一标识(高德的POI ID),才真正把“历史列表”这个维度做出来。
2. 高德POI数据获取的核心技术选型
2.1 关键词搜索 vs. 周边搜索的取舍
我见过不少人直接用关键词搜索“全城咖啡店”,但全城这个词对于高德接口来说,并不是一个可以输入的参数。你的查询本质上还是落到一个矩形或圆形范围内,所以所谓全城,也只是“用范围去框“。关键词搜索适合类型很精确、范围不大的场景,比如查某个商场3公里内的便利店。
但如果你要“某市所有餐饮类POI”,靠关键词来枚举那几十个大类、几百个子类,查询次数爆炸不说,还会因为结果上限导致漏数据。所以我实际做城市级抓取时,首选周边搜索按栅格扫描,然后拿类目编码做二次过滤。
2.2 为什么推荐“矩形栅格 + 分页叠加”而不是简单圆形扩大
圆形周边搜索有一个软肋:到了城市边缘,圆形半径一大,很容易把邻市的数据带进来,后期过滤麻烦。而且圆与圆之间的覆盖空隙需要处理,如果拿半径1公里打点,可能边缘拼不上。
矩形栅格更可控。拿高德的矩形区域查询举例,它的结果是按左上到右下的顺序分页返回的。把城市边界根据精度划分为边长约0.025度的格子(约2.5公里),每个格子请求一次周边搜索,配合关键词和分页参数,就能保证覆盖相对均匀。
有同学问网格到底切多大,这没有标准答案,但我的经验是:
- 城市中心区域,POI密度高,格子切小一点(约0.01度),防止单格数据溢出返回值上限;
- 郊区密度低,格子可放宽到0.02到0.03度;
- 边界不规则的部分,先按行政边界多边形做外接矩形切分,再丢弃落在边界外的点。
算下来一个百万级人口城市大概需要跑几千个格子,代价可控,但得做好跑批剧本和断点续传。
2.3 高德API版本与参数细节的坑
高德开放平台的Web服务API里,周边搜索、关键字搜索和多边形搜索是有差异的。具体差异看下表:
| 接口 | 核心参数 | 单次返回数 | 典型返回字段 |
|---|---|---|---|
| 周边搜索 | location、radius、types、offset、page | 默认10,可达25 | 名称、经纬度、地址、类型、POI ID等 |
| 关键字搜索 | city、keywords、types、offset、page | 默认10,可达25 | 同上 |
| 多边形搜索 | polygon、keywords、types、offset、page | 默认10,可达25 | 同上 |
这里有个关键坑:不同接口对types参数的层级识别不一样。高德的POI类型编码是分层级的,大类、中类、小类各两位数字。比如“餐饮服务”大类是“05”。如果你只传大类的两位数字,返回的范围会比较宽松,某些子类型会被拆到别的归属里。我建议直接传六位小类编码,宁可不传大类。
再就是分页偏移。周边搜索里,单格内POI数量如果超过了单页上限,你得循环翻页。但注意接口没有提供总页数字段,只能翻到返回数组为空为止。这本身不难,难的是在翻页过程中,高德偶尔返回重复项或者跳序。所以我在抓取端做了两个保险:一是同时记录每页第一条和最后一条POI的ID,用于本格去重;二是只保留最后一次抓取的时间快照,防止历史数据污染。
3. 全量拉取与增量更新的完整实操流程
3.1 环境准备与按天快照的落地方式
这个项目我用的是常规的Python技术栈,核心依赖是requests、pandas、SQLite,之间不需要额外重型组件。高德接口本身是HTTP同步请求,并发主要靠多线程或协程控制。
数据库方面,因为需要保存“每天的每一个版本”,我用SQLite打底,实际生产换到了另一套轻量数据库,但思路是一样的。核心表结构:
- poi_snapshot:记录每次快照的日期和批次ID
- poi_detail:POI详情,包括名称、经纬度、地址、类型、高德POI ID等
- poi_compare:存放两个批次之间的差异结果
可能有人问为什么不用一张大表存所有记录。因为POI的字段在每次快照中可能变化,比如某家店改了名、换了坐标,你要保留旧记录还得保留新旧对应关系,单表维护起来非常痛。
3.2 关键环节:城市栅格生成与边界过滤
既然选定了周边搜索加栅格的方式,第一步就是生成网格。我不会直接手动去画边界面,而是拿高德的行政区划查询接口取城市边界坐标串,然后转成最小外接矩形,再切分。
切分时有个细节:不要只对经纬度做等距切分。因为高德的坐标是火星坐标系,经纬度的一度在不同维度对应的实际距离不同,切出来的格子并不是等面积。不过对于POI覆盖来说,只要格子足够密,误差可控,不需要做大范围坐标转换。给个参考值——中部某省会城市,我用0.015度切分,大约生成了接近五千个格子,实测覆盖率对比高德网页端检索结果能达到九成以上,剩下的边角料通过第二轮小格子补充。
边界过滤我会先把行政边界的坐标点做一个简单点在多边形内的判断。这一步如果等到全量抓完再过滤,数据量一大,内存容易爆。最好在做完每个格子的抓取后立刻过滤,只将落在边界内部的点入库。
3.3 每天抓取与历史版本对比的调度设计
因为是要建“历史POI列表”,不是抓一次就结束,所以必须把调度设计成可断点续跑的批处理任务,而不是一次性脚本。
我分享一个简化版的调度思路,用Python伪代码写出来大概是这样:
import time import sqlite3 import requests def fetch_grid(grid_id, center_location, radius, types_code, key): # 单格抓取,翻页直到返回空 page = 1 results = [] while True: params = { "location": center_location, "radius": radius, "types": types_code, "offset": 25, "page": page, "key": key, "extensions": "all", } resp = requests.get("https://restapi.amap.com/v3/place/around", params=params, timeout=10) data = resp.json() pois = data.get("pois", []) if not pois: break for poi in pois: results.append({ "poi_id": poi.get("id"), "name": poi.get("name"), "lng": poi.get("location").split(",")[0], "lat": poi.get("location").split(",")[1], "address": poi.get("address"), "type": poi.get("type"), "pcode": poi.get("pcode"), "pname": poi.get("pname"), "cityname": poi.get("cityname"), "adname": poi.get("adname"), "grid_id": grid_id, "snapshot_date": time.strftime("%Y-%m-%d") }) page += 1 if page > 20: # 保险丝,防止死循环 break time.sleep(0.3) return results def main(): conn = sqlite3.connect("poi_history.db") cursor = conn.cursor() grids = load_grid_list() # 从格子表加载 available_keys = load_keys() # 多个Key轮询 offset = get_checkpoint() # 断点续传用 for grid_id, center, radius in grids[offset:]: try: rows = fetch_grid(grid_id, center, radius, "060000", available_keys[grid_id % len(available_keys)]) cursor.executemany("INSERT INTO poi_detail (poi_id, name, lng, lat, address, type, pcode, pname, cityname, adname, grid_id, snapshot_date) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)", rows) conn.commit() set_checkpoint(grid_id) except Exception as exc: log_error(grid_id, exc) conn.close() if __name__ == "__main__": main()代码只是示意,关键点在于两个地方:
一是断点续传。格子任务跑到一半如果被中断,重启后不需要从头开始。我维护一个checkpoint表,每完成一个格子就记录当前格子ID,重启时从列表里定位到下一个未完成的格子即可。这个机制在长跑批任务里至关重要,否则跑到一大半程序崩了心态直接爆炸。
二是多Key轮询。单个Key的配额有限,如果你有多个开发者账号(实名认证过的),可以给格子取模分配Key,提升整体吞吐。但提醒一句:高德的开发者协议对并发有要求,太猛的并发可能导致限流,所以我在每个Key后面做了一个简单的时间间隔控制,实测稳定很多。
3.4 历史版本对比的核心算法
抓完了历史数据,怎么“找差异”就是最核心的环节。项目里我给客户交付的对比报告,围绕三个维度:
- 新增POI:当前批次有、上一批次没有的。
- 消失POI:上一批次有、当前批次没有的。
- 属性变更:名称、坐标、地址或类型发生变化。
听起来很简单,但对齐记录必须靠POI ID,而不是名称。名称很容易因为商户招牌改动而变化。不过POI ID也分两种:高德返回的id字段,在某些场景下,不同商圈返回的ID可能不同,官方文档里也提到ID在某些情况下可能变动,所以,我采用“ID主匹配 + 名称与坐标模糊匹配”的兜底策略。
比对过程我直接用pandas做:
import pandas as pd today_df = pd.read_sql("SELECT poi_id, name, lng, lat, address, type FROM poi_detail WHERE snapshot_date = '2024-06-01'", conn) yesterday_df = pd.read_sql("SELECT poi_id, name, lng, lat, address, type FROM poi_detail WHERE snapshot_date = '2024-05-31'", conn) merge_df = today_df.merge(yesterday_df, on="poi_id", how="outer", suffixes=("_new", "_old"), indicator=True) added = merge_df[merge_df["_merge"] == "left_only"] removed = merge_df[merge_df["_merge"] == "right_only"] modified = merge_df[(merge_df["_merge"] == "both") & ((merge_df["name_new"] != merge_df["name_old"]) | (merge_df["lng_new"] != merge_df["lng_old"]))]但这里有个非常大的坑:当天快照本身要先去重。因为同一个POI可能在相邻格子被抓到两次,如果不先去重,第二天比对时,标记为新增的可能其实是昨天就存在但昨天被漏抓的数据。这个我吃了大亏,后来在快照表上加了一个“按POI ID去重”的逻辑:同一天内同一个ID只保留最早抓取的一条记录。
去重逻辑不复杂,直接用SQLite的窗口函数或者pandas的drop_duplicates即可,但它直接影响差量的可信度,别偷懒。
4. 数据差异分析与趋势追踪的方法论
4.1 不只是“多了少了”,要能回答“哪些区域在变化”
很多运营和业务同事拿到一套新增/消失清单后还是会头痛:几百上千条差异数据,不可能一条条看。所以我后来把数据再拆细,按行政区、按功能区块(商圈ID)聚合差异,输出一版“区域变化热力概览”。
比如某市某个商圈附近,一个月新增了60家餐饮类POI,同时关闭了20家。那这个商圈的餐饮饱和度、替换率这些指标就可以量化了。基于这个数据,我写了按月生成趋势表的逻辑:
- 每月最后一个快照作为基准版本;
- 计算该月对比上月的净增数量;
- 按大类分类汇总(餐饮、购物、医疗、教育等);
- 输出到行政区维度,并标出Top 5变化商圈。
这套报表客户反馈非常直观,因为它不再是一堆技术字段,而是能直接指导选址和投放决策的可读信息。
4.2 数据噪声的过滤:装修、临时停业、幽灵店铺
做历史趋势,最烦的是数据噪声。有些店铺本身一直存在,但一个月内抓到的数据坐标轻微漂移,导致被误判为“消失+新增”;有些商场内部商铺闭店装修,几个月后重新开张,在数据上表现为“删除再新增”;还有些地址导错、POI被重复创建的“幽灵店”,会一直挂在数据里干扰统计。
为了提升数据质量,我在比对结果的产出上引入了三个过滤规则:
- 坐标偏移阈值:同一POI ID下,如果前后坐标距离超过设定值(比如300米),才认为真正发生了新迁;否则视为坐标噪声。
- 生命周期过滤:POI出现在最新批次但上一批次缺失,如果该POI在再上一个批次里存在,则标记为“临时中断”,不直接算作新增。
- 重复POI合并:同一地址或同一坐标附近出现两条名称相似度很高的记录,做合并处理,避免重复计数。
这套规则在验证数据时效果明显,尤其是“临时中断”这条,能在很大程度上减少月环比数据里的虚惊。
4.3 最容易被忽略的一点:POI分类的“变心”
高德的POI类型字段并不是静态的。同一家店,上个月可能标注为美食,下个月可能成了甜品店;同一个商场,类型描述从购物到综合商场都会变。类型变化其实也是一种商业信号,值得单独跟踪。
我在历史表里直接保存了每次快照的原始类型字段,然后做类型变化的月度统计。有一次做某区域商业规划时,发现很多店从“服装店”改成了“餐饮店”,直接反映出该商圈客群的消费结构变化。这个发现靠单纯的新增/消失是看不出来的,必须得有历史类型字段做支撑。
5. 常见问题与排查技巧实录(避坑指南)
5.1 配额限制与KEY并发导致的数据不完整
高德的个人开发者配额门槛不高,日调用量基本够小规模测试,但真做城市级全量抓取,就会撞上限制。第一版项目脚本因为没做并发控制,跑到两千多个格子后就开始报错,返回的数据要么是空的,要么是重复的。
排查过程先看返回码,确认是日配额超限还是并发限制。后来我把主调线程改成单线程加多Key轮询,每格之间加固定延迟,同时在Checkpoint表增加被跳过的格子重试逻辑。整体跑批时间拉长了一些,但数据完整性恢复到了百分之百。
给一个可参考的节奏:单个Key每秒钟最多1到2次请求是稳的,并发跑要加大间隔;多Key轮询非常有效,但在正式跑全量之前,建议先用小范围格子做5分钟压测,观察是否被限流。
5.2 为什么你抓到的坐标“感觉不对”
高德返回的是火星坐标系(GCJ-02),不是原始GPS坐标(WGS-84)。如果直接拿高德坐标去和GPS轨迹做叠加,会有几百米的偏差。做历史数据对比时可能看不出问题,但一旦和数据可视化或地图叠加扯上关系,就会很麻烦。
我的建议是,在入库时就同时保存坐标系信息,并在字段名里标注来源,别等到分析阶段再猜。如果需求方要的是WGS-84,那就得在入库前做一次坐标转换,而不是拿到数据后再批量转。实测批量转换在几万条数据量下性能也可接受,但一定要保留原始坐标备份。
5.3 数据量一大就跑得很慢怎么办
城区POI数据量大起来,尤其在几个大城市叠加多版本快照后,数据库查询和比对明显变慢。我拆过几个方向:
- 索引策略:
poi_detail表按(snapshot_date, poi_id)建联合索引;grid_id上单独建索引。别把所有字段都加索引,只给过滤和关联字段加。 - 分区存储:按月份做分区表,比对只扫描相关月区间,而不是全表扫。
- 比对逻辑改为快照前后两表的内提前过滤:先取当前批次的目标城市和目标大类,再和上一批次关联,避免先全量合并再过滤。
还有一个技巧:清洗时可以把已经确定不再变化的老版本快照归档到单独的“存档表”,日常跑批只对比最近两期,加速非常明显。历史版本列表不等于所有版本都在主库里,归档后仍然可以随时读出来。
5.4 POI ID可能让你迷惑的几种情况
高德POI ID在大多数情况下是稳定的,但有些POI会合并(比如同一商场的多入口被合并成一个)、有些会拆分(一个大店拆成几个小店),ID就会变化。这种情况下,你会看到一家店“消失”了,同时另一家店“新增”了,但实际上只是ID结构调整。
为了尽量识别这类情况,我在比对脚本里加了一个“疑似迁移”规则:若一条记录消失,在它周围两百米内出现了另一个POI,且名称编辑距离相似,就标记为“疑似迁移或合并”,单独生成一个核查清单。这样最终交付的差异数据里,业务方不会误判为大量闭店。
6. 高德POI数据的进阶使用思路与扩展玩法
除了基本的城市POI快照和历史对比,这套基础设施还能发展出不少衍生能力,这里聊几个我后来验证过的方向。
首先是区域成熟度模型。有了多个时间版本的POI类型分布,你可以对任意区域做打分,比如“餐饮占比高、教育设施齐全、医疗服务覆盖”的维度,再叠加时间变化斜率,就能粗略判断该区域的配套成熟度是在上升还是下降。这在选址场景里非常实用,比单一维度的看房租数据靠谱得多。
其次是竞品网点监控。选定某个连锁品牌的关键词,比如把品牌名作为关键词输入,只抓它旗下直营门店的POI,再叠加历史快照分析,就能看出它每个月在各城市的开闭店节奏。做这类分析的时候,建议不要用品牌名直接做模糊查询,最好能结合工商数据和门店编码,不然很容易混入同名店铺。
再次是城市热力对比。把POI密度按格子汇总成热力值,再把多期热力值做差值,就能得到一张“哪个片区POI在快速增长”的直观图。配合底图工具,完全可以输出给决策者看。这个玩法我交付给某个咨询项目时,客户觉得价值最高。
最后提醒一点:高德数据不是唯一的数据源,多源交叉验证更可靠。虽然高德的覆盖面还不错,但它的数据也有更新延迟、分类偏差。如果项目对数据质量要求很高,建议再接入一些其它合规数据源(比如工商注册信息里的经营状态、企业地址坐标等)做交叉验证,尤其是判断“闭店”或“迁移”这类重决策场景,单一来源的准确率永远是有上限的。
回到标题里的“城市最新POI”和“历史POI列表”,说到底这俩从来不是那一张表里的几千行数据那么简单。做透了会发现,它的本质是一套可以不断积累、不断追问商业问题的数据底座。我从最初只想抓一份全量数据,到最后做成了带版本管理、差异分析、趋势报表的小系统,最大的体会是:项目开始前多花时间确认业务要什么,比急着写爬虫脚本重要得多。
给大家一个建议,如果只在城市边缘做小范围试验,可以用现成的网页端搜索手工验证。但一旦要跑城市级全量加历史追踪,就一定要按本文思路把“抓取-存量管理-比对-清洗-归档”这五个环节都搭建起来,否则后期数据量起来后返工成本极高。这套系统现在还在每周末自动跑一次月度快照,出一次差异报告,已经稳定运行了大半年。中间真正需要人工介入的情况寥寥无几,大概也就是高德接口偶尔的版本升级或参数变动,只要保留好运行日志,这些都不难应对。
如果你也在做类似的事,欢迎沿着这个思路继续往前踩。POI数据这行,越是细节越值得打磨。