写爬虫写了四五年,我有个挺固执的习惯:能走官方API就绝不硬怼网页。很多人一听“爬地点数据”几个字,第一反应就是去抓网页版地图的搜索结果。到真动手的时候才知道,页面结构和接口返回值说变就变,好不容易写好的解析规则,隔几天就可能废掉,更不用说每次最多翻那么几页,数据完整性根本没法保证。百度地图开放平台其实早就把地点检索做成了标准接口,直接请求JSON就能拿到带坐标、地址、电话的POI数据,稳定性和效率都靠谱得多。
这篇博客是“百度API爬虫”系列的第一篇,核心就一件事:从百度API中爬取地点数据。我会把“申请密钥 -> 读懂参数 -> 发起请求 -> 解析返回 -> 分页去重 -> 落到CSV”的完整链路拆开讲,并结合武汉热干面POI数据做一遍实战演示。适合刚开始学Python爬虫、或者已经会抓网页正打算往地理数据方向深入的读者,算是把最难绕开的第一块硬骨头先啃下来。
1. 百度地点检索接口选型与准备
1.1 为什么用地点检索API,而不是网页抓取
地图类数据的采集,最常见的问题是:靠网页抓取很难拿到结构化的坐标。网页上虽然能看到店铺名、地址、评论数,但经纬度往往藏在各种加密脚本里,需要断点调试才能拿到,改版一次就得重新逆向一轮。就算侥幸从网页里找到了坐标字段,这种数据的口径也未必干净,经常混着展示坐标和导航坐标,落到地图上全错位。
百度地图开放平台提供的地点检索API(place/v2/search)就没这些破事。它返回的是标准JSON,字段包括名称、所在区域、地址、电话、经纬度、类型标签等。这些字段本身就是地图业务在用的数据,清洗成本低很多,拿来直接做Excel统计、做热力图、做PyQt桌面工具都顺手。
这个接口还有一个很实用的能力:支持按关键词和行政区划组合查询。比如我想查武汉所有叫“蔡林记”的热干面门店,或者查武昌区所有“咖啡厅”,都不用自己画范围框。API里面可以传region=武汉市,也可以传bounds=经纬度矩形范围,两种方式覆盖了绝大多数批量采集场景。
另外在选型时要分清一个概念:地点检索不等于周边检索。周边检索是place/v2/around,需要你先有中心点坐标,适合做“某个地铁站附近有什么”的查询;而地点检索按区域和关键词直接搜索,更适合做全城范围的POI盘点。系列后面我会单独讲around接口,这篇先围绕search把底子打牢。
1.2 申请密钥与配置的完整流程
先解决钥匙问题。地点检索API用的是AK(Access Key),申请流程不复杂,我第一次操作大概花了十分钟。打开百度地图开放平台,登录后进控制台,左侧找到“我的应用”,创建应用时应用类型选“服务端”,然后填IP白名单。
这里有个细节,不少新手会卡住:IP白名单如果填错,请求返回会提示权限校验失败,但错误提示并不会直接告诉你“白名单不对”。个人测试阶段可以直接填0.0.0.0/0,意思是允许所有IP访问。正式部署到服务器,再把服务器公网IP填进去,比如123.123.123.123/32。这样白名单锁死后AK泄露的风险会小很多。
创建完成后控制台会给你一组AK。这个字符串一定要自己保存好,别直接贴到公开仓库里。有次我看到有人把AK写在Gitee的示例项目里,结果被路人刷爆配额,自己的项目反而跑不动,属于很典型的低级事故。请求地址建议用HTTPS,百度地图接口本身支持,能少一层传输层的麻烦。
我整理一个快速核对清单:
- 完成开发者实名认证,否则部分接口不可用。
- 创建“服务端”类型应用,不是浏览器端。
- IP白名单设置正确,测试期可放全。
- AK不提交到Git,用环境变量或者配置文件读取。
- 确认要调用的服务已经开通,地点检索默认开通,不用额外申请。
2. 发起一次地点检索请求,到底在做什么
2.1 请求参数解析:query、tag、region怎么配合
百度地点检索API的接入路径是固定的GET请求,参数看起来多,核心就几个。先看一个基础请求模板:
import requests import json import time AK = "你的AK" def build_params(query, region, page_size=20, page_num=0, tag=None): params = { "query": query, "tag": tag or "", "region": region, "output": "json", "scope": "2", "page_size": page_size, "page_num": page_num, "ak": AK, } return params params = build_params("热干面", "武汉市", tag="美食") resp = requests.get( "https://api.map.baidu.com/place/v2/search", params=params, timeout=5 ) data = resp.json() print(json.dumps(data, ensure_ascii=False, indent=2))这里几个参数的理解很关键。
query表示查询关键词,你可以传店铺名,也可以直接传“美食”“加油站”“药店”这类大类词。tag是类型标签,相当于在关键词结果里再筛选一次。比如query传“热干面”,tag传“美食”,返回结果会比不传tag更贴近餐饮类POI。tag的取值一般是百度地图内部的分类体系,常用的有美食、酒店、购物、生活服务、丽人等,具体以官方文档的分类表为准。
region就是行政区划,可以填“武汉市”,也可以填“武汉市武昌区”,甚至填“腾讯大厦”这种地标名。注意这里填的是搜索范围,而不是中心点。如果只需要某个矩形范围内的数据,就改用bounds参数,格式是"纬度下限,经度下限,纬度上限,经度上限"。实际项目里,全城盘点用region,商圈分析用bounds,两条路可以交叉验证。
scope参数也值得留意。取值为1时只返回基础信息,名称、坐标、地址;取值为2会额外返回电话、区域、所在商圈等明细。批量抓的时候,我一般直接上scope=2,省的漏字段再补一次。
2.2 返回JSON结构和字段优先级
请求发出去之后,常见的成功返回长这样:
{ "status": 0, "message": "ok", "total": 328, "results": [ { "name": "蔡林记热干面(户部巷店)", "location": {"lat": 30.54827, "lng": 114.30232}, "address": "武昌区户部巷", "province": "湖北省", "city": "武汉市", "area": "武昌区", "telephone": "027-88888888", "detail_info": { "type": "餐饮服务", "tag": "热干面", "navi_latlng": {"lat": 30.54811, "lng": 114.30218} } } ] }status是0,说明请求正常。total是符合条件的结果总数,但百度地图API并不会把全部结果一次性返回,单次查询最多返回前400条,每页最多20条,所以分页上限其实是20页。这个限制必须记在脑子里,否则会误以为没抓到完。
返回结果里的name、location、address是基础字段,尽量别选为空。telephone经常有缺失,很正常,因为不是所有POI都上报了电话。detail_info里面包含类型标签和导航坐标,对后续做业务分析很有用。
2.3 把单次请求封装成可复用函数
直接写一坨请求代码容易乱,推荐封装成函数,把单个请求和解析分开。我习惯先定义一个fetch_page(),负责发请求、判断返回状态、返回解析好的列表;再定义一个parse_result(),负责把JSON字段拍平成统一格式。
def parse_result(item): location = item.get("location", {}) detail = item.get("detail_info", {}) row = { "uid": item.get("uid", ""), "name": item.get("name", ""), "province": item.get("province", ""), "city": item.get("city", ""), "area": item.get("area", ""), "address": item.get("address", ""), "telephone": item.get("telephone", ""), "type": detail.get("type", ""), "tag": detail.get("tag", ""), "lat": location.get("lat", ""), "lng": location.get("lng", ""), } return row def fetch_page(query, region, page_num, page_size=20): params = build_params(query, region, page_size, page_num) resp = requests.get( "https://api.map.baidu.com/place/v2/search", params=params, timeout=10 ) data = resp.json() if data.get("status") != 0: return [], data.get("total", 0) rows = [parse_result(item) for item in data.get("results", [])] return rows, data.get("total", 0)这样后面写循环抓取就清晰多了,逻辑都在主控里,不用每个函数都重复请求细节。
3. 实战:抓武汉热干面POI数据的完整流程
3.1 设计关键词和行政区划的抓取策略
以一个实际项目为例:抓武汉市所有热干面相关的POI。很多人上来就把query填成“热干面”,region填“武汉市”,然后分页循环20页结束。这个思路没错,但抓出来的数据量可能比预想的小很多,原因是API最多返回400条。
如果武汉市的热干面POI总量已经超过400条,单靠一个关键词和整座城市,是抓不全的。合理做法是把城市拆成区,武汉有武昌、汉口、汉阳等区域,同时把关键词适当扩展,比如“热干面”“热干面馆”“蔡林记”“常青麦香园”组合来搜。每个词在每个区的搜索结果都控制在400以内,数据覆盖率会明显提升。
组合方式直接用两层循环:
regions = ["武汉市", "武昌区", "洪山区", "江岸区", "江汉区", "硚口区", "汉阳区", "青山区"] queries = ["热干面", "热干面馆", "蔡林记", "常青麦香园"] for region in regions: for query in queries: # 这里做分页抓取 ...这样做的代价是会产生重复数据,因为“武汉市热干面”和“武昌区热干面”的结果会有重叠,所以后面去重步骤不是可选项,而是必选项。
3.2 循环抓取与页数控制
单个关键词在单个区域的分页逻辑,需要注意total和page_num的关系。已经明确最多返回400条,每页20条,也就是最多20页,所以循环条件要同时满足两个判断:页数小于等于19,并且当前页结果数等于20。
def fetch_all(query, region): all_rows = [] page_num = 0 while True: rows, total = fetch_page(query, region, page_num) all_rows.extend(rows) if not rows or len(rows) < 20: break page_num += 1 if page_num >= 20: break time.sleep(1.2) # 控制请求频率 return all_rowstotal可以用来打印进度,但不要当成循环终止的唯一条件,因为total可能有400+,而我们实际最多只能拿到400。拿len(rows) < 20作为终止条件更可靠:说明该页已经不足一页,说明没有更多数据了。
这里我故意在循环里加了sleep(1.2),目的就是限制请求频率。百度地图开发者账号默认QPS很低,短时间猛发请求,系统直接给你返回限流提示,反而拖慢整个抓取过程。个人感受是,每秒一次左右对免费配额来说比较稳。
3.3 去重、坐标处理和落盘CSV
抓完的数据不能直接交差,去重是必须做的一步。去重主键最好用uid,这是百度地图POI的唯一标识。但有些边缘场景下uid会缺失,所以完整的去重逻辑是:优先用uid,uid为空时用“名称+地址”拼一个复合键。
seen = set() unique_rows = [] for row in all_rows: key = row["uid"] or f"{row['name']}|{row['address']}" if key in seen: continue seen.add(key) unique_rows.append(row)去重结束后,要把数据导出成CSV。这里必须提醒一个坑:文件编码。直接用df.to_csv("poi.csv")在Windows上用Excel打开,中文几乎必定乱码,因为默认编码可能是utf-8不带BOM。正确做法是用encoding="utf-8-sig",写出来的CSV Excel能直接打开不乱码。
import pandas as pd df = pd.DataFrame(unique_rows) df.to_csv("wuhan_热干面_poi.csv", index=False, encoding="utf-8-sig")坐标字段我在前面parse_result里已经拆成lat和lng两列,方便后面做地图可视化。注意这个坐标系是百度独有的bd09ll,直接拿它和GPS经纬度比较会偏移几百米,如果是给别人对接或者导入高德地图,需要先做坐标转换,这个后面单独讲。
4. 并发与限流:别再盲目上多线程
4.1 QPS限制是必须知道的底线
网上搜Python爬虫,十个教程里有八个在讲多线程并发,好像不开线程就不算会爬虫。但百度地图API和普通网页抓取不一样,它的限制非常明确:开发者账号有QPS(每秒请求数)上限,个人认证默认通常只有1,意思是一秒最多打一次请求,超过就会被限流。
很多人一上来就上线程池,发现请求报错比成功还多,然后怀疑是不是IP被拉黑了。其实就是QPS撞墙了。我在实际测试里对比过,同一个关键词抓20页数据,顺序请求加sleep(1.2)大概花了30秒,数据完整;开了5个线程同时打,结果大量请求返回状态码4“配额校验失败”,重试多次才补齐,总耗时反而更长。
放在表格里看就很直观:
| 请求方式 | 单页耗时 | 20页总耗时 | 报错情况 | 说明 |
|---|---|---|---|---|
| 单线程顺序请求 | 约1.2秒 | 约30秒 | 少 | 稳定推荐 |
| 多线程max_workers=3 | 约1.2秒 | 约35秒 | 多 | QPS受限反而更慢 |
| 多线程max_workers=8 | 约1.2秒 | 约50秒 | 非常多 | 基本不可用 |
所以结论反直觉:在默认QPS只有1的情况下,老老实实顺序请求就是最优解。
4.2 如果配额够,线程池怎么写
如果你认证等级提升,账号QPS上去了,或者业务上确实需要并发抓取多个城市,那时候再用线程池也不迟。写法上不要无脑创建一堆线程,先用信号量控制最大并发数,再在worker内部做失败重试。
from concurrent.futures import ThreadPoolExecutor, as_completed import threading semaphore = threading.Semaphore(3) def worker(query, region): with semaphore: return fetch_all(query, region) tasks = [(q, r) for q in queries for r in regions] with ThreadPoolExecutor(max_workers=3) as executor: futures = [executor.submit(worker, q, r) for q, r in tasks] for future in as_completed(futures): rows = future.result() # 处理rows...信号量设成3,意思是最多3个线程同时发请求,其他任务排队等待。配合每个worker内部对失败页做2到3次指数退避重试,并发抓取才算可以上线。
4.3 什么时候才需要上分布式
有些场景下,比如全网级别的POI采集,量级到百万甚至千万,单机串行肯定跑不完。这时候可以考虑分布式方案,把任务按城市、区县、关键词拆成队列,多台机器并发调度。但这是后面文章的话题,现阶段把这个接口搞清楚,把400条上限、坐标、去重这些问题处理明白,才是正经基础。地基都没打牢就上分布式,最后大概率是在给运维加工作量。
5. 高频报错与数据坑位速查
5.1 status状态码排查表
用到这个API,首先要学会看返回的status字段。我整理了一张常见状态码速查表,方便大家现场排查:
| status | 含义 | 常见处理方式 |
|---|---|---|
| 0 | 成功 | 正常解析results |
| 1 | 服务器内部错误 | 稍后重试,通常过几分钟就好 |
| 2 | 请求参数非法 | 检查query、region、page_size等参数格式 |
| 3 | 权限校验失败 | 检查AK是否错误,应用类型是否服务端 |
| 4 | 配额校验失败 | 请求频率过高或当日配额用完,降低频率 |
| 5 | ak不存在或者非法 | 确认AK是否正确,有没有粘贴多余空格 |
| 6 | 白名单校验失败 | 检查IP白名单是否包含当前出口IP |
这里的2和3特别容易搞混。遇到2,大部分是我把region填成了数字,或者page_num传了负数;遇到3,先检查AK有没有填对,再看应用类型。遇到6,别急着改代码,先去控制台看自己当前公网IP到底是不是白名单里那个。
有一个排查技巧:自己本机访问https://myip.ipip.net这类页面查看出口IP,然后去控制台把白名单改对,再重新发起请求。如果还是报6,有可能是公司内网出口IP频繁变化,要确认网络环境。
5.2 坐标体系是隐藏大坑
百度地图用的坐标系是BD-09,高德地图用的是GCJ-02,GPS用的是WGS-84。同一个地点的经纬度,在不同坐标系下会差几百米甚至上千米。如果你抓完百度API的数据,直接拿去高德地图上打点,点会整体偏移,看起来就像数据抓错了一样。
我遇到过最尴尬的情况,是拿百度POI坐标去一个基于WGS-84的系统里做距离计算,结果两点距离全部偏大。这不是数据错,是坐标系没对齐。解决方案有三种:第一,只在百度系产品里用这套坐标,比如百度地图JS API;第二,自己写坐标转换算法;第三,调用第三方转换服务。系列后面我会专门写一篇坐标转换的测试对比,这里先提醒大家有这个坑,别到用的时候踩进去。
5.3 数据缺失和编码细节
抓回来的POI数据,基本不可能做到每个字段都完整。telephone缺失很常见,有些小店根本没有联系电话;address缺失也不少见,尤其是偏远地区的POI。处理思路是保留原始字段,宁可留空也不随便填充虚假内容。另外name字段里容易出现重复,比如“老王热干面”和“老王热干面(武珞路店)”,它们确实是两家店,但括号里的店名变体有时候会造成统计上的困惑,清洗时可以用正则把括号内容去掉后做一次汇总统计。
CSV编码问题前面已经提过了,输出用utf-8-sig。还有一个细节是Excel对大CSV的支持有限,超过100万行会卡,如果你抓的数据量到了这个量级,建议直接存SQLite或者PostgreSQL,别跟CSV死磕。
6. 后续扩展与个人体会
6.1 从地点检索延伸出去还能玩什么
这篇只讲了place/v2/search一个接口,实际上百度地图开放平台还有好几个跟地点数据强相关的接口,完全可以串起来用。place/v2/around能做周边检索,比如给一批地铁站坐标,抓每个站周边500米的所有餐饮店;place/v2/detail能根据uid查单个POI的详细详情;地理编码接口则能把文字地址转成经纬度。
如果愿意再做一层产品化,可以把抓回来的POI数据接进PyQt做的桌面工具,也可以直接在web端调用百度地图JS API渲染成热力图。我之前做过一个小项目,把某市外卖店铺数据抓下来后在Qt窗口里按区县分布展示,同时联动一个地图组件显示点位,选餐饮品类时热力图实时变化,整体效果比单纯看Excel直观太多。这也是为什么我打算在系列后面安排一篇Qt和数据可视化结合的实践。
6.2 合规意识和实测后的心里话
说句实在的,百度地图API本身是官方接口,调用它不属于传统意义上的破解类爬虫。但这不代表可以滥用。每个开发者都有配额,超高频请求不仅会把自己的AK搞到临时封禁,还会影响别人的正常使用。合规的爬虫应该尊重接口约束,按频率控制,按用途申请,不绕过付费授权去拿商业数据。
另一个经验是关于数据时效性的。POI数据不是静止的,店铺会关停,地址会变更,电话会换。我抓过一次半年前的数据,回头核验时已经有大约百分之七八的店铺对不上了。所以如果你要做商业分析类项目,尽量在正式使用前抽样验证,最好能在抓取流程里加上最近更新时间这一列。光靠一次快照就做长期决策,风险会很大。
最后分享一个小习惯:每次抓完数据,我都会把请求参数、抓取时间、累计条数、去重后条数打成一个简单日志,跟CSV文件放一起。别小瞧这个动作,后面复盘数据质量、排查缺失、说服客户,全靠这份现场记录。操作上也就几行代码,收益却很大。做爬虫要到一定程度,比写代码更重要的,是形成一套自己能追溯、能解释、能复用的工作方式。