一、原始脚本分析:问题诊断
首先,让我们回顾原始的电商抓取脚本(伪代码):
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')
存在的问题:
- 逻辑耦合:页面操作、解析、存储全部堆在一个脚本中,无法复用。
- 硬编码严重:URL、选择器、用户名密码、文件路径全部写死,环境变更需改代码。
- 无错误处理:网络失败、元素缺失将直接崩溃。
- 无日志记录:运行过程黑盒,无法追踪问题。
- 不可扩展:若要抓取另一个网站,需复制整个脚本,维护成本倍增。
二、重构目标与项目结构
基于工程化原则,我们确定重构目标:
- 模块分离:将功能拆分为独立模块,通过清晰的接口交互。
- 配置驱动:所有可变参数从配置读取,支持多环境。
- 健壮性:引入重试、异常捕获、日志记录。
- 可扩展性:通过设计模式(如策略模式)支持不同网站和存储方式。
最终项目结构:
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日志,包含请求、解析、存储各环节信息 |
七、设计模式应用回顾
在这次重构中,我们有意或无意地应用了多个设计模式:
- 模板方法模式 :
Crawler.run()定义了流程骨架,具体步骤(如分页处理)在子方法中实现。 - 策略模式 :
StorageStrategy及其实现类,使存储方式可插拔;PageOperator中的登录逻辑也可以抽象为策略。 - 工厂模式 :
StorageFactory根据类型创建存储对象。 - 单例模式 :
RpaLogger确保全局只有一个日志实例。 - 配置模式 :通过
ConfigLoader集中管理配置。
这些模式让系统低耦合、高内聚,为后续扩展奠定了坚实基础。
八、未来扩展方向
基于当前架构,我们可以轻松增加以下功能:
- 支持更多存储 :如数据库(MySQL、MongoDB),只需实现新的
StorageStrategy。 - 支持更多解析器:如使用XPath或正则,通过策略模式切换。
- 分布式抓取 :将
PageOperator改为异步请求,或通过消息队列分发任务。 - 增量抓取:利用状态机记录上次抓取时间,只抓取新增数据。
- 反爬对抗 :在
PageOperator中集成代理IP轮换、随机User-Agent等。