写爬虫写了四五年我有个挺固执的习惯能走官方API就绝不硬怼网页。很多人一听“爬地点数据”几个字第一反应就是去抓网页版地图的搜索结果。到真动手的时候才知道页面结构和接口返回值说变就变好不容易写好的解析规则隔几天就可能废掉更不用说每次最多翻那么几页数据完整性根本没法保证。百度地图开放平台其实早就把地点检索做成了标准接口直接请求JSON就能拿到带坐标、地址、电话的POI数据稳定性和效率都靠谱得多。这篇博客是“百度API爬虫”系列的第一篇核心就一件事从百度API中爬取地点数据。我会把“申请密钥 - 读懂参数 - 发起请求 - 解析返回 - 分页去重 - 落到CSV”的完整链路拆开讲并结合武汉热干面POI数据做一遍实战演示。适合刚开始学Python爬虫、或者已经会抓网页正打算往地理数据方向深入的读者算是把最难绕开的第一块硬骨头先啃下来。1. 百度地点检索接口选型与准备1.1 为什么用地点检索API而不是网页抓取地图类数据的采集最常见的问题是靠网页抓取很难拿到结构化的坐标。网页上虽然能看到店铺名、地址、评论数但经纬度往往藏在各种加密脚本里需要断点调试才能拿到改版一次就得重新逆向一轮。就算侥幸从网页里找到了坐标字段这种数据的口径也未必干净经常混着展示坐标和导航坐标落到地图上全错位。百度地图开放平台提供的地点检索APIplace/v2/search就没这些破事。它返回的是标准JSON字段包括名称、所在区域、地址、电话、经纬度、类型标签等。这些字段本身就是地图业务在用的数据清洗成本低很多拿来直接做Excel统计、做热力图、做PyQt桌面工具都顺手。这个接口还有一个很实用的能力支持按关键词和行政区划组合查询。比如我想查武汉所有叫“蔡林记”的热干面门店或者查武昌区所有“咖啡厅”都不用自己画范围框。API里面可以传region武汉市也可以传bounds经纬度矩形范围两种方式覆盖了绝大多数批量采集场景。另外在选型时要分清一个概念地点检索不等于周边检索。周边检索是place/v2/around需要你先有中心点坐标适合做“某个地铁站附近有什么”的查询而地点检索按区域和关键词直接搜索更适合做全城范围的POI盘点。系列后面我会单独讲around接口这篇先围绕search把底子打牢。1.2 申请密钥与配置的完整流程先解决钥匙问题。地点检索API用的是AKAccess 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_size20, page_num0, tagNone): 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, paramsparams, timeout5 ) data resp.json() print(json.dumps(data, ensure_asciiFalse, indent2))这里几个参数的理解很关键。query表示查询关键词你可以传店铺名也可以直接传“美食”“加油站”“药店”这类大类词。tag是类型标签相当于在关键词结果里再筛选一次。比如query传“热干面”tag传“美食”返回结果会比不传tag更贴近餐饮类POI。tag的取值一般是百度地图内部的分类体系常用的有美食、酒店、购物、生活服务、丽人等具体以官方文档的分类表为准。region就是行政区划可以填“武汉市”也可以填“武汉市武昌区”甚至填“腾讯大厦”这种地标名。注意这里填的是搜索范围而不是中心点。如果只需要某个矩形范围内的数据就改用bounds参数格式是纬度下限,经度下限,纬度上限,经度上限。实际项目里全城盘点用region商圈分析用bounds两条路可以交叉验证。scope参数也值得留意。取值为1时只返回基础信息名称、坐标、地址取值为2会额外返回电话、区域、所在商圈等明细。批量抓的时候我一般直接上scope2省的漏字段再补一次。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_size20): params build_params(query, region, page_size, page_num) resp requests.get( https://api.map.baidu.com/place/v2/search, paramsparams, timeout10 ) 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会缺失所以完整的去重逻辑是优先用uiduid为空时用“名称地址”拼一个复合键。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。正确做法是用encodingutf-8-sig写出来的CSV Excel能直接打开不乱码。import pandas as pd df pd.DataFrame(unique_rows) df.to_csv(wuhan_热干面_poi.csv, indexFalse, encodingutf-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_workers3约1.2秒约35秒多QPS受限反而更慢多线程max_workers8约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_workers3) 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成功正常解析results1服务器内部错误稍后重试通常过几分钟就好2请求参数非法检查query、region、page_size等参数格式3权限校验失败检查AK是否错误应用类型是否服务端4配额校验失败请求频率过高或当日配额用完降低频率5ak不存在或者非法确认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-02GPS用的是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文件放一起。别小瞧这个动作后面复盘数据质量、排查缺失、说服客户全靠这份现场记录。操作上也就几行代码收益却很大。做爬虫要到一定程度比写代码更重要的是形成一套自己能追溯、能解释、能复用的工作方式。