Python 爬虫实战:用 requests 抓取并保存文章列表
用 requests + BeautifulSoup 写懂规矩的采集脚本:会话复用、超时重试、指数退避、限速与断点续抓。
#这篇文章解决什么问题
爬虫最难的部分从来不是解析 HTML,而是怎么在目标站点的容忍范围内稳定地拿到数据。很多初学者写一个 while True: requests.get(url) 就去睡觉,第二天醒来发现 IP 被封了。这篇文章用 requests + BeautifulSoup 写一个「懂规矩」的采集脚本:会话复用、超时、指数退避重试、限速、断点续抓,最后把文章列表存成 CSV。
要理解这套代码为什么这样写,先看一个最基本的事实:每一次 HTTP 请求都会消耗目标服务器的一小份资源,而服务器完全有权利拒绝你。requests 只是把「发请求、收响应」的底层细节封装好了,它不会替你处理「请求太频繁会被封 IP」「连接断了要重试」「对方限流了要等多久」。这些都要在协议层和工程层自己补齐。
#从底层机制出发:一次 HTTP 请求到底经历了什么
#请求生命周期:DNS、TCP、TLS、HTTP
一个 requests.get(url) 从发起到底层完成,链路大致是:
- DNS 解析:把主机名解析成 IP(先查本地缓存和 hosts,再走 DNS 服务器);
- TCP 三次握手:SYN → SYN-ACK → ACK,建立一条可靠的字节流连接;
- TLS 握手(仅 https):协商加密参数、校验证书,通常还要多 1~2 个 RTT;
- 发送请求:把请求行(方法、路径、HTTP 版本)、请求头和请求体写到连接上;
- 接收响应:服务器返回状态行(状态码)、响应头和响应体;
- 释放或复用连接:HTTP/1.1 默认 keep-alive,连接可以继续给下一个请求用。
其中 RTT(往返时延)是决定速度的关键:一次请求从发出到收到第一个字节至少要一个 RTT,TLS 又额外加 1~2 个 RTT;如果每次都重新建连,这些握手成本会被重复支付。
#为什么用 Session:复用连接而不是每次重新握手
裸调 requests.get() 虽然方便,但每次都会走一遍「找连接 → 建连接 → 用完丢弃」的流程。抓 100 个页面,如果不复用连接,就是 100 次 TCP 握手;如果目标是 https,还要再加 100 次 TLS 握手。
requests.Session 背后是 urllib3 的连接池(ConnectionPool):它对同一个目标主机维护一组空闲连接,请求结束后连接不关闭,下一个请求直接复用;同时 Session 还会自动管理 Cookie 和你在 session.headers 里设置的默认请求头。所以:
import requests
session = requests.Session()
session.headers.update({
"User-Agent": "MyLearningBot/1.0 (+https://example.com/bot)",
"Accept-Language": "zh-CN,zh;q=0.9",
})这里把「身份」统一放在 Session 上,之后所有 session.get(...) 都会自动带上这两个头,不用每个请求单独传。
User-Agent 里写明身份和联系页面,是 HTTP 协议里的基本礼貌。很多站点对来源明确、可联系的采集请求反而宽容;反过来,伪装成浏览器去绕反爬是另一件事,涉及目标站点的服务条款与当地法律,本文不鼓励也不讨论。
#为什么要超时:连接超时和读取超时是两段不同的风险
不设 timeout 的请求,底层 socket 可能无限期等待:要么 TCP 连接一直建不起来(目标丢包、防火墙丢弃 SYN 包),要么服务器接受了连接却迟迟不返回数据(进程排队、代理挂起)。表现出来就是脚本「卡死」,像是在死锁。
timeout 要拆成两段理解:
- 连接超时(connect):从发起 TCP SYN 到连接建立的最大等待时间;
- 读取超时(read):两次读操作之间的最大间隔,而不是「整个响应不超过多少秒」——它约束的是数据流没有动静的最长时间。
requests 接受 timeout=(connect, read) 的二元组;只传一个数字则两段共用同一个值。例如:
resp = session.get(url, timeout=(3.05, 10))含义是连接 3.05 秒内必须建立、两次读取之间最多等 10 秒。这是社区里常见的取值,具体数值应该按目标站的响应速度调整:抓大文件、慢接口要调大 read,网络差的环境要调大 connect。
#重试为什么必须带指数退避:重试风暴的因果链
失败往往不是孤立的。服务器过载、重启或网络抖动时,一批客户端会同时失败;如果它们都「立刻重试」,服务器刚想恢复就被这批重试再压垮,客户端又失败、又重试——负载被放大 N 倍,形成重试风暴。
指数退避的思路是让每次重试的间隔按 2 的幂增长,把同时落在服务器上的重试在时间上错开。urllib3 的 Retry 用 backoff_factor 控制基数,官方文档的约定是:backoff_factor=0.1 时实际间隔约为 0.0s、0.2s、0.4s、……(第一次重试不等待,因为多数错误第二次尝试就能恢复)。按这个约定,backoff_factor=0.5 时,三次重试的等待大致是 0s、1s、2s。
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry = Retry(
total=3, # 每个请求最多重试 3 次
connect=2, # 其中连接失败最多重试 2 次
backoff_factor=0.5, # 退避基数:实际等待约 0s、1s、2s
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"], # 只自动重试幂等的 GET
respect_retry_after_header=True, # 尊重服务器指定的等待时间
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)几个参数背后的语义:
status_forcelist列出了「收到这些状态码就值得重试」的白名单。429 一定要放进去:429 是服务器明确的限流信号,意思是「你太快了,等一等」;5xx 是服务端瞬时故障,同样值得重试。respect_retry_after_header=True:当服务器在响应里给出Retry-After头(RFC 9110 定义,值为秒数或 HTTP 日期)时,对方已经明确告诉你等多久,urllib3 会优先按这个值等待,而不是套本地退避公式——这是最「懂规矩」的做法。allowed_methods=["GET"]:只对幂等方法自动重试。GET 重试是安全的;POST 通常带副作用(创建订单、写数据),自动重试可能造成重复提交,所以禁止。
注意一个容易被忽略的行为:重试发生在传输层,发生在你的业务代码看到响应之前。配置了 status_forcelist 后,如果 5xx 一直持续到重试次数耗尽,requests 抛出的不是普通响应,而是 requests.exceptions.RetryError(RequestException 的子类),后面写异常处理时要兜住它。
#请求与响应的状态语义:4xx 和 5xx 不是同一类错误
HTTP 状态码把结果分成几类,语义完全不同:
- 2xx:成功;
- 3xx:重定向(requests 默认自动跟随,可关);
- 4xx:客户端错误——请求本身有问题;
- 5xx:服务端错误——服务器一时处理不了。
对爬虫最关键的区分是 4xx 和 5xx:
- 404 是稳定状态。资源不存在是事实,重试 100 次也不会出现。在分页场景里,404 通常意味着「翻到最后一页了」,应该停止而不是继续;
- 5xx 是瞬时状态。服务器过载、网关超时,过一会儿可能就恢复了,值得配合退避重试。
response.raise_for_status() 的作用,就是把 4xx/5xx 从「拿到了响应」的假成功里揪出来抛成 HTTPError 异常,避免你把错误页当正常 HTML 解析出空数据或重复数据。配合上面的重试:5xx 已经在传输层重试过了,raise_for_status() 之后还能看到 5xx,说明是重试耗尽仍失败;而 404 不会触发重试,直接以 HTTPError 形式出现,正好可以用来判断翻页尽头。
#协议礼貌性:为什么 UA、限速会影响封禁
从服务器的角度看,可观测的爬虫特征主要是:单位时间请求数、请求间隔的规律性、User-Agent 是否常见、是否带正常浏览器才有的请求头、是否突然对大量页面发起请求。封禁本质上是成本控制:异常流量会挤占带宽和计算资源,还可能带来合规风险。
限速 sleep() 的本质,就是把请求间隔放大到和人类浏览同一个数量级,让频率特征不触发阈值。更精细的做法是在 sleep 之外加随机抖动,避免「每 1.5 秒整一次」这种机械规律,但固定 sleep 是性价比最高的一步。
robots.txt 是站点运营者声明的采集边界:Allow/Disallow 规则告诉爬虫哪些路径允许访问。它不是法律文件,但代表了运营者的意愿;许多站点还在 sitemap.xml 里列出页面结构。写爬虫之前先读这两个文件,往往比硬写选择器更省事——很多站其实提供了 JSON API,比解析 HTML 优雅得多;没有 API 的,robots.txt 里写着哪些路径允许爬。
#实现:完整可复现的采集脚本
#前置条件与依赖安装
以 Python 3.9+ 为例(代码里用到了 list[dict] 这类内置泛型注解;若是 3.8 及以下,需要 from __future__ import annotations 或改用 typing.List)。macOS/Linux 下用 venv 隔离环境:
python3 -m venv .venv
source .venv/bin/activate
python -m pip install requests beautifulsoup4请先确认目标站的 robots.txt 允许抓取你打算访问的路径,再运行下面的脚本。
#完整脚本
把下面内容保存为 article_list_crawler.py:
"""article_list_crawler.py —— 分页抓取文章列表并保存为 CSV。
用法:
python article_list_crawler.py --start 1 --end 10
"""
import argparse
import csv
import logging
import os
import time
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
# 示例目标:example.com 是 RFC 2606 保留给文档的域名,只返回静态页。
# 跑真实数据前,把这里替换成你自己的目标,并核对下方选择器。
BASE_URL = "https://example.com/blog"
# 列表页选择器:以「article > h2 > a + time」这种博客结构为例。
# 每个目标的 HTML 都可能不同,先用浏览器 DevTools 确认再填。
SELECTORS = {
"card": "article",
"link": "h2 a[href]",
"date": "time",
}
CSV_PATH = "articles.csv"
REQUEST_TIMEOUT = (3.05, 10) # (连接超时, 读取超时),单位:秒
DELAY_SECONDS = 1.5 # 每成功写入一页后等待的秒数
def build_session() -> requests.Session:
"""创建带默认头和重试策略的会话:身份、退避、限流尊重一次配齐。"""
session = requests.Session()
session.headers.update({
"User-Agent": "MyLearningBot/1.0 (+https://example.com/bot)",
"Accept-Language": "zh-CN,zh;q=0.9",
})
retry = Retry(
total=3,
connect=2,
backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"],
respect_retry_after_header=True,
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)
return session
def parse_listing(html: str) -> list[dict]:
"""从列表页 HTML 中提取 title / url / date。"""
soup = BeautifulSoup(html, "html.parser")
rows: list[dict] = []
for card in soup.select(SELECTORS["card"]):
link = card.select_one(SELECTORS["link"])
if not link:
continue
date_tag = card.select_one(SELECTORS["date"])
rows.append({
"title": link.get_text(" ", strip=True),
"url": urljoin(BASE_URL, link["href"]),
"date": date_tag.get("datetime") if date_tag else "",
})
return rows
def append_to_csv(rows: list[dict]) -> None:
"""追加写入 CSV。utf-8-sig 带 BOM,Excel 打开中文不乱码。"""
is_new = not os.path.exists(CSV_PATH)
with open(CSV_PATH, "a", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=["title", "url", "date"])
if is_new:
writer.writeheader()
writer.writerows(rows)
def load_done_urls() -> set[str]:
"""从已有 CSV 读回已处理的 URL,实现断点续抓。"""
done: set[str] = set()
if not os.path.exists(CSV_PATH):
return done
with open(CSV_PATH, newline="", encoding="utf-8-sig") as f:
for row in csv.DictReader(f):
if row.get("url"):
done.add(row["url"])
return done
def crawl(start: int, end: int) -> None:
session = build_session()
done = load_done_urls()
for page in range(start, end + 1):
url = f"{BASE_URL}?page={page}"
try:
resp = session.get(url, timeout=REQUEST_TIMEOUT)
resp.raise_for_status()
except requests.Timeout:
logging.warning("[timeout] page=%s url=%s", page, url)
continue
except requests.HTTPError as exc:
status = exc.response.status_code if exc.response is not None else 0
logging.warning("[http %s] page=%s url=%s", status, page, url)
if status == 404:
logging.info("遇到 404:视为翻页结束,停止。")
break
continue
except requests.RequestException as exc:
logging.warning("[request failed] page=%s url=%s err=%s", page, url, exc)
continue
rows = [r for r in parse_listing(resp.text) if r["url"] not in done]
if not rows:
continue
append_to_csv(rows)
done.update(r["url"] for r in rows)
logging.info("page=%s 新增 %d 条,累计 %d 条", page, len(rows), len(done))
time.sleep(DELAY_SECONDS)
def main() -> None:
parser = argparse.ArgumentParser(description="分页抓取文章列表并保存 CSV")
parser.add_argument("--start", type=int, default=1, help="起始页码")
parser.add_argument("--end", type=int, default=10, help="结束页码")
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
crawl(args.start, args.end)
if __name__ == "__main__":
main()几个关键实现点再拆开说明:
- 限速与断点:每成功写入一页后
time.sleep(DELAY_SECONDS)控制频率;load_done_urls()启动时从 CSV 读回已完成 URL,主循环里用r["url"] not in done跳过已抓数据。这样脚本半夜断了,早上重跑一次就能接着补,而不是从头再来。 - CSV 编码:
encoding="utf-8-sig"会在文件头写 BOM(b"\xef\xbb\xbf")。Excel 靠 BOM 识别 UTF-8,没有它中文会乱码;pandas 这类库不依赖 BOM 也能读,所以「只给 pandas 用可以不写 BOM,要交给 Excel 就必须写」。 - 解析器选择:默认
html.parser够用。页面特别大、要求速度时换lxml,但需要单独pip install lxml;不要为了省事用html5lib,那是最慢的一个。 - urljoin 的作用:
urljoin(BASE_URL, link["href"])按 RFC 3986 的相对引用规则解析,能正确处理../catalogue/page-2.html这类相对路径,比手动拼接字符串可靠。
#验证:怎么确认我做对了
分三层验证,从请求层到解析层再到端到端。
#1. 请求层:用 httpbin 验证身份、重试与超时
httpbin 是一个专门回显请求信息的公开测试服务。先验证请求头真的发送出去了:
python - <<'PY'
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
s = requests.Session()
retry = Retry(total=3, backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"])
s.mount("https://", HTTPAdapter(max_retries=retry))
r = s.get("https://httpbin.org/headers", timeout=10)
print(r.json()["headers"]["User-Agent"])
PY预期输出是当前安装的 requests 版本对应的默认 UA,形如 python-requests/2.34.2(版本号随安装环境不同);如果先设置了自定义 User-Agent,则会打印你设置的值。
接着验证 503 触发的重试:
python - <<'PY'
import time, requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
s = requests.Session()
s.mount("https://", HTTPAdapter(max_retries=Retry(
total=3, backoff_factor=0.5,
status_forcelist=[429, 500, 502, 503, 504], allowed_methods=["GET"])))
t0 = time.monotonic()
try:
s.get("https://httpbin.org/status/503", timeout=10)
except requests.RequestException as exc:
print(type(exc).__name__, "| 耗时 %.1f 秒" % (time.monotonic() - t0))
PY预期:抛 RetryError(503 连续重试 3 次后耗尽,RequestException 的子类),总耗时约 3~5 秒——多出来的部分是退避等待(约 0+1+2 秒)加上 4 次请求自身的往返。想观察退避过程,可以加 logging.basicConfig(level=logging.INFO) 看 urllib3 输出的 Retrying 日志。
超时部分分两段验证。读取超时:请求一个故意延迟的接口,read 超时会在上限附近抛 ReadTimeout:
python - <<'PY'
import time, requests
t0 = time.monotonic()
try:
requests.get("https://httpbin.org/delay/5", timeout=(3.05, 2))
except requests.Timeout as exc:
print(type(exc).__name__, "| 耗时 %.2f 秒" % (time.monotonic() - t0))
PY预期约 2 秒后抛 ReadTimeout。连接超时:把目标指向 RFC 5737 保留的测试网段(必然不可达),connect 超时会在上限附近抛 ConnectTimeout:
python - <<'PY'
import time, requests
t0 = time.monotonic()
try:
requests.get("http://192.0.2.1/", timeout=(3.05, 10))
except requests.Timeout as exc:
print(type(exc).__name__, "| 耗时 %.2f 秒" % (time.monotonic() - t0))
PY预期约 3 秒后抛 ConnectTimeout。(上面两段在网络环境特殊的机器上可能略有差异,但异常类型与「耗时约等于对应超时值」的判定标准不变。)
#2. 解析层:用本地固定样例验证选择器
把一小段真实 HTML 存成 sample.html:
<!doctype html>
<html>
<head><meta charset="utf-8"><title>示例列表页</title></head>
<body>
<article>
<h2><a href="/posts/1">文章一</a></h2>
<time datetime="2026-08-01">2026-08-01</time>
</article>
<article>
<h2><a href="/posts/2">文章二</a></h2>
<time datetime="2026-08-02">2026-08-02</time>
</article>
</body>
</html>然后在项目目录运行(脚本里的 BASE_URL 保持 https://example.com/blog,与样例一致):
python - <<'PY'
from article_list_crawler import parse_listing
rows = parse_listing(open("sample.html", encoding="utf-8").read())
assert len(rows) == 2, rows
assert rows[0]["url"] == "https://example.com/posts/1", rows[0]
assert rows[0]["date"] == "2026-08-01", rows[0]
print(rows)
PY所有断言通过、无输出异常,说明选择器和 urljoin 都工作正常。这一步把「选择器对不对」和「真实站点」解耦:先在小样板上调通,再上真实目标。
#3. 端到端:用公开练习站跑通全流程
books.toscrape.com 是一个专门给爬虫学习者练习的演示站点,页面里明确写着 "This is a demo website for web scraping purposes",其分页 URL 是 https://books.toscrape.com/catalogue/page-{n}.html。它没有 robots.txt(请求 /robots.txt 返回 404),页面结构是 article.product_pod > h3 > a 而不是本例的 article > h2 > a,也没有 time 元素——这正好演示了「选择器必须对着实际 HTML 调整」:
python - <<'PY'
import requests
from bs4 import BeautifulSoup
from urllib.parse import urljoin
BASE = "https://books.toscrape.com/catalogue"
s = requests.Session()
for page in (1, 2):
url = f"{BASE}/page-{page}.html"
resp = s.get(url, timeout=10)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
for card in soup.select("article.product_pod"):
link = card.select_one("h3 a[title]")
price = card.select_one("p.price_color")
print(urljoin(BASE, link["href"]), "|", link["title"], "|", price.get_text(" ", strip=True))
PY预期输出 40 行(每页 20 本书),URL、标题、价格字段齐全。跑通后把同样的调整逻辑套回你自己的目标站即可。
#边界与常见坑
- 不设 timeout 导致假死:最常见的坑。表现为脚本挂起数分钟无输出,本质是 socket 在等一个永远不会来的数据。诊断:先确认卡在连接阶段还是读取阶段,再把对应超时值调小复现。
- 404 和 5xx 混为一谈:我在这上面吃过亏——当时没区分 404 和 500,脚本对着一个不存在的分页连抓十几分钟,把同一页的重复数据存了一遍。记住:404 通常意味着「翻到底了」,5xx 是「对方扛不住了」,处理逻辑完全不同。另一个隐蔽点:配置了
status_forcelist后,5xx 重试耗尽抛的是RetryError而不是HTTPError,异常处理里要兜住requests.RequestException。 - 无退避的立即重试:服务器一抖动就瞬间重发一遍,等于把负载翻倍,重试风暴又加剧抖动。坚持指数退避,429/503 配合
Retry-After时优先听服务器的。 - 手动拼 URL 而不是 urljoin:相对链接里常见
../,自己拼接很容易拼错层级;urljoin按 RFC 3986 解析,正确处理../和绝对路径。 - CSV 无 BOM:
open(..., encoding="utf-8")写出的文件 pandas 能读,Excel 打开中文就是乱码。要交给 Excel 就用utf-8-sig。 - 解析不出数据不加日志:选择器没匹配上时
rows为空,脚本静默跳页,最后交出一个空白 CSV。先单页打印soup.select(...)的结果,再决定要不要调选择器。 - 数据落盘后才算完成:先写 CSV 再继续下一页;如果先抓下一页再落盘,进程中断会丢掉整批数据。
#什么时候不该用这套写法
- 目标站有官方 API:优先用 API。JSON 结构稳定、省流量、不占对方带宽,采集边界通常也更明确。「有 API 就不用爬 HTML」是性价比最高的原则。
- 规模大或需要 JS 渲染:单机 requests 足够应付中小规模的列表页;遇到登录态、动态渲染、大量异步数据的场景,评估 Scrapy(调度与去重框架)或 Playwright(浏览器自动化)是否更合适。
- 已经被拒绝时:收到 403/封禁信号就停下来分析原因,而不是加大并发硬刚。只爬公开页面、控制频率、尊重 Disallow、失败就退——数据是别人的资产,采集协议是合作而不是对抗,这样脚本才活得久。
下一步建议:把上面的脚本接到你自己的目标站,先跑 1~2 页验证选择器和编码,再放开页码范围;同时把已完成 URL 的存储从 CSV 升级成 SQLite,断点续抓会更可靠。
相关阅读
Related留言讨论
0 条还没有留言,来说点什么吧。