爬虫到底是什么
第一次听到「爬虫」两个字,很多人会联想到黑客或灰色产业。真实情况恰好相反:爬虫(Web Scraper)本质就是把浏览器的动作自动化——用程序发出 HTTP 请求、拿回 HTML 或 JSON、再从里面提取你需要的那部分数据。它没有魔法,只有「发请求、解析、存下来」这三件事。
举个最常见的例子:你想整理某个站点的公开文章目录,手动复制粘贴一百次会累到手酸,而一段几十行的 Python 脚本几秒钟就能跑完,还把结果直接写成 CSV。这就是爬虫的价值:把重复的浏览与记录工作交给程序。
但能力越大,边界越要清楚。下面几条务必要放在写代码之前:
- 先看 robots.txt:站点用它可以声明哪些路径不允许被自动采集,遵守它是最基本的礼仪。
- 控制频率:请求之间留出间隔,不要用高并发把对方站点打垮。
- 只抓公开数据:涉及登录态、隐私、付费内容的数据不要碰。
- 注明身份:在 User-Agent 里写清用途与联系方式,方便站点管理员联系你。
爬虫的正当性来自「公开数据的合理使用」和「不给对方造成负担」,而不是技术手段本身。把频率和身份这两件事做好,九成的麻烦都不会发生。
- requests发送 HTTP 请求的库,负责把页面内容拿回来。
- BeautifulSoup把 HTML 解析成可查询的树结构,负责提取数据。
- 选择器CSS 选择器与 find / find_all 是定位元素的两把钥匙。
- 合规与限速遵守 robots.txt、控制频率、失败重试,才能长期稳定采集。
准备工作:requests 与解析器
需要的库只有两个,外加一个可选的解析器。建议在虚拟环境里安装(不熟悉虚拟环境的话,可以先看同频道的《Python 开发环境搭建与虚拟环境管理实战》):
python -m venv .venv
.venv\Scripts\activate # Windows;macOS / Linux 用 source .venv/bin/activate
pip install requests beautifulsoup4 lxml
# 验证是否装好
python -c "import requests, bs4; print(requests.__version__, bs4.__version__)"
三个组件各司其职:requests 负责与服务器通信,beautifulsoup4 负责把 HTML 变成 Python 对象,lxml 是解析引擎,速度比标准库的 html.parser 快数倍。如果安装 lxml 时遇到编译问题,退回内置解析器也能跑,只是慢一点。
| 组件 | 职责 | 备注 |
|---|---|---|
| requests | 发请求、管理会话与重试 | 也可以用 httpx,接口风格相近 |
| beautifulsoup4 | 解析 HTML、提供查找 API | 需与某个解析器搭配使用 |
| lxml | 高性能解析引擎 | 编译失败时可用 html.parser 代替 |
装好后先跑一个最小例子确认环境没问题,再进入下一步。这一步不要省——环境带来的报错和代码逻辑的报错是两回事,先把环境排干净,后面排查会轻松很多。
第一个请求:GET 与响应处理
发一个 GET 请求只要一行,但真正健壮的代码要把超时、状态码、编码都处理好:
import requests
resp = requests.get(
"https://httpbin.org/html",
headers={"User-Agent": "JijiyunCodeBot/1.0 (+https://jijiyun.example/bot)"},
timeout=10,
)
resp.raise_for_status() # 4xx / 5xx 直接抛异常,避免拿坏数据继续跑
resp.encoding = resp.apparent_encoding # 让 requests 猜一个更准的编码
print(resp.status_code)
print(resp.headers.get("Content-Type"))
print(resp.text[:200])
几个容易被忽略的点:
- timeout 必写:不写超时,一个卡住的请求就能让脚本永久挂起。
- raise_for_status:把 HTTP 错误变成异常,出错时早失败、早暴露。
- 编码:中文站点常见 GBK / GB2312,若响应头没声明编码,resp.text 可能乱码,用 apparent_encoding 兜底更稳。
- Session:需要保持 Cookie 或复用连接时,用 requests.Session() 代替裸 requests.get。
还有一个实践细节:请求头里的 User-Agent 不要照抄浏览器的完整串,也不要留空。写一个能表明身份、带用途说明的 UA,既符合礼仪,也方便对方在你抓出问题时找到你,而不是直接封掉。
BeautifulSoup:把 HTML 变成可查询的树
拿到 HTML 字符串后,交给 BeautifulSoup 解析。它把文档转成一棵可以「按标签名、按属性、按选择器」查询的树:
from bs4 import BeautifulSoup
soup = BeautifulSoup(resp.text, "lxml")
h1 = soup.find("h1")
print(h1.get_text(strip=True))
# CSS 选择器:找出所有带 href 的链接
for a in soup.select("a[href]"):
print(a["href"], a.get_text(strip=True))
# 按 class 找元素:find_all 返回列表
for li in soup.find_all("li", class_="item"):
print(li.get_text(" ", strip=True))
两套 API 记住就够用了:find / find_all 适合结构已知、层级简单的场景,select 则直接写 CSS 选择器,表达力更强。抽取文本统一用 get_text(strip=True) 去掉首尾空白;取属性用字典下标(a["href"]),属性名带连字符时用 a.get("data-id") 更安全,不会因为属性缺失直接抛异常。
| 方法 | 返回 | 典型用法 |
|---|---|---|
| find("h1") | 单个元素或 None | 取首个匹配,需判空 |
| find_all("li", class_="item") | 列表 | 批量取同类项 |
| select(".card a") | 列表 | 用 CSS 选择器精确定位 |
| get_text(strip=True) | 字符串 | 取纯文本、去首尾空白 |
| a["href"] | 属性值 | 取链接等属性 |
先理解 DOM 的层级再写选择器,比盲目试错快得多。打开浏览器开发者工具,右键目标元素选「检查」,就能看到它的标签、class 与父子关系,照着写 select 一次就中。
把页面抽成结构化数据
真正的爬虫要产出的是结构化数据,而不是一堆字符串。做法是遍历列表容器,为每条记录组装一个字典:
import csv
from urllib.parse import urljoin
from bs4 import BeautifulSoup
soup = BeautifulSoup(html, "lxml")
base = "https://example.com"
rows = []
for item in soup.select(".repo-item"):
link = item.select_one("a.name")
rows.append({
"name": link.get_text(strip=True),
"stars": item.select_one(".stars").get_text(strip=True),
"url": urljoin(base, link["href"]), # 相对链接补成绝对地址
})
with open("repos.csv", "w", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=["name", "stars", "url"])
writer.writeheader()
writer.writerows(rows)
print("共写入", len(rows), "条")
清洗环节决定了数据能不能直接用,常见动作有:
- 去空白:get_text(strip=True) 之后再用 " ".join(text.split()) 压掉多余空格与换行。
- 统一类型:把 "1.2k" 这类字符串按规则还原成整数 1200,方便后续排序与统计。
- 补全链接:相对路径用 urljoin 拼成绝对地址,后续才能直接访问。
- 去重:用集合或数据库唯一键过滤重复条目,避免翻页时把同一篇抓两遍。
要特别注意:选择器与页面的 DOM 结构是强耦合的。站点改版一次,.repo-item 这类 class 可能就失效了——所以要把选择器集中写在一处,方便统一适配,而不是散落在几十个函数里。
分页、限速与重试
单页只是开始,真实任务总要翻页。翻页的关键不是循环本身,而是别把对方站点当成免费的 API:
import time
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
retry = Retry(total=3, backoff_factor=0.5, status_forcelist=[429, 500, 502, 503, 504])
session.mount("https://", HTTPAdapter(max_retries=retry))
def fetch(url):
resp = session.get(url, timeout=10,
headers={"User-Agent": "JijiyunCodeBot/1.0"})
resp.raise_for_status()
return resp.text
for page in range(1, 6):
html = fetch("https://example.com/list?page=%d" % page)
# 交给解析层处理
time.sleep(1) # 每次请求之间停 1 秒,给站点留余量
| 关注点 | 建议做法 |
|---|---|
| 频率 | 单线程每请求间隔 0.5 到 2 秒,高峰期更保守 |
| 重试 | 仅对 429 / 5xx 与网络异常重试,用指数退避(backoff_factor) |
| 并发 | 需要提速时用少量线程,并加信号量限制同时在飞的请求数 |
| 缓存 | 调试阶段把响应存到本地,避免反复请求同一页 |
退避(backoff)的意义在于:当对方返回 429 或 503,说明它已经吃不消了,这时正确的反应是等更久再试,而不是立刻重发。指数退避能把「越重试越糟」的雪崩变成一条平缓的恢复曲线。
反爬与常见坑
采集过程中遇到的问题,九成集中在下面几类。先按现象对号入座,再动手改代码:
- 403 / 429:多为频率过高或缺 UA,先把间隔调大、补上身份标识再试。
- 页面是 JS 渲染的:requests 拿到的 HTML 里没有内容。优先找页面背后的 JSON 接口,实在不行再用 Playwright / Selenium 渲染。
- 需要登录态:把浏览器里已登录的 Cookie 复用进 Session;注意只处理自己有权限访问的数据。
- 中文乱码:手动设置 resp.encoding,或改用 resp.content 配合 BeautifulSoup 的 from_encoding 参数。
- 选择器突然失效:先打印 resp.text 看结构是否改版,而不是盲目改代码。
- 被限流封禁:降低频率、复用 Session、必要时申请官方 API,而不是换 IP 硬扛。
排查口诀:先确认拿到的是不是你想要的 HTML,再确认选择器能不能选中,最后才怀疑解析方式。九成「爬不到」的问题,根因都在前两步。
工程化:从脚本到可维护的采集任务
把临时脚本变成能长期运行的任务,核心是「可重跑、可观测、可停止」:
- 配置外置:把目标 URL、请求间隔、重试次数写成常量或配置文件,别散落在代码各处。
- 日志代替 print:记录每页抓取条数、耗时与异常,出问题时能回溯。
- 断点续采:把已完成的页码或记录 ID 落盘,中断后能接着跑,不用从头再来。
- 小步验证:先用 1 页跑通全流程,再放量到全部页码。
更进一步,可以把采集与解析拆成两层:采集层只负责把原始 HTML 存下来,解析层负责把 HTML 变成数据。这样站点改版时只需重跑解析层,不必重新抓一遍,既省时间也更礼貌。
爬虫的上限不是技术,而是克制:能少抓就少抓,能缓存就缓存,能走官方接口就不去解析页面。稳,比快重要得多。
想继续深入,可以搭配阅读同频道的《Python 开发环境搭建与虚拟环境管理实战》把依赖管好,再用《FastAPI 快速上手:从第一个接口到部署上线》把采集结果做成对外服务。