Python爬虫实战(三):水果行情网站大规模分页爬取

一、前言

在前两篇中,我们分别学习了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文件(追加模式)                       │
│  - 每爬完一页即时写入,避免内存溢出                      │
│  - 支持断点续传:记录已爬页码                            │
└─────────────────────────────────────────────────────────┘

设计原则:

  1. 流式处理:不一次性加载所有数据到内存,逐页处理逐页写入
  2. 防御性编程:每个环节都有异常捕获,单页失败不影响整体
  3. 礼貌爬取:强制延时,降低服务器压力,避免IP被封
  4. 可观测性:实时打印进度,便于监控与调试

三、代码实现与深度解析

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
  • 请求头完整化 :添加AcceptAccept-LanguageReferer等字段

四、运行效果展示

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}")

六、总结

通过本次实战,我们完整掌握了大规模分页爬虫的工程化技术:

  1. 分页URL构造 :通过?page={n}参数循环遍历,理解偏移量与页码的关系
  2. CSV追加持久化 :使用'a'模式实现流式写入,支持断点续传,内存占用恒定
  3. 异常隔离机制:多层try-except捕获,单点失败不中断整体流程
  4. 反爬策略:请求延时、User-Agent模拟、SSL警告处理
  5. 工程化思维:实时进度打印、数据预览、成功/失败统计
相关推荐
神明9311 小时前
如何处理ORA-01152报错_恢复未完成导致的数据文件仍需介质恢复
jvm·数据库·python
m0_596749091 小时前
mysql如何导出特定条件的查询数据_使用mysqldump加where参数
jvm·数据库·python
Pocker_Spades_A2 小时前
Python快速入门专业版(五十八)——正则表达式(re):爬虫文本提取利器(从语法到实战)
爬虫·python·正则表达式
还是鼠鼠2 小时前
AI掘金头条新闻系统 (Toutiao News)-获取新闻分类
后端·python·mysql·fastapi·web
m0_690825822 小时前
c++ RAII机制详解 c++如何利用RAII管理资源
jvm·数据库·python
小郑加油2 小时前
python学习Day13:实际应用——pandas 进阶计算
python·学习·pandas
熊猫_豆豆2 小时前
基于真实火星探测任务的实际轨道设计(Python版)
python·天体物理·火星探测器轨迹·数学物理
RSTJ_16252 小时前
PYTHON+AI LLM DAY FOURTY-SEVEN
开发语言·人工智能·python·深度学习