
做地理数据的人十有八九在百度地图的地点检索上栽过跟头。你输入一个名字想拿回它在全城的门店列表结果接口只给你 400 条剩下的要你自己想办法更麻烦的是配额——日调用量和 QPS 就摆在那儿跑一次全城扫描可能要几千次请求手一抖当天的额度就烧光了。这篇东西讲的是配额限制下利用百度地图按名称获取 POI这条路怎么走把一个名字对应的 POI 尽可能完整、尽量省额度地捞回来并且把结果干净地落进自己的库里。顺带说一句搜POI的时候你会撞见两个完全不同的东西一个是地理信息里的 Point of Interest一个是 Apache POIJava 操作 Office 文档的库前几年还爆过 XXE 漏洞。这俩除了缩写一样没有任何关系找资料的时候别被带偏。下面的内容全部围绕地理 POI 展开代码示例用 Python 写其他语言照着翻译逻辑就行。1. 先搞清楚按名称取POI卡在哪三个地方很多人第一次用place/v2/search的时候是懵的明明城里这个牌子的门店有两千多家返回的results数组加上翻页也就凑到 400 条然后就没了。这不是你参数写错了是接口本身的设计边界。所以在写任何爬取逻辑之前先把三件事分清楚后面所有优化都是围绕这三件事做的。1.1 单次检索400条比日配额更容易被忽略的硬顶地点检索的每一种检索方式——行政区划区域检索、圆形区域检索、矩形区域检索——单次请求能返回的结果总数是有天花板的。这个天花板由page_size和page_num共同决定page_size最大 20page_num从 0 到 19乘起来就是 400。你翻到第 19 页之后再往上加页码也不会给你新数据甚至连报错都不会有只是安静地返回空数组或者重复结果。这个限制的坑在于它不像配额那样会明确告诉你你没额度了。它伪装成这个城市就这么点数据尤其是在做小城市的时候你根本发现不了自己被截断了。我见过有人拿这套逻辑去跑一个连锁品牌的全国门店最后数据量比实际少了将近一半还以为是对方数据没上网。判断有没有被截断唯一可靠的信号是响应里的total字段。它告诉你的是这个检索条件下匹配到的总数而不是本次返回了多少条。当total大于等于 400 的时候基本可以确定你已经被削顶了必须换策略。1.2 QPS与日配额两把尺子量的是不同的东西日配额是一天能发多少次请求QPS 是一秒能发多少次请求。这两个数字在开发者控制台里是分开显示的消耗也是分开算的。QPS 超了接口直接给你返回错误请求白发了但是不算你配额日配额超了接口会一直返回配额校验失败的状态码直到第二天零点左右重置。需要注意的是百度这套 API 的配额和并发策略改过好几轮不同账号类型未认证、个人认证、企业认证拿到的数值差异很大从个位数 QPS 到几十 QPS 都有。我不会在这里写死一个数字你自己登录控制台看一眼我的服务里的实时额度那个才是准的。实际做项目的时候真正卡人的往往不是日配额而是 QPS。因为你做网格切分之后请求数量会膨胀得很快一个中等城市切成几百个方格很正常如果每个方格再翻十几页一次全量跑下来就是几千次请求。这时候如果你的并发开得太猛QPS 一下就打到上限了。1.3 名称检索为什么天然比周边检索更烧额度周边检索按圆心半径找的写法是这个点附近有什么你可以把城市均匀切成网格每个网格的请求相互独立互不影响。名称检索不一样它的匹配逻辑是模糊的你查A 品牌它会连A 品牌旗舰店A 品牌XX路店A 品牌 XX 分店一起给你甚至可能给你一些名字里带了A和品牌两个字的无关商户。这就带来两个后果。第一结果集变得很脏你必须做后置过滤和排序否则数据没法用。第二为了覆盖全你会忍不住用各种关键词组合去试探比如品牌名 城市、品牌名 区、品牌名 分类词每试一次都是真金白银的配额。我自己的做法是名称检索只用来拿候选集精度靠本地排序解决不要指望接口帮你做精确匹配。这句话是整篇内容的地基后面的网格切分、缓存、排序都是围绕它来的。2. 网格切分把一个大范围拆成不超400条的小块既然单次最多 400 条那最直接的办法就是把大地盘切成小地盘每块的结果数都控制在这个数字以下。听起来简单但实际操作里有几个参数细节非常容易出错尤其是坐标顺序。2.1 矩形检索的bounds参数与最容易被写反的坐标顺序矩形区域检索用的是bounds参数格式是四个数字用逗号连起来左下角纬度、左下角经度、右上角纬度、右上角经度。也就是minLat,minLng,maxLat,maxLng。注意它是先纬度后经度而且纬度在前。这一点和 GeoJSON 的习惯正好相反GeoJSON 是经度在前所以从 GIS 工具导出来的边界框直接塞进去八成是错的。错的后果很有意思不会报错但返回结果会跑到地球另一边去或者干脆返回空。我第一次踩这个坑的时候排查了小半天最后发现是把[lng, lat]直接丢进去了。import requests AK 你的ak URL https://api.map.baidu.com/place/v2/search def search_rect(query, bounds, page_num0, tagNone, akAK): params { query: query, bounds: bounds, # minLat,minLng,maxLat,maxLng output: json, page_size: 20, page_num: page_num, scope: 1, coord_type: 3, # 3 bd09ll ak: ak, } if tag: params[tag] tag return requests.get(URL, paramsparams, timeout10).json()coord_type这个参数容易被忽略它决定返回坐标的坐标系。传 1 是 wgs84传 2 是 gcj02传 3 是 bd09ll。如果你的下游系统用的是高德或者 GPS 原始坐标这里一定要统一不然后面做去重和距离计算全是错的。我一般统一用 bd09ll 入库需要的时候在出库环节统一转一次避免每次调用都要转换。提示bounds里的四个数字如果顺序写反了接口不会报参数非法而是正常返回空结果。这类静默失败是排查成本最高的建议在代码里加一个断言确认minLat maxLat且minLng maxLng。2.2 四叉树自适应细分让每块结果都落在安全线以下均匀网格的问题是浪费市中心一个 0.02 度的方格可能有几千个 POI郊区同样大小的方格可能只有三个。固定的切法要么市中心不够切要么郊区过度请求。四叉树的思路是先给一个大框跑一次看total。如果total小于 400这一块就收工了数据完整。如果total大于等于 400说明被削顶了把这块横竖各切一刀变成四块对每块递归做同样的事情直到每一块的total都小于 400或者达到预设的最大深度。def split_bounds(bounds): min_lat, min_lng, max_lat, max_lng [float(x) for x in bounds.split(,)] mid_lat (min_lat max_lat) / 2 mid_lng (min_lng max_lng) / 2 return [ f{min_lat},{min_lng},{mid_lat},{mid_lng}, f{min_lat},{mid_lng},{mid_lat},{max_lng}, f{mid_lat},{min_lng},{max_lat},{mid_lng}, f{mid_lat},{mid_lng},{max_lat},{max_lng}, ] def crawl_node(query, bounds, tag, out, depth0, max_depth6): total 0 for page in range(20): resp search_rect(query, bounds, page, tag) if resp.get(status) ! 0: handle_error(resp) return results resp.get(results, []) total resp.get(total, total) out.extend(results) if len(results) 20: break if total 400 and depth max_depth: for sub in split_bounds(bounds): crawl_node(query, sub, tag, out, depth 1)max_depth这个参数必须设。理论上递归会一直细分下去但实际中会遇到一些极端情况某个区域里的数据量确实巨大无限细分下去请求数会爆炸或者遇到接口返回的total不稳定同一条件两次请求返回值不一样递归可能永远收敛不了。我一般设在 5 到 7 之间深度到这个级别单块面积已经小到 0.001 度量级再细下去性价比就很低了。2.3 用total字段判断是否需要继续下钻total是整个策略里最关键的信号但它有一个陷阱它反映的是当前检索条件下匹配的总数而这个匹配是模糊匹配。你查一个品牌名total可能把很多名字沾边的商户也算进去了。这意味着一个尴尬情况第一层大框的total显示 800你以为被削顶了切成四块之后每块的total加起来可能只有 300 多。多出来的那些是因为大框检索时的模糊匹配命中了跨块的重复结果或者某些低相关度的条目在大范围下才被召回。所以我的判断逻辑是本地实际拿到的去重后数量接近 400才认为需要继续细分只看total容易被误导。具体做法是每块跑完之后先做一次 uid 去重再看去重后的条数。这样能避免为了一个虚高的total多花几十次请求。3. 关键词侧省额度的做法少发请求多拿结果网格切分解决的是空间上不够全的问题关键词解决的是语义上不够准的问题。这两件事配合起来用才能把配额花在刀刃上。3.1 query、tag、region三件套的组合逻辑这三个参数的作用完全不同搞清楚分工能省很多瞎试的请求query是自由文本做的是模糊匹配召回率高但精度差tag是分类筛选比如美食酒店购物公司企业这类它能把结果限定在某个大类里显著降低噪声region是行政区划限定填城市名或者城市区名。我通常的组合是query品牌名tag分类词region城市名。tag的作用不是筛出你想要的东西而是排除掉明显不相关的领域。举个例子你查一个连锁便利店品牌加上tag购物能把同名的服装店、餐饮店过滤掉一大半这样一次请求拿回来的有效结果比例就从三成提到了七成以上。需要注意的是tag的值并不是随便填的它有一套预定义的分类体系填错了要么不生效要么直接把结果清空。稳妥的做法是先不带tag跑一次看看返回结果的detail_info里的分类字段照着那个填。3.2 关键词扩展的取舍泛词高消耗窄词多轮次有一种说法是关键词写得越泛一次拿到的越多越省请求。这在 POI 检索里恰恰是反的。泛词的问题是它会把大量不相关的条目拉进来占满 400 条的名额真正你要的那些反而被挤出去了。比如查一个很短的品牌名它可能匹配到几百个毫不相干的商户total直接顶到上限然后你就必须做网格切分每个网格里还是同样的噪声比例。窄词的问题是每个关键词只能召回一部分你需要发多轮请求。但每一轮的精度都高落库的数据可以直接用。我实测下来的平衡点是用品牌名作为主查询用tag做粗筛用网格做空间下钻三者结合。不要试图靠关键词组合去穷举覆盖那是无底洞。如果你的品牌名本身就短、歧义大比如两个字的名字可以考虑加一个城市或者区域前缀作为限定但这个前缀最好通过region参数传而不是拼进query里——拼进query会让它参与模糊匹配反而把结果搞脏。3.3 分页的边界与它失效的几种情况分页本身没什么技巧page_num从 0 递增page_size固定 20翻到返回条数小于 20 就停。但有几种情况会让分页变得不可靠第一种是结果集在翻页过程中发生变化。你翻到第 5 页的时候榜单里新开了一家店整个排序往后挪了一位第 5 页和第 4 页的内容就会有一两条重叠。靠 uid 去重能解决重复但漏掉的那条就找不回来了。第二种是排序不稳定。同样的查询条件两次请求返回的顺序可能不一致这在较小的结果集里不明显一旦total接近 400翻页边界的漂移就会很明显。第三种是scope2的坑。开启详细模式确实能拿到更丰富的detail_info但在部分检索类型下它会压缩单次返回的条数导致你翻页翻得更多、请求数成倍增长。我的做法是批量阶段一律用scope1拿基础字段名称、坐标、地址、uid需要详细信息的那些再单独处理。3.4 详情字段按需拉取别在批量阶段开重字段地点检索接口有一个配套的详情检索接口用 uid 单独拉某一条 POI 的完整信息评分、营业时间、电话、评价标签等。它是独立的接口、独立的配额。这个设计其实挺贴心的因为绝大多数场景根本不需要那么多字段。做数据看板只需要名称和坐标做门店分布分析要的是地址和商圈归属只有做商户画像的时候才会关心营业时间和评分。批量阶段把重字段全开等于用昂贵的配额换了一堆你三个月后才可能用到的字段。我的建议是入库时只保留轻字段把 uid 存好。以后要做补充分析拿 uid 列表单独跑详情接口按 uid 去重之后请求量会小得多而且可以按优先级分批做不至于一次性把额度吃光。4. 本地缓存与去重让同一份额度撑更久配额是有限的所以每一次请求都必须有理由。缓存和去重的本质就是让重复的请求根本不发生。4.1 uid是主键坐标网格是兜底POI 的uid是百度给每条记录的唯一标识用它做主键去重是最可靠的。INSERT OR IGNORE就能搞定不需要写复杂的比对逻辑。但 uid 有个问题同一条 POI 在不同关键词下可能返回不同的 uid。百度对不同来源的记录做过合并处理同一个商铺在A 品牌和A 品牌 XX 店两次查询里拿到两个 uid 的情况是存在的。所以需要一层兜底用坐标做二次判重。做法是把坐标按精度取整比如精确到小数点后四位大约是十米量级拼上一个名称的规范化结果去掉括号内容、去掉空格、去掉常见后缀词算一个哈希作为辅助键。同一个辅助键的记录视为同一条保留字段最全的那条。这里有个平衡点要把握。取整精度太高同一个店铺因为坐标微小的差异被当成两条精度太低同一个商场里的不同商户会被合并成一条。小数点后四位是我试过比较合适的值商场里不同门店的坐标差异通常大于十米而同一家店的多次返回差异通常小于五米。4.2 SQLite表设计与请求指纹缓存数据量不大的时候几十万条以内SQLite 完全够用不需要上 PostgreSQL 或者 MongoDB。关键在于表设计要同时支持去重和请求缓存两件事。CREATE TABLE IF NOT EXISTS poi ( uid TEXT PRIMARY KEY, aux_key TEXT, -- 坐标取整 名称规范化的哈希 name TEXT NOT NULL, lat REAL, lng REAL, address TEXT, province TEXT, city TEXT, area TEXT, source_query TEXT, tag TEXT, grid TEXT, -- 命中的网格标识 updated_at INTEGER ); CREATE INDEX IF NOT EXISTS idx_poi_aux ON poi(aux_key); CREATE INDEX IF NOT EXISTS idx_poi_name ON poi(name); CREATE INDEX IF NOT EXISTS idx_poi_grid ON poi(grid); CREATE TABLE IF NOT EXISTS req_cache ( fingerprint TEXT PRIMARY KEY, -- md5(query tag bounds page_num) bounds TEXT, page_num INTEGER, total INTEGER, hit_count INTEGER, -- 本次实际返回条数 fetched_at INTEGER );请求指纹那张表的作用是每次发请求之前先查指纹如果这个条件在有效期内已经跑过而且当时没有被削顶就直接跳过。这能省下大量重复劳动尤其是你在调试排序逻辑、反复跑同一批数据的时候。import hashlib, time, sqlite3 def fingerprint(query, tag, bounds, page_num): raw f{query}|{tag or }|{bounds}|{page_num} return hashlib.md5(raw.encode(utf-8)).hexdigest() def need_fetch(conn, fp, ttl7 * 86400): row conn.execute( SELECT total, hit_count, fetched_at FROM req_cache WHERE fingerprint ?, (fp,) ).fetchone() if not row: return True total, hit_count, fetched_at row if time.time() - fetched_at ttl: return True if total 400: return True # 被削顶过不能复用 return False那个total 400的短路判断很重要。如果一个请求当时是被削顶的说明那次拿到的数据本来就不全缓存它只会让你一直用着不完整的结果。4.3 增量更新什么时候该重跑什么时候只补差集全量重跑是最省事也最费额度的做法。如果你每天跑一次全量一个中等城市的连锁品牌可能就要几千次请求一周下来额度就见底了。更实际的做法是分层更新更新类型触发条件大致成本差集补充库中该品牌记录数低于预期阈值只跑没覆盖过的网格抽样校验每周一次随机抽若干网格重跑固定的小额开销全量重跑季度一次或品牌方明确说有大调整全额开销差集补充是性价比最高的。做法是先在本地按网格统计已有的记录数然后只对那些零记录或者记录数明显偏少的网格发请求。判断偏少可以用历史均值的某个比例比如低于均值 30% 就补一次。注意增量更新的前提是你的网格划分是固定的、可复现的。如果每次跑网格都不一样差集就无从算起。所以在第一次全量之前把网格划分方案定下来并存档包括切分规则、最大深度、每块的边界坐标。5. 限流、重试与配额账本前面都是怎么少发请求这一段讲怎么把发出去的请求管住。请求发得再多如果没有节流和错误处理最后大概率是配额烧了、数据没拿全。5.1 令牌桶限流与并发数的实测取值QPS 限制是硬线撞上去就是错误。与其等它报错不如自己先限住。令牌桶是最简单的实现按固定速率往桶里放令牌取不到就等。import time, threading class TokenBucket: def __init__(self, rate10, capacity10): self.rate rate self.capacity capacity self.tokens float(capacity) self.ts time.monotonic() self.lock threading.Lock() def acquire(self): while True: with self.lock: now time.monotonic() self.tokens min(self.capacity, self.tokens (now - self.ts) * self.rate) self.ts now if self.tokens 1: self.tokens - 1 return wait (1 - self.tokens) / self.rate time.sleep(wait)rate该设多少这是个经验问题。控制台里显示的 QPS 上限是理论值实际能稳定跑到的往往是它的七八成。我一般从 5 起步跑十分钟观察错误率如果没有错误码就往上加加到开始零星出现限流错误再退回来一档。这个值因账号类型和当前时段而异晚上跑和白天跑能承受的速率都不一样所以别写死在配置里做成可调参数。并发数方面纯 IO 等待的场景下线程数可以开到 8 到 16但前提是令牌桶卡在前面。如果并发开得高而令牌桶速率很低线程大部分时间都在等待只是浪费上下文切换。我实测下来10 个 worker 配 8 的速率吞吐和稳定性都比较均衡。5.2 状态码分类处理与退避重试策略接口返回的status字段是唯一的判据必须逐类处理不能一刀切地错了就重试。status含义处理策略0正常继续处理结果1服务端内部错误指数退避重试连续 3 次失败则跳过该网格并记录2请求参数非法不重试立即抛弃回头检查 bounds 格式和坐标顺序3权限校验失败全局停止检查 ak 配置、白名单和签名4配额校验失败立即熔断写入当日停止标记等配额重置5ak 不存在或非法全局停止101服务被禁用全局停止去控制台核实这里最需要区别对待的是 status 1 和 status 4。status 1 是服务端的偶发问题重试有意义status 4 是配额用完了重试一万次也不会成功只会白白浪费时间和日志空间。我见过有人把这两类混在一起重试结果程序跑了六个小时一条数据没多拿。退避重试的间隔建议用指数增长加随机抖动第一次 1 秒第二次 3 秒第三次 9 秒每次叠加一个 0 到 1 秒的随机量。随机抖动的作用是避免多个 worker 在同一个时刻集体重试形成新的尖峰。5.3 配额监控给自己留一条熔断线配额的消耗速度必须实时可见不然你永远不知道自己离红线有多远。我的做法是维护一个本地计数器每次请求成功返回 status 0 就加一写进当天的日志文件。同时设两个阈值一个软阈值比如预估配额的 70%到了之后自动把令牌桶速率降半保证能跑完全程一个硬阈值比如 85%到了之后直接停止所有非必要的请求只保留正在进行的任务。为什么不把熔断线设在 100%因为配额是用完就断的留不出余量。如果你在 99% 的时候才停那剩下的 1% 根本不够收尾。而且很多场景下你会在跑完之后发现某个网格的数据有问题需要补跑这时候没有余量就只能等第二天。提示把每天的调用量、成功数、各类错误码的分布打成一张小表存在本地一周之后回看你能非常清楚地看出哪些查询条件是高消耗低产出的下一轮直接砍掉。6. 按名称精确匹配的排序一堆同名结果怎么挑数据拿回来了真正难的部分才开始。名称检索会给你一堆名字相似的结果其中只有一部分是你真正要的。怎么排序、怎么判定直接决定这批数据的可用性。6.1 名称相似度打分的几个维度与权重我的打分函数主要看四件事名称是否完全相等、是否互相包含、编辑距离、以及地理位置。import difflib def name_score(target, cand, dist_kmNone): if cand target: base 1.0 elif target in cand or cand in target: base 0.85 else: base difflib.SequenceMatcher(None, target, cand).ratio() * 0.8 if dist_km is not None: base - min(dist_km / 50.0, 0.2) return round(base, 4)完全相等的给满分互相包含的给 0.85其余按序列相似度打折。为什么要先判包含因为 POI 的正式名称通常带后缀比如你要找的A 品牌库里实际叫A 品牌人民路店。直接算编辑距离的话后缀会拉低分数反而是一些无关的短名字分数更高。相似度用difflib的SequenceMatcher就够了中文场景下它按字符比较效果比按词比较更稳定。如果要更精细可以先用 jieba 分词对品牌主体词和地点词分别加权但大多数场景下没必要做到这一步。6.2 参考点与距离权重怎么给名称相同的商户在不同城市、不同商圈都会出现。如果你知道目标大概在哪个区域距离就是一个非常强的信号。距离权重的给法有几个讲究。第一距离要换算成公里再参与计算不能直接用经纬度差值因为纬度一度的实际距离和经度一度不一样直接算会导致南北方向和东西方向的权重不一致。第二扣分的上限要设住我设的是 0.2也就是距离再远最多把分数压掉两成。这样即使目标在很偏的地方名称完全匹配的结果也不会被一个名称不太相关但离得近的结果挤下去。参考点怎么来如果是批量补全某个品牌的门店参考点可以用已经确认的高分结果的坐标均值动态更新。如果是单条查询就让用户传一个大概的位置或者用城市中心点兜底。6.3 几类容易误判的名称以及我踩过的坑实跑下来有几类名称特别容易出问题值得单独提一下。第一类是短名称。两个字的名字在城市里能匹配到几百个毫不相干的商户。这种情况必须靠tag和区域限定来压光靠排序救不回来。第二类是含括号的名称。有些品牌的官方名称里带括号注解比如带中国之类的后缀。检索的时候如果原样传进去模糊匹配会被括号干扰返回的结果会少很多。我的做法是查之前先把括号内容剥掉只留主体名拿到结果之后再在本地按完整名称匹配。第三类是同音字和近形字。这个在模糊匹配里非常常见尤其是品牌名里有生僻字的时候。解决办法是在关键词扩展阶段就把同音变体一起查然后用相似度排序归并。听起来麻烦但比起漏数据这点成本是值得的。第四类是地名和品牌名撞车。有些品牌名本身就是地名或者和某个地标重名。这时候tag的作用就体现出来了加上分类限定能把这部分噪声砍掉大半。踩过的坑里印象最深的一次是有个网格我漏跑了因为它在第一层就返回了total0我以为是空地就没管。后来发现那个网格的边界刚好压在一条行政边界上而行政区划检索在边界区域的行为不太一样换个region参数就出结果了。从那以后我加了一条规则任何返回 total0 的网格都用一个更小的子网格再确认一次成本很低但能避免整块数据缺失。