title: 构建增量式新闻聚合爬虫系统:ArticleCrawler 的设计与实践 slug: article-crawler-project-intro date: 2026-06-23 tags: [Scrapy, 爬虫, 增量抓取, FastAPI, Python, 反反爬] description: 深入解析基于 Scrapy 框架构建的增量式新闻聚合爬虫系统 ArticleCrawler,涵盖架构设计、关键技术决策与工程化实践经验。

构建增量式新闻聚合爬虫系统:ArticleCrawler 的设计与实践

前言

在信息过载的时代,垂直领域的内容聚合需求从未像今天这样迫切。无论是 SEO 站群运营、行业舆情监控,还是多语言内容本地化,都需要一个可靠、高效、可维护的爬虫基础设施。

半年前,我启动了一个名为 ArticleCrawler 的开源项目——一个基于 Scrapy 框架构建的增量式新闻聚合爬虫系统,专注于垂直领域的多站点文章采集与自动化发布。经过十余次迭代,项目已经稳定支撑 10 个目标站点的日常采集,并集成了定时调度、图片本地化、MySQL 持久化、WordPress 自动发布以及 AI 伪原创等完整能力链。

本文将梳理项目从零到一的架构演进过程,分享关键技术决策背后的思考。

项目地址:https://github.com/Lireal-w/ArticleCrawler

一、设计目标与演进脉络

项目最初的诉求非常简单:每天定时从多个新闻站点抓取最新文章,处理后自动发布到 WordPress 站点。但随着实践深入,需求列表逐渐拉长:

  • 增量抓取,避免重复劳动
  • 图片本地化与 CDN 分发
  • 反反爬绕过浏览器指纹检测
  • 通过 Web 界面动态管理定时任务
  • 数据库持久化与多站点分发
  • 利用大模型做文章的伪原创改写

这些需求并非一蹴而就,而是通过一次次 commit 逐步沉淀。回顾项目的版本历史,可以清晰看到一条"由简入繁、由硬编码到可配置"的演进路线:

first commit                      # 初始原型
feat: 添加增量爬虫基类             # 引入 SunSpider 基类
feat: 重构所有爬虫为增量爬虫       # 全部爬虫统一基类
feat: 使用curl_cffi替代默认下载器  # 反反爬升级
feat: 添加Fastapi作为后端          # 从脚本走向服务化
refactor: 使用.env管理配置         # 配置收敛
feat: 添加MYSQL数据库管道          # 持久化升级
feat: 增加定时任务时间修改接口      # 运营可观测性
feat: 添加Openapi大模型接口        # AI 能力接入
feat: 添加可持久化的配置系统        # 系统完整闭环

二、架构总览

┌─────────────────────────────────────────────────────────┐
│                    FastAPI + Uvicorn                     │
│  ┌──────────────┐  ┌──────────────┐  ┌──────────────┐  │
│  │  爬虫调度接口  │  │  站点管理 API │  │ 静态管理面板  │  │
│  └──────┬───────┘  └──────┬───────┘  └──────┬───────┘  │
│         │                  │                  │          │
│  ┌──────┴─────────────────┴──────────────────┴───────┐  │
│  │             APScheduler 后台调度器                   │  │
│  └──────────────────────┬────────────────────────────┘  │
└─────────────────────────┼──────────────────────────────┘
                          │
┌─────────────────────────┴──────────────────────────────┐
│                   Scrapy 引擎                            │
│  ┌──────────┐  ┌──────────────┐  ┌──────────────────┐  │
│  │ SunSpider │  │ CurlCffi     │  │  Pipelines 管道   │  │
│  │ 增量基类   │  │ Middleware   │  │ ┌──────────────┐ │  │
│  │ URL 去重   │  │ 浏览器指纹模拟 │  │ │图片下载/替换  │ │  │
│  │ 持久化     │  │              │  │ │JSON 文件导出  │ │  │
│  └──────────┘  └──────────────┘  │ │MySQL 持久化   │ │  │
│                                   │ └──────────────┘ │  │
│                                   └──────────────────┘  │
└─────────────────────────────────────────────────────────┘
                          │
              ┌───────────┼───────────┐
              ▼           ▼           ▼
          WordPress    MySQL       JSON 文件
          REST API    数据库       (中间产物)

整个系统分为三层:

  1. Web 管理层:FastAPI 提供 RESTful 接口和静态管理面板,运营人员可以随时调整调度策略
  2. 任务调度层:APScheduler 管理爬虫执行、数据清理、自动提交等周期性任务
  3. 爬虫执行层:Scrapy 引擎驱动 10 个爬虫模块,经管道处理后将数据写入多种目标

三、核心设计亮点

3.1 SunSpider:不到 50 行的增量爬虫基类

增量爬取是项目最核心的能力。实现方式出乎意料地简单——一个继承 scrapy.Spider 的基类,用内存中的 set 做 O(1) 去重查询,用 JSON 文件做磁盘持久化:

import scrapy
import json
import os

class SunSpider(scrapy.Spider):
    """基于 URL 去重的增量爬虫基类"""
    name = "sun"

    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.seen_urls = set()
        self.count = 0
        self.load_seen_urls()

    def load_seen_urls(self):
        file_path = f'./crawled_articles/{self.name}.json'
        if os.path.exists(file_path):
            with open(file_path, 'r', encoding='utf-8') as f:
                self.seen_urls = set(json.load(f))

    def save_seen_urls(self):
        file_path = f'./crawled_articles/{self.name}.json'
        os.makedirs(os.path.dirname(file_path), exist_ok=True)
        with open(file_path, 'w', encoding='utf-8') as f:
            json.dump(list(self.seen_urls), f, ensure_ascii=False, indent=2)

    def is_seen_url(self, url):
        return url in self.seen_urls

    def mark_url_as_seen(self, url):
        self.seen_urls.add(url)
        self.count += 1
        if self.count % 100 == 0:
            self.save_seen_urls()   # 每 100 条增量保存

    def closed(self, reason):
        self.save_seen_urls()       # 爬虫关闭时全量保存

设计要点:

  • 以爬虫名称为粒度维护独立的 URL 集合文件,互不干扰
  • is_seen_url 遇到已爬取 URL 时直接 return,停止翻页——这是增量爬虫的终止条件
  • 每 100 条自动保存一次,防止运行过程中崩溃导致全部丢失
  • 爬虫关闭时通过 Scrapy 的 closed 信号做最终持久化

子类继承后只需专注于解析逻辑,增量能力零成本获得:

from ..utils.SunSpider import SunSpider

class SbcnewsSpider(SunSpider):
    name = "sbcnews"
    start_urls = ["https://sbcnews.co.uk/category/sportsbook/"]

    def parse(self, response):
        article_links = response.css(
            'div.category-post a.sbc-article-card::attr(href)'
        ).getall()
        for link in article_links:
            absolute_url = urljoin(response.url, link)
            if self.is_seen_url(absolute_url):
                return  # 已采集过,停止翻页
            yield Request(absolute_url, callback=self.parse_article)
            self.mark_url_as_seen(absolute_url)

        next_page = response.css(
            'div.sbc-pagination a.next.page-numbers::attr(href)'
        ).get()
        if next_page:
            yield Request(urljoin(response.url, next_page), callback=self.parse)

这个模式在后来的 sigmafocusgngamblinginsider 等爬虫中反复复用,真正做到了一处实现、处处受益。

3.2 CurlCffiMiddleware:用"真浏览器指纹"绕过反爬

Scrapy 默认的请求头容易被反爬系统识别。项目早期依赖 scrapy-fake-useragent 做 User-Agent 轮换,但效果并不理想——现代反爬系统已经进化到检查 TLS 握手指纹、HTTP/2 帧顺序等深层特征。

引入 curl_cffi 后,我们实现了一个自定义的下载中间件,让 Scrapy 的所有请求都走 curl_cffiimpersonate 能力:

class CurlCffiMiddleware:
    async def process_request(self, request):
        headers = {}
        # 排除会被 impersonate 自动生成的标头
        skip_headers = {
            'accept', 'user-agent', 'accept-language',
            'sec-ch-ua', 'sec-ch-ua-platform', ...
        }
        for k, v in request.headers.items():
            key = k.decode('utf-8') if isinstance(k, bytes) else k
            if key.lower() in skip_headers:
                continue
            headers[key] = value

        impersonate = request.meta.get('impersonate', 'safari15_5')
        async with curl_requests.AsyncSession() as session:
            resp = await session.get(
                request.url,
                headers=headers,
                impersonate=impersonate,  # 模拟 Safari 15.5 指纹
                timeout=30,
                allow_redirects=True
            )

        return HtmlResponse(
            url=resp.url,
            status=resp.status_code,
            body=resp.content,
            ...
        )

关键配置是在 settings.py 中禁用 Scrapy 原生的 User-Agent 中间件,避免冲突:

DOWNLOADER_MIDDLEWARES = {
    'ArticleCrawler.middlewares.CurlCffiMiddleware': 543,
    'scrapy.downloadermiddlewares.useragent.UserAgentMiddleware': None,
    'scrapy_ua_rotator.middleware.RandomUserAgentMiddleware': None,
    ...
}

这个改造对应 commit feat:使用curl_cffi替代默认的Scrapy默认下载器,提升成功率。上线后抓取成功率从 60% 直接提升到 95% 以上。

3.3 图片全生命周期管理

爬虫系统的图片处理通常涉及三个阶段:下载 → 替换 → 上传。ArticleCrawler 在这三个阶段都有对应的实现:

阶段一:爬虫管道中下载并替换

ArticleImagesPipeline 继承 Scrapy 的 ImagesPipeline,下载图片后将正文 HTML 中的远程 URL 替换为本地路径:

class ArticleImagesPipeline(ImagesPipeline):
    def file_path(self, request, response=None, info=None, *, item=None):
        parsed = urlparse(request.url)
        path = parsed.path.lstrip('/')
        return posixpath.normpath(path)

    def item_completed(self, results, item, info):
        url_to_path = {}
        for ok, res in results:
            if ok:
                url_to_path[res['url']] = res['path']

        if 'content' in item and url_to_path:
            for original_url, local_path in url_to_path.items():
                new_url = f'/{local_path}'
                item['content'] = item['content'].replace(original_url, new_url)

        item.pop('image_urls', None)
        return item

阶段二:发布时上传至 WordPress

utils/wordpress.py 中的 process_and_submit 函数负责将本地图片上传至目标 WordPress 站点,并替换正文中的图片引用。这意味着最终发布到 CDN 的文章,所有图片都已托管在目标服务器上,不存在外链失效的问题。

3.4 FastAPI + APScheduler:可观测的任务调度

随着站点数量增长,每天早上 8 点通过 python scheduler.py 跑完所有爬虫的模式越来越难以维护。运营同学希望能在浏览器里看到下次执行时间随时调整调度策略

FastAPI 的引入将系统从脚本升级为服务。APScheduler 的 BackgroundScheduler 在 FastAPI 的 lifespan 事件中启动和关闭,确保了调度器与 Web 服务的生命周期一致:

@asynccontextmanager
async def lifespan(app: FastAPI):
    init_scheduler()  # 启动时初始化调度器
    db.connect()
    yield
    scheduler.shutdown()  # 关闭时清理
    db.close()

app = FastAPI(lifespan=lifespan)

配套的 RESTful API 允许运营人员随时查询和修改定时任务的执行时间:

@app.put("/schedule/{job_name}")
async def update_schedule(job_name: str, time: ScheduleTime):
    scheduler.reschedule_job(
        job_name,
        trigger='cron',
        hour=time.hour,
        minute=time.minute
    )
    return {"message": f"Job '{job_name}' rescheduled to {time.hour:02d}:{time.minute:02d}"}

前端基于 Bootstrap 5 + jQuery 构建了一个简易的管理面板,支持定时任务的 CRUD 和 WordPress 站点配置管理。运营人员不需要接触命令行,打开浏览器即可完成日常运维。

对应 commits:

  • feat:添加Fastapi作为后端,提供交互接口
  • feat: 增加定时任务时间修改与查询接口,集成前端静态页面

3.5 AI 伪原创:大模型改写 HTML 正文

内容站运营中,"重复内容"是 SEO 的天敌。项目后期引入的 AI 伪原创模块,通过调用 DeepSeek 等大语言模型 API,在不破坏 HTML 标签结构的前提下,对正文文本进行同义改写:

def rewrite_html_direct(html_content, api_key=None, model=""):
    client = OpenAI(api_key=config.get("api_key"),
                    base_url=config.get("base_url"))
    system_prompt = (
        "You are an HTML text rewriter. "
        "rewrite ONLY the visible text content inside the given HTML code, "
        "while keeping ALL HTML tags, attributes, and structure exactly the same. "
        "Perform synonym replacement and slight rephrasing on the text content only."
    )
    response = client.chat.completions.create(
        model=model,
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": f"Rewrite the text content in this HTML:\n\n{html_content}"},
        ],
        ...
    )
    return response.choices[0].message.content.strip()

这条路径的巧妙之处在于:大模型只负责文本改写,标签结构完全保留。这避免了传统的"先解析 HTML → 分别翻译文本 → 重新组装"的复杂流程,直接将问题转化为一个"文本到文本"的 LLM 任务,大幅降低了实现复杂度。

对应 commit:feat:添加Openapi大模型接口作为伪原创工具

四、工程化实践与踩坑记录

4.1 配置管理的演进

项目的配置经历了三个阶段:

  1. 硬编码阶段:API Key、代理地址直接写在代码里(最早期的几个 commit)
  2. 环境变量阶段refactor: 使用.env管理配置并重构WP发布函数,通过 python-dotenv 加载 .env
  3. 持久化配置系统feat: 添加可持久化的配置系统,引入 JSON 文件作为配置中心,支持运行时动态修改
CONFIG_FILE = "./outfile/config.json"

def load_config():
    if os.path.exists(CONFIG_FILE):
        with open(CONFIG_FILE, 'r', encoding='utf-8') as f:
            return json.load(f)
    default_config = {
        "proxy_url": "http://127.0.0.1:7890",
        "cron_hour": 8,
        "api_key": "",
        "base_url": "https://api.deepseek.com",
        "model": "deepseek-v4-flash"
    }
    save_config(default_config)
    return default_config

最终方案兼顾了"启动时无需环境变量"的便利性和"运行时热修改"的灵活性。

4.2 翻页终止条件的陷阱

增量爬虫最容易被忽视的问题是翻页终止条件。早期版本没有 if self.is_seen_url(url): return 这行代码,爬虫会在当天的新文章爬完后继续翻到上一页,把历史文章全部重新采集一遍。

修复方案很简单:在提取到已爬取 URL 时立即返回,这意味着"从最新文章开始翻页,遇到已处理的就停止"。这是增量爬虫的核心假设——目标站点的文章按时间倒序排列

4.3 爬虫注册的自动导入

项目中的 10 个爬虫通过 spiders/__init__.py 统一注册。早期每次新增爬虫都要手动添加 import 和列表注册,后来用一个 spiders 列表集中管理:

from .affpapa import AffpapaSpider
from .bnldata import BnldataSpider
# ... 省略其他导入

spiders = [
    AffpapaSpider,
    BnldataSpider,
    EnvmediaSpider,
    FocusgnSpider,
    GamblinginsiderSpider,
    IgamingbusinessSpider,
    IntergameonlineSpider,
    SbcnewsSpider,
    SigmaSpider,
    # LanceSpider  # 可选启用
]

LanceSpider 被注释掉的设计也很有意思——对于内容质量不稳定的数据源,可以在列表层面直接"摘除",无需删除代码。

4.4 Twisted Reactor 的选择

Scrapy 基于 Twisted 异步框架构建,而 curl_cffiAsyncSession 基于 asyncio。两者混用时需要一个桥梁。项目在 settings.py 中显式指定了 AsyncioSelectorReactor

TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'

这个配置确保 Twisted 和 asyncio 的事件循环可以协同工作。如果不做此设置,CurlCffiMiddlewareasync with curl_requests.AsyncSession() 会抛出 RuntimeError 异常。

4.5 MySQL 管道的幂等插入

数据管道的 MySQLPipeline 使用了 INSERT ... ON DUPLICATE KEY UPDATE 模式,确保同一个 URL 的文章不会因为重复运行而产生重复记录。配合爬虫的 .is_published 标志位,整个管道的幂等性得到了保障:

INSERT INTO article (url, title, ...)
VALUES (%s, %s, ...)
ON DUPLICATE KEY UPDATE
title=VALUES(title),
content=VALUES(content),
is_published=VALUES(is_published)

4.6 爬虫详情的 HTML 清洗

不同站点的正文 HTML 质量参差不齐。有的夹杂广告脚本,有的带有推荐阅读板块,还有的内嵌社交媒体 iframe。项目在每个爬虫的 parse_article 方法中,使用 lxml 的 XPath 对正文元素进行清洗:

# 清洗策略因站而异——这是每个爬虫最"个性化"的部分
# 以 sigma 爬虫为例:
container_element = container_sel[0].root

# 移除推荐阅读板块
for bad_elem in container_element.xpath(
    './/*[contains(@class, "wp-block-columns")]'
):
    parent = bad_elem.getparent()
    if parent is not None:
        parent.remove(bad_elem)

# 移除广告容器
for ad in container_element.xpath(
    './/*[contains(@class, "sync-adwrapper")]'
):
    parent = ad.getparent()
    if parent is not None:
        parent.remove(ad)

# 移除所有 script 标签
for script in container_element.xpath('.//script'):
    parent = script.getparent()
    if parent is not None:
        parent.remove(script)

每个爬虫的清洗规则都不相同,这是爬虫开发中最需要"人工介入"的环节,也是最难被抽象的部分。项目对此采取的策略是:不做统一抽象,每个爬虫各自维护自己的清洗逻辑。与其设计一个面面俱到的"通用清洗器",不如让每个爬虫的代码直接可读、可改。

五、项目全景:数据流与运营流程

从数据采集到最终发布,一条完整的数据链路如下:

目标网站 HTML
    │
    ▼
SunSpider (增量去重) → CurlCffiMiddleware (反反爬)
    │
    ▼
parse_article() → ArticleItem
    │
    ▼
ArticleImagesPipeline → 图片下载 + URL 替换
    │
    ├──→ JsonFilePipeline → outfile/*.json (中间产物)
    │
    ├──→ MySQLPipeline → article 表 (is_published = 0)
    │
    ▼
APScheduler (每天 9:00)
    │
    ▼
submit_active_to_site()
    │
    ├──→ process_and_submit()
    │       ├── 图片上传 → WordPress Media Library
    │       ├── 正文改写 → OpenAI / DeepSeek API
    │       └── wp_insert_post → WordPress 草稿箱
    │
    └──→ mark_article_published (is_published = 1)

每个环节都设计为可独立使用、可替换的模块。

六、总结与展望

ArticleCrawler 在半年时间里,从一个单文件脚本演变为一个具备完整数据管道的工程化系统。回顾整个过程,几个关键决策对项目质量影响最大:

  1. 基类先行SunSpider 的增量模式让所有爬虫从一开始就具备去重能力,避免了后期统一改造的高昂成本
  2. 中间件解耦:反反爬逻辑放在 CurlCffiMiddleware 中,爬虫代码无需感知底层传输协议的变化
  3. 管道化设计:图片处理、JSON 导出、MySQL 存储各自是独立的 Pipeline,按需启用、灵活组合
  4. 服务化转型:从脚本到 FastAPI 服务,让系统的可观测性和可运维性上了大台阶

如果你也在构建内容采集系统,项目中有几个模式值得直接借鉴:

  • Set + JSON 文件的轻量级增量方案,无需引入 Redis 等外部组件即可满足中小规模需求
  • curl_cffi 接管 Scrapy 下载器的反反爬方案,对 Cloudflare 等保护下的站点效果显著
  • LLM 直接改写 HTML 文本的伪原创策略,比传统 NLP 流程简单一个数量级

未来计划中,项目还有几个方向可以探索:支持更多目标站点的爬虫模板、集成消息队列做分布式采集、以及用 LLM 做更智能的内容分类与摘要生成。

七、项目结构速查

为方便读者快速理解项目全貌,这里列出关键文件及其职责:

ArticleCrawler/
├── ArticleCrawler/
│   ├── spiders/                  # 10 个爬虫模块
│   │   ├── __init__.py           # 爬虫注册与自动发现
│   │   ├── sbcnews.py            # SBC News 爬虫
│   │   ├── sigma.py              # Sigma World 爬虫
│   │   ├── affpapa.py            # AffPapa 爬虫
│   │   ├── bnldata.py            # BNL Data 爬虫
│   │   ├── focusgn.py            # Focus GN 爬虫
│   │   ├── gamblinginsider.py    # Gambling Insider 爬虫
│   │   ├── igamingbusiness.py    # iGaming Business 爬虫
│   │   ├── intergameonline.py    # InterGame Online 爬虫
│   │   ├── envMedia.py           # envMedia 爬虫
│   │   └── lance.py              # Lance 爬虫
│   ├── utils/
│   │   ├── SunSpider.py          # 增量爬虫基类(核心 45 行)
│   │   └── time.py               # 时间解析工具
│   ├── middlewares.py            # CurlCffiMiddleware 反反爬中间件
│   ├── pipelines.py              # 三大数据管道
│   ├── items.py                  # ArticleItem 数据结构定义
│   ├── settings.py               # Scrapy 全局配置
│   └── log_formatter.py          # 日志静默格式化
├── utils/
│   ├── openapi.py                # DeepSeek AI 伪原创接口
│   └── wordpress.py              # WordPress 发布全套逻辑
├── app.py                        # FastAPI 主服务入口
├── scheduler.py                  # APScheduler 定时调度
├── config.py                     # 持久化配置系统
├── db.py                         # MySQL 数据库操作层
├── run.py                        # 批量启动入口
└── static/index.html             # 任务管理面板

八、写在最后

ArticleCrawler 这个项目从一开始就不是为了"造一个完美的爬虫框架",而是为了解决一个非常具体的问题:如何每天自动化地从多个新闻站点采集最新内容,经过处理后发布到自己的站点上。它不求大而全,但求每个环节都能真正落地、稳定运行。

如果你刚接触爬虫开发,这个项目可以作为一个不错的参考——从一个简单的 Scrapy 项目开始,逐步加入反反爬、增量抓取、定时调度、Web 管理界面、数据库持久化,再到 AI 能力集成,每一步都有对应的 commit 可以追溯。

如果你已经在做类似的事情,也希望这个项目中的一些实践(尤其是 SunSpider 的增量模式、curl_cffi 的反反爬方案、LLM 伪原创的策略)能给你带来一些启发。

最后,技术选型没有银弹。Scrapy 的单机架构在日均数千篇文章的场景下完全够用,但如果数据量再上一个量级,可能需要引入消息队列(如 RabbitMQ 或 Kafka)来做爬虫任务的分布式分发。同样是增量去重,当爬虫数量扩大到上百个时,JSON 文件的方案也该换成 Redis 的 Set 结构了。在合适的阶段做合适的事情,这也是这个项目教给我的最重要的一课。

项目地址:https://github.com/Lireal-w/ArticleCrawler

如果你觉得这个项目有帮助,欢迎在 GitHub 上点个 Star 支持一下!