☰
Python爬虫获取网页数据的5种方法:从requests到Playwright
2026/10/2 5:58:21 网站建设 项目流程

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 后 403Header 顺序或特殊字段不合法复制浏览器完整的请求头并精简

还有一个经常被忽略的情况是:网站对请求的 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 故障排查速查表

我把平时排查爬虫问题的顺序整理成了一个清单,碰到问题照着走就行:

  1. 先用浏览器打开目标页,确认页面数据是否存在,排除页面本身为空的情况。
  2. 复制目标 URL 到 Postman 或 curl 中访问,确认 URL 和请求头是否正确。
  3. 打印响应状态码和响应前 500 个字符,判断返回的是否是预期的 HTML/JSON。
  4. 如果返回是 HTML 但解析为空,检查页面数据是否由 JS 动态加载。
  5. 如果是动态加载,转头看 XHR 接口里的 JSON 数据。
  6. 如果接口有签名参数,回到前端 JS 逆向签名逻辑。
  7. 最后才考虑用浏览器自动化方案兜底。

这个顺序本质上对应了前面讲到的五种方法,从最轻量到最重量级依次尝试。大部分场景在第 3 步或第 5 步就能解决。

7.4 我的一些个人经验

做爬虫这些年,我最大的体会是:不要一上来就写代码,先把目标页面的数据结构搞清楚。花十分钟看 Network 面板,可能省下两个小时调试浏览器自动化的时间。第二个体会是要有纪律性——请求频率、延时、失败重试、日志记录,这些“不性感”的东西才是项目稳定运行的关键。我见过太多爬虫写起来容易、跑几天就挂,最后全是在补反爬的处理。

还有一个建议,如果你打算长期做数据采集,建议从一开始就建立一个简单的数据存储方案,比如把抓到的数据统一保存成 JSON 或写入 SQLite,不要把抓取和存储混在同一个脚本里。这样即使解析逻辑改了,历史数据还在,不用重新跑一遍全量抓取。

最后,模拟接口、控制频率、做好异常兜底,是我认为爬虫项目里最重要的三件事。从 requests 到 Playwright,工具可以换,但这三个原则始终不变。希望这份总结能帮你少踩一些我当年踩过的坑。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询