Python 爬虫实战:用 requests 抓取并保存文章列表
用 requests + BeautifulSoup 写懂规矩的采集脚本:会话复用、超时重试、指数退避、限速与断点续抓。
爬虫最难的部分从来不是解析 HTML,而是怎么在目标站点的容忍范围内稳定地拿数据。我见过太多人写个 while True: requests.get(url) 就去睡大觉,第二天起来发现 IP 被封了。这篇文章用 requests + BeautifulSoup 写一个「懂规矩」的采集脚本:超时、重试、限速、断点续抓。
#会话:复用连接,别每次新建
每调一次 requests.get() 就新建一个 TCP 连接,100 个页面就是 100 次握手。用 requests.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",
})User-Agent 里写明身份和联系页面是基本礼貌,很多站点对诚实的 UA 反而宽容。别伪装成浏览器去绕反爬——那是另一个法律和道德层面的话题,本文不讨论。
#超时和重试:网络请求必须有的两件套
永远给 timeout。不设 timeout 的请求可能挂几分钟,你的脚本看起来像死锁。timeout=(3.05, 10) 分别是连接超时和读取超时。
重试要带退避,否则服务器一抖动,你的重试风暴把它打死:
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
retry = Retry(
total=3,
connect=2,
backoff_factor=0.5, # 0.5, 1, 2, 4... 指数退避
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"], # 只重试 GET,POST 别自动重试
respect_retry_after_header=True, # 尊重 Retry-After
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("https://", adapter)
session.mount("http://", adapter)backoff_factor 的退避时间是 {backoff_factor} * (2 ** (retry_number - 1)),0.5 就是 0.5s、1s、2s。429 一定要退避,很多站点用 Retry-After 告诉你等多久,respect_retry_after_header=True 让它按对方要求等。
#解析:BeautifulSoup 的 select 就够用
from bs4 import BeautifulSoup
from urllib.parse import urljoin
def parse_listing(html: str, base_url: str) -> list[dict]:
soup = BeautifulSoup(html, "html.parser")
rows = []
for card in soup.select("article"):
link = card.select_one("h2 a[href]")
date = card.select_one("time")
if not link:
continue
rows.append({
"title": link.get_text(" ", strip=True),
"url": urljoin(base_url, link["href"]),
"date": date.get("datetime") if date else "",
})
return rowsurljoin(base_url, href) 处理相对链接,比手动拼接字符串可靠。get_text(" ", strip=True) 把标签里多个空格/换行压成单空格。
解析器注意一点:默认 html.parser 够用;页面特别大、要求速度时换 lxml,但要用 lxml 就得单独 pip install lxml,别为了省事用 html5lib——那是最慢的一个。
#限速和断点:对目标友好,对自己省事
页面之间强制 sleep,最简单也最有效:
import time
for page in range(1, 11):
resp = session.get(f"{START_URL}?page={page}", timeout=10)
resp.raise_for_status()
rows = parse_listing(resp.text, START_URL)
append_to_csv(rows)
time.sleep(1.5) # 每页间隔 1.5 秒但 sleep 只是最粗暴的限速。更稳的是记录上次进度,失败后能续跑:把「已处理的 URL」存在本地集合里,每次启动加载,处理过的跳过。断点续抓配合重试,脚本半夜断了,早上重跑一次就能补完,而不是从头再来。
保存数据用 CSV 时注意编码:
import csv
def append_to_csv(rows: list[dict], path: str = "articles.csv"):
new = not __import__("os").path.exists(path)
with open(path, "a", newline="", encoding="utf-8-sig") as f:
writer = csv.DictWriter(f, fieldnames=["title", "url", "date"])
if new:
writer.writeheader()
writer.writerows(rows)utf-8-sig 带 BOM,Excel 打开中文不乱码;不加 BOM 的话 pandas 读没问题,但 Excel 会当成乱码。
#异常处理:把「哪一页挂了」打清楚
抓 1000 页不可能全成功,关键是把失败信息留全:
for page in range(1, 11):
try:
resp = session.get(f"{START_URL}?page={page}", timeout=10)
resp.raise_for_status()
except requests.Timeout:
print(f"[timeout] page={page}")
continue
except requests.HTTPError as e:
print(f"[http {e.response.status_code}] page={page}")
if e.response.status_code == 404:
break # 翻页翻到尽头了
continue
rows = parse_listing(resp.text, START_URL)
append_to_csv(rows)
time.sleep(1.5)这里我吃过一次亏:当时没区分 404 和 500,脚本对着一个不存在的分页一直抓了十几分钟,全存成同一页的重复数据。404 通常是「翻到底了」,500/503 是「对方在扛不住」,两个的处理逻辑完全不同。
#经验:先读 robots.txt,再谈采集
写任何爬虫之前,先看目标的 robots.txt 和数据接口。很多站其实提供了 JSON API,比解析 HTML 优雅得多;没有 API 的,robots.txt 里写着哪些路径允许爬。我自己的原则:只爬公开页面、控制频率、尊重 Disallow、失败了就退,绝不硬刚。数据是别人的资产,采集协议是合作而不是对抗,这样脚本才能活得久。
写于 2025 年 9 月 27 日
- 栏目
- 技术文章
- 约
- 4 分钟
- 字数
- 3.2K
- 阅读
- 248
本文为原创记录,转载请注明出处。如果这篇替你省了时间,欢迎留言说说你踩到的坑。
同题 · related
- Python 文件操作实战:安全整理下载目录用 pathlib 写安全的文件整理脚本:预览模式、防覆盖、递归扫描与原子替换。
- Python 单元测试入门:用 pytest 写出可靠测试从 fixture、参数化到 mock 和 CI 排障,把 pytest 从「能跑通」练到「跑得稳」。
- 把博客从 docker-compose 搬到 k3s:单机迁移与上线全过程在 4C3.6G 的云主机上把博客从 docker-compose 迁到单机 k3s。附完整 YAML 清单(Deployment、Service、PVC、Ingress、Certificate 等)与全部配置命令,记录部署、数据迁移与切换上线的踩坑过程。
留言 · remarks
00 条还没有留言,来说点什么吧。