Python 爬虫:获取网页数据的 5 种方法
刚上手 Python 爬虫的时候,我也有过一段迷茫期:网页明明就摆在浏览器里,可一到代码里就各种拿不到数据。要么返回空页面,要么直接报错,要么拿到一堆看不懂的 HTML 标签。后来才慢慢想明白,爬虫最核心的问题从来不是“会不会用某个库”,而是“这个网页的数据到底藏在哪里,又该用哪种方式把它取出来”。
这篇文章就把我实际项目中用过的 5 种获取网页数据的方法整理出来——从最基础的 requests 请求,到 BeautifulSoup 解析,再到 lxml 的 XPath 提取,最后是 Selenium 和 Playwright 这类处理动态页面的方案。每种方法我都会说清楚适用场景、核心代码、容易踩的坑,以及我自己的选型经验。无论你是刚准备写第一个爬虫的新手,还是已经写过一些脚本、想系统梳理思路的进阶用户,这篇内容都能帮你少走不少弯路。
1. 五种方法的能力边界与选型逻辑
1.1 为什么同样抓网页,需要用五种不同方法
很多人以为爬虫就是“发个请求、拿回 HTML、用正则抠数据”三步走。但真实情况远比这个复杂:静态页面可以直接解析 HTML,动态页面需要等待 JavaScript 执行,接口型网站的数据藏在 XHR 请求里,还有的网站做了各种反爬限制。同一个 URL,在不同场景下,适合它的数据获取方式完全不同。
我习惯把网页数据获取拆成两个层面:第一层是“把数据拿回来”,第二层是“把数据提取出来”。requests、urllib 负责第一层,BeautifulSoup、lxml、正则表达式、Selenium 负责第二层。而实际方法的选择,主要取决于三个因素:数据是否在初始 HTML 中、数据是否需要浏览器渲染、网站的反爬强度。
这里给你一个我常用的选型判断逻辑:先在浏览器里按 F12 打开 Network 面板,刷新页面看第一个请求返回的内容里有没有你要的数据。如果有,就用请求库加解析库;如果没有,看 XHR 接口里能不能直接拿到 JSON,能的话就模拟接口;都拿不到,大概率是 JavaScript 动态渲染,这时候再上浏览器自动化工具。
1.2 五种方法的能力对比
为了帮助新手快速建立全局印象,我把五种典型方案整理成了一个对照表,后面每个方法再逐一展开。
| 方法 | 核心工具 | 适用场景 | 依赖 | 学习成本 |
|---|---|---|---|---|
| 标准库请求法 | urllib | 简单静态页面、无第三方库环境 | 无 | 低 |
| 会话请求法 | requests | 绝大多数静态页面、带 cookie 的页面 | 第三方 | 低 |
| DOM 解析法 | BeautifulSoup | 需要灵活遍历 HTML 结构 | 第三方 | 低 |
| 结构化查询法 | lxml + XPath | 复杂 HTML、频繁提取特定节点 | 第三方 | 中 |
| 动态渲染法 | Selenium / Playwright | 前端渲染页面、需模拟用户操作 | 第三方 | 高 |
另外还有一种常被忽略但效率极高的方式——直接模拟接口请求拿 JSON 数据。严格来说它不能算“解析网页”,但在实战中我遇到的大多数数据获取需求,最后都落到了这个方案上。第 6 部分我会单独聊它。
在实际项目里,这几种方法不是互斥的,我经常在同一个爬虫里组合使用:requests 拿页面、BeautifulSoup 解析结构、遇到动态内容再局部调用 Playwright。理解每种方法的边界,才能组合出最合适的方案。
2. 请求层基础:requests 与 urllib 的完整用法
2.1 requests 的核心请求链路
requests 是我日常使用频率最高的 HTTP 客户端库,它比标准库 urllib 好用太多,API 设计简洁,处理 cookie、session、重定向都省心。这里有一段完整的请求示例,我标注了关键参数:
import requests 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-Language": "zh-CN,zh;q=0.9", "Referer": "https://example.com/", } session = requests.Session() session.headers.update(headers) response = session.get( "https://example.com/list", params={"page": 1, "size": 20}, timeout=(5, 15), ) response.encoding = response.apparent_encoding html = response.text这段代码里有两个容易忽略的细节:第一个是timeout=(5, 15),前面是连接超时、后面是读取超时,防止某个网站响应慢导致整个任务卡死。第二个是response.encoding = response.apparent_encoding,很多网站的 charset 声明与实际内容不一致,强制用 apparent_encoding 可以从内容里探测真实编码,能有效避免中文乱码。
requests 最实用的能力是 Session 对象。它会自动保存服务端下发的 cookie,并且在你调用session.get()时自动携带。这意味着你只需要先访问一次登录页,再提交表单,后续的请求就会自动带上登录态,不需要手动维护 cookie 字典。
提示:requests 本身不执行 JavaScript。如果页面数据是通过 JS 异步加载的,requests 拿到的 HTML 里不会有这些数据。遇到这种情况请直接跳转到第 5 部分或第 6 部分。
2.2 urllib:没有第三方库时的备选方案
有些公司的生产环境出于安全考虑不允许随便安装第三方包,或者你只是临时写个一次性脚本,这时候可以用 urllib 完成任务。标准库方案稍微啰嗦一些,但核心思路是一样的:
from urllib import request, parse url = "https://example.com/search" params = parse.urlencode({"q": "python 爬虫"}) full_url = f"{url}?{params}" req = request.Request( full_url, headers={ "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64)", }, method="GET", ) with request.urlopen(req, timeout=10) as resp: html = resp.read().decode("utf-8", errors="ignore")需要特别提醒的是errors="ignore"这个参数。urllib 读取的原始字节流解码时,遇到非法字符会抛 UnicodeDecodeError,加上 ignore 可以跳过这些字符,保证拿到完整数据而不是中断报错。
urllib 也支持携带 cookie,但需要手动创建http.cookiejar.CookieJar并配合HTTPCookieProcessor使用。代码比 requests 复杂不少,日常开发我还是推荐优先用 requests,只有在环境受限时才退回到 urllib。
2.3 请求层最容易被忽略的“反爬三件套”
新手拿 requests 请求网页,最常遇到的就是 403 Forbidden 或 418 I'm a teapot。这两个状态码基本宣告了你的请求被识别为爬虫。我的经验是,请求头里至少要带好“反爬三件套”:User-Agent、Referer、Accept-Language。
User-Agent 告诉服务器你是什么浏览器,Referer 告诉服务器你从哪个页面跳转过来,Accept-Language 表明你的语言偏好。很多网站的反爬系统会同时校验这几个字段,缺一个就可能命中风控规则。建议把常用的浏览器 UA 字符串存成一个列表,每次请求随机取一个,降低被连续识别为同一客户端的概率。
还有一个细节是请求频率。我刚开始写爬虫时喜欢在循环里快速请求,结果不到十次就被封了 IP。后来养成一个习惯:每次请求之间加随机延时,不要用time.sleep(1)这种固定值,而是用time.sleep(random.uniform(1, 3)),让请求间隔像是真人操作。
3. BeautifulSoup:从 HTML 树里提取数据的经典方案
3.1 三种解析器的选择逻辑
requests 拿到响应文本后,数据还是“一张纸”,而 BeautifulSoup 的作用是把这张纸变成一棵可以灵活操作的树。BeautifulSoup 本身不做网络请求,它只负责解析。它依赖的底层解析器有三种:html.parser、lxml、html5lib。
| 解析器 | 速度 | 容错性 | 依赖 |
|---|---|---|---|
| html.parser | 中等 | 一般 | 标准库自带 |
| lxml | 快 | 较好 | 需安装 lxml |
| html5lib | 慢 | 极好 | 需安装 html5lib |
我日常基本都用 lxml 作为解析器,因为它速度最快,容错性也够用。只有当页面 HTML 写得极不规范、lxml 都在报警告时,我才会换 html5lib 试试。创建 BeautifulSoup 对象的标准写法如下:
from bs4 import BeautifulSoup soup = BeautifulSoup(html, "lxml")3.2 核心选择器用法与实战示例
BeautifulSoup 最常用的方法就三个:find()、find_all()、select()。前两个是按标签名或属性查找,第三个是支持 CSS 选择器风格的查找。我实际写代码时用得最多的是select(),因为 CSS 选择器表达能力强,而且和前端同事沟通时的语法完全一致。
举个例子,现在要提取一个商品列表页里所有商品名称和价格,HTML 结构大概是这样的:
<div class="goods"> <div class="item"> <h3 class="title">无线蓝牙耳机</h3> <span class="price">199.00</span> </div> <div class="item"> <h3 class="title">机械键盘</h3> <span class="price">399.00</span> </div> </div>对应的提取代码是:
items = soup.select("div.item") for item in items: title = item.select_one("h3.title").get_text(strip=True) price = item.select_one("span.price").get_text(strip=True) print(title, price)这里有个我踩过很多次的坑:get_text()会取出节点内所有文本,包括内部的空格和换行,所以一定要传strip=True。另外,如果某个商品标题缺失,select_one()会返回 None,调用.get_text()时就报 AttributeError。稳妥的做法是先判断是否为 None 再取值:
title_node = item.select_one("h3.title") title = title_node.get_text(strip=True) if title_node is not None else ""BeautifulSoup 还有一个很实用的能力是修改节点。你可以直接node["class"] = "new-class"修改属性,或者node.string.replace_with("新文本")替换文本。配合相关库把清洗后的 HTML 导出成 Markdown 或纯文本的场景,这个能力很省事。
4. lxml 结合 XPath:高效定位复杂节点的利器
4.1 XPath 的核心语法速查
当页面结构越来越复杂,纯靠 BeautifulSoup 的 CSS 选择器会写出很长一串嵌套选择,维护成本高。这时候我会切换到 lxml 的 XPath。XPath 是一门专门用来在 XML/HTML 文档中定位节点的语言,很多浏览器插件都支持直接调试,写起来比 CSS 选择器更灵活。
下面这张表是我常用的 XPath 表达式速查,建议收藏:
| 表达式 | 含义 |
|---|---|
//div | 从任意位置找所有 div 节点 |
//div[@class="item"] | 找 class 属性等于 item 的 div |
//div[contains(@class, "item")] | 找 class 属性包含 item 的 div |
//a/@href | 取所有 a 节点的 href 属性 |
//span/text() | 取所有 span 节点的直接文本 |
//div[@id="list"]//ul/li[2] | 取 id 为 list 的 div 下第二个 li |
//h3[position()<3] | 取前两个 h3 节点 |
lxml 的用法分为两步:先etree.HTML(html)构建一个 Element 对象,再调用xpath()方法执行表达式。元素对象与 BeautifulSoup 的树不同,它更像是一个“节点指针”,可以直接操作。
4.2 用 XPath 处理翻页与列表抓取
举一个真实案例。我需要抓取一个资讯网站的文章列表,每个列表页有 10 篇文章,结构是div.article-list > div.article-item,每篇文章有标题链接和发布时间。用 XPath 的写法如下:
from lxml import etree tree = etree.HTML(html) articles = tree.xpath('//div[contains(@class, "article-item")]') for article in articles: title = article.xpath('.//a[contains(@class, "title")]/text()') link = article.xpath('.//a[contains(@class, "title")]/@href') pub_time = article.xpath('.//span[contains(@class, "time")]/text()') if title: print(title[0].strip(), link[0] if link else "", pub_time[0] if pub_time else "")注意这里 XPath 返回的都是列表,即使只有一个匹配项也包在列表里,所以取值时用[0]取出第一个元素,或者先判断列表是否为空。这个特性和很多新手的直觉不一样,很容易漏掉索引导致报错。
XPath 还有一个非常好用的技巧——用contains()做模糊匹配。很多网站的 class 名是动态生成的,比如class="title title-202501",直接用@class="title"匹配不上,但用contains(@class, "title")就能精准命中。这个函数在应对自动化生成的样式名时几乎是必备技能。
注意:XPath 表达式中如果用双引号包裹属性值,表达式内部就不能再嵌套双引号,否则会语法报错。建议外层用单引号、内层用双引号,或者反过来统一风格。
5. 动态渲染页面:Selenium 与 Playwright 的完整方案
5.1 Selenium 的配置与等待策略
当页面数据依赖 JavaScript 动态渲染,requests 和普通解析就失效了。比如某数据可视化平台,列表内容是根据用户操作实时拼接出来的,初始 HTML 里只有一个空壳。这时候需要用一个真正的浏览器去执行 JS,然后读取渲染后的 DOM。
Selenium 是老牌的浏览器自动化工具,核心思路是启动一个 WebDriver 实例,让它打开浏览器访问页面,再通过代码模拟点击、滚动、等待,最后读取页面内容。下面是一段最基础的 Selenium 抓取示例:
from selenium import webdriver from selenium.webdriver.chrome.options import Options from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from selenium.webdriver.support import expected_conditions as EC options = Options() options.add_argument("--headless=new") options.add_argument("--disable-gpu") options.add_argument("--window-size=1920,1080") driver = webdriver.Chrome(options=options) try: driver.get("https://example.com/dashboard") wait = WebDriverWait(driver, 10) items = wait.until( EC.presence_of_all_elements_located((By.CSS_SELECTOR, "div.card")) ) for item in items: print(item.text) finally: driver.quit()这段代码里有几个关键点。第一是--headless=new,无头模式可以在后台运行浏览器,不弹出窗口,适合服务器环境。第二是显式等待WebDriverWait,它比time.sleep()可靠得多,会自动检测元素是否出现,既不浪费等待时间也不会过早读取。
新手最常见的错误是在driver.get()后立刻用driver.find_element()去抓数据,这时候 JS 可能还没执行完,返回空列表。我的建议是:只要页面里有 AJAX 数据,一律用显式等待,不要用固定 sleep。
5.2 Playwright:更现代的浏览器自动化选择
Selenium 用久了你会发现它有两个痛点:一是版本兼容问题,Chrome 更新后 WebDriver 可能失效;二是 API 设计比较老,操作链式写法不够直观。所以我近两年新项目基本迁移到了 Playwright。
Playwright 的安装和使用都非常顺滑,一条命令可以同时安装库和对应的浏览器:
pip install playwright playwright install chromium用 Playwright 抓取动态页面的代码比 Selenium 简洁很多:
from playwright.sync_api import sync_playwright with sync_playwright() as p: browser = p.chromium.launch(headless=True) page = browser.new_page() page.goto("https://example.com/dashboard", wait_until="networkidle") page.wait_for_selector("div.card") cards = page.query_selector_all("div.card") for card in cards: print(card.inner_text()) browser.close()wait_until="networkidle"会等到网络请求基本停止后再继续,对大部分 AJAX 页面够用。如果页面是无限滚动加载,Playwright 还提供了mouse.wheel模拟滚动,每滚一段等待新数据出现,这个能力在抓取瀑布流页面时非常实用。
Playwright 还有一个杀手级功能是自动等待。比如你调用page.click()或page.fill()时,它会自动等待目标元素可交互,不需要你手动写显式等待。这一点比 Selenium 省心太多,也是我迁移的主要原因。
提示:浏览器自动化工具虽然强大,但执行效率和资源占用都远高于 requests,同一时间能启动的浏览器实例有限。能走接口和静态解析的页面,优先用前几种方法;只有确认必须渲染 JS 时再上浏览器方案,这是爬虫性能优化的基本思路。
6. 补充方案:逆向接口直取 JSON 数据
6.1 从浏览器 Network 面板定位真实数据源
这个方法其实是无心插柳发现的。有次我要抓一个数据展示页面的列表,Selenium 跑得很慢,而且页面还时不时弹出滑块验证。我随手打开了浏览器的 Network 面板,刷新页面后看到了一大堆 XHR 请求,点开一个居然直接返回了结构清晰的 JSON,里面就是页面上的所有列表数据。
从那之后,我抓数据的第一优先级就变成了:先看 Network,再写代码。定位真实接口的方法很简单,在 Network 面板筛选 Fetch/XHR 类型,逐个看响应 Preview,找到包含页面核心数据的那个请求。然后右键复制为 cURL,再用工具转换成 Python 代码。
6.2 模拟接口请求的注意事项
接口模拟的好处是拿到的是干净的结构化数据,不用再费劲解析 HTML。但模拟接口也有一些反爬风险,最大的问题是请求签名。很多网站会在请求参数里带上 sign、token 之类的签名,这些参数是前端 JS 用密文生成的,直接复制无法复用。
处理签名有一个比较通用的思路:先看签名参数是纯前端生成,还是需要请求一个 token 接口换取。如果是后者,就用 requests 先请求 token 接口,拿到后再拼到数据接口请求头里。我遇到过一个实际案例,token 的过期时间是 30 分钟,我就在爬虫里维护了 token 缓存和过期时间判断,避免每次都重新获取。
还要注意请求参数的时效性。有些接口的 params 里带有时间戳或页码信息,直接复制旧链接会在服务器端校验失败。所以逆向接口时,代码里最好动态生成时间戳相关参数,不要写死。
接口模拟是我目前生产效率最高的一种方式,但也要克制使用,尤其是并发请求时要控制频率。我的经验是并发控制在 5 个以内,随机延时保持在 0.5 到 2 秒之间,这样既能保证效率,又不至于对目标服务器造成压力。
7. 常见问题与排查技巧实录
7.1 状态码 403 与反爬机制绕过思路
403 是爬虫最常遇到的状态码,意味着服务器理解了请求但拒绝访问。这里我按经验把常见的 403 场景和解决思路整理成了一个表:
| 现象 | 可能原因 | 对策 |
|---|---|---|
| 首次请求就 403 | 缺少 User-Agent 或 UA 被识别 | 补齐请求头,用真实浏览器 UA |
| 请求几次后 403 | 请求频率过高触发风控 | 增加随机延时,降低并发 |
| 带 Cookie 请求后 403 | 登录态过期或会话被标记 | 重新登录,检查 token 有效性 |
| 带特殊 Header 后 403 | Header 顺序或特殊字段不合法 | 复制浏览器完整的请求头并精简 |
还有一个经常被忽略的情况是:网站对请求的 Accept、Accept-Encoding 等头也有校验。有的反爬系统不检查具体值,而是检测这些头是否存在或者顺序是否异常。遇到排查不出来原因的 403,直接下载当前浏览器的完整请求头,一行一行对比,往往能找到问题。
7.2 编码乱码、超时与连接重置
乱码问题的主要原因我前面提过——response.encoding 判断错误。解决方式就是显式设置编码。还有一个技巧:优先从 HTML 的 meta 标签中读取 charset,如果没有再使用apparent_encoding。
超时的处理我一般分两类:单个请求超时用 timeout 参数控制,整个爬虫任务超时用全局信号量或超时线程控制。requests 的 timeout 参数只控制单次请求,如果页面内容很多、读取时间很长,建议用流式响应stream=True分批读取,或者干脆跳过该 URL 记录日志。
“Connection reset by peer” 这种错误通常出现在 HTTPS 握手阶段,常见于某些网站对特定地区的 IP 或特定 TLS 指纹做了拦截。遇到这种情况,可以尝试更换 HTTP/2 支持库,或者调整客户端的 TLS 版本。不过实话实说,这类反爬比较高级,普通入门项目很少遇到,先不用纠结。
7.3 故障排查速查表
我把平时排查爬虫问题的顺序整理成了一个清单,碰到问题照着走就行:
- 先用浏览器打开目标页,确认页面数据是否存在,排除页面本身为空的情况。
- 复制目标 URL 到 Postman 或 curl 中访问,确认 URL 和请求头是否正确。
- 打印响应状态码和响应前 500 个字符,判断返回的是否是预期的 HTML/JSON。
- 如果返回是 HTML 但解析为空,检查页面数据是否由 JS 动态加载。
- 如果是动态加载,转头看 XHR 接口里的 JSON 数据。
- 如果接口有签名参数,回到前端 JS 逆向签名逻辑。
- 最后才考虑用浏览器自动化方案兜底。
这个顺序本质上对应了前面讲到的五种方法,从最轻量到最重量级依次尝试。大部分场景在第 3 步或第 5 步就能解决。
7.4 我的一些个人经验
做爬虫这些年,我最大的体会是:不要一上来就写代码,先把目标页面的数据结构搞清楚。花十分钟看 Network 面板,可能省下两个小时调试浏览器自动化的时间。第二个体会是要有纪律性——请求频率、延时、失败重试、日志记录,这些“不性感”的东西才是项目稳定运行的关键。我见过太多爬虫写起来容易、跑几天就挂,最后全是在补反爬的处理。
还有一个建议,如果你打算长期做数据采集,建议从一开始就建立一个简单的数据存储方案,比如把抓到的数据统一保存成 JSON 或写入 SQLite,不要把抓取和存储混在同一个脚本里。这样即使解析逻辑改了,历史数据还在,不用重新跑一遍全量抓取。
最后,模拟接口、控制频率、做好异常兜底,是我认为爬虫项目里最重要的三件事。从 requests 到 Playwright,工具可以换,但这三个原则始终不变。希望这份总结能帮你少踩一些我当年踩过的坑。