Python爬虫零基础入门【第九章:实战项目教学·第20节】一键运行作品化:CLI + README + Docker(可交付)!

🔥本期内容已收录至专栏《Python爬虫实战》,持续完善知识体系与项目实战,建议先订阅收藏,后续查阅更方便~持续更新中!!

全文目录:

🌟 开篇语

哈喽,各位小伙伴们你们好呀~我是【喵手】。

运营社区: C站 / 掘金 / 腾讯云 / 阿里云 / 华为云 / 51CTO

欢迎大家常来逛逛,一起学习,一起进步~🌟

我长期专注 Python 爬虫工程化实战 ,主理专栏 👉 《Python爬虫实战》:从采集策略反爬对抗 ,从数据清洗分布式调度 ,持续输出可复用的方法论与可落地案例。内容主打一个"能跑、能用、能扩展 ",让数据价值真正做到------抓得到、洗得净、用得上

📌 专栏食用指南(建议收藏)

  • ✅ 入门基础:环境搭建 / 请求与解析 / 数据落库
  • ✅ 进阶提升:登录鉴权 / 动态渲染 / 反爬对抗
  • ✅ 工程实战:异步并发 / 分布式调度 / 监控与容错
  • ✅ 项目落地:数据治理 / 可视化分析 / 场景化应用

📣 专栏推广时间 :如果你想系统学爬虫,而不是碎片化东拼西凑,欢迎订阅/关注专栏《Python爬虫实战》

订阅后更新会优先推送,按目录学习更高效~

上期回顾

上一期《Python爬虫零基础入门【第九章:实战项目教学·第19节】测试驱动爬虫:解析器/清洗/入库三类测试!》,咱们把测试体系搭建起来了,解析器、清洗函数、入库逻辑都有了自动化验证。现在跑 pytest 就能知道代码有没有问题,这让咱们改代码的时候底气足了不少。

但说实话,测试再完善,如果项目难以运行,那价值还是要打折扣。你有没有遇到过这种情况:

  • 同事问你要代码,结果装依赖装了半小时还报错
  • 三个月后自己重新跑项目,完全忘了当时怎么配置的
  • 想分享到 GitHub,但 README 写得太简陋,别人看不懂怎么用

这就是今天要解决的问题:如何让你的爬虫项目达到"可交付"标准。不仅自己能跑,别人拿到手也能三分钟启动,这才叫作品化。

什么是"可交付"级别的项目?

我给你一个清单,满足这些的项目基本就能拿得出手了:

基础标准

  • 一行命令启动:不用翻代码找入口函数
  • 参数可配置:起止页、并发数、输出路径等都能命令行指定
  • README 友好:新人照着文档 5 分钟能跑起来
  • 依赖明确requirements.txt 锁定版本,不会因为环境问题炸掉
  • 日志清晰:运行过程有进度反馈,出错能快速定位

进阶标准

  • Docker 封装:新机器不用装 Python,拉镜像就能跑
  • 配置文件分离:敏感信息(数在代码里
  • 版本化发布:有 CHANGELOG,每个版本功能清晰
  • CI/CD:推送代码自动跑测试、构建镜像

今天重点讲前两个标准,后面两个会简单提一下思路。

第一部分:CLI 命令行工具(让启动变简单)

为什么需要 CLI?

假设你现在的项目启动方式是这样的:

python 复制代码
# 老土的方式
if __name__ == '__main__':
    spider = MySpider()
    spider.run(start_page=1, end_page=10, output='data.csv')

每次改参数都得打开代码文件,改完还得记得改回来。而且如果有多个命令(采集、清洗、导出),就得写一堆 if-else 判断。

用 CLI 工具后

bash 复制代码
# 优雅的方式
myspider crawl --pages 1-10 --output data.csv
myspider clean --input raw.csv --output cleaned.csv
myspider export --format json

参数清晰、命令分离、还能加 --help 自动生成帮助文档。这才是专业项目该有的样子。

选择工具:Typer vs Click

Python 里做 CLI 主要两个库:

  • Click:老牌库,功能强大但稍显繁琐

咱们选 Typer,因为它代码量少还类型安全。

安装:

bash 复制代码
pip install typer[all] rich

rich 是可选依赖,提供漂亮的命令行输出(彩色文本、进度条等)

实战:构建 CLI 入口

项目结构:

json 复制代码
myspider/
├── myspider/              # 主包(注意改名,别和项目重名)
│   ├── __init__.py
│   ├── cli.py            # CLI 入口(重点!)
│   ├── spider.py         # 爬虫主逻辑
│   ├── config.py         # 配置管理
│   └── utils/
│       └── logger.py
├── tests/
├── setup.py              # 打包配置
├── requirements.txt
└── README.md

myspider/cli.py(完整代码):

python 复制代码
"""
命令行入口模块
提供 crawl/clean/export 三个子命令
"""
import typer
from pathlib import Path
from typing import Optional
from rich.console import Console
from rich.progress import Progress, SpinnerColumn, TextColumn

from .spider import NewsSpider
from .config import load_config

# 创建 Typer 应用
app = typer.Typer(
    name="myspider",
    help="Python 爬虫工程化实战 - 新闻采集工具",
    add_completion=False  # 禁用自动补全(可选)
)

# Rich 控制台(用于美化输出)
console = Console()


@app.command()
def crawl(
    pages: str = typer.Option(
        "1-10",
        "--pages", "-p",
        help="采集页码范围,格式:1-10 或单页 5"
    ),
    output: Path = typer.Option(
        "output/news.jsonl",
        "--output", "-o",
        help="输出文件路径(支持 .jsonl/.csv)"
    ),
    config_file: Optional[Path] = typer.Option(
        None,
        "--config", "-c",
        help="配置文件路径(YAML 格式)"
    ),
    concurrency: int = typer.Option(
        3,
        "--concurrency",
        help="并发数(请求速率)"
    ),
    debug: bool = typer.Option(
        False,
        "--debug",
        help="开启调试模式(详细日志)"
    ),
):
    """
    采集新闻数据
    
    示例:
      myspider crawl --pages 1-50 --output news.jsonl
      myspider crawl -p 1 -o test.csv --debug
    """
    # 解析页码范围
    try:
        if '-' in pages:
            start, end = map(int, pages.split('-'))
        else:
            start = end = int(pages)
    except ValueError:
        console.print("[red]错误:页码格式不正确,应为 '1-10' 或 '5'[/red]")
        raise typer.Exit(code=1)
    
    # 加载配置
    config = load_config(config_file) if config_file else {}
    
    # 显示启动信息
    console.print(f"\n[bold cyan]🚀 开始采集新闻[/bold cyan]")
    console.print(f"  页码范围: {start} - {end}")
    console.print(f"  输出路径: {output}")
    console.print(f"  并发数: {concurrency}")
    console.print(f"  调试模式: {'开启' if debug else '关闭'}\n")
    
    # 初始化爬虫
    spider = NewsSpider(
        output_path=output,
        concurrency=concurrency,
        debug=debug,
        **度条)
    try:
        with Progress(
            SpinnerColumn(),
            TextColumn("[progress.description]{task.description}"),
            console=console
        ) as progress:
            task = progress.add_task("采集中...", total=None)
            
            result = spider.run(start_page=start, end_page=end)
            
            progress.update(task, completed=True)
        
        # 显示结果统计
        console.print(f功: {result['success']} 条")
        console.print(f"  失败: {result['failed']} 条")
        console.print(f"  耗时: {result['duration']:.2f} 秒\n")
        
    except KeyboardInterrupt:
        console.print("\n[yellow]⚠️  用户中断采集[/yellow]")
        raise typer.Exit(code=130)
    except Exception as e:
        console.print(f"\n[red]❌ 采集失败: {e}[/red]")
        if debug:
            raise  # 调试模式下显示完整异常
        raise typer.Exit(code=1)


@app.command()
def clean(
    input_file: Path = typer.Argument(..., help="输入文件路径"),
    output_file: Path = typer.Option(
        None,
        "--output", "-o",
        help="输出文件路径(默认覆盖原文件)"
    ),
    rules: Optional[str] = typer.Option(
        None,
        "--rules",
        help="清洗规则(逗号分隔),如:whitespace,date,amount"
    ),
):
    """
    清洗采集的数据
    
    示例:
      myspider clean raw.jsonl -o cleaned.jsonl
      myspider clean data.csv --rules whitespace,date
    """
    from .cleaners import DataCleaner
    
    if not input_file.exists():
        console.print(f"[red]错误:文件不存在 {input_file}[/red]")
        raise typer.Exit(code=1)
    
    output_file = output_file or input_file
    rule_list = rules.split(',') if rules else ['default']
    
    console.print(f"\n[cyan]🧹 开始清洗数据[/cyan]")
    console.print(f"  输入: {input_file}")
    console.print(f"  输出: {output_file}")
    console.print(f"  规则: {', '.join(rule_list)}\n")
    
    cleaner = DataCleaner(rules=rule_list)
    stats = cleaner.process(input_file, output_file)
    
    console.print(f"[green]✅ 清洗完成[/green]")
    console.print(f"  处理: {stats['total']} 条")
    console.print(f"  修改: {stats['modified']} 条\n")


@app.command()
def export(
    input_file: Path = typer.Argument(..., help="输入文件路径(JSONL)"),
    output_format: str = typer.Option(
        "csv",
        "--format", "-f",
        help="导出格式:csv/json/excel"
    ),
    output_file: Optional[Path] = typer.Option(
        None,
        "--output", "-o",
        help="输出文件路径(默认自动生成)"
    ),
):
    """
    导出数据到其他格式
    
    示例:
      myspider export news.jsonl --format csv
      myspider export data.jsonl -f excel -o report.xlsx
    """
    from .exporters import export_data
    
    if not input_file.exists():
        console.print(f"[red]错误:文件不存在 {input_file}[/red]")
        raise typer.Exit(code=1)
    
    # 自动生成输出文件名
    if not output_file:
        output_file = input_file.with_suffix(f'.{output_format}')
    
    console.print(f"\n[cyan]📦 开始导出[/cyan]")
    console.print(f"  格式: {output_format.upper()}\n")
    
    count = export_data(input_file, output_file, output_format)
    
    console.print(f"[green]✅ 导出完成[/green]")
    console.print(f"  记录数: {count}")
    console.print(f"  文件: {output_file}\n")


@app.command()
def version():
    """显示版本信息"""
    from . import __version__
    console.print(f"myspider v{__version__}")


# 默认入口(直接运行 python -m myspider)
if __name__ == "__main__":
    app()

代码详解

  1. 命令注册@app.command() 装饰器把函数变成子命令

  2. 参数定义

    • typer.Argument:位置参数(必填)
    • typer.Option:选项参数(可选,有默认值)
    • 参数支持类型注解,Typer 会自动验证和转换
  3. 帮助文档 :函数的 docstring 会自动变成 --help 内容

  4. Rich 集成 :用 Console 输出彩色文本,Progress 显示进度条

  5. 错误处理 :捕获异常后用 typer.Exit(code=1) 返回非 打包安装(关键步骤)

要让 myspider 命令全局可用,需要配置 setup.py

setup.py

python 复制代码
from setuptools import setup, find_packages

# 读取 README 作为长描述
with open("README.md", encoding="utf-8") as f:
    long_description = f.read()

# 读取依赖列表
with open("requirements.txt") as f:
    requirements = [line.strip() for line in f if line.strip() and not line.startswith('#')]

setup(
    name="myspider",
    version="1.0.0",
    author="你的名字",
    author_email="your.email@example.com",
    description="Python 爬虫工程化实战 - 新闻采集工具",
    long_description=long_description,
    long_description_content_type="text/markdown",
    url="https://github.com/yourusername/myspider",
    packages=find_packages(exclude=["tests*"]),
    install_requires=requirements,
    python_requires=">=3.8",
    
    # 关键:定义命令行入口
    entry_points={
        "console_scripts": [
            "myspider=myspider.cli:app",  # 命令名=模块路径:函数名
        ],
    },
    
    classifiers=[
        "Development Status :: 4 - Beta",
        "Intended Audience :: Developers",
        "License :: OSI Approved :: MIT License",
        "Programming Language :: Python :: 3.8",
        "Programming Language :: Python :: 3.9",
        "Programming Language :: Python :: 3.10",
        "Programming Language :: Python :: 3.11",
    ],
)

安装到本地(开发模式):

bash 复制代码
pip install -e .

-e 表示 editable(可编辑),修改代码后不用重新安装

现在就可以全局使用 myspider 命令了:

bash 复制代码
myspider --help
myspider crawl --pages 1-5 --output test.jsonl
myspider version

第二部分:README 编写(人类友好的说明书)

一个好的 README 应该让新人不看代码也能跑起来。我见过太多项目的 README 只有寥寥几行安装命令,然后就没了,这等于没写。

README 必备章节

参考开源项目的最佳实践,至少包含这些:

  1. 项目简介:一句话说明这是干什么的
  2. 功能特性:核心亮点(带 emoji 更友好)
  3. 快速开始:3 分钟能跑通的最小示例
  4. 安装指南:详细的环境要求和安装步骤
  5. 使用文档:常用命令和参数说明
  6. 配置说明:可选配置项
  7. 常见问题:提前解答典型错误
  8. 贡献指南(可选)
  9. 许可证

完整示例 README.md

markdown 复制代码
# 🕷️ MySpider - Python 爬虫工程化实战

一个生产级的 Python 爬虫框架,专注于**稳定性**和**可维护性**。

## ✨ 功能特性

- 🎯 **结构化采集**:列表页 → 详情页两段式采集模式
- 🔄 **增量更新**:支持断点续爬和去重入动清洗**:内置日期/金额/文本标准化工具
- 💾 **多格式导出**:JSONL / CSV / Excel / SQLite
- 📊 **质量监控**:实时统计缺失率、重复率
- 🐳 **开箱即用**:Docker 一键部署
- 🧪 **测试覆盖**:核心模块测试覆盖率 > 85%

## 🚀 快速开始

### 最小示例(3 分钟体验)


# 1. 克隆项目
git clone xxx
cd myspider

# 2. 安装依赖(推荐使用虚拟环境)
python -m venv venv
source venv/bin/activate  # Windows 用 venv\Scripts\activate
pip install -e .

# 3. 运行示例采集
myspider crawl --pages 1-3 --output demo.jsonl

# 4. 查看结果
cat demo.jsonl


**预期输出**:

```json
🚀 开始采集新闻
  页码范围: 1 - 3
  输出路径: demo.jsonl
  并发数: 3

✅ 采集完成
  成功: 45 条
  失败: 0 条
  耗时: 12.34 秒

📦 安装指南

环境要求

  • Python >= 3.8
  • 操作系统:macOS / Linux / Windows
  • (可选)Docker >= 20.10

方式一:从源码安装

bash 复制代码
# 克隆仓库
git clone https://github.com/yourusername/myspider.git
cd myspider

# 创建虚拟环境(强烈推荐)
python -m venv venv
source venv/bin/activate

# 安装依赖
pip install -r requirements.txt

# 开发模式安装(修改代码立即生效)
pip install -e .

方式二:使用 Docker(推荐生产环境)

bash 复制代码
# 拉取镜像
docker pull yourusername/myspider:latest

# 运行采集
docker run --rm -v $(pwd)/output:/app/output \
  myspider:latest crawl --pages 1-10

验证安装

bash 复制代码
myspider --help
myspider version

如果看到帮助信息和版本号,说明安装成功 ✅

📖 使用文档

命令概览

bash 复制代码
myspider crawl    # 采集数据
myspider clean    # 清洗数据
myspider export   # 导出数据
myspider version  # 查看版本

每个命令都支持 --help 查看详细参数。

1. 采集数据

bash 复制代码
# 基础用法
myspider crawl --pages 1-10 --output news.jsonl

# 自定义并发(默认 3)
myspider crawl -p 1-50 --concurrency 5

# 使用配置文件
myspider crawl -p 1-100 --config config.yaml

# 调试模式(显示详细日志)
myspider crawl -p 1 --debug

参数说明

参数 简写 默认值 说明
--pages -p 1-10 页码范围,格式:1-10 或单页 5
--output -o output/news.jsonl 输出文件路径
--concurrency - 3 并发请求数
--config -c None YAML 配置文件路径
--debug - False 开启调试模式

2. 清洗数据

bash 复制代码
# 清洗采集的原始数据
myspider clean raw.jsonl -o cleaned.jsonl

# 指定清洗规则
myspider clean data.jsonl --rules whitespace,date,amount

内置清洗规则

  • whitespace:去除多余空格和换行
  • date:统一日期格式为 ISO 8601
  • amount:标准化金额(支持万/亿单位)
  • default:应用所有规则

3. 导出数据

bash 复制代码
# 导出为 CSV
myspider export news.jsonl --format csv

# 导出为 Excel(需安装 openpyxl)
myspider export news.jsonl -f excel -o report.xlsx

# 导出为 JSON(格式化)
myspider export news.jsonl -f json

⚙️ 配置说明

使用配置文件(推荐)

创建 config.yaml

yaml 复制代码
# 目标站点配置
target:
  base_url: "https://example.com/news"
  list_selector: "div.news-item"
  detail_selectors:
    title: "h1.title::text"
    author: "span.author::text"
    content: "div.content p::text"

# 采集设置
crawl:
  delay: 1.0          # 请求间隔(秒)
  timeout: 30         # 请求超时
  retry: 3            # 失败重试次数
  
# 存储设置
storage:
  type: "sqlite"      # sqlite / mysql / postgresql
  path: "data.db"     # SQLite 路径
  # 或使用数据库连接串
  # url: "mysql://user:pass@localhost/dbname"

# 日志设置
logging:
  level: "INFO"       # DEBUG / INFO / WARNING / ERROR
  file: "logs/spider.log"

使用配置:

bash 复制代码
myspider crawl --config config.yaml

环境变量(敏感信息)

不要在代码中硬编码密码!使用 .env 文件:

bash 复制代码
# .env
DB_HOST=localhost
DB_USER=admin
DB_PASSWORD=your_secret_password
PROXY_URL=http://proxy.example.com:8080

在代码中读取:

python 复制代码
from dotenv import load_dotenv
import os

load_dotenv()
db_password = os.getenv('DB_PASSWORD')

记得把 .env 加入 .gitignore

🐳 Docker 部署

构建镜像

bash 复制代码
docker build -t myspider:1.0.0 .

运行容器

bash 复制代码
# 一次性采集
docker run --rm \
  -v $(pwd)/output:/app/output \
  -v $(pwd)/config.yaml:/app/config.yaml \
  myspider:1.0.0 crawl --config config.yaml

# 定时任务(结合 cron)
docker run -d \
  --name myspider-cron \
  -v $(pwd)/output:/app/output \
  myspider:1.0.0 \
  sh -c "echo '0 2 * * * myspider crawl --pages 1-50' | crontab && crond -f"

Docker Compose(完整配置)

json 复制代码
# docker-compose.yml

version: '3.8'

services:
spider:
build: .
volumes:
- ./output:/app/output
- ./config.yaml:/app/config.yaml
environment:
- DB_HOST=db
- DB_PASSWORD=${DB_PASSWORD}
depends_on:
- db
command: crawl --config config.yaml

db:
image: mysql:8.0
environment:
MYSQL_ROOT_PASSWORD: ${DB_PASSWORD}
MYSQL_DATABASE: spider
volumes:
- db_data:/var/lib/mysql

volumes:
db_data:

启动:

bash 复制代码
docker-compose up -d

❓ 常见问题

1. 安装依赖时报错 lxml 编译失败

原因:缺少 C 编译器或 libxml2 开发包。

解决方案

bash 复制代码
# Ubuntu/Debian
sudo apt-get install python3-dev libxml2-dev libxslt-dev

# macOS
brew install libxml2

# 或使用预编译包
pip install lxml --only-binary lxml

2. 采集时出现 SSLErrorConnectionError

可能原因

  • 目标网站 SSL 证书问题
  • 网络不稳定或被墙
  • 需要使用代理

解决方案

python 复制代码
# 方法1:跳过 SSL 验证(仅测试用)
spider = NewsSpider(verify_ssl=False)

# 方法2:使用代理
spider = NewsSpider(proxy="http://proxy.com:8080")

# 方法3:增加重试次数和超时
myspider crawl --config config.yaml  # 在配置文件中设置

3. 数据库连接失败

检查清单

  • 数据库服务是否启动
  • 用户名密码是否正确
  • 防火墙是否开放端口
  • Docker 容器间网络是否互通
bash 复制代码
# 测试数据库连接
mysql -h localhost -u admin -p

# Docker 内测试
docker exec -it myspider-db mysql -u root -p

4. 内存占用过高

原因:一次性加载过多数据到内存。

解决方案

  • 减少 --concurrency 并发数
  • 分批采集(如每次 100 页)
  • 使用流式写入(代码中已实现)

5. 如何调试选择器失效?

bash 复制代码
# 开启调试模式
myspider crawl -p 1 --debug

# 会在 debug/ 目录生成:
# - 原始 HTML
# - 截图(如果使用 Playwright)
# - 解析失败的字段日志

🤝 贡献指南

欢迎提交 Issue 和 Pull Request!

开发流程

bash 复制代码
# 1. Fork 并克隆仓库
git clone https://github.com/your-fork/xxx.git

# 2. 创建功能分支
git checkout -b feature/your-feature

# 3. 安装开发依赖
pip install -r requirements-dev.txt

# 4. 运行测试
pytest tests/ -v

# 5. 提交代码
git commit -m "feat: 你的功能描述"
git push origin feature/your-feature

代码规范

  • 遵循 PEP 8
  • 使用类型注解
  • 添加必要的注释和文档字符串
  • 新功能需附带测试用例

第三部分:Docker 封装(一键部署)

为什么需要 Docker?

假设你的项目要交给运维同事部署到生产服务器,他们可能会遇到:

  • Python 版本不一致(你用 3.11,服务器是 3.8)
  • 系统依赖缺失(如 lxml 需要的 libxml2)
  • 环境污染(服务器上其他项目的依赖冲突)

用 Docker 后

bash 复制代码
# 一行命令,任何机器都能跑
docker run myspider:1.0.0 crawl --pages 1-10

不用管 Python 版本、不用装依赖、不用担心环境问题。这就是容器化的价值。

Dockerfile 编写(分层优化)

Dockerfile(完整版,带注释):

dockerfile 复制代码
# ============================================
# 第一阶段:构建阶段(builder)
# 用途:编译依赖、安装包,减少最终镜像体积
# ============================================
FROM python:3.11-slim as builder

# 设置工作目录
WORKDIR /build

# 安装编译依赖(仅构建阶段需要)
RUN apt-get update && apt-get install -y --no-install-recommends \
    gcc \
    g++ \
    libxml2-dev \
    libxslt-dev \
    && rm -rf /var/lib/apt/lists/*

# 复制依赖文件(利用 Docker 缓存机制)
COPY requirements.txt .

# 安装 Python 依赖到 /install 目录
# --prefix 指定安装路径,方便后续复制
RUN pip install --no-cache-dir --upgrade pip && \
    pip install --no-cache-dir --prefix=/install -r requirements.txt


# ============================================
# 第二阶段:运行阶段(最终镜像)
# 只包含运行时需要的文件,体积更小
# ============================================
FROM python:3.11-slim

# 元数据(镜像信息)
LABEL maintainer="your.email@example.com" \
      version="1.0.0" \
      description="MySpider - Python 爬虫工程化实战"

# 创建非 root 用户(安全最佳实践)
RUN useradd -m -u 1000 spider && \
    mkdir -p /app /app/output /app/logs && \
    chown -R spider:spider /app

# 设置工作目录
WORKDIR /app

# 安装运行时依赖(仅需 libxml2 运行库,不需要开发包)
RUN apt-get update && apt-get install -y --no-install-recommends \
    libxml2 \
    libxslt1.1 \
    && rm -rf /var/lib/apt/lists/*

# 从构建阶段复制已安装的 Python 包
COPY --from=builder /install /usr/local

# 复制项目代码(利用 .dockerignore 排除不必要文件)
COPY --chown=spider:spider . .

# 安装项目(开发模式,方便调试)
RUN pip install --no-cache-dir -e .

# 切换到非 root 用户
USER spider

# 健康检查(可选,用于容器编排)
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
    CMD myspider version || exit 1

# 声明数据卷(输出目录、日志目录)
VOLUME ["/app/output", "/app/logs"]

# 暴露端口(如果有 Web 界面)
# EXPOSE 8000

# 默认命令(可被覆盖)
ENTRYPOINT ["myspider"]
CMD ["--help"]

代码详解

  1. 多阶段构建

    • 第一阶段安装编译工具和依赖
    • 第二阶段只复制编译好的包,不含编译工具
    • 减少镜像体积约 40%(从 800MB → 500MB)
  2. 缓存优化

    • 先复制 requirements.txt 再复制代码
    • 代码修改不会触发依赖重新安装
  3. 安全实践

    • 创建非 root 用户运行(防止容器逃逸)
    • 清理 apt 缓存减少攻击面
  4. 数据持久化

    • VOLUME 声明挂载点,避免数据随容器删除丢失

.dockerignore(减少构建上下文)

.dockerignore

json 复制代码
# Python 缓存
__pycache__/
*.py[cod]
*$py.class
*.so
.Python

# 虚拟环境
venv/
env/
ENV/

# 测试和覆盖率
.pytest_cache/
.coverage
htmlcov/

# IDE
.vscode/
.idea/
*.swp

# Git
.git/
.gitignore

# 输出目录(避免复制到镜像)
output/
logs/
*.log

# Docker 相关
Dockerfile
docker-compose.yml
.dockerignore

# 文档
README.md
docs/

# CI/CD
.github/
.gitlab-ci.yml

构建和运行

bash 复制代码
# 构建镜像(带版本标签)
docker build -t myspider:1.0.0 -t myspider:latest .

# 查看镜像大小
docker images myspider

# 运行示例
docker run --rm \
  -v $(pwd)/output:/app/output \
  myspider:1.0.0 crawl --pages 1-5

# 进入容器调试
docker run -it --rm myspider:1.0.0 /bin/bash

# 查看日志
docker run --rm \
  -v $(pwd)/logs:/app/logs \
  myspider:1.0.0 crawl --pages 1-10 --debug

Docker Compose(完整编排)

docker-compose.yml(生产级配置):

yaml 复制代码
version: '3.8'

services:
  # 爬虫服务
  spider:
    build:
      context: .
      dockerfile: Dockerfile
      args:
        - BUILD_DATE=${BUILD_DATE:-2025-01-26}
        - VERSION=${VERSION:-1.0.0}
    image: myspider:${VERSION:-latest}
    container_name: myspider-crawler
    restart: unless-stopped
    
    # 环境变量(从 .env 文件读取)
    environment:
      - DB_HOST=db
      - DB_PORT=3306
      - DB_USER=${DB_USER:-spider}
      - DB_PASSWORD=${DB_PASSWORD}
      - DB_NAME=${DB_NAME:-spider_data}
      - LOG_LEVEL=${LOG_LEVEL:-INFO}
      - TZ=Asia/Shanghai  # 时区
    
    # 挂载卷
    volumes:
      - ./output:/app/output           # 输出目录
      - ./logs:/app/logs               # 日志目录
      - ./config.yaml:/app/config.yaml # 配置文件(只读)
      - spider_cache:/app/.cache       # 缓存目录
    
    # 依赖服务
    depends_on:
      db:
        condition: service_healthy
    
    # 网络
    networks:
      - spider_network
    
    # 资源限制(防止单个容器占满资源)
    deploy:
      resources:
        limits:
          cpus: '2.0'
          memory: 2G
        reservations:
          cpus: '0.5'
          memory: 512M
    
    # 默认命令(可用 docker-compose run 覆盖)
    command: crawl --config config.yaml --pages 1-100

  # 数据库服务(MySQL)
  db:
    image: mysql:8.0
    container_name: myspider-db
    restart: unless-stopped
    
    environment:
      - MYSQL_ROOT_PASSWORD=${DB_ROOT_PASSWORD}
      - MYSQL_DATABASE=${DB_NAME:-spider_data}
      - MYSQL_USER=${DB_USER:-spider}
      - MYSQL_PASSWORD=${DB_PASSWORD}
      - TZ=Asia/Shanghai
    
    volumes:
      - db_data:/var/lib/mysql         # 数据持久化
      - ./init.sql:/docker-entrypoint-initdb.d/init.sql  # 初始化脚本
    
    ports:
      - "3306:3306"  # 暴露端口(生产环境建议删除)
    
    networks:
      - spider_network
    
    # 健康检查
    healthcheck:
      test: ["CMD", "mysqladmin", "ping", "-h", "localhost"]
      interval: 10s
      timeout: 5s
      retries: 5
      start_period: 30s

  # (可选)定时任务调度器
  scheduler:
    image: myspider:${VERSION:-latest}
    container_name: myspider-scheduler
    restart: unless-stopped
    
    environment:
      - DB_HOST=db
      - DB_PASSWORD=${DB_PASSWORD}
    
    volumes:
      - ./output:/app/output
      - ./logs:/app/logs
      - ./crontab:/etc/cron.d/spider-cron  # 自定义 cron 任务
    
    depends_on:
      - db
    
    networks:
      - spider_network
    
    # 运行 cron 守护进程
    command: >
      sh -c "
        echo '0 2 * * * myspider crawl --pages 1-50 >> /app/logs/cron.log 2>&1' > /etc/cron.d/spider-cron &&
        chmod 0644 /etc/cron.d/spider-cron &&
        crontab /etc/cron.d/spider-cron &&
        cron -f
      "

# 网络定义
networks:
  spider_network:
    driver: bridge

# 卷定义
volumes:
  db_data:
    driver: local
  spider_cache:
    driver: local

配套的 .env 文件

bash 复制代码
# .env(不要提交到 Git!)
VERSION=1.0.0
BUILD_DATE=2025-01-26

# 数据库配置
DB_USER=spider
DB_PASSWORD=your_strong_password_here
DB_ROOT_PASSWORD=root_password_here
DB_NAME=spider_data

# 应用配置
LOG_LEVEL=INFO

使用方式

bash 复制代码
# 启动所有服务
docker-compose up -d

# 查看日志
docker-compose logs -f spider

# 运行一次性任务
docker-compose run --rm spider crawl --pages 1-10

# 停止服务
docker-compose down

# 完全清理(包括数据卷)
docker-compose down -v

第四部分:版本管理与发布

语义化版本号

遵循 SemVer 规范:主版本.次版本.修订号

  • 主版本:不兼容的 API 变更(如 1.x → 2.0)
  • 次版本:向下兼容的功能新增(如 1.2 → 1.3)
  • 修订号:向下兼容的 bug 修复(如 1.2.3 → 1.2.4)

myspider/init.py

python 复制代码
"""
MySpider - Python 爬虫工程化实战

生产级爬虫框架,专注稳定性与可维护性。
"""

__version__ = "1.0.0"
__author__ = "Your Name"
__email__ = "your.email@example.com"
__license__ = "MIT"

# 导出核心类(方便外部使用)
from .spider import NewsSpider
from .parsers import NewsParser
from .pipelines import SQLitePipeline

__all__ = [
    "NewsSpider",
    "NewsParser",
    "SQLitePipeline",
]

CHANGELOG(变更日志)

CHANGELOG.md

markdown 复制代码
# Changelog

本项目的所有重要变更都会记录在此文件。

格式基于 [Keep a Changelog](https://keepachangelog.com/zh-CN/1.0.0/),
版本号遵循 [语义化版本](https://semver.org/lang/zh-CN/)。

## [Unreleased]

### 计划中
- [ ] 支持代理池自动切换
- [ ] Web 可视化界面
- [ ] 分布式采集(Celery)

 

## [1.0.0] - 2025-01-26

### 新增
- 🎉 首个稳定版本发布
- ✨ CLI 命令行工具(crawl/clean/export)
- 📦 Docker 镜像和 Docker Compose 配置
- 🧪 完整测试套件(覆盖率 85%+)
- 📖 详细的 README 和使用文档

### 功能
- 两段式采集(列表 + 详情)
- 自动重试和指数退避
- 数据清洗工具(日期/金额/文本)
- 多格式导出(JSONL/CSV/Excel)
- SQLite/MySQL 入库支持
- 增量更新和断点续爬

### 已知问题
- MySQL 批量写入性能待优化(单次 < 1000 条建议用 SQLite)
- Playwright 截图在 Docker 中需额外配置

 

## [0.2.0] - 2025-01-15

### 新增
- Playwright 动态页面支持
- 配置文件驱动(YAML)

### 修复
- 修复日期解析时区问题
- 修复 CSV 导出中文乱码

 

## [0.1.0] - 2025-01-01

### 新增
- 项目初始版本
- 基础 Requests 采集
- BeautifulSoup 解析

 

[Unreleased]: https://github.com/yourusername/myspider/compare/v1.0.0...HEAD
[1.0.0]: https://github.com/yourusername/myspider/releases/tag/v1.0.0
[0.2.0]: https://github.com/yourusername/myspider/releases/tag/v0.2.0
[0.1.0]: https://github.com/yourusername/myspider/releases/tag/v0.1.0

发布流程(GitHub Release)

bash 复制代码
# 1. 确保测试通过
pytest tests/ -v

# 2. 更新版本号
# 修改 myspider/__init__.py 中的 __version__

# 3. 更新 CHANGELOG
# 在 CHANGELOG.md 中添加本次变更

# 4. 提交版本变更
git add .
git commit -m "chore: bump version to 1.0.0"

# 5. 打 tag
git tag -a v1.0.0 -m "Release version 1.0.0"

# 6. 推送到远程
git push origin main --tags

# 7. 构建并推送 Docker 镜像
docker build -t yourusername/myspider:1.0.0 .
docker tag yourusername/myspider:1.0.0 yourusername/myspider:latest
docker push yourusername/myspider:1.0.0
docker push yourusername/myspider:latest

在 GitHub 上创建 Release:

  1. 进入仓库的 "Releases" 页面
  2. 点击 "Draft a new release"
  3. 选择 tag v1.0.0
  4. 填写标题和描述(从 CHANGELOG 复制)
  5. 附加构建产物(可选)
  6. 发布

第五部分:CI/CD 自动化(进阶)

GitHub Actions 配置

.github/workflows/ci.yml(完整 CI 流程):

yaml 复制代码
name: CI/CD Pipeline

on:
  push:
    branches: [ main, develop ]
    tags: [ 'v*' ]
  pull_request:
    branches: [ main ]

jobs:
  # ==================== 测试任务 ====================
  test:
    name: 测试 (Python ${{ matrix.python-version }})
    runs-on: ubuntu-latest
    
    strategy:
      matrix:
        python-version: ['3.8', '3.9', '3.10', '3.11']
    
    steps:
      - name: 检出代码
        uses: actions/checkout@v3
      
      - name: 设置 Python ${{ matrix.python-version }}
        uses: actions/setup-python@v4
        with:
          python-version: ${{ matrix.python-version }}
          cache: 'pip'
      
      - name: 安装依赖
        run: |
          python -m pip install --upgrade pip
          pip install -r requirements.txt
          pip install pytest pytest-cov
      
      - name: 运行测试
        run: |
          pytest tests/ -v --cov=myspider --cov-report=xml
      
      - name: 上传覆盖率报告
        if: matrix.python-version == '3.11'
        uses: codecov/codecov-action@v3
        with:
          files: ./coverage.xml
          flags: unittests
          name: codecov-umbrella

  # ==================== 代码质量检查 ====================
  lint:
    name: 代码质量检查
    runs-on: ubuntu-latest
    
    steps:
      - uses: actions/checkout@v3
      
      - name: 设置 Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'
      
      - name: 安装检查工具
        run: |
          pip install flake8 black isort mypy
      
      - name: 检查代码格式(Black)
        run: black --check myspider/ tests/
      
      - name: 检查导入顺序(isort)
        run: isort --check-only myspider/ tests/
      
      - name: 静态检查(Flake8)
        run: flake8 myspider/ tests/ --max-line-length=100
      
      - name: 类型检查(MyPy)
        run: mypy myspider/ --ignore-missing-imports

  # ==================== Docker 镜像构建 ====================
  build:
    name: 构建 Docker 镜像
    runs-on: ubuntu-latest
    needs: [test, lint]
    if: github.event_name == 'push'
    
    steps:
      - uses: actions/checkout@v3
      
      - name: 设置 Docker Buildx
        uses: docker/setup-buildx-action@v2
      
      - name: 登录 Docker Hub
        uses: docker/login-action@v2
        with:
          username: ${{ secrets.DOCKERHUB_USERNAME }}
          password: ${{ secrets.DOCKERHUB_TOKEN }}
      
      - name: 提取元数据
        id: meta
        uses: docker/metadata-action@v4
        with:
          images: yourusername/myspider
          tags: |
            type=ref,event=branch
            type=semver,pattern={{version}}
            type=semver,pattern={{major}}.{{minor}}
            type=sha
      
      - name: 构建并推送
        uses: docker/build-push-action@v4
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}
          cache-from: type=registry,ref=yourusername/myspider:buildcache
          cache-to: type=registry,ref=yourusername/myspider:buildcache,mode=max

  # ==================== 发布到 PyPI ====================
  publish:
    name: 发布到 PyPI
    runs-on: ubuntu-latest
    needs: [test, lint, build]
    if: startsWith(github.ref, 'refs/tags/v')
    
    steps:
      - uses: actions/checkout@v3
      
      - name: 设置 Python
        uses: actions/setup-python@v4
        with:
          python-version: '3.11'
      
      - name: 安装构建工具
        run: |
          pip install build twine
      
      - name: 构建分发包
        run: python -m build
      
      - name: 检查分发包
        run: twine check dist/*
      
      - name: 发布到 PyPI
        env:
          TWINE_USERNAME: __token__
          TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
        run: twine upload dist/*

配置 Secrets(在 GitHub 仓库设置中):

  • DOCKERHUB_USERNAME:Docker Hub 用户名
  • DOCKERHUB_TOKEN:Docker Hub 访问令牌
  • PYPI_API_TOKEN:PyPI 发布令牌

实战技巧与最佳实践

1. 项目结构规范

json 复制代码
myspider/
├── myspider/              # 主包(可导入)
│   ├── __init__.py        # 版本信息和导出
│   ├── cli.py             # CLI 入口
│   ├── spider.py          # 核心爬虫逻辑
│   ├── parsers/           # 解析器
│   ├── cleaners/          # 清洗工具
│   ├── pipelines/         # 数据管道
│   └── utils/             # 工具函数
├── tests/                 # 测试目录
│   ├── fixtures/          # 测试数据
│   └── test_*.py
├── docs/                  # 文档(可选)
├── output/                # 输出目录(不提交)
├── logs/                  # 日志目录(不提交)
├── .github/               # GitHub Actions
├── Dockerfile
├── docker-compose.yml
├── setup.py
├── requirements.txt
├── requirements-dev.txt   # 开发依赖
├── .env.example           # 环境变量示例
├── .gitignore
├── .dockerignore
├── README.md
├── CHANGELOG.md
└── LICENSE

2. requirements.txt 最佳实践

requirements.txt(生产依赖):

json 复制代码
# 网络请求
requests>=2.31.0,<3.0.0
urllib3>=2.0.0,<3.0.0

# 解析
parsel>=1.8.1
lxml>=4.9.0

# 数据处理
python-dateutil>=2.8.2

# 数据库
sqlalchemy>=2.0.0  # ORM(可选)

# CLI
typer[all]>=0.9.0
rich>=13.0.0

# 配置
pyyaml>=6.0
python-dotenv>=1.0.0

# 日志
loguru>=0.7.0

requirements-dev.txt(开发依赖):

json 复制代码
-r requirements.txt

# 测试
pytest>=7.4.0
pytest-cov>=4.1.0
pytest-mock>=3.11.0

# 代码质量
black>=23.0.0
flake8>=6.0.0
isort>=5.12.0
mypy>=1.5.0

# 文档
mkdocs>=1.5.0
mkdocs-material>=9.0.0

3. 配置管理

config.py(统一配置加载):

python 复制代码
"""
配置管理模块
支持从 YAML 文件和环境变量读取配置
"""
from pathlib import Path
from typing import Dict, Any, Optional
import yaml
from dotenv import load_dotenv
import os

# 加载 .env 文件
load_dotenv()

class Config:
    """配置类(单例模式)"""
    
    _instance = None
    _config: Dict[str, Any] = {}
    
    def __new__(cls):
        if cls._instance is None:
            cls._instance = super().__new__(cls)
        return cls._instance
    
    @classmethod
    def load(cls, config_file: Optional[Path] = None) -> Dict[str, Any]:
        """加载配置文件"""
        if config_file and config_file.exists():
            with open(config_file, encoding='utf-8') as f:
                file_config = yaml.safe_load(f) or {}
        else:
            file_config = {}
        
        # 合并配置:环境变量 > 配置文件 > 默认值
        cls._config = {
            # 数据库配置
            'db': {
                'host': os.getenv('DB_HOST', file_config.get('db', {}).get('host', 'localhost')),
                'port': int(os.getenv('DB_PORT', file_config.get('db', {}).get('port', 3306))),
                'user': os.getenv('DB_USER', file_config.get('db', {}).get('user', 'root')),
                'password': os.getenv('DB_PASSWORD', file_config.get('db', {}).get('password', '')),
                'database': os.getenv('DB_NAME', file_config.get('db', {}).get('database', 'spider')),
            },
            
            # 爬虫配置
            'spider': {
                'delay': float(os.getenv('SPIDER_DELAY', file_config.get('spider', {}).get('delay', 1.0))),
                'timeout': int(os.getenv('SPIDER_TIMEOUT', file_config.get('spider', {}).get('timeout', 30))),
                'retry': int(os.getenv('SPIDER_RETRY', file_config.get('spider', {}).get('retry', 3))),
                'concurrency': int(os.getenv('SPIDER_CONCURRENCY', file_config.get('spider', {}).get('concurrency', 3))),
            },
            
            # 日志配置
            'logging': {
                'level': os.getenv('LOG_LEVEL', file_config.get('logging', {}).get('level', 'INFO')),
                'file': Path(os.getenv('LOG_FILE', file_config.get('logging', {}).get('file', 'logs/spider.log'))),
            },
        }
        
        return cls._config
    
    @classmethod
    def get(cls, key: str, default: Any = None) -> Any:
        """获取配置项(支持点号分隔的嵌套键)"""
        keys = key.split('.')
        value = cls._config
        
        for k in keys:
            if isinstance(value, dict):
                value = value.get(k)
            else:
                return default
        
        return value if value is not None else default


# 便捷函数
def load_config(config_file: Optional[Path] = None) -> Dict[str, Any]:
    """加载配置"""
    return Config.load(config_file)


def get_config(key: str, default: Any = None) -> Any:
    """获取配置项"""
    return Config.get(key, default)

4. 日志配置(Loguru)

utils/logger.py

python 复制代码
"""
日志配置模块
使用 Loguru 提供结构化日志
"""
from loguru import logger
from pathlib import Path
import sys

def setup_logger(
    log_file: Path = Path("logs/spider.log"),
    level: str = "INFO",
    rotation: str = "100 MB",
    retention: str = "30 days",
    debug: bool = False
):
    """
    配置日志器
    
    Args:
        log_file: 日志文件路径
        level: 日志级别
        rotation: 日志轮转大小
        retention: 日志保留时间
        debug: 是否开启调试模式
    """
    # 移除默认 handler
    logger.remove()
    
    # 控制台输出(带颜色)
    logger.add(
        sys.stderr,
        format="<green>{time:YYYY-MM-DD HH:mm:ss}</green> | <level>{level: <8}</level> | <cyan>{name}</cyan>:<cyan>{function}</cyan>:<cyan>{line}</cyan> - <level>{message}</level>",
        level="DEBUG" if debug else level,
        colorize=True,
    )
    
    # 文件输出(JSON 格式,便于分析)
    log_file.parent.mkdir(parents=True, exist_ok=True)
    logger.add(
        log_file,
        format="{time:YYYY-MM-DD HH:mm:ss} | {level: <8} | {name}:{function}:{line} - {message}",
        level=level,
        rotation=rotation,
        retention=retention,
        compression="zip",  # 压缩旧日志
        serialize=True,     # JSON 格式
        enqueue=True,       # 异步写入
    )
    
    return logger

完整代码目录树

最终项目结构:

json 复制代码
myspider/
├── myspider/
│   ├── __init__.py                # 版本和导出
│   ├── cli.py                     # CLI 入口(450+ 行)
│   ├── config.py                  # 配置管理(120+ 行)
│   ├── spider.py                  # 核心爬虫(300+ 行)
│   ├── parsers/
│   │   ├── __init__.py
│   │   └── news_parser.py         # 解析器(100+ 行)
│   ├── cleaners/
│   │   ├── __init__.py
│   │   └── text_cleaner.py        # 清洗工具(150+ 行)
│   ├── pipelines/
│   │   ├── __init__.py
│   │   ├── sqlite_pipeline.py     # SQLite 管道(200+ 行)
│   │   └── mysql_pipeline.py      # MySQL 管道(可选)
│   ├── exporters/
│   │   ├── __init__.py
│   │   └── exporter.py            # 导出器(100+ 行)
│   └── utils/
│       ├── __init__.py
│       ├── logger.py              # 日志配置(80+ 行)
│       └── http_client.py         # HTTP 客户端(150+ 行)
│
├── tests/
│   ├── fixtures/
│   │   └── sample.html
│   ├── test_parser.py
│   ├── test_cleaner.py
│   ├── test_pipeline.py
│   └── conftest.py                # pytest 配置
│
├── .github/
│   └── workflows/
│       └── ci.yml                 # GitHub Actions
│
├── docs/                          # 文档(可选)
│   ├── index.md
│   └── api.md
│
├── output/                        # 输出目录(不提交)
├── logs/                          # 日志目录(不提交)
│
├── Dockerfile
├── docker-compose.yml
├── .dockerignore
├── .gitignore
├── .env.example
│
├── setup.py
├── requirements.txt
├── requirements-dev.txt
│
├── README.md
├── CHANGELOG.md
├── LICENSE
├── config.yaml.example            # 配置示例
└── pytest.ini

核心代码实现(完整版)

spider.py(核心爬虫逻辑)

python 复制代码
"""
核心爬虫模块
实现两段式采集:列表页 → 详情页
"""
from pathlib import Path
from typing import List, Dict, Optional, Any
from datetime import datetime
import time

from loguru import logger
from rich.progress import Progress, TaskID

from .utils.http_client import HttpClient
from .parsers.news_parser import NewsParser
from .cleaners.text_cleaner import TextCleaner
from .pipelines.sqlite_pipeline import SQLitePipeline


class NewsSpider:
    """新闻爬虫主类"""
    
    def __init__(
        self,
        output_path: Path = Path("output/news.jsonl"),
        concurrency: int = 3,
        debug: bool = False,
        **kwargs
    ):
        """
        初始化爬虫
        
        Args:
            output_path: 输出文件路径
            concurrency: 并发数
            debug: 调试模式
            **kwargs: 其他配置参数
        """
        self.output_path = Path(output_path)
        self.output_path.parent.mkdir(parents=True, exist_ok=True)
        
        self.concurrency = concurrency
        self.debug = debug
        
        # 初始化组件
        self.http_client = HttpClient(
            timeout=kwargs.get('timeout', 30),
            retry=kwargs.get('retry', 3),
            delay=kwargs.get('delay', 1.0),
        )
        self.parser = NewsParser()
        self.cleaner = TextCleaner()
        self.pipeline = SQLitePipeline(
            db_path=kwargs.get('db_path', 'data.db')
        )
        
        # 统计信息
        self.stats = {
            'success': 0,
            'failed': 0,
            'start_time': None,
            'end_time': None,
        }
        
        logger.info(f"爬虫初始化完成 | 输出={output_path} | 并发={concurrency}")
    
    def run(self, start_page: int = 1, end_page: int = 10) -> Dict[str, Any]:
        """
        执行采集任务
        
        Args:
            start_page: 起始页码
            end_page: 结束页码
        
        Returns:
            统计信息字典
        """
        self.stats['start_time'] = datetime.now()
        logger.info(f"开始采集 | 页码范围={start_page}-{end_page}")
        
        # 打开数据库连接
        self.pipeline.open()
        
        try:
            # 阶段1:采集列表页,获取详情链接
            detail_urls = self._fetch_list_pages(start_page, end_page)
            logger.info(f"列表页采集完成 | 获得详情链接={len(detail_urls)} 条")
            
            # 阶段2:采集详情页
            self._fetch_detail_pages(detail_urls)
            
        except Exception as e:
            logger.error(f"采集过程发生错误: {e}", exc_info=True)
        finally:
            # 关闭连接
            self.pipeline.close()
            
            # 计算耗时
            self.stats['end_time'] = datetime.now()
            duration = (self.stats['end_time'] - self.stats['start_time']).total_seconds()
            self.stats['duration'] = duration
            
            logger.info(
                f"采集完成 | 成功={self.stats['success']} | "
                f"失败={self.stats['failed']} | 耗时={duration:.2f}秒"
            )
        
        return self.stats
    
    def _fetch_list_pages(self, start_page: int, end_page: int) -> List[str]:
        """
        采集列表页,提取详情链接
        
        Args:
            start_page: 起始页码
            end_page: 结束页码
        
        Returns:
            详情页 URL 列表
        """
        detail_urls = []
        
        for page_num in range(start_page, end_page + 1):
            try:
                # 构造列表页 URL(根据实际站点调整)
                list_url = f"https://example.com/news/page/{page_num}"
                
                logger.debug(f"请求列表页 | page={page_num} | url={list_url}")
                
                # 发起请求
                html = self.http_client.get(list_url)
                if not html:
                    logger.warning(f"列表页返回为空 | page={page_num}")
                    continue
                
                # 解析列表页(提取详情链接)
                urls = self.parser.parse_list(html)
                detail_urls.extend(urls)
                
                logger.debug(f"列表页解析完成 | page={page_num} | 链接数={len(urls)}")
                
            except Exception as e:
                logger.error(f"列表页采集失败 | page={page_num} | 错误={e}")
                self.stats['failed'] += 1
        
        # 去重
        detail_urls = list(set(detail_urls))
        return detail_urls
    
    def _fetch_detail_pages(self, urls: List[str]):
        """
        采集详情页
        
        Args:
            urls: 详情页 URL 列表
        """
        total = len(urls)
        
        for idx, url in enumerate(urls, start=1):
            try:
                logger.debug(f"请求详情页 | [{idx}/{total}] | url={url}")
                
                # 发起请求
                html = self.http_client.get(url)
                if not html:
                    logger.warning(f"详情页返回为空 | url={url}")
                    continue
                
                # 解析详情页
                item = self.parser.parse_detail(html)
                if not item:
                    logger.warning(f"详情页解析失败 | url={url}")
                    continue
                
                # 添加元信息
                item['url'] = url
                item['crawl_time'] = datetime.now().isoformat()
                
                # 数据清洗
                item = self._clean_item(item)
                
                # 保存到数据库
                if self.pipeline.save(item):
                    self.stats['success'] += 1
                    logger.info(
                        f"详情页采集成功 | [{idx}/{total}] | "
                        f"标题={item.get('title', 'N/A')[:30]}"
                    )
                else:
                    self.stats['failed'] += 1
                
                # 控制速率
                time.sleep(1.0 / self.concurrency)
                
            except Exception as e:
                logger.error(f"详情页采集失败 | url={url} | 错误={e}")
                self.stats['failed'] += 1
                
                # 调试模式下保存错误信息
                if self.debug:
                    self._save_error_bundle(url, html, e)
    
    def _clean_item(self, item: Dict[str, Any]) -> Dict[str, Any]:
        """
        清洗单条数据
        
        Args:
            item: 原始数据字典
        
        Returns:
            清洗后的数据字典
        """
        # 清洗文本字段
        if 'title' in item:
            item['title'] = self.cleaner.clean_whitespace(item['title'])
        
        if 'content' in item and isinstance(item['content'], list):
            item['content'] = [
                self.cleaner.clean_whitespace(p) for p in item['content']
            ]
        
        # 标准化日期
        if 'publish_time' in item:
            try:
                item['publish_time'] = self.cleaner.parse_date(item['publish_time'])
            except ValueError as e:
                logger.warning(f"日期解析失败 | 原始值={item['publish_time']} | 错误={e}")
                item['publish_time'] = None
        
        return item
    
    def _save_error_bundle(self, url: str, html: Optional[str], error: Exception):
        """
        保存错误调试包
        
        Args:
            url: 请求的 URL
            html: 响应的 HTML
            error: 异常对象
        """
        error_dir = Path("debug/errors")
        error_dir.mkdir(parents=True, exist_ok=True)
        
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        error_file = error_dir / f"{timestamp}_error.txt"
        
        with open(error_file, 'w', encoding='utf-8') as f:
            f.write(f"URL: {url}\n")
            f.write(f"Time: {datetime.now().isoformat()}\n")
            f.write(f"Error: {error}\n")
            f.write(f"\n{'='*80}\n")
            f.write(f"HTML:\n{html[:5000] if html else 'N/A'}\n")
        
        logger.debug(f"错误调试包已保存 | 文件={error_file}")

exporters/exporter.py(导出器)

python 复制代码
"""
数据导出模块
支持 CSV/JSON/Excel 格式
"""
import json
import csv
from pathlib import Path
from typing import List, Dict, Any

from loguru import logger


class DataExporter:
    """数据导出器"""
    
    @staticmethod
    def export_to_csv(data: List[Dict[str, Any]], output_file: Path):
        """
        导出为 CSV
        
        Args:
            data: 数据列表
            output_file: 输出文件路径
        """
        if not data:
            logger.warning("数据为空,跳过导出")
            return
        
        # 提取所有字段(合并所有记录的键)
        fieldnames = set()
        for item in data:
            fieldnames.update(item.keys())
        fieldnames = sorted(fieldnames)
        
        # 写入 CSV
        with open(output_file, 'w', newline='', encoding='utf-8-sig') as f:
            writer = csv.DictWriter(f, fieldnames=fieldnames)
            writer.writeheader()
            writer.writerows(data)
        
        logger.info(f"CSV 导出完成 | 文件={output_file} | 记录数={len(data)}")
    
    @staticmethod
    def export_to_json(data: List[Dict[str, Any]], output_file: Path):
        """
        导出为 JSON(格式化)
        
        Args:
            data: 数据列表
            output_file: 输出文件路径
        """
        with open(output_file, 'w', encoding='utf-8') as f:
            json.dump(data, f, ensure_ascii=False, indent=2)
        
        logger.info(f"JSON 导出完成 | 文件={output_file} | 记录数={len(data)}")
    
    @staticmethod
    def export_to_excel(data: List[Dict[str, Any]], output_file: Path):
        """
        导出为 Excel
        
        Args:
            data: 数据列表
            output_file: 输出文件路径
        """
        try:
            import openpyxl
            from openpyxl import Workbook
        except ImportError:
            logger.error("Excel 导出需要安装 openpyxl: pip install openpyxl")
            return
        
        if not data:
            logger.warning("数据为空,跳过导出")
            return
        
        wb = Workbook()
        ws = wb.active
        ws.title = "News Data"
        
        # 写入表头
        fieldnames = sorted(set().union(*[item.keys() for item in data]))
        ws.append(fieldnames)
        
        # 写入数据
        for item in data:
            row = [item.get(field, '') for field in fieldnames]
            ws.append(row)
        
        wb.save(output_file)
        logger.info(f"Excel 导出完成 | 文件={output_file} | 记录数={len(data)}")


def export_data(input_file: Path, output_file: Path, format: str) -> int:
    """
    导出数据到指定格式
    
    Args:
        input_file: 输入文件(JSONL)
        output_file: 输出文件
        format: 导出格式(csv/json/excel)
    
    Returns:
        导出的记录数
    """
    # 读取 JSONL 数据
    data = []
    with open(input_file, 'r', encoding='utf-8') as f:
        for line in f:
            if line.strip():
                data.append(json.loads(line))
    
    # 根据格式导出
    exporter = DataExporter()
    
    if format == 'csv':
        exporter.export_to_csv(data, output_file)
    elif format == 'json':
        exporter.export_to_json(data, output_file)
    elif format == 'excel':
        exporter.export_to_excel(data, output_file)
    else:
        raise ValueError(f"不支持的格式: {format}")
    
    return len(data)

测试用例补充

conftest.py(pytest 全局配置)

python 复制代码
"""
pytest 配置文件
定义全局 fixtures 和钩子函数
"""
import pytest
from pathlib import Path
import tempfile
import shutil


@pytest.fixture(scope="session")
def test_data_dir():
    """测试数据目录"""
    return Path(__file__).parent / "fixtures"


@pytest.fixture
def temp_dir():
    """临时目录(测试后自动清理)"""
    temp_path = Path(tempfile.mkdtemp())
    yield temp_path
    shutil.rmtree(temp_path, ignore_errors=True)


@pytest.fixture
def mock_html(test_data_dir):
    """加载 mock HTML"""
    html_file = test_data_dir / "sample.html"
    return html_file.read_text(encoding='utf-8')


@pytest.fixture(autouse=True)
def reset_logger():
    """每个测试前重置日志"""
    from loguru import logger
    logger.remove()
    logger.add(lambda msg: None)  # 静默日志

部署与运维

1. 生产环境检查清单

部署前必查:

bash 复制代码
# ✅ 测试通过
pytest tests/ -v

# ✅ 代码质量
flake8 myspider/
black --check myspider/

# ✅ 安全检查
safety check  # 检查依赖漏洞

# ✅ Docker 镜像构建
docker build -t myspider:prod .

# ✅ 配置文件准备
cp config.yaml.example config.yaml
vim config.yaml  # 修改生产配置

# ✅ 环境变量
cp .env.example .env
vim .env  # 填写密码等敏感信息

2. 定时任务配置

方式一:Crontab(Linux 服务器)
bash 复制代码
# 编辑 crontab
crontab -e

# 添加定时任务(每天凌晨 2 点运行)
0 2 * * * cd /path/to/myspider && /path/to/venv/bin/myspider crawl --pages 1-50 >> /var/log/myspider/cron.log 2>&1
方式二:Systemd Timer

myspider.service

ini 复制代码
[Unit]
Description=MySpider News Crawler
After=network.target

[Service]
Type=oneshot
User=spider
WorkingDirectory=/opt/myspider
ExecStart=/opt/myspider/venv/bin/myspider crawl --config /etc/myspider/config.yaml
StandardOutput=journal
StandardError=journal

[Install]
WantedBy=multi-user.target

myspider.timer

ini 复制代码
[Unit]
Description=MySpider Daily Crawl Timer
Requires=myspider.service

[Timer]
OnCalendar=daily
OnCalendar=02:00
Persistent=true

[Install]
WantedBy=timers.target

启用:

bash 复制代码
sudo systemctl enable myspider.timer
sudo systemctl start myspider.timer
sudo systemctl status myspider.timer
方式三:Docker + Cron

Dockerfile(带 cron)

dockerfile 复制代码
FROM myspider:latest

# 安装 cron
USER root
RUN apt-get update && apt-get install -y cron && rm -rf /var/lib/apt/lists/*

# 添加 cron 任务
COPY crontab /etc/cron.d/myspider-cron
RUN chmod 0644 /etc/cron.d/myspider-cron && \
    crontab /etc/cron.d/myspider-cron

# 启动 cron
CMD ["cron", "-f"]

crontab

json 复制代码
0 2 * * * myspider crawl --pages 1-50 >> /app/logs/cron.log 2>&1

3. 监控与告警

Prometheus + Grafana(可选,高级)

导出指标(在爬虫中添加):

python 复制代码
from prometheus_client import Counter, Histogram, start_http_server

# 定义指标
requests_total = Counter('spider_requests_total', 'Total requests', ['status'])
request_duration = Histogram('spider_request_duration_seconds', 'Request duration')

# 在代码中记录
requests_total.labels(status='success').inc()

# 启动指标服务器
start_http_server(8000)
简易告警(邮件/企业微信)

utils/notifier.py

python 复制代码
"""
简易告警模块
支持邮件和企业微信
"""
import smtplib
from email.mime.text import MIMEText
import requests


def send_email(subject: str, body: str, to: str):
    """发送邮件告警"""
    msg = MIMEText(body, 'plain', 'utf-8')
    msg['Subject'] = subject
    msg['From'] = 'spider@example.com'
    msg['To'] = to
    
    try:
        with smtplib.SMTP('smtp.example.com', 587) as server:
            server.starttls()
            server.login('user', 'password')
            server.send_message(msg)
    except Exception as e:
        print(f"邮件发送失败: {e}")


def send_wechat_work(message: str, webhook_url: str):
    """发送企业微信告警"""
    data = {
        "msgtype": "text",
        "text": {
            "content": message
        }
    }
    
    try:
        resp = requests.post(webhook_url, json=data, timeout=10)
        resp.raise_for_status()
    except Exception as e:
        print(f"企业微信告警失败: {e}")

项目总结与复盘

你学到了什么?

完成这个项目后,你已经具备了:

  1. CLI 开发能力:用 Typer 构建专业命令行工具
  2. Docker 技能:多阶段构建、Docker Compose 编排
  3. 配置管理:YAML + 环境变量的最佳实践
  4. 文档编写:README、CHANGELOG、代码注释
  5. CI/CD 流程:GitHub Actions 自动化测试和发布
  6. 工程化思维:测试、日志、监控、部署的完整链路

这个项目的价值

这不仅仅是一个爬虫,而是一个可交付的生产级项目

  • 📝 简历加分项:展示你的工程化能力
  • 🎁 开源作品:可以放到 GitHub 吸引关注
  • 🛠️ 实用工具:真的能解决实际问题
  • 📚 学习范本:后续项目都可以复用这套架构

下一步建议

如果你想让项目更进一步:

  1. 性能优化

    • 引入异步 IO(aiohttp + asyncio)
    • 使用消息队列(Celery + Redis)
    • 分布式爬取(Scrapy-Redis)
  2. 功能扩展

    • Web 管理界面(FastAPI + Vue)
    • 实时监控看板(Grafana)
    • 智能反爬策略(代理池、UA 池)
  3. 商业化

    • 封装成 SaaS 服务
    • 提供定制化采集服务
    • 数据产品化(API 接口)

最后的话

从第一行代码到现在,你已经走过了完整的爬虫工程化之路。希望这个专栏不仅教会你怎么写爬虫 ,更重要的是教会你怎么写好项目

工程化不是堆技术,而是:

  • 让项目易于理解(好文档)
  • 让项目易于运行(一键启动)
  • 让项目易于维护(测试覆盖)
  • 让项目易于交付(Docker + CI/CD)

记住这四个"易于",你的每个项目都会比别人更专业一分

🌟 文末

好啦~以上就是本期 《Python爬虫实战》的全部内容啦!如果你在实践过程中遇到任何疑问,欢迎在评论区留言交流,我看到都会尽量回复~咱们下期见!

小伙伴们在批阅的过程中,如果觉得文章不错,欢迎点赞、收藏、关注哦~
三连就是对我写作道路上最好的鼓励与支持! ❤️🔥

📌 专栏持续更新中|建议收藏 + 订阅

专栏 👉 《Python爬虫实战》,我会按照"入门 → 进阶 → 工程化 → 项目落地"的路线持续更新,争取让每一篇都做到:

✅ 讲得清楚(原理)|✅ 跑得起来(代码)|✅ 用得上(场景)|✅ 扛得住(工程化)

📣 想系统提升的小伙伴:强烈建议先订阅专栏,再按目录顺序学习,效率会高很多~

✅ 互动征集

想让我把【某站点/某反爬/某验证码/某分布式方案】写成专栏实战?

评论区留言告诉我你的需求,我会优先安排更新 ✅


⭐️ 若喜欢我,就请关注我叭~(更新不迷路)

⭐️ 若对你有用,就请点赞支持一下叭~(给我一点点动力)

⭐️ 若有疑问,就请评论留言告诉我叭~(我会补坑 & 更新迭代)


免责声明:本文仅用于学习与技术研究,请在合法合规、遵守站点规则与 Robots 协议的前提下使用相关技术。严禁将技术用于任何非法用途或侵害他人权益的行为。

相关推荐
2301_790300967 小时前
Python单元测试(unittest)实战指南
jvm·数据库·python
Data_Journal7 小时前
Scrapy vs. Crawlee —— 哪个更好?!
运维·人工智能·爬虫·媒体·社媒营销
VCR__7 小时前
python第三次作业
开发语言·python
韩立学长7 小时前
【开题答辩实录分享】以《助农信息发布系统设计与实现》为例进行选题答辩实录分享
python·web
Suchadar7 小时前
Docker常用命令
运维·docker·容器
你才是臭弟弟7 小时前
MinIo开发环境配置方案(Docker版本)
运维·docker·容器
深蓝电商API8 小时前
async/await与多进程结合的混合爬虫架构
爬虫·架构
2401_838472518 小时前
使用Scikit-learn构建你的第一个机器学习模型
jvm·数据库·python
u0109272718 小时前
使用Python进行网络设备自动配置
jvm·数据库·python
工程师老罗8 小时前
优化器、反向传播、损失函数之间是什么关系,Pytorch中如何使用和设置?
人工智能·pytorch·python