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 伪原创等完整能力链。
本文将梳理项目从零到一的架构演进过程,分享关键技术决策背后的思考。
一、设计目标与演进脉络
项目最初的诉求非常简单:每天定时从多个新闻站点抓取最新文章,处理后自动发布到 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 数据库 (中间产物)
整个系统分为三层:
- Web 管理层:FastAPI 提供 RESTful 接口和静态管理面板,运营人员可以随时调整调度策略
- 任务调度层:APScheduler 管理爬虫执行、数据清理、自动提交等周期性任务
- 爬虫执行层: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)
这个模式在后来的 sigma、focusgn、gamblinginsider 等爬虫中反复复用,真正做到了一处实现、处处受益。
3.2 CurlCffiMiddleware:用"真浏览器指纹"绕过反爬
Scrapy 默认的请求头容易被反爬系统识别。项目早期依赖 scrapy-fake-useragent 做 User-Agent 轮换,但效果并不理想——现代反爬系统已经进化到检查 TLS 握手指纹、HTTP/2 帧顺序等深层特征。
引入 curl_cffi 后,我们实现了一个自定义的下载中间件,让 Scrapy 的所有请求都走 curl_cffi 的 impersonate 能力:
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 配置管理的演进
项目的配置经历了三个阶段:
- 硬编码阶段:API Key、代理地址直接写在代码里(最早期的几个 commit)
- 环境变量阶段:
refactor: 使用.env管理配置并重构WP发布函数,通过python-dotenv加载.env - 持久化配置系统:
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_cffi 的 AsyncSession 基于 asyncio。两者混用时需要一个桥梁。项目在 settings.py 中显式指定了 AsyncioSelectorReactor:
TWISTED_REACTOR = 'twisted.internet.asyncioreactor.AsyncioSelectorReactor'
这个配置确保 Twisted 和 asyncio 的事件循环可以协同工作。如果不做此设置,CurlCffiMiddleware 中 async 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 在半年时间里,从一个单文件脚本演变为一个具备完整数据管道的工程化系统。回顾整个过程,几个关键决策对项目质量影响最大:
- 基类先行:
SunSpider的增量模式让所有爬虫从一开始就具备去重能力,避免了后期统一改造的高昂成本 - 中间件解耦:反反爬逻辑放在
CurlCffiMiddleware中,爬虫代码无需感知底层传输协议的变化 - 管道化设计:图片处理、JSON 导出、MySQL 存储各自是独立的 Pipeline,按需启用、灵活组合
- 服务化转型:从脚本到 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 结构了。在合适的阶段做合适的事情,这也是这个项目教给我的最重要的一课。
如果你觉得这个项目有帮助,欢迎在 GitHub 上点个 Star 支持一下!
评论