我曾经为了规划一次自由行硬是一个一个打开网页复制景点评分和门票价格抄到怀疑人生。后来意识到这种事完全可以交给Python去做。用requests把页面抓回来BeautifulSoup找出景点名称和评分Pandas把结果整理成表格存进CSV全程不到100行代码整个过程也成了我入门爬虫数据分析的最好练习。这篇文章就把这套完整流程拆开讲。你会学到怎么准备环境、分析页面结构、用BeautifulSoup抽取字段、用Pandas清洗数据并保存还会看到我在实际操作中踩过的反爬坑。内容偏实战代码可以直接复制参考适合有Python基础、想系统上手爬虫的同学。开始之前先说明白本文代码仅用于个人学习和技术研究请严格遵守目标网站的robots协议和服务条款合理控制抓取频率不要对线上服务造成压力也不要用于任何商业用途。1. 爬去哪儿网之前先把这几个问题想清楚1.1 为什么选“去哪儿网景点数据”当练习对象做爬虫练习最怕选错目标。太简单的页面练不到东西太复杂的网站光验证码和登录就能把人劝退。去哪儿网的景点列表页是一个很合适的中间档它既有静态HTML的部分字段又有一部分动态加载的数据数据结构比较规整景点名称、评分、地址、门票价格这些字段也都有明确标签很适合练习BeautifulSoup的CSS选择器和Pandas的数据清洗。另一个好处是“景点数据”天然适合做表格分析。抓完之后你能用它做排名、比价、画可视化图表成就感比抓一堆无意义的HTML强得多。对新手来说这种能快速看到结果的项目比纯刷语法题更容易坚持。不过要提醒一句网站的结构经常改版你在某个时间点看到的class名、URL格式可能过几个月就变了。所以这篇文章不会只给一套死代码而是会讲清楚“如何找到这些信息”的方法。方法才是可以长期复用的东西。1.2 技术选型requests BeautifulSoup Pandas的组合优势很多新手一上来就想上Scrapy觉得它“正规”。但Scrapy的学习曲线比较陡异步框架、item pipeline、spider中间件这些概念会分散你对爬虫核心逻辑的注意力。用requests BeautifulSoup Pandas做单机小规模抓取逻辑非常直观请求页面、解析HTML、整理并保存数据每一步都在代码里看得到排错也容易。我用一张表格说明这三个库的分工库职责典型代码requests发起HTTP请求获取页面内容requests.get(url, headersheaders)BeautifulSoup解析HTML定位和提取数据soup.select(.sight_item)Pandas结构化表格、清洗、筛选、导出pd.DataFrame(data).to_csv(...)这个组合还有一个好处三个库都在Python生态里属于“毕业级”工具即使以后转向Scrapy或数据分析这些技能也不会浪费。requests是底层请求逻辑BeautifulSoup让你理解HTML节点树Pandas更是数据处理的必修课。1.3 合规与反爬意识必须放在代码前面我不会假装爬虫没有灰色地带。如果一个网站明令禁止抓取或者需要登录才能看核心数据那就不要用技术绕过。本文只抓公开的景点概要信息不涉及个人隐私不抓评论不抓用户数据。实际操作中还应该注意几点请求之间要加随机延时不要一口气并发几十个请求设置真实的User-Agent伪装成普通浏览器如果遇到验证码或者429状态码说明频率太高要么停下来要么降低速度而不是用各种手段硬闯。合法合规不是一句口号是为了让你这个教程能长期用下去不把自己的IP搞进黑名单。2. 环境与抓包工具准备顺带理清HTML结构2.1 创建虚拟环境和安装依赖我建议所有爬虫项目都建虚拟环境哪怕只是练习。因为pip安装的包版本经常冲突尤其是lxml这类带二进制的库全局装很容易污染其他项目。在终端里执行mkdir qunar_spider cd qunar_spider python -m venv venv source venv/bin/activate # Windows系统用 venv\Scripts\activate然后安装依赖pip install requests beautifulsoup4 pandas lxml如果你用的是PyCharm更简单新建项目时选择虚拟环境然后直接在Terminal里执行上面的pip命令或者在Settings的Project Interpreter里点加号搜索pandas、beautifulsoup4安装。这里多说一句国内pip源下载可能慢可以临时使用清华源加速pip install requests beautifulsoup4 pandas lxml -i https://pypi.tuna.tsinghua.edu.cn/simple安装成功后顺手验证一下python -c import requests, bs4, pandas; print(ok)输出ok就说明环境没问题。2.2 在Chrome开发者工具里找到真实请求地址拿到一个页面直接写requests.get(url)之前一定要先打开Chrome开发者工具看看真实情况。因为很多网站的数据并不是服务端直接输出到HTML里的而是先给一个框架页然后浏览器再异步加载数据。如果你直接请求景点列表页可能只得到一个空壳。具体操作在浏览器里打开去哪儿网的景点搜索页按F12进入开发者工具切到Network面板刷新页面。你会看到大量请求。重点关注类型为xhr或fetch的请求因为这些才是动态加载数据的接口。点击某个请求在Preview标签页里看返回内容如果是整齐的JSON那说明景点数据其实是接口返回的。但并不是所有字段都在JSON里。有些景点名称和评分离线渲染在HTML中有些价格是异步加载的。所以我会采用一个比较稳妥的策略先用requests拿到HTML源码再用BeautifulSoup解析哪些静态字段能抓到如果发现核心字段缺失再回头从XHR接口里补数据。这个方法比单纯教某一种方式更抗真实变化。2.3 确认页面编码与响应对象新手最常见的乱码问题根源在于requests默认使用HTTP头里的charset判断编码但很多网站的头信息写得不准。如果你发现HTML里的中文全变成乱码可以先手动指定编码import requests url 替换成实际景点列表页 headers { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 } resp requests.get(url, headersheaders, timeout10) resp.encoding resp.apparent_encoding # 根据内容自动推测编码 print(resp.status_code) print(resp.text[:500])resp.apparent_encoding是requests基于页面内容推断出来的编码绝大多数中文页面都能被它正确处理。如果还是乱码再手动赋值为utf-8或gbk。这一步虽然不起眼却能省掉一晚上的排查时间。3. 用BeautifulSoup从景点列表页抽取字段3.1 抓取列表页的HTML并解析拿到编码正确的HTML源码后就可以用BeautifulSoup解析了。我喜欢给页面请求单独写一个函数这样后续处理分页、异常重试时不用改主逻辑。import requests from bs4 import BeautifulSoup import time import random HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 } def fetch_html(url, retry_times3): for i in range(retry_times): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: resp.encoding resp.apparent_encoding return resp.text else: print(f状态码异常: {resp.status_code}, 重试 {i1}/{retry_times}) except requests.RequestException as e: print(f请求异常: {e}, 重试 {i1}/{retry_times}) time.sleep(random.uniform(1, 2)) return None html fetch_html(替换成实际景点列表URL) if html: soup BeautifulSoup(html, lxml)这里我用lxml而不是默认的html.parser原因是lxml解析速度更快容错能力也更好。缺点是要额外安装不过我们已经在环境准备阶段装过了。BeautifulSoup支持的解析器很多但实际项目里lxml是最省心的选择。3.2 找出每条景点信息的CSS选择器在解析之前你需要先搞明白页面里“一条景点信息”到底长什么样。我习惯在开发者工具里直接右键页面中的景点区块选择“检查”就能看到对应的HTML结构。假设某段时间的页面结构长这样div classlist_item h3 classtitle故宫博物院/h3 span classgrade4.8分/span span classaddress北京市东城区景山前街4号/span span classprice¥60/span /div那核心选择器就是items soup.select(.list_item)拿到每个item之后再用更精确的CSS选择器往里抽字段。注意真实网站的class名往往很长且会变化我这里用一个简化结构做演示你实际操作时要根据真实HTML调整。一个很实用的技巧是使用CSS选择器而不是find_all因为.select()语法更直观组合起来也方便。比如需要同时满足两个class直接写成.a.b需要取某个标签下的文本就写.title。3.3 提取景点名称、评分、门票价格等字段现在开始写解析代码。我这里用一个循环把每个景点的信息装进字典最终得到一个列表data_list [] for item in items: title_tag item.select_one(.title) grade_tag item.select_one(.grade) address_tag item.select_one(.address) price_tag item.select_one(.price) title title_tag.get_text(stripTrue) if title_tag else grade grade_tag.get_text(stripTrue) if grade_tag else address address_tag.get_text(stripTrue) if address_tag else price price_tag.get_text(stripTrue) if price_tag else # 如果标题为空直接跳过避免把无效数据写进CSV if not title: continue data_list.append({ 景点名称: title, 评分: grade, 地址: address, 门票价格: price }) print(len(data_list))get_text(stripTrue)会把标签里的空白字符清理掉避免出现“故宫博物院 ”这种带尾巴的数据。每一个字段都做了空值判断防的就是某个景点恰好缺了价格或评分。写爬虫和写业务代码不一样外部数据永远充满惊喜不判空的话一个None就能让后面的Pandas报错。如果页面上的推荐标签、热度排名这些字段你也想要用同样的方法添加即可。但不要贪多刚开始抓三四核心字段就够了后面需要再扩展。4. Pandas清洗数据与CSV落盘别急着直接保存4.1 为什么不用csv模块而用Pandas有些同学可能会疑惑Python内置的csv模块也能写CSV为什么还要专门用Pandas原因在于数据清洗的复杂度。从网页上抓下来的数据几乎不可能直接可用评分字符串里带“分”价格字符串里带“¥”地址里可能混杂乱码或多余换行。用csv模块写文件还得自己处理这些清洗逻辑。Pandas把这套东西封装得特别顺手。你可以把抓下来的列表直接变成DataFrame然后像操作Excel一样过滤、替换、排序、去重最后一行代码导出CSV。对于“抓下来还要做分析”这种需求Pandas是天然的选择。4.2 将解析结果转成DataFrame先把目标数据列表转成DataFrameimport pandas as pd df pd.DataFrame(data_list) print(df.head())输出大概是这样景点名称评分地址门票价格故宫博物院4.8分北京市东城区景山前街4号¥60八达岭长城4.7分北京市延庆区G6京藏高速58号出口¥40这时候“评分”还是带单位“分”的字符串“门票价格”带“¥”。直接排序或求平均会出问题所以必须做类型转换。4.3 处理评分和价格字段的类型转换Pandas里有几个专门处理这类问题的函数比如str.replace、pd.to_numeric。我的做法是先把非数字字符去掉再转成数值类型# 把评分列从“4.8分”转为float df[评分] ( df[评分] .str.replace(分, , regexFalse) .astype(float) ) # 把价格列从“¥60”转为int df[门票价格] ( df[门票价格] .str.replace(¥, , regexFalse) .str.replace(,, , regexFalse) # 有的价格写成 1,200 .str.strip() ) # 空字符串会被转换出错先替换成pd.NA df[门票价格] pd.to_numeric(df[门票价格], errorscoerce)这里有两个重点。第一errorscoerce可以让Pandas在遇到无法转成数值的内容时把对应位置变成NaN而不是直接抛异常终止脚本。第二转完类型之后建议再检查一下print(df.dtypes) print(df.isna().sum())如果某个字段的NaN数量非常多说明你的选择器或者清洗逻辑有问题需要回头检查原始HTML。这一步在真实项目里非常关键能避免“数据写进去了但全是空值”的尴尬。4.4 去重、排序、保存成CSV抓取过程中可能因为页面重复加载导致数据重复所以保存前先按“景点名称”去重df df.drop_duplicates(subset[景点名称]) # 按评分降序排序 df df.sort_values(评分, ascendingFalse)然后导出CSV。有一个必须记住的参数是encodingutf-8-sig。如果只写utf-8生成的CSV用Excel打开时中文会乱码因为Excel默认用ANSI编码去读。utf-8-sig会在文件开头写入一个BOM头Excel就能正确识别UTF-8了。df.to_csv(qunar_attractions.csv, indexFalse, encodingutf-8-sig)这里的indexFalse表示不把DataFrame自带的行索引写进文件否则CSV里会多出一列没有意义的序号。到这里一个最基础的“抓取-解析-清洗-保存”流程就走完了。但真实场景还没这么简单下一章说说我遇到的反爬问题那才是爬虫实战里真正刷经验的地方。5. 我被去哪儿网反爬逼疯的两次经历请求头、限速和Cookie5.1 第一次忘了带User-Agent被重定向到验证页我第一次写这个爬虫时只写了一句requests.get(url)结果打印出来的不是景点列表反而是一堆跟景点无关的页面代码。仔细一看浏览器地址栏跳转到了一个安全验证页。这就是典型的反爬拦截。很多网站的第一道防护就是检查User-Agent。正常的浏览器请求会带上一长串UA信息标识自己的浏览器版本、系统类型。而requests默认的UA是python-requests/x.x.x一看就是程序很容易被服务器识别并拒绝。解决办法很简单就是给每个请求都加上浏览器UA。我习惯把它定义成常量HEADERS放在文件顶部后续所有函数共用HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36, Accept: text/html,application/xhtmlxml,application/xml;q0.9,image/webp,*/*;q0.8, Accept-Language: zh-CN,zh;q0.9 }这个坑几乎每个爬虫新手都会踩没什么技术含量但能直接影响你能不能拿到数据。把它写进习惯里以后写任何requests请求都先带上headers。5.2 第二次爬太快突然返回403我一开始没把请求频率当回事写了个for循环连续爬了十几页每页间隔连0.1秒都不到。结果跑到第五页左右突然返回403 Forbidden。那一刻我才直观明白什么叫“接口被限流”。解决方案很朴素慢下来。在每次请求之间加随机延时模拟人类操作节奏import time import random for page in range(1, 6): url fhttps://example.com/attractions?page{page} html fetch_html(url) # 解析并保存…… time.sleep(random.uniform(1.5, 3.5))随机延时的作用是让请求间隔看起来更像真人1.5秒到3.5秒的间隔对个人小规模爬虫来说足够了。你不需要用高并发去证明什么抓几千条数据都这么过来了速度完全够用。另外错误处理一定要做。如果服务器已经返回403就不要继续无脑重试了可以放弃这一页记录一下等会儿再跑。我在fetch_html函数里写的重试机制正好可以配合这个场景。如果连续重试都失败就说明你的IP可能暂时被限制了这时候最聪明的做法是停手等几个小时再继续而不是换个姿势反复打。5.3 动态加载数据HTML解析不到景点时怎么办去哪儿网的列表页其实有一个很典型的特点一部分数据在HTML里渲染另一部分比如销量、评价数是异步接口加载的。很多同学第一次抓的时候会发现用BeautifulSoup能拿到景点名称但评分和价格怎么选都选不到。原因就是这些字段在初始HTML里根本不存在。这时候就要回到第2章的开发者工具了。刷新页面在Network里找XHR请求尤其是那些返回JSON的接口。拿一个返回JSON的接口举例api_url 替换成Network里看到的真实接口地址 resp requests.get(api_url, headersHEADERS, timeout10) resp.encoding utf-8 data resp.json() # 解析JSON # 假设接口返回 {data: {poi_list: [...]}} poi_list data[data][poi_list] for poi in poi_list: tmp { 景点名称: poi.get(name, ), 评分: poi.get(score, ), 地址: poi.get(address, ), 门票价格: poi.get(price, ), } data_list.append(tmp)用poi.get(字段, )的好处是即使某个字段缺失也会返回默认空字符串不会直接抛出KeyError。Pandas拿到这种字典列表直接pd.DataFrame(data_list)就能用了。动态接口这个东西说起来不复杂但第一次遇到确实能卡很久。核心经验是看到页面是空的第一时间别怀疑代码写错了而是先看浏览器到底发了哪些请求。这个排查思路适用于几乎所有现代网站。6. 完整代码串联与后续扩展建议6.1 把以上流程封装成完整脚本前面每个环节都是分开拎出来讲的现在把它们合成一个可以运行的完整脚本。为了让你能直接测试我保留了一个parse_item的演示逻辑。真实使用时把选择器和URL替换成对应页面即可。import time import random import requests import pandas as pd from bs4 import BeautifulSoup HEADERS { User-Agent: Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 } # 页面解析函数返回 DataFrame def parse_poi_page(html): soup BeautifulSoup(html, lxml) data_list [] # 注意这里的 .list_item 只是示例实际以开发者工具看到的 HTML 为准 for item in soup.select(.list_item): title_tag item.select_one(.title) grade_tag item.select_one(.grade) address_tag item.select_one(.address) price_tag item.select_one(.price) title title_tag.get_text(stripTrue) if title_tag else if not title: continue data_list.append({ 景点名称: title, 评分: grade_tag.get_text(stripTrue) if grade_tag else , 地址: address_tag.get_text(stripTrue) if address_tag else , 门票价格: price_tag.get_text(stripTrue) if price_tag else , }) return pd.DataFrame(data_list) # 请求函数 def fetch_html(url, retry_times3): for i in range(retry_times): try: resp requests.get(url, headersHEADERS, timeout10) if resp.status_code 200: resp.encoding resp.apparent_encoding return resp.text else: print(f状态码: {resp.status_code}, 重试 {i1}/{retry_times}) except requests.RequestException as e: print(f请求异常: {e}, 重试 {i1}/{retry_times}) time.sleep(random.uniform(1, 2)) return None def main(): url 替换成真实景点列表页URL html fetch_html(url) if not html: print(网页获取失败程序终止) return df parse_poi_page(html) if df.empty: print(没有解析到任何数据请检查选择器) return # 清洗 df[评分] ( df[评分] .str.replace(分, , regexFalse) .astype(float, errorsignore) ) df[门票价格] ( df[门票价格] .str.replace(¥, , regexFalse) .str.replace(,, , regexFalse) .str.strip() ) df[门票价格] pd.to_numeric(df[门票价格], errorscoerce) # 去重和排序 df df.drop_duplicates(subset[景点名称]) df df.sort_values(评分, ascendingFalse) # 落盘 output_file qunar_attractions.csv df.to_csv(output_file, indexFalse, encodingutf-8-sig) print(f保存成功共 {len(df)} 条数据 - {output_file}) if __name__ __main__: main()这版脚本里astype(float, errorsignore)是Pandas 2.x的写法如果版本较老可能会报错。稳妥起见也可以用我前面讲的pd.to_numeric把评分也转一遍。总之跑通脚本后的第一件事是打开CSV检查数据质量而不是直接去做下一步可视化。6.2 如何从列表页扩展为多城市、分页抓取如果你想要的是“北京所有景点”那一个列表页通常不够需要处理分页。去哪儿网的分页URL里一般有page2这种参数你可以用循环拼接all_pages [] for page in range(1, 6): page_url f{base_url}?page{page} html fetch_html(page_url) if html: page_df parse_poi_page(html) all_pages.append(page_df) time.sleep(random.uniform(1.5, 3.5)) df pd.concat(all_pages, ignore_indexTrue)多城市逻辑也类似把城市URL维护在一个列表里循环处理最终合并所有DataFrame。但别忘了在每个城市请求之间额外增加延时因为跨页面和跨城市的请求模式更容易被识别为程序操作。爬虫脚本写完后不是运行一次就结束了。网站一旦改版选择器就可能失效所以建议定时做一次小范围测试比如只抓第一页看数据结构有没有变化。选择器最好统一定义在文件顶部的常量区而不是散落在循环里。这样出了问题时你只需要改一处不用满文件找。6.3 后续延伸用matplotlib做简单的可视化分析CSV文件落地后爬虫这趟活才算完成一半。另一半是用Pandas读回来做分析比如看看这些景点里评分分布如何或者门票价格最高的几个是哪些。import pandas as pd import matplotlib.pyplot as plt df pd.read_csv(qunar_attractions.csv) top10 df.nlargest(10, 评分)[[景点名称, 评分]] plt.figure(figsize(10, 6)) plt.barh(top10[景点名称], top10[评分], color#4C72B0) plt.xlabel(评分) plt.title(评分最高的10个景点) plt.gca().invert_yaxis() plt.tight_layout() plt.show()这段代码很简单但它能把“爬数据”转成“用数据”完整跑通一个数据获取到数据展示的流程。很多所谓爬虫可视化项目本质上就是这几十行代码的重复和美化。如果后续想更进一步可以尝试把抓到的数据存入SQLite或者用Flask搭个简单Web页面做一个景点查询工具。爬虫只是入口真正有价值的是你围绕数据做的处理和应用。我在实际使用中发现这个项目的核心难点永远不在写代码而在“面对一个没见过结构的页面你能不能冷静地拆解它”。URL变了就抓包class变了就重新看HTML字段缺失就逐层排查。这套排查问题的能力就是爬虫实战带给你的最大收获。