单页商品抓取不难,真正容易出问题的是从第1页跑到第50页。
我做采集这些年遇到过不少类似情况:前几页运行正常,到了中间突然超时;某一页结构变化,整个程序直接退出;代理已经失效,脚本却还在对同一个地址反复重试。
所以,多页采集不能只在单页代码外面套一个for循环。至少要补上会话复用、有限重试、随机间隔、异常记录和CSV存储。
本文仍使用公开爬虫练习网站演示。实际采集电商平台前,应确认访问授权、服务条款和数据使用边界。
先建立可复用的请求会话
如果每一页都重新创建连接,会增加额外开销。requests.Session()可以复用部分连接及公共请求头:
import requests
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
session = requests.Session()
session.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
),
"Accept-Language": "zh-CN,zh;q=0.9"
})
retry = Retry(
total=2,
connect=2,
read=2,
status=2,
backoff_factor=0.8,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"]
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("http://", adapter)
session.mount("https://", adapter)
重试次数不是越多越好。我一般先设为2次,用于处理短暂网络波动。遇到403、登录提示或验证码时,应停止并检查权限与访问策略,而不是通过无限重试继续施压。
编写多页商品解析逻辑
完整示例代码如下:
import csv
import random
import time
from urllib.parse import urljoin
import requests
from bs4 import BeautifulSoup
from requests.adapters import HTTPAdapter
from urllib3.util.retry import Retry
BASE_URL = "https://books.toscrape.com/catalogue/page-{}.html"
OUTPUT_FILE = "ecommerce_products.csv"
session = requests.Session()
session.headers.update({
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/126.0.0.0 Safari/537.36"
)
})
retry = Retry(
total=2,
backoff_factor=0.8,
status_forcelist=[429, 500, 502, 503, 504],
allowed_methods=["GET"]
)
adapter = HTTPAdapter(max_retries=retry)
session.mount("http://", adapter)
session.mount("https://", adapter)
def fetch_page(page_number):
page_url = BASE_URL.format(page_number)
response = session.get(
page_url,
timeout=(5, 15)
)
response.raise_for_status()
soup = BeautifulSoup(response.text, "html.parser")
cards = soup.select("article.product_pod")
products = []
for card in cards:
title_tag = card.select_one("h3 a")
price_tag = card.select_one(".price_color")
stock_tag = card.select_one(".instock.availability")
if not title_tag or not price_tag:
continue
products.append({
"页码": page_number,
"商品名称": title_tag.get("title", "").strip(),
"价格": price_tag.get_text(strip=True),
"库存状态": (
stock_tag.get_text(" ", strip=True)
if stock_tag else ""
),
"详情链接": urljoin(
page_url,
title_tag.get("href", "")
)
})
return products
def save_products(products):
fieldnames = [
"页码",
"商品名称",
"价格",
"库存状态",
"详情链接"
]
with open(
OUTPUT_FILE,
"w",
newline="",
encoding="utf-8-sig"
) as file:
writer = csv.DictWriter(
file,
fieldnames=fieldnames
)
writer.writeheader()
writer.writerows(products)
def main():
all_products = []
failed_pages = []
for page_number in range(1, 11):
try:
page_products = fetch_page(page_number)
all_products.extend(page_products)
print(
f"第{page_number}页完成,"
f"获取{len(page_products)}条"
)
except requests.RequestException as error:
failed_pages.append(page_number)
print(f"第{page_number}页失败:{error}")
time.sleep(random.uniform(1.5, 3.0))
save_products(all_products)
print(f"共保存{len(all_products)}条商品数据")
print(f"失败页码:{failed_pages}")
if __name__ == "__main__":
main()
这段代码有一个比较实用的设计:某一页失败后,只记录失败页码,不会让整个任务立即退出。后续可以单独补采失败页面,而不必把成功页面全部重跑一遍。
为什么要保留页码字段
保存结果时,我特意增加了“页码”字段。
商品名称、价格和链接是业务数据,页码则是采集链路数据。出现数量异常时,可以直接查看是哪一页缺失。如果不保存页码,最后只知道总量少了,却很难快速定位断点。
正式任务中还可以继续增加:
- 采集时间;
- HTTP状态码;
- 请求耗时;
- 出口标识;
- 重试次数。
这些字段未必都要交付给业务人员,但对排查稳定性很有帮助。
代理IP应该在哪一步接入
如果只是抓取练习网站的10个页面,没有必要为了使用代理而使用代理。
当任务扩大到大量授权页面、不同地区商品展示验证或持续价格监测时,才需要把请求出口纳入设计。我一般会把代理认证地址写入环境变量:
import os
proxy_url = os.getenv("PROXY_URL")
if proxy_url:
session.proxies.update({
"http": proxy_url,
"https": proxy_url
})
接入极安代理时,也可以沿用这种方式。代理地址变化后只调整运行环境,不改采集逻辑,更适合长期维护。
不过,代理不能替代访问授权,也不能解决所有失败。403可能是权限或页面策略问题,429通常与请求频率有关,连接超时才更需要检查代理存活状态、认证配置和链路响应。
别一开始就把并发调得很高
不少人为了“提速”,单线程代码刚跑通就直接开20个线程。结果目标站响应变慢、429增加、代理连接数也被打满,最终实际完成速度反而下降。
我的习惯是先用低并发跑一小批页面,记录成功率和平均响应时间,再逐步增加任务量。每次只改一个参数,才能判断性能变化来自哪里。
对于BeautifulSoup多页采集来说,能稳定完成、失败可定位、结果可补采,比短时间内冲出一个很高的请求数更重要。先把单页解析做准,再把多页链路做稳,最后才轮到并发和代理优化。