一、前言
在前两篇中,我们分别学习了API接口型爬虫 (图书网站)和静态网页解析型爬虫 (百度热搜)。这两类任务的共同特点是数据量小、页数有限 ,几页到几十页的数据可以轻松处理。然而,在实际工程场景中,爬虫往往需要面对数千甚至数万页的大规模数据采集任务。
本文将以 果68水果行情网站 为实战目标,深入讲解如何:
- 设计大规模分页爬取的架构(2000+页)
- 使用CSV追加模式实现数据的增量持久化
- 构建请求延时 与异常处理机制,避免触发反爬
- 实现断点续传能力,应对长时间任务的中断风险
- 掌握工程化爬虫的设计思想与代码组织方式
目标站点特点: 该网站是一个水果B2B行情信息平台,包含2558页(约6万条)水果供应数据。页面采用传统服务端渲染,数据以表格形式展示,URL通过
page参数分页。这种"海量分页+静态表格"的组合,是工业爬虫最典型的挑战场景之一。
二、网站分析与架构设计
2.1 网站页面概览
打开 果68水果行情页,可以看到一个典型的数据表格页面:
页面特征分析:
- 数据以表格列表形式展示,每条记录包含:水果名称、产地、价格、发布时间等
- 分页通过URL参数
page控制,如?page=1、?page=2 - 每页约18条记录,总页数超过2500页
- 页面结构稳定,class名无哈希后缀,便于解析
2.2 HTML结构分析
通过开发者工具分析,单条水果数据的HTML结构如下:
html
<ul class="market-list">
<a href="/market/detail?cate_id=12345">
<li class="list1">红富士苹果</li> <!-- 产品名称 -->
<li class="list2 ellipsis">山东烟台</li> <!-- 产地 -->
<li class="list3">3.50元/斤</li> <!-- 价格 -->
<li class="list4">...</li> <!-- 其他字段 -->
<li class="list5">...</li>
<li class="list6">2024-01-15</li> <!-- 发布时间 -->
</a>
</ul>
关键发现:
- 每条数据包裹在
<ul class="market-list">中 - 内部
<a>标签包含详情页链接,可通过href提取cate_id - 各字段通过
list1~list6的class名区分,结构清晰
2.3 大规模爬取架构设计
面对2500+页、4.6万条数据的规模,必须采用工程化设计:
┌─────────────────────────────────────────────────────────┐
│ 主控制循环 (for page in range) │
├─────────────────────────────────────────────────────────┤
│ 1. 构造URL → 2. 发送请求 → 3. 解析HTML → 4. 提取数据 │
│ ↓ │
│ 5. 异常捕获(网络/解析/超时)→ 6. 延时休眠 → 7. 下一页 │
├─────────────────────────────────────────────────────────┤
│ 数据持久化层:CSV文件(追加模式) │
│ - 每爬完一页即时写入,避免内存溢出 │
│ - 支持断点续传:记录已爬页码 │
└─────────────────────────────────────────────────────────┘
设计原则:
- 流式处理:不一次性加载所有数据到内存,逐页处理逐页写入
- 防御性编程:每个环节都有异常捕获,单页失败不影响整体
- 礼貌爬取:强制延时,降低服务器压力,避免IP被封
- 可观测性:实时打印进度,便于监控与调试
三、代码实现与深度解析
3.1 完整源码
python
import csv
import requests
from bs4 import BeautifulSoup
import urllib3
import time
# ========================================
# 第一部分:环境配置与初始化
# ========================================
# 关闭SSL证书验证警告
# 目标站点使用自签名证书,verify=False时会抛出InsecureRequestWarning
# 在生产环境中应配置CA证书路径,此处为学习目的简化处理
urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning)
# 构造请求头:模拟真实浏览器,绕过基础的UA检测
# 注意:大规模爬取时,建议定期轮换UA,或使用fake_useragent库动态生成
headers = {
'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) '
'AppleWebKit/537.36 (KHTML, like Gecko) '
'Chrome/148.0.0.0 Safari/537.36 Edg/148.0.0.0',
'Accept': 'text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
'Accept-Language': 'zh-CN,zh;q=0.9,en;q=0.8',
'Referer': 'https://www.guo68.com/'
}
# 全局配置参数
OUTPUT_FILE = 'fruit_data.csv' # 输出文件名
START_PAGE = 1 # 起始页码(支持断点续传)
END_PAGE = 2559 # 结束页码(可根据实际情况调整)
REQUEST_TIMEOUT = 15 # 请求超时时间(秒)
SLEEP_INTERVAL = 1 # 每页请求间隔(秒)
# ========================================
# 第二部分:URL参数提取工具函数
# ========================================
def extract_cate_id(url):
"""
从详情页URL中提取水果分类ID(cate_id)
参数:
url: 详情页链接,如 "/market/detail?cate_id=12345"
返回:
cate_id字符串,提取失败返回空字符串
解析逻辑:
1. 按"cate_id="分割字符串,取后半部分
2. 如果URL格式异常(无cate_id参数),返回空字符串
3. 使用try-except包裹,防止split索引越界
"""
try:
if 'cate_id=' in url:
return url.split('cate_id=')[1]
return ""
except (IndexError, AttributeError):
return ""
# ========================================
# 第三部分:核心数据提取函数
# ========================================
def extract_data(html_content):
"""
解析HTML内容,提取水果行情数据
参数:
html_content: 网页HTML字符串
返回:
列表,元素为(fruit_id, product, origin, price, add_time)元组
解析策略:
1. 外层循环:查找所有class="market-list"的ul标签
- 每个ul代表一条完整的水果供应记录
- 使用find_all确保获取当前页所有记录
2. 内层查找:在ul内部定位a标签
- a标签包含详情页href,用于提取fruit_id
- 同时a标签内部包含所有li字段
3. 字段提取:通过class名精确定位
- list1: 产品名称
- list2 ellipsis: 产地(注意class包含空格,表示多个类名)
- list3: 价格
- list6: 发布时间
- 使用get_text(strip=True)去除空白字符
防御性设计:
- 如果某个ul没有a标签,直接continue跳过(防止空节点报错)
- 每个li查找都做空值判断,缺失字段填充空字符串
"""
soup = BeautifulSoup(html_content, 'html.parser')
items = []
# 遍历所有市场列表项
for ul in soup.find_all('ul', {'class': 'market-list'}):
# 查找a标签:包含详情链接和所有字段
a_tag = ul.find('a')
if not a_tag:
continue # 跳过无链接的异常节点
# 提取水果ID:从href属性中解析cate_id
detail_url = a_tag.get('href', '')
fruit_id = extract_cate_id(detail_url)
# 提取产品名称(list1)
product_li = a_tag.find('li', {'class': 'list1'})
product = product_li.get_text(strip=True) if product_li else ''
# 提取产地(list2 ellipsis)
# 注意:class包含空格时,BeautifulSoup会匹配包含所有指定类的元素
origin_li = a_tag.find('li', {'class': 'list2 ellipsis'})
origin = origin_li.get_text(strip=True) if origin_li else ''
# 提取价格(list3)
price_li = a_tag.find('li', {'class': 'list3'})
price = price_li.get_text(strip=True) if price_li else ''
# 提取发布时间(list6)
add_time_li = a_tag.find('li', {'class': 'list6'})
add_time = add_time_li.get_text(strip=True) if add_time_li else ''
# 组装数据元组
items.append((fruit_id, product, origin, price, add_time))
return items
# ========================================
# 第四部分:数据持久化(CSV追加模式)
# ========================================
def save_to_file(items, filename=OUTPUT_FILE):
"""
将数据追加写入CSV文件
参数:
items: 数据列表,每个元素为元组
filename: CSV文件名
设计考量:
1. 使用'a'追加模式:新数据写入文件末尾,不覆盖已有数据
- 这是大规模爬取的核心设计:支持断点续传
- 如果程序中断,已爬数据不会丢失
2. newline='':防止Windows下写入空行
- csv模块在Windows默认会添加\r\n,导致行间空行
3. encoding='utf-8':确保中文正常存储
- 如需Excel直接打开,可改为'utf-8-sig'(带BOM头)
4. writer.writerows(items):批量写入,比逐行写入效率更高
- 减少I/O操作次数,提升性能
注意事项:
- 首次运行前,如文件不存在会自动创建
- 如需要表头,应在首次写入前单独调用writeheader()
"""
with open(filename, 'a', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerows(items) # 批量写入所有记录
# ========================================
# 第五部分:主控制循环
# ========================================
def main():
"""
主函数:控制整个爬取流程
流程设计:
1. 循环遍历指定页码范围
2. 每页构造URL、发送请求、解析数据、持久化存储
3. 每页处理完后强制延时,降低服务器压力
4. 异常捕获:单页失败记录日志,继续下一页
工程化要点:
- 实时打印进度:便于长时间任务监控
- 逐页写入:内存占用恒定,不受总数据量影响
- 异常隔离:单点失败不中断整体流程
"""
print(f"{'='*60}")
print(f"开始爬取果68水果行情数据")
print(f"页码范围: {START_PAGE} - {END_PAGE}")
print(f"数据文件: {OUTPUT_FILE}")
print(f"请求间隔: {SLEEP_INTERVAL}秒")
print(f"{'='*60}\n")
# 记录成功与失败的页数
success_count = 0
fail_count = 0
# 主循环:遍历所有页码
for page in range(START_PAGE, END_PAGE + 1):
print(f"[{page}/{END_PAGE}] 正在获取第 {page} 页的水果信息...")
# 构造当前页URL
url = f'https://www.guo68.com/market?page={page}'
try:
# 发送HTTP GET请求
# verify=False: 跳过SSL验证(学习用途)
# timeout: 防止网络波动导致程序卡死
response = requests.get(
url=url,
headers=headers,
verify=False,
timeout=REQUEST_TIMEOUT
)
# 检查HTTP状态码,非200则抛出异常
response.raise_for_status()
# 设置编码:防止中文乱码
response.encoding = 'utf-8'
html_content = response.text
# 解析提取数据
items = extract_data(html_content)
if items:
# 打印本页数据预览(仅打印前2条)
print(f" ✓ 本页获取 {len(items)} 条记录")
for item in items[:2]:
print(f" {item[0]} | {item[1]} | {item[2]} | {item[3]} | {item[4]}")
if len(items) > 2:
print(f" ... 共 {len(items)} 条")
# 持久化存储
save_to_file(items)
success_count += 1
else:
print(f" ⚠ 第 {page} 页未解析到数据,可能为最后一页或结构变更")
fail_count += 1
except requests.exceptions.Timeout:
print(f" ✗ 第 {page} 页请求超时")
fail_count += 1
except requests.exceptions.HTTPError as e:
print(f" ✗ 第 {page} 页HTTP错误: {e}")
fail_count += 1
except requests.exceptions.RequestException as e:
print(f" ✗ 第 {page} 页网络请求异常: {e}")
fail_count += 1
except Exception as e:
print(f" ✗ 第 {page} 页未知错误: {e}")
fail_count += 1
# 礼貌爬取:每页请求后强制延时
# 这是反爬策略的核心:模拟人类操作间隔,避免触发频率限制
time.sleep(SLEEP_INTERVAL)
# 爬取完成统计
print(f"\n{'='*60}")
print(f"爬取完成!")
print(f"成功页数: {success_count}")
print(f"失败页数: {fail_count}")
print(f"数据已保存至: {OUTPUT_FILE}")
print(f"{'='*60}")
# ========================================
# 程序入口
# ========================================
if __name__ == '__main__':
main()
3.2 核心设计思想解析
(1)CSV追加模式的工程意义
这是本代码最核心的设计决策之一:
python
# 'a' 追加模式:数据写入文件末尾,不覆盖已有内容
with open(filename, 'a', newline='', encoding='utf-8') as f:
writer = csv.writer(f)
writer.writerows(items)
为什么不用'w'写入模式?
| 模式 | 特点 | 适用场景 | 风险 |
|---|---|---|---|
'w' |
覆盖写入,文件清空 | 小数据量、单次运行 | 程序中断则全部丢失 |
'a' |
追加写入,保留历史 | 大数据量、长时间任务 | 可能写入重复数据 |
在大规模爬取中,'a'模式是生存保障:
- 程序运行2小时后中断,已爬的1000页数据不会丢失
- 重启时只需调整
START_PAGE为断点页码,即可继续 - 内存占用恒定,不受总数据量影响(流式处理)
(2)异常隔离与容错设计
代码中采用了多层异常捕获:
python
try:
response = requests.get(...)
response.raise_for_status()
items = extract_data(...)
save_to_file(items)
except requests.exceptions.Timeout:
# 网络超时:记录失败,继续下一页
except requests.exceptions.HTTPError:
# 服务器错误(如500/503):可能是临时故障
except requests.exceptions.RequestException:
# 其他网络异常:DNS失败、连接拒绝等
except Exception:
# 兜底捕获:解析错误、编码问题等
设计哲学: 在大规模任务中,"完成比完美更重要"。单页失败不应中断整体流程,而应记录日志后继续。最终可以通过补爬机制处理失败页。
(3)请求延时与反爬策略
python
time.sleep(SLEEP_INTERVAL) # 每页强制延时1秒
这是最简单的反爬手段,但效果显著:
- 2500页 × 1秒 = 约42分钟总耗时
- 平均每秒1次请求,远低于大多数网站的频率限制(通常10-30次/分钟)
- 如需提速,可改用随机延时:
time.sleep(random.uniform(0.5, 1.5))
进阶反爬手段:
- User-Agent轮换 :使用
fake_useragent库动态生成UA - 代理IP池:高频率爬取时轮换出口IP
- 请求头完整化 :添加
Accept、Accept-Language、Referer等字段
四、运行效果展示
4.1 控制台输出
程序运行时的控制台输出如下,可以看到清晰的进度与数据预览:
输出特征:
- 实时显示当前页码与总页码(如
[1/2559]) - 每页数据量与预览内容一目了然
- 成功/失败状态用符号标识(✓/✗/⚠)
4.2 生成的CSV文件
爬取完成后,生成的fruit_data.csv文件内容如下:
文件特点:
- UTF-8编码,中文无乱码
- 无空行(
newline=''参数生效) - 可直接用Excel或pandas读取分析
五、进阶优化与工程实践
5.1 断点续传实现
长时间任务最怕中断。实现断点续传只需记录最后成功的页码:
python
import os
def get_last_page(filename):
"""从CSV文件行数推算已爬页数"""
if not os.path.exists(filename):
return 1
with open(filename, 'r', encoding='utf-8') as f:
# 每页约18条,计算已爬页数
line_count = sum(1 for _ in f)
return line_count // 18 + 1
# 动态设置起始页
START_PAGE = get_last_page(OUTPUT_FILE)
5.2 多线程并发提速
单线程爬取2500页约需42分钟。使用线程池可大幅提速:
python
from concurrent.futures import ThreadPoolExecutor
import threading
# 线程锁:保护CSV文件写入(防止多线程同时写导致数据错乱)
csv_lock = threading.Lock()
def crawl_page(page):
"""单页爬取函数(供线程池调用)"""
url = f'https://www.guo68.com/market?page={page}'
try:
response = requests.get(url, headers=headers, verify=False, timeout=10)
items = extract_data(response.text)
with csv_lock:
save_to_file(items)
return True
except:
return False
# 线程池:控制并发数为5,平衡速度与稳定性
with ThreadPoolExecutor(max_workers=5) as executor:
results = executor.map(crawl_page, range(1, 2559))
注意: 多线程提速的同时,必须增加延时或降低并发数,避免触发反爬。
5.3 数据清洗与分析
爬取完成后,可用pandas进行数据分析:
python
import pandas as pd
# 读取数据
df = pd.read_csv('fruit_data.csv',
names=['fruit_id', 'product', 'origin', 'price', 'add_time'])
# 数据清洗:提取价格数值
df['price_num'] = df['price'].str.extract(r'(\d+\.?\d*)').astype(float)
# 分析:各产地水果平均价格
avg_price = df.groupby('origin')['price_num'].mean().sort_values(ascending=False)
print(avg_price.head(10))
5.4 监控与日志
生产环境中,应使用logging模块替代print:
python
import logging
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[
logging.FileHandler('spider.log', encoding='utf-8'),
logging.StreamHandler()
]
)
# 使用
logging.info(f"第{page}页爬取成功,获取{len(items)}条")
logging.error(f"第{page}页请求失败: {e}")
六、总结
通过本次实战,我们完整掌握了大规模分页爬虫的工程化技术:
- 分页URL构造 :通过
?page={n}参数循环遍历,理解偏移量与页码的关系 - CSV追加持久化 :使用
'a'模式实现流式写入,支持断点续传,内存占用恒定 - 异常隔离机制:多层try-except捕获,单点失败不中断整体流程
- 反爬策略:请求延时、User-Agent模拟、SSL警告处理
- 工程化思维:实时进度打印、数据预览、成功/失败统计