技术文章2025 年 9 月 27 日约 4 分钟

Python 爬虫实战:用 requests 抓取并保存文章列表

用 requests + BeautifulSoup 写懂规矩的采集脚本:会话复用、超时重试、指数退避、限速与断点续抓。

爬虫最难的部分从来不是解析 HTML,而是怎么在目标站点的容忍范围内稳定地拿数据。我见过太多人写个 while True: requests.get(url) 就去睡大觉,第二天起来发现 IP 被封了。这篇文章用 requests + BeautifulSoup 写一个「懂规矩」的采集脚本:超时、重试、限速、断点续抓。

#会话:复用连接,别每次新建

每调一次 requests.get() 就新建一个 TCP 连接,100 个页面就是 100 次握手。用 requests.Session 复用连接池,顺便统一设置 headers:

python
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) 分别是连接超时和读取超时。

重试要带退避,否则服务器一抖动,你的重试风暴把它打死:

python
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 就够用

python
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 rows

urljoin(base_url, href) 处理相对链接,比手动拼接字符串可靠。get_text(" ", strip=True) 把标签里多个空格/换行压成单空格。

解析器注意一点:默认 html.parser 够用;页面特别大、要求速度时换 lxml,但要用 lxml 就得单独 pip install lxml,别为了省事用 html5lib——那是最慢的一个。

#限速和断点:对目标友好,对自己省事

页面之间强制 sleep,最简单也最有效:

python
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 时注意编码:

python
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 页不可能全成功,关键是把失败信息留全:

python
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

留言 · remarks

00 条

还没有留言,来说点什么吧。

Python 爬虫实战:用 requests 抓取并保存文章列表 · LXH·BLOG