Python网络爬虫入门:Requests与BeautifulSoup组合实战指南

发布时间:2026/9/26 6:12:15
Python网络爬虫入门:Requests与BeautifulSoup组合实战指南
做 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, paramsparams)Requests 会自动把参数做 URL 编码比如把空格转成 %20。返回值统一放在 resp 对象里既有状态码也有响应文本后面解析就很方便了。有一点必须提醒直接用 requests.get 发出的请求默认不带浏览器特征。服务器看到一个“自称 Python 脚本”的访问者很多时候态度不会太友好。所以每写一个爬虫先准备一个合理的 headers 字典能省掉后面一大堆麻烦。这一点第三节专门展开。1.2 BeautifulSoup把HTML从“一长串字符”变成可检索的对象树服务器返回的 HTML 本质上是文本虽然浏览器会把它渲染成带层级结构的页面但抓下来那一刻就是一段字符串。BeautifulSoup 做的就是把这串文本转换成 Python 对象树让你用“找标签”“找属性”这种自然的方式去取数据。举个例子如果页面里是titlePython入门教程/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 .venvWindows 下激活命令是.venv\Scripts\activateLinux 和 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 lxmlRequests 的包名就叫 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 classlist div classitem h2a href/post/1AI落地应用盘点/a/h2 span classdate2024-01-01/span /div div classitem h2a href/post/2Python生态观察/a/h2 span classdate2024-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)它会把页面上所有 classitem 的节点都选出来返回一个列表。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, headersheaders)做这一步不是为了跟谁斗智斗勇而是让自己的请求在服务器看来更接近一个普通访客。如果加了 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_encodingapparent_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, timeout10) 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, headersheaders, timeout(5, 10))元组第一个值是连接超时指建立 TCP 连接的最长等待第二个是读取超时指拿到响应报文的最长时间。两个分开设的好处是定位问题时能区分是连不上还是服务器响应太慢。网络请求天生不稳定一个健壮的请求层应该捕获异常并做有限重试import time from requests.exceptions import RequestException def fetch(url, max_retries3): 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 classpost h1 classtitlea href/news/detail/1001最新消息标题/a/h1 p classauthor作者张三/p span classdate2024-02-20/span div classsummary这里是新闻摘要文字量稍多。/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, encodingutf-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, encodingutf-8) as f: json.dump(data, f, ensure_asciiFalse, indent2)CSV 用 utf-8-sig 而不是 utf-8这个细节很多教程不会提。Excel 打开无 BOM 的 UTF-8 文件时会识别错编码中文直接乱成一团加 BOM 后就能正常识别。JSON 用 utf-8同时加 ensure_asciiFalse让中文以中文形式写入文件而不是一长串 \uXXXX 转义调试时一眼能看清内容。indent2 只是让文件更易读。关于存储格式的选择我的建议是早期调试阶段用 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, encodingutf-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(bindengine) class News(Base): __tablename__ news id Column(Integer, primary_keyTrue, autoincrementTrue) title Column(String(200)) link Column(String(500), uniqueTrue) 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 做动态页面自动化这段打基础的经历都会成为你的直觉。这里最后说一个小技巧写爬虫时宁可先把请求间隔调大一点也不要一开始就追求速度等整个流程稳定了再逐步压缩时间。凡是急着加速的最终都会花更多时间处理反噬。