☰
Python网络爬虫入门:Requests与BeautifulSoup组合实战指南
2026/9/26 6:12:08 网站建设 项目流程

做 Python Web 爬虫,我猜你多半会在某个教程里看到这句话:用 Requests 拿网页,用 BeautifulSoup 解析网页。这两个库的组合几乎是中文互联网上所有爬虫入门文章的标准开场,因为它确实足够简单,也足够解决问题。我自己最早写爬虫也是这条路,当时就是想把某个资讯站的文章标题和发布时间批量摘下来,省得手动复制。后来一路加了请求头、限速、异常重试、存数据库,才发现这套组合能承载的复杂度远比想象中高。

这篇文章不搞复杂框架,就从零开始,把环境搭建、第一个可用爬虫、编码与限流问题、数据保存、常见报错排查,一趟讲清楚。适合刚学完 Python 基础、想找个真实项目练手的人,也适合那些每天都要从网页上整理信息、做到一半就烦躁的朋友。如果你连 Requests 都没装过,下面这些步骤可以直接照抄;如果你已经写了一点爬虫但老被 429、编码乱码这些破事卡住,那直接跳到第三节和第五节会更有收获。

1. 先想清楚:Requests和BeautifulSoup到底各自干什么

1.1 Requests:把“打开网址”变成代码里的一个步骤

每个网页的获取过程背后都是同一个基础动作:客户端向服务器发送 HTTP 请求,服务器把 HTML、JSON、图片等资源还回来。Requests 这个库做的事情,就是把“发请求”这个动作简化成一行代码,屏蔽掉 URL 编码、TCP 连接、重定向、Cookie 管理等大量底层细节。

如果你用过 Python 标准库的 urllib,应该能体会那种繁琐。urllib 不是不能用,但它把很多事情丢给你手动处理,比如 URL 里的参数要自己编码,重定向要自己追,Cookie 要自己维护。Requests 就不一样,requests.get(url) 一行就完成了带着默认连接逻辑的 GET 请求。它在底层使用了 urllib3 连接池,同一个站点多次请求时能复用底层连接,省去反复握手的开销。爬虫往往要连续请求几十个页面,没有连接池时性能差距会非常明显。

打个比方,Requests 像你去快餐店前台点餐,不用自己进后厨管火候、管调料、管打包。你要关心的只有两件事:点什么(URL 和参数),以及端上来的东西该怎么处理(响应状态码和内容)。

实际使用时,带参数的请求写起来也非常直白:

params = {"page": 1, "keyword": "python"} resp = requests.get("https://example.com/search", params=params)

Requests 会自动把参数做 URL 编码,比如把空格转成 %20。返回值统一放在 resp 对象里,既有状态码也有响应文本,后面解析就很方便了。

有一点必须提醒:直接用 requests.get 发出的请求默认不带浏览器特征。服务器看到一个“自称 Python 脚本”的访问者,很多时候态度不会太友好。所以每写一个爬虫,先准备一个合理的 headers 字典,能省掉后面一大堆麻烦。这一点第三节专门展开。

1.2 BeautifulSoup:把HTML从“一长串字符”变成可检索的对象树

服务器返回的 HTML 本质上是文本,虽然浏览器会把它渲染成带层级结构的页面,但抓下来那一刻就是一段字符串。BeautifulSoup 做的就是把这串文本转换成 Python 对象树,让你用“找标签”“找属性”这种自然的方式去取数据。

举个例子,如果页面里是:

<title>Python入门教程</title>

那抓回来之后,soup.title.text 就能直接得到“Python入门教程”。从原理上讲,BeautifulSoup 会借助 HTML 解析器把文档组织成树形结构,HTML 标签是节点,标签里的文字是节点内容。不同解析器的差别主要在对不规范 HTML 的容忍度上,比如标签没闭合、属性没加引号这些真实世界的常态。所以我一直推荐显式指定 lxml 解析器,容错能力更稳。

BeautifulSoup 常用的提取方式有三类:soup.find() 返回第一个匹配节点;soup.find_all() 返回所有匹配节点组成的列表;soup.select() 用 CSS 选择器匹配节点。初学者从 select 入手会轻松得多,因为 CSS 选择器语法和你在网页样式里看到的一致,比如 .item 选类名, div > a 选直接子元素。

那正则表达式呢?很多零基础教程会讲“用正则提取数据”,但我建议面对 HTML 时优先用 BeautifulSoup。正则匹配 HTML 有一个天然弱点:只要标签层级多、属性顺序变一下、多了几个空格,你的规则就可能全线崩溃。用对象树去遍历,天然适配嵌套结构,不需要你去猜文本模式。正则不是没用,而是应该留给更细的场景,比如从一段 JS 脚本里抠出一段 JSON 数据。

1.3 把爬虫拆成三层,别把逻辑混在一起

看过不少初学者代码,请求、解析、打印、存文件全揉在一个脚本里。当时跑通挺开心,第二天目标网站改版,改代码时连哪段管什么都找不着了。一个更清晰的爬虫通常分三层:

  • 请求层:负责拿到指定 URL 的响应,统一处理 headers、超时、重试。
  • 解析层:把响应文本转成结构化字段,比如标题、链接、作者、发布时间。
  • 存储层:把字段写入 CSV、JSON 或数据库。

Requests 和 BeautifulSoup 分别对应前两层,存储层用 Python 自带的 csv、json 模块就能起步。分层的好处是:目标站点改版时,大部分改动只集中在解析层;换一个站点时,请求层和存储层大概率能复用。后面你想加限速、加代理、加异常重试,都是在请求层集中操作,不会把代码改成一团乱麻。

这个“三层”现在听起来可能像设计模式,但其实不用搞得多正式。先用三个函数隔开就行,比如 get_html、parse_page、save_data。等爬虫跑的项目稍微大一点,你会发现自己已经在不知不觉中用上了这种结构。

2. 环境准备与第一个能正常跑起来的爬虫

2.1 Python与虚拟环境:装库之前先避几个雷

先说 Python 安装。从官网下载安装包时,安装向导里有个“Add Python to PATH”选项,默认是不勾选的。很多人装完之后在命令行输入 python 提示“不是内部或外部命令”,就是因为没勾它。把它勾上,或者装完手动把 Python 目录加进环境变量,这个问题就解决了一大半。装好以后用python --version验证一下。

关于 pip 安装库,我强烈建议每个爬虫项目开一个虚拟环境。虚拟环境相当于给这个项目单独划一块地盘,里面装什么包都不会污染系统全局环境。Python 3 自带虚拟环境模块,用法很简单:

python -m venv .venv

Windows 下激活命令是.venv\Scripts\activate,Linux 和 macOS 是source .venv/bin/activate。激活后命令行前缀会出现(.venv),这时候你用 pip 装的一切都会进到这个环境里。如果看到报错提示“activate 不是内部命令”,大概率是你当前目录不在 .venv 对应层级,或者路径写错了。

还有一件事,教程里偶尔会看到py -m venv .venv,那是 Windows 多版本 Python 共存时的写法,py 是启动器。你只需要记住自己当前用的 Python 版本,别混着用就行。

2.2 安装requests、beautifulsoup4和lxml

执行:

pip install requests beautifulsoup4 lxml

Requests 的包名就叫 requests。BeautifulSoup 的 pip 包名虽然是 beautifulsoup4,但代码导入写的却是from bs4 import BeautifulSoup。这个包名和模块名不一致的坑,我见过不少朋友踩过,卡了半天以为装错了。

lxml 我也建议一起装上,它是给 BeautifulSoup 当解析器用的。写BeautifulSoup(html, "lxml")时如果系统里没装 lxml 会直接报错。默认的 html.parser 不需要额外安装,但处理真实网页时稳定性和速度都不如 lxml。

装完验证:

import requests from bs4 import BeautifulSoup print(requests.__version__)

能输出版本号基本就成了。如果安装过程慢,通常是网络问题,可以换成国内镜像源,不过这是公开操作,自己按需选择就行。

2.3 5分钟写一个最小爬虫:抓取页面标题

新建一个 spider.py,先用最简单的目标练手:

import requests from bs4 import BeautifulSoup url = "https://example.com/" resp = requests.get(url) soup = BeautifulSoup(resp.text, "lxml") print(soup.title.text)

运行后如果能输出 example.com 的页面标题,说明整条链路已经通了。这个流程看起来短,拆开解释却很有价值:

  • requests.get(url) 发送 GET 请求,resp 对象保存响应。
  • resp.text 是响应正文,也就是 HTML 字符串。
  • BeautifulSoup(resp.text, "lxml") 把字符串转成可查询的 soup 对象。
  • soup.title 定位到<title>节点,.text 取出内部文字。

需要注意,requests.get 默认会完整下载响应体进内存。对小页面没问题,动辄几十 MB 的大文件就应考虑流式下载,但入门阶段完全不用操心。

这个最小例子值得亲手敲一遍。它虽然只抓一个标题,但爬虫的核心动作全都占了:发请求、拿响应、解析、提取。后面所有更复杂的爬虫,都只是这个循环的放大版。

2.4 升级一下:用一个列表页抓取所有标题和链接

一只标题不过瘾,来点更实用的。假设目标页面结构长这样:

<div class="list"> <div class="item"> <h2><a href="/post/1">AI落地应用盘点</a></h2> <span class="date">2024-01-01</span> </div> <div class="item"> <h2><a href="/post/2">Python生态观察</a></h2> <span class="date">2024-01-02</span> </div> </div>

用这段代码把所有标题和链接取出来:

import requests from bs4 import BeautifulSoup url = "https://example.com/news" resp = requests.get(url) soup = BeautifulSoup(resp.text, "lxml") for item in soup.select(".item"): title_node = item.select_one("h2 a") title = title_node.text.strip() href = title_node.get("href", "") print(title, href)

这里最关键的是soup.select(".item"),它会把页面上所有 class="item" 的节点都选出来,返回一个列表。select_one("h2 a")则是在当前节点内部找第一个同时匹配 h2 标签和 a 标签的后代节点。

有一个细节容易忽略:页面里的链接通常是相对路径,比如 /post/1,并没有站点域名。如果后续要顺着链接访问详情页,必须先拼出完整 URL。手工字符串拼接虽然可以,但遇到../这类相对路径很容易出错,统一用 urljoin 更稳:

from urllib.parse import urljoin full_url = urljoin(url, href) print(full_url)

urljoin 会自动处理相对路径、上级目录这些问题。这段代码跑通之后,你已经具备抓取大部分静态列表页的能力了。

3. 写爬虫时避不开的坑:请求头、编码、429与重试

3.1 请求头:服务端判断“是不是正常访客”的第一道依据

Requests 默认的请求头里,User-Agent 直接写着 Python-requests/x.x.x,服务器一看就知道不是浏览器。很多站点对这种访问并不想直接提供服务。最简单的解法是设置 User-Agent,让它长得像主流浏览器:

headers = { "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0 Safari/537.36" } resp = requests.get(url, headers=headers)

做这一步不是为了跟谁斗智斗勇,而是让自己的请求在服务器看来更接近一个普通访客。如果加了 User-Agent 仍然被拒,还可以继续补 Referer、Accept-Language 等字段,看目标站点具体对什么敏感。

我的习惯是把 headers 集中放在代码开头,所有请求共用同一份。这样以后要调整,只改一处。实测下来很多小站加不加请求头返回内容都一样,但访问频率一上去,没加头的那一方会更快被限制,因为它的指纹太明显。

另有一类站点用 Cookie 维持会话状态。单独使用 requests.get 时,Cookie 并不会自动在多次请求之间保留,只有 Session 对象能维护这块状态。只要打算批量请求,我更建议从头就用 requests.Session():

session = requests.Session() session.headers.update(headers) resp = session.get(url)

可以把 Session 理解成一个持续存在的浏览器标签页,它内部会维持 Cookie 和连接池。单次请求看不出差别,跑几十个页面之后稳定性和速度都会好不少。

3.2 编码问题:中文抓回来才发现乱码

爬取中文站点时,乱码是排名前三的劝退问题。HTTP 响应头里带着字符集,页面 HTML 的 meta 标签里也可能声明了 charset,当两个地方不一致时,resp.text 就会用错误的编码去解码,结果自然满是侧漏乱码。

一个粗暴而有效的修正方式是:

resp.encoding = resp.apparent_encoding

apparent_encoding 会根据响应内容本身去检测编码,通常比响应头可靠。如果你已经确定某个站点是 GBK,也可以直接指定:

resp.encoding = "gbk"

知道目标编码时手动指定更保险,因为猜测毕竟有猜错的可能。这个乱码问题看似小,不确定编码的时候可能浪费一整晚。做过一次就会记住:拿到响应先确认编码,再考虑怎么解析。

有一点要注意,apparent_encoding 依赖 chardet 做检测,对大响应体会增加额外耗时。列表页这种小文件无所谓,下载超大文件时不建议先全量载入再做编码猜测。

3.3 429 Too Many Requests:限流不是末日,降频就有路

“exceeded retry limit, last status: 429 too many requests” 这类报错,爬虫新手遇到基本就慌。429 的意思是服务器明确告诉你:请求太频繁,请放慢速度。它其实不是一个坏信号,恰恰说明服务器在正常履行限流职责。

我看到很多人的第一反应是换 IP、上代理、绕开限制,这个思路从源头就不太对。除开极少数特殊情况,批量抓取公开页面时,设置合理的访问间隔才是可持续的做法。最基础的实现方式:

import time for url in url_list: resp = session.get(url, timeout=10) if resp.status_code == 429: retry_after = resp.headers.get("Retry-After", "5") time.sleep(int(retry_after) + 1) continue # 处理响应 time.sleep(1)

Retry-After 头是服务器返回的“请多少秒后再来”的提示,比自己随机拍一个延迟靠谱。无人值守的定时爬虫里,我一般用偏保守的策略:列表页间隔 0.5 到 1 秒,详情页间隔 2 到 5 秒。看起来慢,其实比因为频繁被限流导致任务永远跑不完强得多。

频率控制的本质很简单:给服务器保留正常服务能力。一个发货成熟的爬虫,最好的评价不是“快”,而是目标站点几乎察觉不到它存在。日志里记得把 URL 和状态码打出来,方便回头判断是哪一步触发了限流。

3.4 超时与异常处理:不写这两项,脚本会深夜神秘中断

入门代码最常见的问题之一是不写 timeout。请求可能无限期挂起,服务器迟迟不响应,你的脚本就卡在那一步,等到第二天才发现半夜就断了。给每个请求加超时是基本素养:

resp = requests.get(url, headers=headers, timeout=(5, 10))

元组第一个值是连接超时,指建立 TCP 连接的最长等待;第二个是读取超时,指拿到响应报文的最长时间。两个分开设的好处是定位问题时能区分是连不上还是服务器响应太慢。

网络请求天生不稳定,一个健壮的请求层应该捕获异常并做有限重试:

import time from requests.exceptions import RequestException def fetch(url, max_retries=3): for attempt in range(max_retries): try: resp = session.get(url, timeout=(5, 10)) if resp.status_code == 200: return resp elif resp.status_code in (429, 503): time.sleep((attempt + 1) * 2) except RequestException as e: print("请求失败", e, "重试", attempt) time.sleep(1) return None

这个 fetch 函数虽然简单,但已经集中了重试、退避、超时三要素。以后不管遇到什么新的状态码,改动都可以收敛在函数内部。我长期实践下来最大的感受是,爬虫脚本很少死在解析逻辑上,更多时候是死在没有处理网络层的偶发故障。

4. 动手:解析一个列表页并保存数据

4.1 动手前先学会用开发者工具看页面结构

拿到一个目标列表页,我不建议直接开写代码。先按 F12 打开浏览器开发者工具,切到 Elements 面板,你会看到已经渲染完成的 HTML DOM。这时候要做三件事:确认数据在哪个父节点下面,确认标签和类名是什么,确认它是静态渲染还是动态渲染。

判断动态渲染最直接的方式:用 Requests 抓下来的 resp.text 里,用文本查找功能搜一下你在页面上看到的关键词。如果搜不到,而浏览器里明明显示着,就说明数据是 JavaScript 后来填充的,直接抓静态 HTML 只会拿到空壳。入门阶段尽量选静态页面练手,会省心很多。

还要留意 iframe。有些页面把正文嵌在 iframe 中,Requests 抓外层页面拿不到内容,需要先定位 iframe 的真实地址,再对它单独发起请求。这些都属于小场景,但排查流程是一致的:先看清楚数据到底从哪里来,再决定怎么抓。

4.2 用选择器精确提取:标题、链接、作者、时间

假设目标页面结构固定为:

<article class="post"> <h1 class="title"><a href="/news/detail/1001">最新消息标题</a></h1> <p class="author">作者:张三</p> <span class="date">2024-02-20</span> <div class="summary">这里是新闻摘要,文字量稍多。</div> </article>

对应解析代码可以这样写:

from urllib.parse import urljoin soup = BeautifulSoup(resp.text, "lxml") items = soup.select("article.post") data = [] for item in items: title_node = item.select_one("h1.title a") title = title_node.text.strip() if title_node else "" href_node = item.select_one("h1.title a") href = href_node.get("href", "") if href_node else "" link = urljoin(url, href) if href else "" author_node = item.select_one("p.author") author = author_node.text.replace("作者:", "").strip() if author_node else "" date_node = item.select_one("span.date") date = date_node.text.strip() if date_node else "" summary_node = item.select_one("div.summary") summary = summary_node.text.strip() if summary_node else "" data.append({ "title": title, "link": link, "author": author, "date": date, "summary": summary, })

这段代码里我故意多写了几次 None 判断,因为真实页面上总有个别条目缺字段。如果直接item.select_one(...).text,节点不存在时会直接报 AttributeError,整个爬虫崩掉。虽然写起来多几行,但面对数据毛刺时稳定得多。

关于选择器的写法,h1.title a表示 h1.title 这个节点下的 a 标签。类名、标签可以自由组合,只要页面上能区分就行。核心原则是选择范围不要过泛,太宽会把不相关内容混进来,后面数据清洗会苦不堪言。

4.3 数据清洗:去空白、去重与字段标准化

解析出来的数据通常带着多余空白、全角空格和各种前缀,直接用不是不行,但存下来再处理就见鬼了。我习惯在采集阶段做最小清洗:

  • 文本两侧统一 strip()。
  • 连续多个空白用正则 \s+ 替换成单个空格,尤其适合摘要这种长文本。
  • 链接如果是完整 URL 就不动,是相对路径就用 urljoin 补全。

去重问题是另一件早晚要面对的事。定时爬虫每天跑一遍,同一个页面重复入库非常常见。最简方案是用链接去重:

seen = set() for item in data: if item["link"] in seen: continue seen.add(item["link"]) # 写入存储

如果同一链接的文章内容更新了,只看链接去重还不够,可以把“链接 + 标题前20字”拼起来算哈希:

item_hash = hash(item["link"] + item["title"][:20]) if item_hash not in seen: seen.add(item_hash) ...

入门阶段不必追求完美的去重方案,先用链接去重跑几千条数据,真遇到“重复但略有更新”的场景再升级哈希逻辑。道理很简单:方案复杂度要跟着问题规模走,不然你是在用大炮打蚊子。

4.4 保存为CSV和JSON的两种姿势

数据变成列表字典之后,保存很简单。CSV 适合 Excel 直接打开,JSON 适合程序间交换、调试查看。

import csv with open("news.csv", "w", newline="", encoding="utf-8-sig") as f: writer = csv.DictWriter(f, fieldnames=["title", "link", "author", "date", "summary"]) writer.writeheader() writer.writerows(data)
import json with open("news.json", "w", encoding="utf-8") as f: json.dump(data, f, ensure_ascii=False, indent=2)

CSV 用 utf-8-sig 而不是 utf-8,这个细节很多教程不会提。Excel 打开无 BOM 的 UTF-8 文件时会识别错编码,中文直接乱成一团,加 BOM 后就能正常识别。JSON 用 utf-8,同时加 ensure_ascii=False,让中文以中文形式写入文件,而不是一长串 \uXXXX 转义,调试时一眼能看清内容。indent=2 只是让文件更易读。

关于存储格式的选择,我的建议是:早期调试阶段用 JSON,出了问题文本编辑器打开就能修;小批量手工使用用 CSV;后续要频繁查询、增量更新,再引入数据库。很多爬虫项目越往后越发现,存储层的设计才是花费时间的大头。

5. 常见问题排查速查表与避坑经验

5.1 第一直觉:打印原始响应前500字

排查数据抓不到的问题,第一反应不应该是改选择器,而是先看手里拿到了什么。打印 resp.text 的前 500 个字符,异常情况大部分一眼就能判断出来。

现象常见原因排查方法
resp.text 为空或极短页面动态渲染、重定向到空页打印 resp.url 看是否有跳转
resp.text 包含登录页 HTML服务器做了身份校验检查 Cookie 和请求头
状态码 200 但解析出 0 条选择器错误、数据在 iframe 里打开开发者工具核对选择器
状态码 403 或 418被识别为脚本访问修正请求头并降低频率
状态码 429请求过于频繁暂停后看 Retry-After

有一个很实用的习惯:把抓到的 HTML 存成 test.html,用 BeautifulSoup 在本地反复试选择器。这样改一次选择器立刻有反馈,不用反复请求目标服务器,调试速度更快,也不会给对方增加压力。后续目标网站改版,这份本地样例还能用来快速判断是页面结构变了还是代码问题。

5.2 浏览器打开正常,Requests结果却对不上怎么办

这是初学者最容易懵的场景。浏览器打开目标页面,标题、列表清清楚楚,Requests 抓回来却是一堆无关内容,甚至啥都没有。常见原因有三类:

  • JavaScript 动态渲染:数据是浏览器执行脚本后填充的,Requests 不会执行 JS。
  • 登录态和 Cookie:页面内容依赖登录身份,必须带上相应 Cookie。
  • 地域识别或客户端识别:服务端根据 IP、User-Agent 返回不同版本的内容。

排查方法也直接:先打印 resp.url,看有没有被重定向到登录页;再看 resp.text 的前几百个字符,判断到底是静态页面还是验证页面。如果确认是动态渲染,可以去开发者工具的 Network 面板找 XHR 请求,往往能直接找到返回 JSON 的数据接口。用 Requests 请求这个接口,对比直接解析 HTML 反而更稳定,因为 JSON 结构化程度远高于 HTML。

注意:如果返回的是验证码或安全校验页,这时候一味增加请求频率、无限重试只会更糟。正确做法是停下重试,重新核对请求头、Cookie 以及抓取频率是否合理。

5.3 被限流后怎么调整,而不是跟站点对着干

每个爬虫都会遇到被拒绝的时刻。我的原则是:先冷静评估频率是不是太高。从真实访客的角度看,没人会在一秒内连着打开五个页面,所以把间隔拉大到一两秒再观察,往往就能缓解。

很容易犯的错是无限重试。403 或 429 之后仍然高频重试,只会让服务器更警惕,甚至把限制时间拉得更长。正确的策略是有限次数重试,每次退避时间递增,重试耗尽后放弃这一条,继续处理下一条。脚本的面目应该是“坚韧且有礼貌”,而不是“愤怒且执着”。

频率控制再往外说一点:爬虫的成熟程度并不体现在能爬到多快,而是体现在会不会给目标站点造成压力。一个整晚跑完任务的爬虫,可能不及一个每小时只取少量数据的爬虫有价值,尤其在对方的服务条款和反爬机制比较复杂的时候。做长期项目,稳定比速度重要得多。

5.4 本地快照与日志:成本极低的两种排障手段

我强烈建议开发阶段把每次抓到的页面存一份快照。不需要多少磁盘空间,但价值极高:

with open("snapshot.html", "w", encoding="utf-8") as f: f.write(resp.text)

解析结果不对时,打开本地 snapshot.html 看源码,不需要再次访问目标服务器。页面结构偶尔变化时,这份快照能让你慢慢对比差异,而不必担心反复触发服务器限制。

日志方面,我用最简单的 print 也能满足需求,核心是记录四个信息:时间、URL、状态码、解析条数。等到定时任务连续跑几天,打开日志一看,哪个 URL 总是失败、哪天的解析条数突然下降,都一目了然。没有日志的爬虫一旦数据中断,排查成本比写日志的成本高好几个数量级。

6. 再进一步:Requests和BeautifulSoup的边界

6.1 什么情况下该换Scrapy或自动化浏览器

Requests 和 BeautifulSoup 是好起点,但到一定规模会有瓶颈。我把它分成三类场景:

  • 一次性要抓几十万页面,纯 Requests 循环速度上不去,更适合用 Scrapy 框架。调度、去重、并发、下载中间件都有现成组件,分布式扩展也方便。
  • 页面重度依赖 JavaScript 渲染,比如数据通过 AJAX 加载、点击事件触发翻页,这种只能考虑 Playwright 或 Selenium。它们会启动一个真实浏览器内核执行脚本、渲染页面,代价是更吃资源、速度更慢。
  • 数据其实来自接口。如果 Network 面板里已经能看到返回 JSON 的接口,你甚至不需要解析 HTML,直接用 Requests 请求那个接口就可以了。

即使换了框架,Requests 和 BeautifulSoup 这几年积累的思维依然能用。Scrapy 里同样有发起请求和选择器提取数据,只是换成了框架层的 Request 和 Selector。Playwright 拿到页面 HTML 后,你还是可以交给 BeautifulSoup 做进一步解析。它们不是互相替代的关系,而是随项目规模出现的升级路径。

6.2 数据入库:用SQLAlchemy把爬虫结果存进数据库

很多人问爬虫数据存 CSV 够不够用。几十条几百条完全够,但数据量上来、每天定时抓取、要按条件查询的时候,数据库是更合适的选择。以 SQLite 为例,用 SQLAlchemy 定义一个简单的数据表:

from sqlalchemy import create_engine, Column, Integer, String, Text from sqlalchemy.orm import declarative_base, sessionmaker Base = declarative_base() engine = create_engine("sqlite:///news.db") Session = sessionmaker(bind=engine) class News(Base): __tablename__ = "news" id = Column(Integer, primary_key=True, autoincrement=True) title = Column(String(200)) link = Column(String(500), unique=True) author = Column(String(100), default="") date = Column(String(50), default="") summary = Column(Text, default="") Base.metadata.create_all(engine)

写入数据时可以借助 link 字段的唯一约束去重:

session = Session() for item in data: news = News(**item) session.merge(news) session.commit() session.close()

这里用 session.merge() 而不是 session.add(),是因为 merge 会根据唯一约束自动判断:如果 link 已存在就更新,否则新增。对定时抓取非常友好,省去先查询再决定新增还是更新的繁琐判断。

SQLAlchemy 在爬虫项目里的价值,是把存储层正式工程化。换数据库、加字段、做增量更新都比手写 SQL 省心。当然,如果数据结构深度嵌套,也可以考虑直接用 MongoDB 这类文档数据库。存储层工具较多,核心目标只有一个:把抓下来的数据稳定、简洁、可查询地留下来。

6.3 我的经验:从“能跑”到“好维护”之间练什么

如果只看代码量,Requests 加 BeautifulSoup 的入门门槛确实很低。但真正写一段时间后会发现,有积累的差距不是“谁写的爬虫能跑”,而是“谁的爬虫能连续跑几天不出事,跑挂了能不能快速恢复”。

我给自己的练习方向有三条。第一,多抓不同结构的页面。新闻、论坛、商品列表三类页面的 DOM 差异很大,都练过之后写选择器的直觉会准很多。第二,尽早引入异常处理和降频控制。哪怕爬虫只是自己用,也要把网络故障当作常态来设计,不然一旦断网、超时、限流,重跑成本远高于一开始多写几行防护代码。第三,尽快让存储层脱离“打印到控制台”。打印只是演示,数据真正有价值是从进入文件或数据库开始的。

我自己用了很久 Requests 和 BeautifulSoup,直到现在处理小批量、静态页面时仍然最喜欢这对组合。它们轻量、直接,出了问题一眼能看到头。后面无论你转向 Scrapy 处理大规模任务,还是用 Playwright 做动态页面自动化,这段打基础的经历都会成为你的直觉。这里最后说一个小技巧:写爬虫时宁可先把请求间隔调大一点,也不要一开始就追求速度,等整个流程稳定了再逐步压缩时间。凡是急着加速的,最终都会花更多时间处理反噬。

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

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

立即咨询