做地理数据的人,十有八九在百度地图的地点检索上栽过跟头。你输入一个名字,想拿回它在全城的门店列表,结果接口只给你 400 条,剩下的要你自己想办法;更麻烦的是配额——日调用量和 QPS 就摆在那儿,跑一次全城扫描可能要几千次请求,手一抖当天的额度就烧光了。这篇东西讲的是配额限制下利用百度地图按名称获取 POI这条路怎么走:把一个名字对应的 POI 尽可能完整、尽量省额度地捞回来,并且把结果干净地落进自己的库里。
顺带说一句,搜"POI"的时候你会撞见两个完全不同的东西:一个是地理信息里的 Point of Interest,一个是 Apache POI(Java 操作 Office 文档的库,前几年还爆过 XXE 漏洞)。这俩除了缩写一样没有任何关系,找资料的时候别被带偏。下面的内容全部围绕地理 POI 展开,代码示例用 Python 写,其他语言照着翻译逻辑就行。
1. 先搞清楚"按名称取POI"卡在哪三个地方
很多人第一次用place/v2/search的时候是懵的:明明城里这个牌子的门店有两千多家,返回的results数组加上翻页也就凑到 400 条,然后就没了。这不是你参数写错了,是接口本身的设计边界。所以在写任何爬取逻辑之前,先把三件事分清楚,后面所有优化都是围绕这三件事做的。
1.1 单次检索400条:比日配额更容易被忽略的硬顶
地点检索的每一种检索方式——行政区划区域检索、圆形区域检索、矩形区域检索——单次请求能返回的结果总数是有天花板的。这个天花板由page_size和page_num共同决定:page_size最大 20,page_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_num=0, tag=None, ak=AK): 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, params=params, timeout=10).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, depth=0, max_depth=6): 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,翻页边界的漂移就会很明显。
第三种是scope=2的坑。开启详细模式确实能拿到更丰富的detail_info,但在部分检索类型下它会压缩单次返回的条数,导致你翻页翻得更多、请求数成倍增长。我的做法是批量阶段一律用scope=1,拿基础字段(名称、坐标、地址、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, ttl=7 * 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, rate=10, capacity=10): 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 | 配额校验失败 | 立即熔断,写入当日停止标记,等配额重置 |
| 5 | ak 不存在或非法 | 全局停止 |
| 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_km=None): 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的作用就体现出来了,加上分类限定能把这部分噪声砍掉大半。
踩过的坑里印象最深的一次是:有个网格我漏跑了,因为它在第一层就返回了total=0,我以为是空地就没管。后来发现那个网格的边界刚好压在一条行政边界上,而行政区划检索在边界区域的行为不太一样,换个region参数就出结果了。从那以后我加了一条规则:任何返回 total=0 的网格,都用一个更小的子网格再确认一次,成本很低,但能避免整块数据缺失。