RPA工程化实践:重构电商抓取项目——从混乱脚本到模块化、可配置化系统

一、原始脚本分析:问题诊断

首先,让我们回顾原始的电商抓取脚本(伪代码):

python 复制代码
import requests
from bs4 import BeautifulSoup
import openpyxl

# 硬编码URL
url = "https://www.example.com/products?page=1"
username = "user"
password = "pass"

# 无日志,无异常处理
response = requests.get(url, auth=(username, password))
soup = BeautifulSoup(response.text, 'html.parser')

# 硬编码选择器
products = soup.select('.product-item')
data = []
for p in products:
    name = p.select_one('.product-name').text
    price = p.select_one('.price').text.strip('$')
    data.append([name, price])

# 硬编码文件路径
wb = openpyxl.Workbook()
ws = wb.active
ws.append(['名称', '价格'])
for row in data:
    ws.append(row)
wb.save('output.xlsx')

存在的问题

  1. 逻辑耦合:页面操作、解析、存储全部堆在一个脚本中,无法复用。
  2. 硬编码严重:URL、选择器、用户名密码、文件路径全部写死,环境变更需改代码。
  3. 无错误处理:网络失败、元素缺失将直接崩溃。
  4. 无日志记录:运行过程黑盒,无法追踪问题。
  5. 不可扩展:若要抓取另一个网站,需复制整个脚本,维护成本倍增。

二、重构目标与项目结构

基于工程化原则,我们确定重构目标:

  • 模块分离:将功能拆分为独立模块,通过清晰的接口交互。
  • 配置驱动:所有可变参数从配置读取,支持多环境。
  • 健壮性:引入重试、异常捕获、日志记录。
  • 可扩展性:通过设计模式(如策略模式)支持不同网站和存储方式。

最终项目结构

复制代码
ecommerce_crawler/
├── config/                 # 配置目录
│   ├── config.yaml         # 主配置文件
│   └── sites/              # 各网站特定配置
│       ├── example.yaml
│       └── another.yaml
├── logs/                   # 日志输出目录
├── src/                    # 源代码
│   ├── __init__.py
│   ├── page_operator.py    # 页面操作模块(HTTP请求、登录等)
│   ├── data_parser.py      # 数据解析模块
│   ├── data_storage.py     # 数据存储模块
│   ├── crawler.py          # 流程编排模块
│   └── utils/              # 通用工具
│       ├── logger.py       # 日志封装
│       └── config_loader.py # 配置加载器
├── main.py                 # 程序入口
└── requirements.txt

三、基础设施:日志与配置模块

在实现具体业务前,我们先搭建日志和配置基础设施,这些是我们之前几天学习的成果。

3.1 日志模块 (utils/logger.py)

我们将之前设计的日志系统封装为单例,供全局使用。

python 复制代码
import logging
import logging.handlers
import os
from datetime import datetime

class RpaLogger:
    _instance = None
    _logger = None

    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance

    def setup(self, log_dir='logs', app_name='crawler', level=logging.INFO):
        if self._logger:
            return self._logger

        if not os.path.exists(log_dir):
            os.makedirs(log_dir)

        log_file = os.path.join(log_dir, f"{app_name}_{datetime.now().strftime('%Y%m%d')}.log")
        self._logger = logging.getLogger(app_name)
        self._logger.setLevel(level)

        formatter = logging.Formatter(
            '%(asctime)s - %(name)s - %(threadName)s - %(levelname)s - %(message)s'
        )

        file_handler = logging.handlers.TimedRotatingFileHandler(
            log_file, when='midnight', interval=1, backupCount=30, encoding='utf-8'
        )
        file_handler.setLevel(level)
        file_handler.setFormatter(formatter)

        console_handler = logging.StreamHandler()
        console_handler.setLevel(logging.INFO)
        console_handler.setFormatter(formatter)

        self._logger.addHandler(file_handler)
        self._logger.addHandler(console_handler)
        return self._logger

    def get_logger(self):
        if self._logger is None:
            self.setup()
        return self._logger

# 全局函数
def get_logger():
    return RpaLogger().get_logger()

3.2 配置加载模块 (utils/config_loader.py)

支持多环境、多网站配置,使用YAML格式,并提供环境变量替换功能。

python 复制代码
import os
import yaml
from typing import Any, Dict

class ConfigLoader:
    def __init__(self, env=None):
        self.env = env or os.getenv('RPA_ENV', 'development')
        self.config = None

    def load(self, site_name=None):
        """加载主配置和特定网站配置"""
        # 加载主配置
        main_config_path = 'config/config.yaml'
        with open(main_config_path, 'r', encoding='utf-8') as f:
            main_config = yaml.safe_load(f)

        # 加载网站特定配置
        if site_name:
            site_config_path = f'config/sites/{site_name}.yaml'
            if os.path.exists(site_config_path):
                with open(site_config_path, 'r', encoding='utf-8') as f:
                    site_config = yaml.safe_load(f)
                # 深度合并配置
                main_config = self._deep_merge(main_config, site_config)

        # 环境变量替换
        main_config = self._replace_env_vars(main_config)
        self.config = main_config
        return self.config

    def _deep_merge(self, base, override):
        """递归合并两个字典,override优先"""
        for key, value in override.items():
            if key in base and isinstance(base[key], dict) and isinstance(value, dict):
                base[key] = self._deep_merge(base[key], value)
            else:
                base[key] = value
        return base

    def _replace_env_vars(self, obj):
        if isinstance(obj, dict):
            return {k: self._replace_env_vars(v) for k, v in obj.items()}
        elif isinstance(obj, list):
            return [self._replace_env_vars(i) for i in obj]
        elif isinstance(obj, str):
            if obj.startswith('${') and obj.endswith('}'):
                env_var = obj[2:-1]
                return os.getenv(env_var, '')
        return obj

主配置文件示例 (config/config.yaml)

yaml 复制代码
# 全局默认配置
default:
  timeout: 30
  retry_times: 3
  retry_delay: 2

storage:
  type: "excel"   # 可选: excel, csv, database
  output_dir: "./output"

logging:
  level: "INFO"
  dir: "./logs"

# 网站配置将覆盖这些值
site:
  name: ""
  base_url: ""
  login:
    required: false
    username: ""
    password: ""
  pagination:
    enabled: false
    param: "page"
    start: 1
    end: 1
  selectors:
    product_container: ""
    name: ""
    price: ""
    # 其他字段

网站特定配置示例 (config/sites/example.yaml)

yaml 复制代码
site:
  name: "example"
  base_url: "https://www.example.com"
  login:
    required: true
    username: "${SITE_USERNAME}"
    password: "${SITE_PASSWORD}"
  pagination:
    enabled: true
    param: "page"
    start: 1
    end: 5
  selectors:
    product_container: "div.product-item"
    name: "h3.product-name"
    price: "span.price"

四、模块化实现

4.1 页面操作模块 (page_operator.py)

负责所有与HTTP请求和页面交互的操作。支持登录、会话保持、重试机制。我们可以使用requests.Session,并结合之前设计的重试装饰器。

python 复制代码
import requests
from typing import Optional, Dict
from urllib.parse import urljoin
from utils.logger import get_logger
from utils.retry import retry  # 假设我们之前实现了重试装饰器

logger = get_logger()

class PageOperator:
    def __init__(self, config):
        self.config = config
        self.session = requests.Session()
        self.session.timeout = config.get('default', {}).get('timeout', 30)
        self._setup_auth()

    def _setup_auth(self):
        """如果需要登录,执行登录"""
        site_cfg = self.config.get('site', {})
        if site_cfg.get('login', {}).get('required', False):
            self._login()

    @retry(max_retries=3, delay=2)
    def _login(self):
        login_info = self.config['site']['login']
        # 根据网站实现具体登录逻辑,此处仅为示例
        login_url = urljoin(self.config['site']['base_url'], '/login')
        payload = {
            'username': login_info['username'],
            'password': login_info['password']
        }
        response = self.session.post(login_url, data=payload)
        response.raise_for_status()
        logger.info("登录成功")

    @retry(max_retries=3, delay=2)
    def get_page(self, url, params=None):
        """获取页面内容,返回文本"""
        logger.info(f"请求URL: {url}, params: {params}")
        response = self.session.get(url, params=params)
        response.raise_for_status()
        return response.text

    def build_url(self, path, params=None):
        """构建完整URL"""
        base = self.config['site']['base_url']
        return urljoin(base, path)

4.2 数据解析模块 (data_parser.py)

负责根据配置的选择器,从HTML中提取结构化数据。使用BeautifulSoup,并且支持配置化的字段映射。为了应对不同网站解析规则的差异,可以应用策略模式,但为简洁,我们直接基于配置解析。

python 复制代码
from bs4 import BeautifulSoup
from typing import List, Dict, Any
from utils.logger import get_logger

logger = get_logger()

class DataParser:
    def __init__(self, config):
        self.config = config
        self.selectors = config['site']['selectors']

    def parse_page(self, html: str) -> List[Dict[str, Any]]:
        """解析单页,返回产品列表"""
        soup = BeautifulSoup(html, 'html.parser')
        container_sel = self.selectors.get('product_container')
        if not container_sel:
            logger.error("未配置产品容器选择器")
            return []

        products = soup.select(container_sel)
        logger.info(f"找到 {len(products)} 个产品")
        result = []
        for prod in products:
            item = {}
            for field, selector in self.selectors.items():
                if field == 'product_container':
                    continue
                element = prod.select_one(selector)
                item[field] = element.text.strip() if element else ''
            result.append(item)
        return result

    def extract_total_pages(self, html: str) -> int:
        """可选:从页面提取总页数,用于分页"""
        # 根据配置实现
        return self.config['site'].get('pagination', {}).get('end', 1)

4.3 数据存储模块 (data_storage.py)

支持多种存储方式,使用策略模式。这里实现Excel和CSV两种方式,便于扩展。

python 复制代码
from abc import ABC, abstractmethod
import csv
import os
import openpyxl
from utils.logger import get_logger

logger = get_logger()

class StorageStrategy(ABC):
    @abstractmethod
    def save(self, data, config):
        pass

class ExcelStorage(StorageStrategy):
    def save(self, data, config):
        output_dir = config.get('storage', {}).get('output_dir', './output')
        os.makedirs(output_dir, exist_ok=True)
        file_path = os.path.join(output_dir, f"{config['site']['name']}_products.xlsx")
        wb = openpyxl.Workbook()
        ws = wb.active
        # 写入表头
        if data:
            headers = data[0].keys()
            ws.append(list(headers))
            for row in data:
                ws.append(list(row.values()))
        wb.save(file_path)
        logger.info(f"数据已保存到Excel: {file_path}")

class CsvStorage(StorageStrategy):
    def save(self, data, config):
        output_dir = config.get('storage', {}).get('output_dir', './output')
        os.makedirs(output_dir, exist_ok=True)
        file_path = os.path.join(output_dir, f"{config['site']['name']}_products.csv")
        with open(file_path, 'w', newline='', encoding='utf-8-sig') as f:
            if data:
                writer = csv.DictWriter(f, fieldnames=data[0].keys())
                writer.writeheader()
                writer.writerows(data)
        logger.info(f"数据已保存到CSV: {file_path}")

class StorageFactory:
    @staticmethod
    def get_storage(storage_type):
        if storage_type == 'excel':
            return ExcelStorage()
        elif storage_type == 'csv':
            return CsvStorage()
        else:
            raise ValueError(f"不支持的存储类型: {storage_type}")

4.4 流程编排模块 (crawler.py)

负责协调各模块,完成抓取流程。这里可以使用模板方法模式定义流程骨架,但为了简单,我们直接用函数编排,同时使用状态机记录任务状态(可选)。我们将集成重试、日志和配置。

python 复制代码
import time
from utils.logger import get_logger
from utils.config_loader import ConfigLoader
from page_operator import PageOperator
from data_parser import DataParser
from data_storage import StorageFactory

logger = get_logger()

class Crawler:
    def __init__(self, site_name):
        self.site_name = site_name
        self.config = ConfigLoader().load(site_name)
        self.page_op = PageOperator(self.config)
        self.parser = DataParser(self.config)
        self.storage = StorageFactory.get_storage(
            self.config.get('storage', {}).get('type', 'excel')
        )
        self.all_data = []

    def run(self):
        """主流程"""
        logger.info(f"开始抓取网站: {self.site_name}")
        try:
            # 处理分页
            pagination = self.config['site'].get('pagination', {})
            if pagination.get('enabled', False):
                self._run_with_pagination(pagination)
            else:
                # 单页模式
                url = self.page_op.build_url('')
                html = self.page_op.get_page(url)
                data = self.parser.parse_page(html)
                self.all_data.extend(data)
            # 保存数据
            self.storage.save(self.all_data, self.config)
            logger.info(f"抓取完成,共获取 {len(self.all_data)} 条数据")
        except Exception as e:
            logger.exception(f"抓取过程中发生异常: {e}")
            raise

    def _run_with_pagination(self, pagination):
        """处理分页逻辑"""
        param = pagination.get('param', 'page')
        start = pagination.get('start', 1)
        end = pagination.get('end', 1)
        for page_num in range(start, end + 1):
            logger.info(f"正在抓取第 {page_num} 页")
            params = {param: page_num}
            url = self.page_op.build_url('', params=params)
            html = self.page_op.get_page(url, params=params)
            page_data = self.parser.parse_page(html)
            self.all_data.extend(page_data)
            # 可添加延时,避免反爬
            time.sleep(1)

五、程序入口与使用方式

最后,编写main.py作为入口,支持命令行参数指定网站。

python 复制代码
import argparse
from utils.logger import RpaLogger
from crawler import Crawler

def main():
    parser = argparse.ArgumentParser(description='电商抓取工具')
    parser.add_argument('site', help='要抓取的网站名称(对应config/sites下的文件名)')
    parser.add_argument('--env', default='development', help='运行环境')
    args = parser.parse_args()

    # 初始化日志
    logger = RpaLogger()
    logger.setup(app_name=f'crawler_{args.site}')

    try:
        crawler = Crawler(args.site)
        crawler.run()
    except Exception as e:
        logger.get_logger().error(f"程序异常退出: {e}")
        return 1
    return 0

if __name__ == '__main__':
    exit(main())

使用示例

bash 复制代码
# 设置环境变量(敏感信息)
export SITE_USERNAME="myuser"
export SITE_PASSWORD="mypass"
export RPA_ENV="production"

# 运行抓取
python main.py example --env production

六、重构成果对比

维度 原始脚本 重构后
代码行数 约30行,所有逻辑混合 模块化,每个文件<100行,职责清晰
可配置性 硬编码 YAML配置,支持多网站、多环境
可维护性 修改需改动代码 修改配置即可,新增网站只需添加配置文件
健壮性 无异常处理 重试机制、全局异常捕获、日志记录
可扩展性 无法扩展 新增存储方式只需实现StorageStrategy;新增解析规则无需修改核心代码
可观测性 无日志 详细的INFO/ERROR日志,包含请求、解析、存储各环节信息

七、设计模式应用回顾

在这次重构中,我们有意或无意地应用了多个设计模式:

  1. 模板方法模式Crawler.run() 定义了流程骨架,具体步骤(如分页处理)在子方法中实现。
  2. 策略模式StorageStrategy 及其实现类,使存储方式可插拔;PageOperator 中的登录逻辑也可以抽象为策略。
  3. 工厂模式StorageFactory 根据类型创建存储对象。
  4. 单例模式RpaLogger 确保全局只有一个日志实例。
  5. 配置模式 :通过ConfigLoader集中管理配置。

这些模式让系统低耦合、高内聚,为后续扩展奠定了坚实基础。


八、未来扩展方向

基于当前架构,我们可以轻松增加以下功能:

  • 支持更多存储 :如数据库(MySQL、MongoDB),只需实现新的StorageStrategy
  • 支持更多解析器:如使用XPath或正则,通过策略模式切换。
  • 分布式抓取 :将PageOperator改为异步请求,或通过消息队列分发任务。
  • 增量抓取:利用状态机记录上次抓取时间,只抓取新增数据。
  • 反爬对抗 :在PageOperator中集成代理IP轮换、随机User-Agent等。
相关推荐
商业数据派2 小时前
快手估值重构的“隐藏彩蛋”
大数据·人工智能·重构
MarkHD4 小时前
RPA工程化实践:三种核心设计模式让复杂流程优雅可控
linux·设计模式·rpa
Alter12307 小时前
华为吴辉:AI正在重构生产系统,“大增量时代”已经到来
人工智能·华为·重构
weixin_307779138 小时前
2025年中国研究生数学建模竞赛C题:围岩裂隙精准识别与三维模型重构
c语言·数学建模·重构
木斯佳9 小时前
HarmonyOS 6 SDK对接实战:从原生ASR到Copilot SDK(下)- Copilot SDK对接与重构(全网最新)
ai·重构·copilot·harmonyos
飞Link9 小时前
动态嵌入:Transformer 架构下的语义重构与演进
人工智能·深度学习·重构·transformer
晨曦蜗牛9 小时前
Windows 上 Claude Code 报错 “requires git-bash“ 的完整解决方案
windows·git·bash
2501_933329559 小时前
深度解析:Infoseek数字公关AI中台的技术架构与实践
人工智能·自然语言处理·重构·架构
bitbrowser9 小时前
2026年浏览器自动化(RPA)技术
安全·自动化·rpa