尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

配额限制下百度地图按名称获取POI的工程优化实践

配额限制下百度地图按名称获取POI的工程优化实践 做 POI 相关项目的人都会遇到同一个坎代码写完了逻辑也通了结果跑了两天发现配额没了数据只抓了三分之一。百度地图的地点检索服务在处理按名称获取 POI这类需求时特别好用但它的配额限制、单次返回上限、分页规则这几条约束叠在一起会把很多看起来理所当然的做法直接堵死。这篇文章想聊的就是在配额限制下利用百度地图按名称获取 POI的完整思路和实操细节。我把这两年做门店数据、加油站数据、充电桩数据积累下来的经验整了一遍包括配额怎么算、关键词怎么打包、网格怎么切、缓存怎么做、报错怎么查。适合正在做 POI 数据集、门店地理信息补全、竞品分布分析的朋友也适合刚接触百度地图开放平台、准备写第一个采集脚本的初学者。不涉及任何绕过平台规则的手段全部是在服务条款允许范围内的工程优化。1. 先把配额这件事算明白再谈怎么省1.1 配额分三层日调用量、并发量、权限范围很多人一上来就写代码跑到报错才开始翻文档结果发现配额根本不是一个数字那么简单。百度地图开放平台的 Web 服务 API 配额实际要拆成三个维度来看。第一层是日调用量配额。这个是最直观的每天零点重置用完了当天就没了。不同服务地点检索、地理编码、逆地理编码、路线规划的配额池是分开计算的这一点特别重要——很多新手以为所有服务共享一个总配额结果不敢用地理编码来辅助纠偏白白浪费了精度。实际上你只调地点检索就不会被其他服务的调用挤占。第二层是并发配额也就是 QPS。同一时刻允许在途的请求数超了就直接拒绝返回的是并发超限类的错误码。这里有个细节并发超限返回的错误一般不消耗你的日配额但会浪费一次网络往返和一次重试窗口所以限流器该做还是要做。第三层是权限范围。未认证、个人认证、企业认证能调用的服务不一样配额等级也不一样。如果你做的是商业项目走企业认证把配额拉上去是最正当也最稳定的路线比任何技术手段都靠谱。我的建议是把这三层写进配置文件启动脚本时先打印一遍让每次跑批之前心里有数。见过太多次跑了六个小时天亮发现配额早爆了的场面全都是因为没人算这一笔账。1.2 400 条天花板为什么你的结果永远抓不全地点检索有两个硬约束几乎所有采集失败都跟它们有关。page_size最大只能给到 20page_num从 0 开始且page_num × page_size的结果不能超过 400。也就是说同一个检索条件同样的 query 同样的 region 或 bounds最多只能拿回 400 条。你在城市级别检索餐厅一座二线城市可能有几万家但接口在第 20 页之后就不给数据了。这时候你会看到一个很迷惑的现象total字段显示一个很大的数字但你翻到第 19 页就是空的。这个限制直接决定了整个采集架构的形态——必须做空间分片。分片的目的不是提高精度这种虚话而是让每一片的检索结果数量都落在 400 以内这样每一片才可能被完整取回。分片方式参数适用场景能否突破 400行政区划检索region省级、市级粗筛总量摸底不能市级别基本必超圆形区域检索locationradius商圈、站点周边、以点为中心能半径缩小即可矩形区域检索bounds规则网格切分、批量跑批能格子缩小即可从上表能看出来真正适合做批量采集的是矩形检索因为它的切分逻辑最规整容易做成四叉树递归。圆形检索适合做某个坐标周围多少米内有什么这类补漏不属于主力。1.3 AK 类型与调用方式的选择服务端 AK 和浏览器端 AK 是两套东西别搞混。服务端调用可以直接带ak参数请求建议同时配上 IP 白名单浏览器端调用必须走 SN 校验因为 AK 会出现在前端代码里暴露是无法避免的。关于多 AK 分摊这件事我态度很明确同一个账号下建多个应用、按业务模块分开管理 AK 是合理的工程实践方便做用量归因和故障隔离。但通过批量注册账号来绕开配额属于明确违反平台规则的行为账号回收、服务中断只是时间问题。做长期项目的人应该把精力放在算法和缓存上而不是这种一次性收益上。我自己在项目里的做法就是把配额当成一个硬约束条件写进设计文档所有优化都在这个约束下做跑得慢一点没关系跑得稳才重要。2. 按名称检索接口怎么调、参数怎么配2.1 三种检索方式先搞清楚各自的语义百度地点检索的 v2 接口路径是/place/v2/search三种检索方式共用同一个入口靠参数区分行政区划区域检索传region可以是城市名杭州市也可以是行政区划编码。它是检索这个行政区内符合条件的 POI返回结果会跨区边界。圆形区域检索传locationlng,lat和radius单位米1 到 50000。语义是以这个点为中心、半径多少米内。矩形区域检索传bounds左下纬,左下经,右上纬,右上经。注意顺序是纬度在前、经度在后而且必须先左下再右上。这里有个特别容易踩的坑bounds参数顺序写反了不会报错接口会照常返回 200但结果是一堆莫名其妙的点或者干脆返回空。我见过有人排查了一整天最后发现是纬度经度写倒了。建议在客户端封装里加一层断言lat值必须在 -90 到 90 之间lng在 -180 到 180 之间写错立刻抛异常。还有一个认知偏差需要纠正矩形检索不是严格裁剪。它返回的是与该矩形相关的结果边界附近、甚至矩形外一点的点也可能出现。所以拿回来的数据必须做一次本地裁剪不能直接入库。2.2 用符号一次性打包多个关键词——省配额的第一招这是整个方案里性价比最高的技巧。地点检索的query参数支持多关键词并集检索不同关键词之间用$符号分隔最多支持 10 个关键字。举个例子你要抓某市的加油站本来可能要分四轮query加油站 query中国石化 query中国石油 query壳牌加油站改成一次请求query加油站$中国石化$中国石油$壳牌加油站一次请求拿回四类结果配合本地按关键词归类等于把 4 次配额消耗压缩成 1 次。如果你的关键词组有 8 组那就是 8 倍效率差。对于日配额只有几千次的账号来说这个差别直接决定项目能不能在一天内跑完。这里必须提醒一句有些旧资料、二手博客上写的是竖线|分隔那是其他地图平台的写法。百度地点检索官方文档用的是$。这类细节一定要翻当前版本的官方文档确认照抄博客的代价往往是请求成功但结果完全不对比报错还难排查。多关键词打包还有几个使用禁忌我踩过别把量级差太多的词放一起。医院$奶茶店这种组合奶茶店的结果会把 400 条上限迅速填满医院反而一条都拿不到。同一组关键词的 POI 密度应该在一个量级上。返回结果不区分来源关键词。接口只是把并集给你不告诉你是哪个词命中的。所以本地必须做匹配判断判断逻辑后面第 4 节会讲。关键词数量别打满 10 个。留一两个位置给后续补漏因为实际跑的时候经常发现某个子类目漏了。2.3 tag 与 query 的组合用法tag是分类偏好参数它不决定返不返回但决定排前面的返回什么。当query比较泛比如只写银行的时候加一个tag银行能让结果更贴近你要的分类。我的经验用法是query 负责精准命中tag 负责兜底召回。比如抓社区支行query 写社区支行tag 写银行这样既拿得到精准名称匹配的结果也不至于因为命名差异漏掉一堆。注意tag和query不能同时为空至少给一个。另外tag有优先级给了 tag 之后排序会明显偏向该分类翻页的时候顺序会变做增量更新时要留意这一点。2.4 返回字段里真正有用的那几个接口返回的字段不少但真正要落库的不多。我一般只留这些字段说明是否作为主键uidPOI 唯一标识是优先使用name名称否但用于关键词归类location.lat/lng坐标默认 bd09ll 坐标系否address地址否province/city/area行政区信息否detail_info.tag分类标签否用于交叉验证detail_info.type分类编码否street_id街景/道路 ID否关于scope参数scope1返回基础信息scope2会带上评分、营业时间、图片这类详情。看起来 scope2 更划算但实际不是——详情字段体积大解析慢而且很多 POI 本来就没有这些数据。批量采集阶段建议一律用scope1等筛出目标 POI 之后再对少量关键对象单独调详情接口补全。还有一个隐藏的成本点详情接口也消耗配额。别对着几万个 POI 无脑补详情那等于把采集阶段的配额翻倍消耗掉。3. 省配额的三个核心架构设计3.1 缓存分三层请求前先问本地缓存这件事听起来老生常谈但在配额受限的场景下它的收益是数量级的。我在项目里一般做三层第一层进程内 LRU 缓存。同一个 key 在同一进程里重复出现直接返回内存对象连网络都不走。适合处理关键词分组时出现的重复组合。第二层请求日志表。以(query_group, grid_id, page_num)作为唯一键记录这次请求的结果条数和时间戳。任何一次新的请求发起之前先查这张表命中且未过期的直接读库。POI 数据变化很慢7 天内重复检索同一个网格基本是纯浪费。第三层POI 主表。以uid为唯一键做去重这是最终的数据资产。第二层是省配额的关键因为它拦掉的是完全相同参数的重复请求。实际跑批时网格切分产生的重叠区域、多天补跑、调试重跑全部会在这一层被拦掉。我做过一个对比加了这层日志之后同样的采集量配额消耗下降了大约四成。3.2 空间分片四叉树动态细分固定切网格的做法很浪费。城市建成区 POI 密集郊区稀疏你用统一大小的格子切密集区格子不够用超过 400 条郊区格子又太多每格只有几条结果白花配额。四叉树动态细分的逻辑是这样的先拿一个覆盖目标范围的外接矩形用矩形检索跑一次读返回的total如果total小于阈值我一般设 380不是 400说明这一片一次能拿全标记为完成如果total触及上限把这个矩形四等分对四个子矩形递归执行第 2 步设置最大递归深度我设 6 层防止在极端密集区无限细分。阈值为什么设 380 而不是 400因为total是个估算值而且翻页过程中结果集会动态变化卡在 400 这个临界点上翻到第 19 页很可能断在半路。留 20 条余量实际测试下来稳定性明显更好。这套做法比固定网格省多少我实测过一个地级市固定网格切了 1200 格四叉树只用了 340 格左右配额消耗差了 3 倍多。原因很简单大片郊区一次就搞定了。3.3 任务表 断点续跑批量采集最怕的就是跑到一半中断。所以网格任务的状态必须持久化不能放在内存里。表结构大致这样CREATE TABLE grid_task ( grid_id TEXT PRIMARY KEY, bounds TEXT NOT NULL, -- 左下纬,左下经,右上纬,右上经 query_group TEXT NOT NULL, depth INTEGER DEFAULT 0, status TEXT DEFAULT pending, -- pending / done / failed fetched INTEGER DEFAULT 0, total INTEGER DEFAULT 0, updated_at TEXT );有了这张表第二天配额刷新之后直接SELECT * FROM grid_task WHERE statuspending从断点继续跑无需重新计算网格也不会重复消耗已经完成的格子。failed状态要单独处理不要在循环里立刻重试而是扫一遍看错误码分布如果全是配额类错误说明今天真的没额度了直接停如果混着网络超时才针对性地重试那几个。3.4 配额预算怎么估先算后跑这一步很多人跳过结果就是跑着跑着发现不够。给个估算公式总请求数 ≈ 网格数 × 关键词组数 × 平均翻页数举个具体例子一个中等城市四叉树细分后得到 300 个网格关键词分了 8 组平均每组每格翻 1.2 页那么300 × 8 × 1.2 ≈ 2880 次请求如果日配额是 3000 左右一天刚好能跑完但要留出调试和补漏的空间实际可能要拆成两天。如果日配额只有 1000那就要重新设计关键词分组或者把城市按区拆分分多天完成。这套先算后跑的习惯能避免最尴尬的情况跑了 8 个小时配额定格在 97%数据半残既不能交付也不好续跑。算一遍只要五分钟。4. 手把手从零跑通一条按名称抓取 POI 的链路4.1 目录结构与依赖我把整个项目拆成六个模块职责分明方便单独调试poi_spider/ ├── config.py # AK、QPS、阈值、关键词组 ├── client.py # 接口封装、SN 计算 ├── limiter.py # 令牌桶限流 ├── grid.py # 四叉树切分与任务表管理 ├── cleaner.py # 名称匹配、坐标去重、边界裁剪 ├── storage.py # SQLite / MySQL 落库 ── main.py # 主流程编排依赖很简单核心就一个 requestspip install requests如果要做重试和退避可以加tenacity不过我更倾向于自己写因为重试策略里要区分错误类型用现成库反而绕。4.2 限流器令牌桶 有选择的重试限流器解决的是并发配额问题import time import threading class TokenBucket: def __init__(self, rate, capacity): self.rate rate # 每秒补充的令牌数对应 QPS self.capacity capacity # 桶容量允许的突发量 self.tokens capacity self.lock threading.Lock() self.last time.time() def acquire(self, n1): while True: with self.lock: now time.time() delta now - self.last self.tokens min(self.capacity, self.tokens delta * self.rate) self.last now if self.tokens n: self.tokens - n return need (n - self.tokens) / self.rate time.sleep(max(need, 0.01))QPS 建议设成官方限制的 70% 到 80%。设满看起来效率高但网络抖动导致的偶发超限会触发失败失败的请求虽然不消耗日配额却会拖慢整体节奏反而更亏。重试策略是这里最容易写错的地方。必须区分错误类型网络超时、连接重置、5xx 服务器错误 → 值得重试间隔 1s、2s、4s、8s最多 4 次参数错误、权限错误、配额类错误 →不要重试重试一百次结果也一样只会白白烧掉重试窗口。我见过最典型的错误写法是for i in range(3): try: ... except: pass无脑重试所有异常。结果配额被失败重试烧掉一大半日志里全是重复的失败记录排查起来还特别费劲。4.3 客户端封装与 SN 计算import hashlib import requests from urllib.parse import quote class BaiduPlaceClient: HOST https://api.map.baidu.com PATH /place/v2/search def __init__(self, ak, skNone, timeout8): self.ak ak self.sk sk self.timeout timeout self.session requests.Session() def _gen_sn(self, params): if not self.sk: return None # 参数需按 key 排序后拼接具体规则以官方 SN 校验文档为准 query .join(f{k}{params[k]} for k in sorted(params)) raw f{self.PATH}?{query}{self.sk} return hashlib.md5(quote(raw, safe/:?).encode(utf-8)).hexdigest() def search(self, query, boundsNone, regionNone, page_num0, page_size20, scope1): params { query: query, page_num: page_num, page_size: page_size, scope: scope, output: json, ak: self.ak, } if bounds: params[bounds] bounds if region: params[region] region sn self._gen_sn(params) if sn: params[sn] sn resp self.session.get( self.HOST self.PATH, paramsparams, timeoutself.timeout ) resp.raise_for_status() return resp.json()关于坐标系服务端调用默认返回 bd09ll。如果你的业务数据是 WGS84需要额外做坐标转换这部分是独立的一次转换不消耗接口配额可以放心在本地批量处理。4.4 网格切分与递归抓取四叉树的核心就是两个函数一个切分一个递归。def split_bounds(bounds): 把矩形四等分为四个子矩形 lat_min, lng_min, lat_max, lng_max bounds lat_mid (lat_min lat_max) / 2 lng_mid (lng_min lng_max) / 2 return [ (lat_min, lng_min, lat_mid, lng_mid), (lat_min, lng_mid, lat_mid, lng_max), (lat_mid, lng_min, lat_max, lng_mid), (lat_mid, lng_mid, lat_max, lng_max), ] def fetch_grid(client, query_group, bounds, page_size20, threshold380, depth0, max_depth6): 递归抓取一个网格返回 POI 列表 bounds_str {},{},{},{}.format(*bounds) all_poi [] page_num 0 total None while page_num 20: data client.search( queryquery_group, boundsbounds_str, page_numpage_num, page_sizepage_size, ) if data.get(status) ! 0: raise RuntimeError(f接口返回异常: {data}) results data.get(results, []) if total is None: total data.get(total, 0) if not results: break all_poi.extend(results) page_num 1 # 结果触及上限且还能继续细分则四等分递归 if total is not None and total threshold and depth max_depth: merged [] for sub in split_bounds(bounds): merged.extend( fetch_grid(client, query_group, sub, page_size, threshold, depth 1, max_depth) ) return merged return all_poi几个实操细节值得说total只取第一次响应的值。翻页过程中 total 会变每次都用新值判断会导致同一片网格一会儿细分一会儿不细分。空结果页立刻 break。不要傻乎乎地把 20 页翻完每翻一页都是一次配额消耗。我在早期版本里就犯过这个错一个网格白翻了十几页空请求。max_depth一定要设。极端密集的区域比如商业综合体内部递归下去会爆炸深度 6 相当于把初始矩形分成 4096 份实际项目里很少需要超过这个深度。4.5 结果清洗名称匹配、去重与边界裁剪接口返回的是并集本地必须做归类。我的做法是关键词表加一列正则规则目标类目检索关键词本地匹配规则便利店便利店$超市$小卖部名称包含便利超市商店充电桩充电桩$充电站$换电站名称包含充电换电药店药店$药房$大药房名称包含药且不含医药公司匹配规则里最常见的坑是过度包含。比如抓药店时药这个字会匹配到医药科技有限公司兽药经销部这类必须用排除规则挡掉。更稳的做法是用返回的detail_info.tag做二次确认。去重的逻辑分两级有uid的用uid没有uid的部分数据会缺用name 坐标四舍五入到小数点后 5 位拼成合成主键。四舍五入到 5 位大约是 1 米精度足够区分不同门店又能容忍坐标的微小抖动。边界裁剪这块最简单的做法是判断点的经纬度是否落在矩形范围内def in_bounds(lat, lng, bounds): lat_min, lng_min, lat_max, lng_max bounds return lat_min lat lat_max and lng_min lng lng_max如果目标是行政区而不是矩形就需要做点在多边形内的判断。数据量大时可以用射线法配合包围盒预筛先判断外层包围盒不在的直接丢弃能省掉大量计算。4.6 落库与增量更新主表设计CREATE TABLE poi ( uid TEXT PRIMARY KEY, name TEXT, lng REAL, lat REAL, address TEXT, city TEXT, area TEXT, tag TEXT, source_query TEXT, first_seen TEXT, last_seen TEXT, missing_cnt INTEGER DEFAULT 0 );增量更新的思路全量跑完之后下一轮只更新last_seen不重复插入。如果某条 POI 连续三轮比如连续三个季度都没再出现把missing_cnt加一超过 3 就标记为失效从活跃数据里剔出去。这个机制能自动过滤掉已经关店的 POI比人工维护省事得多。source_query这列别省。它能告诉你这条 POI 是通过哪个关键词抓到的后期做覆盖率分析、补充关键词的时候全靠它。5. 报错与掉坑常见问题速查5.1 状态码对照表状态码含义处理建议0成功正常解析1服务内部错误可重试退避后重发2参数错误检查 bounds 顺序、query 是否为空3权限校验失败检查 AK 是否开通该服务、SN 是否正确4配额校验失败检查日配额和并发当天可能已用完5AK 不存在或非法检查 AK 拼写、是否被禁用101 及以上服务禁用、白名单、权限类去控制台核对应用配置上表只是常见分类具体数值以官方控制台和文档为准。我在客户端里会把status非 0 的响应统一记一条日志包含完整的请求参数方便事后复现。注意status0不代表结果就是你要的。名称检索本质是模糊匹配返回 200 但结果全不相干是很常见的情况必须做本地过滤。5.2 配额莫名其妙被跑满的三个原因第一个原因是失败重试没有区分错误类型。这是最常见的。参数错误、配额错误被无脑重试三次三次里没有一次可能成功但请求数是实打实发出去的。第二个原因是翻空页。分页循环没有遇到空结果就 break硬翻到第 20 页。一个网格浪费 19 次请求300 个网格就是 5700 次直接把一天配额吃光。第三个原因是定时任务和手工调试共用同一个 AK。半夜定时任务在跑白天你在本地调试两边同时消耗一个配额池。跑批之前把调试用的 AK 和生产的 AK 分开或者至少在跑批期间暂停调试。5.3 结果少了、重了、错了分别怎么查结果偏少按顺序排查三件事一是total是否达到 400 上限达到就说明该细分了二是bounds的经纬度顺序有没有写反三是关键词分组是不是把低密度类目和高密度类目混在了一起。结果重复通常是网格重叠导致的。检查四叉树切分时有没有边界重叠我用的闭区间写法相邻网格共享边界边界上的点会被两边各返回一次这种情况靠uid去重就能解决。结果不相关说明关键词太泛或者本地匹配规则太宽。两个动作一是收紧匹配规则加排除词二是用detail_info.tag做交叉验证标签不符的直接丢掉。6. 几条我自己踩出来的经验6.1 AK 别写死在代码里本地调试图省事把 AK 硬编码进代码然后代码一提交AK 就进了版本历史。这东西删掉也还在 Git 记录里。用环境变量加配置文件的方式管理import os AK os.environ.get(BAIDU_MAP_AK) QPS float(os.environ.get(BAIDU_MAP_QPS, 3))如果是前端页面接入那就必须用浏览器端 AK 加 SN 校验而且要在控制台配置好 Referer 白名单把泄露风险控制在可接受范围内。6.2 关键词的同义词陷阱同一样东西在不同地区叫法完全不同。便利店在有些地方叫小卖部杂货店士多充电桩和充电站在数据里是两个类目社区卫生服务中心和社区医院也是两回事。我的做法是维护一张关键词表字段包括主类目、标准词、地区变体、排除词四列。每次开始新一轮采集之前先拿一个小区块做小样本测试看看召回率和准确率再决定要不要全量跑。花二十分钟做这个测试能省掉一整天的无效配额消耗。6.3 数据使用的边界有几条线我在项目里一直守着不通过注册大量账号绕开配额、不做超出正常业务频率的压力式调用、不把原始数据对外转售、采集频率控制在合理范围。说这些不是为了讲道德而是工程上的现实考虑——依赖规则漏洞的方案稳定性极差今天能跑的脚本明天就可能全线失败维护成本远高于把配额用足、把算法做精。6.4 抓到什么时候该停手纯 API 采集有它的边界。超长尾的小类目、名称极其不规范的个体店靠名称检索的召回率就是上不去再怎么优化关键词也就那样。这时候我的做法是转向组合策略用行政区划做粗筛拿到全量轮廓用关键词穷举补细节再抽 5% 的样本做人工核对算一下整体覆盖率。如果覆盖率能到 85% 以上对大多数业务场景就够了硬追最后那 15%投入产出比会非常难看。另外提一个交叉验证的小习惯从别的公开数据源抽一小批点位看看在你的百度 POI 数据集里能不能找到对应的记录找不到的比例就是召回率的一个粗略估计。这个方法比内部自查靠谱得多因为它引入了外部参照。最后分享一个小技巧关于跑批时间的安排。把大批量任务放在后半夜执行一方面避开你白天调试的时间段另一方面网络的稳定性通常更好重试次数更少。我自己的定时任务设在凌晨两点启动跑完之后自动生成一份报表包含请求总数、配额消耗比例、各网格的完成状态和异常统计。第二天早上看一眼报表就知道要不要调整关键词分组——这比跑到一半发现配额不够要好得多。
返回列表