Scrapy单工程多spider架构:多网站多栏目采集整合实践

发布时间:2026/10/15 9:53:02
Scrapy单工程多spider架构:多网站多栏目采集整合实践 以前做采集任务我习惯一个网站建一个Scrapy工程时间一长机器上躺了好几个结构几乎一样的爬虫项目同一个数据库连接、同一份清洗逻辑、同一套代理重试每次改公共的东西都得挨个复制粘贴改一遍就是半天。最近接到一个更实际的需求——不光要同时爬不同的网站每个网站底下还要按不同的站点栏目去抓比如资讯站分时政、科技、财经几个频道每个频道页面模板还不完全一样。这种时候再搞一网站一工程就是给自己挖坑。我把所有采集目标收敛进了一个Scrapy项目用一个工程同时调度多个spider在spider内部再按站点规则做分流最终统一走一套Pipeline落库。这套架构跑下来新增一个采集源基本只需要加一个文件公共逻辑全复用。这篇就把整个做法的骨架设计、规则编排、管道分流、并发调度和踩坑过程完整拆一遍写给正在被多目标站、多栏目采集折磨的爬虫工程师参考。1. 为什么要合并单工程多spider的收益与适用边界1.1 多工程分散维护的真实成本很多人对Scrapy的第一反应就是一个项目一个爬虫这个思路在小规模验证阶段没毛病但一旦目标网站数量超过两三个问题就暴露得特别明显。我举个例子。你为网站A建了一个工程为网站B又建了一个工程两个工程里的数据库连接、日志配置、User-Agent池、重试中间件90%都是复制粘贴。结果某天发现连接池需要加一个参数你得在两个工程里同步改某天一篇文章存库前需要统一做关键词过滤又是两处都得动。更麻烦的是每个工程要单独部署、单独看日志、单独维护依赖版本光是把库升个级都能折腾出顺序差异。这种复制粘贴式维护在采集源数量少的时候勉强能忍一旦目标站清单扩展到十几个你花在工程间切换和重复配置上的时间会远远超过写解析规则本身。这也是我做这次重构的第一动力把配置和规则分离规则可以每个站点一套但配置、调度、存储、监控必须共享。1.2 合并后的收益与不该合并的场景合并成一个Scrapy项目后收益非常直接公共代码只维护一份包括Item定义、Pipeline、代理中间件、数据库工具类只需要在一个虚拟环境里管理依赖版本部署变成一个目录走天下可以一次性scrapy crawl site_a site_b site_c批量启动日志按spider分离整个采集链路都在同一个视图里新增采集源时只需要在spiders目录下加一个文件再在配置里声明好运行参数不用动任何公共代码。但有两点我需要提前泼冷水。第一如果两个采集源的数据最终落到完全不同的业务系统且清洗逻辑几乎不重叠那强行合并反而让Pipeline里堆满if-else这种场景保持独立工程更清晰。第二如果某个采集源的数据量特别大、运行频率又和别的源天差地别建议单独跑一个工程实例便于独立扩缩容而不是把所有源都绑死在一个项目里一起部署。一句话总结适用边界多个源之间共享大部分设施、只是目标页面不同合并最划算业务边界清晰、生命周期互相独立拆开更省心。我这次接的需求正好属于第一种情况所以选择了单工程多spider的架构。2. 骨架搭法公共模块、Item设计与爬虫级配置2.1 目录结构里的共享与隔离确定合并之后第一步是设计工程目录。我的原则是所有可复用逻辑下沉到公共模块所有站点相关但非解析的部分放进配置项解析规则只保留在各自的spider文件里。my_collector/ ├── scrapy.cfg ├── requirements.txt └── my_collector/ ├── __init__.py ├── items.py ├── middlewares.py ├── pipelines.py ├── settings.py ├── spiders/ │ ├── __init__.py │ ├── news_a.py │ ├── sports_b.py │ └── travel_c.py └── utils/ ├── cleaner.py ├── db.py └── headers.py这个结构看起来简单但里面有两条隐性规则。一是spiders目录下每个文件只做一件事定义爬虫名、起始URL列表、链接规则、解析回调不做任何数据加工二是所有站点共用的数据库对象、清洗函数、请求头生成器都收进utils避免出现某个解析函数里直接写了个DB操作方法这种代码垃圾。你可能要问为什么不按网站建子包原因很简单Scrapy的SPIDER_MODULES配置要求spider类能在模块路径下被自动发现。你可以在SPIDER_MODULES [my_collector.spiders]下再细分但发现机制会要求import路径对得上徒增心智负担。十几个spider文件平铺在spiders目录下文件名就是爬虫名一眼就知道有哪些采集源比嵌套目录更直观。2.2 Item全字段设计给每条数据打上来源双标多网站多站点场景里Item设计是整个架构的压舱石。如果每个spider各自定义自己的ItemPipeline就没法统一处理如果所有spider共用一个Item就必须在字段上留足来源维度。我在这个项目里用了统一Item结构class ArticleItem(scrapy.Item): # 内容字段 title scrapy.Field() url scrapy.Field() publish_time scrapy.Field() content_text scrapy.Field() author scrapy.Field() # 来源标记字段 source_website scrapy.Field() # 顶级网站标识如 news_a source_site scrapy.Field() # 网站内的站点/栏目如 politics、tech site_category scrapy.Field() # 扩展分类可在解析时附带补充 spider_name scrapy.Field() # 记录实际采集的spider名方便回查为什么强调这两个来源字段因为同时爬不同的网站网站内不同的站点有两个采集维度缺一个都无法定位一条数据到底从哪来。source_website用于区分顶级目标站source_site用于区分网站内部的频道或子站。这两个字段一旦在spider里漏填后续Pipeline做分站落库、去重、差异清洗时就会失去依据。另外我建议把spider_name也放进Item。虽然Pipeline的process_item方法能拿到spider对象但数据一旦异步写入队列或者落到下游做补偿处理时再次识别来源的成本会很高。直接在Item里冗余一个字段查询和排查用起来顺手得多。2.3 公共settings与爬虫级配置的分工settings.py是所有共享配置的控制中心但要注意不同网站的robots策略、下载延迟、并发要求通常是不同的一刀切会出问题。Scrapy的配置优先级是命令行参数 爬虫自定义custom_settings 全局settings。所以我的分工方式是全局settings放适用于所有源的公共项机器人协议、默认超时、Item Pipeline、下载中间件、日志格式每个spider内部用custom_settings覆盖自身偏好的参数比如某个源访问压力大就单独DOWNLOAD_DELAY 0.8某个源响应快、字段轻就单独CONCURRENT_REQUESTS 32。class NewsASpider(scrapy.Spider): name news_a allowed_domains [news-a-example.com] start_urls [https://news-a-example.com/] custom_settings { DOWNLOAD_DELAY: 0.3, CONCURRENT_REQUESTS: 16, RETRY_TIMES: 2, }这种做法能避免一个常见的误伤场景某个网站的服务器响应很慢你又不想给所有采集源都加上高延迟或者某个网站的页面结构简单本来可以高并发快抓却被全局的低并发参数拖了效率。爬虫级配置就是为这种同类工程、不同节奏准备的。3. 网站内又有站点的解析编排规则分流与回调传参3.1 单Spider多入口的两种写法同一个网站内的不同站点最粗暴的做法是给每个站点都写一个spider。这种做法的坏处很明显如果这些站点结构类似、字段相似你会得到一大批只差一个路径前缀的spider文件等于把同一份解析逻辑复制了好几遍。更好的做法是一个域名下凡是用同一套下载策略和字段映射的站点尽量收敛到一个spider里通过起始URL列表或链接规则区分。我在项目里常用两种写法。第一种用start_urls列出多个站点入口在同一个parse里通过URL特征判断当前属于哪个站点再填充Item的source_site。这个方案适合页面结构高度统一的站点代码最省但站点一旦增多parse里全是一长串if-elif判断URL前缀可读性下降明显。第二种用cb_kwargs把站点标识直接传给回调函数让每个站点有独立的解析入口。这个方案在维护性和可读性上明显优于第一种也是我下面重点讲的做法。3.2 CrawlSpider多Rules的正确打开方式网站内不同站点往往有独立的目录路径比如/politics/、/tech/、/sports/。用CrawlSpider时我会为每个站点写一条Rule每条Rule指定当前站点的详情页链接匹配模式并在回调里通过cb_kwargs把站点标识传进去。class NewsASpider(CrawlSpider): name news_a allowed_domains [news-a-example.com] start_urls [ https://news-a-example.com/politics/, https://news-a-example.com/tech/, https://news-a-example.com/health/, ] rules ( Rule( LinkExtractor(allowr/politics/\d\.html), callbackparse_detail, cb_kwargs{site_tag: politics}, ), Rule( LinkExtractor(allowr/tech/\d\.html), callbackparse_detail, cb_kwargs{site_tag: tech}, ), Rule( LinkExtractor(allowr/health/\d\.html), callbackparse_detail, cb_kwargs{site_tag: health}, ), Rule( LinkExtractor(allowr/politics/page-\d), followTrue, ), ) def parse_detail(self, response, site_tag): item ArticleItem() item[source_website] news_a item[source_site] site_tag item[url] response.url item[title] response.css(h1::text).get() return item这里有三处细节值得说清楚。cb_kwargs是Scrapy内置的参数传递机制它会以关键字参数形式传给回调函数。比起在回调里写正则去重新匹配URL这种显式传参的方式既清晰又不容易错。特别是URL里如果同时包含多个关键词或者路径经过了改写靠字符串匹配极容易翻车显式传参不存在这个问题。Rules的顺序有讲究。CrawlSpider从页面提取链接时是按照Rules元组顺序逐个匹配的先命中哪个就用哪个。如果你把站点A的详情页规则放到了通用列表页规则后面详情链接就可能被那个通用规则当成普通链接直接follow掉根本不进parse_detail。所以列表页规则要放在所有详情页规则之后并且尽量用restrict_xpaths缩小链接提取范围。followTrue的规则只负责翻页链接不设callback。很多人会问为什么翻页链接规则不写callback因为在CrawlSpider里一个链接同时匹配多条Rule时优先级行为会变复杂。对于翻页链接我们需要的是递归提取下一页里的新链接而不是把下一页本身当成详情页解析所以单独给翻页规则设followTrue即可不需要回调。3.3 回调传参常见翻车现场我在重构时踩过一个很典型的坑给多个站点都用了同一个parse_detail但一开始没传site_tag而是直接在函数内部用response.url去正则匹配站点路径。结果网站更新了URL结构把原本干净的/politics/123.html改成了带中间层级的/news-list/2024/politics/123.html我的正则一口气匹配不到好几天的数据source_site字段全是None落库后分站统计全乱套。后来我把所有站点标识都改成通过cb_kwargs显式传入解析函数里不再依赖URL字符串猜测站点归属。好处是URL怎么变都不影响字段正确性坏处是新增站点时要记得加一条Rule和对应的callback参数。从长期维护看显式传参的可靠性远超在解析时猜来源。另外要注意cb_kwargs传入的值会原样冻结在request的meta里。如果某个字段是动态生成的比如session信息不能指望它在回调里实时变化。这个我们需要的是静态的站点标签冻结反而更安全。4. Pipeline按来源分流让不同站点的数据走不同加工链路4.1 基于spider.name或Item字段的管道分流不同网站抓下来的数据字段往往不能直接通用有的站发布时间是时间戳有的是2024-05-20格式有的是相对时间3小时前有的站正文里带大量HTML标签有的已经干净得可以直接入库。这些差异不适合在每个spider里各写一套清洗逻辑统一放进Pipeline里按来源分流才是正解。Pipeline的process_item(self, item, spider)方法天然接收两个参数其中spider.name就是当前spider的唯一标识。我通常在每个管道的第一步做分流判断class ContentCleanPipeline: def process_item(self, item, spider): if spider.name news_a: item[publish_time] self.parse_news_a_time(item.get(publish_time)) item[content_text] self.clean_html(item.get(content_text)) elif spider.name sports_b: item[publish_time] self.parse_sports_b_time(item.get(publish_time)) return item这里有一个容易被忽略的细节Pipeline分流也可以用Item字段而不是spider.name。如果你把source_site作为分流依据那就要求每个spider都严格填写这个字段一旦漏填数据就会掉进默认分支表现就是那个站点的数据在最终库里缺失或清洗不符。我的习惯是顶层分站用spider.name因为它不可能为空站内差异用source_site用的时候必须有兜底逻辑。4.2 重复过滤与内容清洗的分层设计多源采集必然面临重复数据问题。同一个网站里不同栏目页面可能指向同一篇文章不同网站之间也可能出现转载相同内容的情况。我的Pipeline分层大致如下100DuplicateFilterPipeline按站点URL去重 200ContentCleanPipeline按来源清洗处理HTML和格式 300NormalizePipeline公共字段标准化、生成数据指纹 400StoragePipeline写入数据库去重管道要注意指纹范围。如果只按url去重那么同一个源的两条spider同时抓同一个URL时会误伤如果不加来源维度跨站同URL这种极小概率事件会影响全库。我一般用sha1(source_website | source_site | url)作为指纹key存在Redis里做全局判重。为什么用Redis而不是直接查数据库因为高并发场景下每条item都去查一次库连接开销和查询延迟都受不了Redis的SETNX既能保证原子性又足够快。内容清洗管道最常踩的坑是以为每个站的HTML结构都稳定。我以前为某个站写好了正文提取XPath结果对方改版后正文摘要全变了清洗出来的内容一段空白。后来我在清洗管道里加了字段完整度检查如果content_text长度低于阈值宁可在日志里打WARN并保留原文也不做二次截断。少一条带原文的数据总好过存一条被误清成空壳的垃圾数据。4.3 落库策略与分表/分区思路数据落库时因为同时有网站和站点两个维度表结构设计需要提前想清楚。我推荐的做法是物理上一张article表逻辑上通过source_website和source_site字段做分区或查询索引。不要为每个站点单独建表那样一旦新增站点就要跑DDL运维成本极高查询多个站点时还得UNION ALL效率也差。CREATE TABLE article ( id BIGINT AUTO_INCREMENT PRIMARY KEY, source_website VARCHAR(32) NOT NULL, source_site VARCHAR(32) NOT NULL, title VARCHAR(255), url VARCHAR(255) UNIQUE, publish_time DATETIME, content LONGTEXT, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, KEY idx_website_site (source_website, source_site) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;数据库连接池可以多个spider共享但要注意不要在每条item里新建连接。这是我见过的最常见的低效写法——每个item处理时pymysql.connect()一次采集量稍微一大连接数就把数据库端口打满。正确做法是在Pipeline的open_spider里建立连接在close_spider里关闭或者用连接池对象整个spider生命周期复用。5. 并行调度与日志隔离别让一个慢站点拖垮全队5.1 并发模型的误区和合理参数你可能以为CONCURRENT_REQUESTS 64是全局并发上限实际不是。Scrapy的调度器是按spider实例独立的每个spider拥有自己的请求队列和连接池。也就是说同时跑三个spider每个spider各自允许64个并发请求总并发可能高达192。这个乘数效应是并行调度里第一个需要心里有数的点。不同网站的响应速度差异极大。某个技术博客响应时间可能只要150ms另一个老旧的新闻门户遇到并发稍微高一点就开始丢连接。如果给所有spider都配同样的高并发后者会把大量请求时间消耗在超时重试上白白浪费线路资源。所以每个spider的custom_settings里我会根据目标站的实际情况单独配置CONCURRENT_REQUESTS和DOWNLOAD_DELAY。AutoThrottle是个被很多人低估的配置。它可以根据目标站的响应时间自动调节下载速度让爬虫对慢站点更温柔对快站点更高效。我的建议是全局开启AUTOTHROTTLE_ENABLED True并设置保守的初始延迟AUTOTHROTTLE_ENABLED True AUTOTHROTTLE_START_DELAY 1.0 AUTOTHROTTLE_MAX_DELAY 10.0 CONCURRENT_REQUESTS_PER_DOMAIN 8需要注意AutoThrottle和手动设置的DOWNLOAD_DELAY存在冲突关系。如果你在某个spider里已经明确指定了固定的下载延迟那AutoThrottle的自动调节会优先作用于当前的爬行行为实际情况以我的经验固定配置个0.3到0.5更可控。开启这个配置前最好先小批量试跑看目标站的容忍度。5.2 超时、重试与慢站点拖累问题多spider并行时最典型的问题是某个站的DNS解析慢、连接迟迟不建立、超时一直拖到30秒才返回。这种慢请求虽然不占用其他spider的并发槽位但会占住这个spider自己的请求队列和等待回调的上下文导致这个站点的整体吞吐量骤降。我给所有spider统一设置了DOWNLOAD_TIMEOUT 20并把重试次数降为1。如果20秒内连不上说明目标站的网络链路有问题继续重试也大概率还是失败不如把槽位让给正常请求。再配合RETRY_HTTP_CODES排除掉一部分不需要重试的编码能明显减少无意义的等待。这里有一个取舍重试次数设得越少单个请求失败率会升高但整体的爬取完成时间更稳定。如果你的采集任务允许今天漏一点、明天再补低重试策略是最优解如果必须当天全量抓完那可以适当提高重试但需要单独的失败补偿机制而不是无限在调度器里重试。5.3 日志分离与运行监控多个spider同时跑的时候日志混在一个终端里几乎没法看。我用的方案是启动时给每个spider单独指定日志文件scrapy crawl news_a --logfile logs/news_a.log --loglevel INFO scrapy crawl sports_b --logfile logs/sports_b.log --loglevel INFO scrapy crawl travel_c --logfile logs/travel_c.log --loglevel INFO如果不想手动开三个终端也可以写个启动脚本后台拉起每个spider独立进程互不影响。这里要提醒一句Scrapy 2.x支持一次性启动多个spiderscrapy crawl news_a sports_b但所有spider共用一份LOG_FILE配置日志会混在一起。如果需要分文件记录我建议还是分别启动进程或者用扩展在spider维度重定向日志输出。监控层面我给项目加了信号回调监听item_scraped和spider_error把每个spider的抓取成功数、失败数、当前待处理请求数输出到自定义状态文件。这样不需要打开日志就能知道是哪个站点在报错、哪个站点的产量正常。整个监控扩展大概几十行代码但对排查问题省下大量时间。6. 实测踩坑复盘三条完整排查链路6.1 少抓一类数据的排查全过程项目上线后第一个异常表现为tech站点的数据正常politics站点的数据只有前一天有后面全没了。我首先怀疑的是Rule匹配问题于是去看日志结果politics站的抓取请求量确实有量还不小但Pipeline里落库的item数量明显少于请求数量。排查链路走了一遍先在parse_detail入口临时打了日志确认字段都填上了接着到DuplicateFilterPipeline检查发现politics站点有大量重复URL被Redis判重拦截。原来这个站的栏目列表页会把同一篇文章同时挂在推荐和最新两个入口下链接相同但采集顺序跨了两条规则一部分在抓取详情前就因重复请求被调度器直接忽略。根因是同一网站内不同站点的链接提取范围存在交叉同一篇详情页被多条Rule或多次采集申请重复调度。我调整了restrict_xpaths让每条规则的提取范围精确到对应栏目容器内同时在去重Pipeline里对请求级去重和生产级去重做了区分这才把问题解决。实践证明LinkExtractor的restrict_xpaths不是可有可无的参数多站点场景里它是防止重复请求的第一道闸门。6.2 Rules顺序导致回调错配的根因另一个让我困惑了半天的问题是sports站点的详情页一直进不了parse_detail反而被当作列表页递归抓取产生了大量无意义的嵌套请求。我一开始觉得Rules写得没问题详情页规则在前翻页规则在后。但实际看源码后发现Rule匹配时并非先匹配到就停止而是所有规则都会尝试匹配同一批链接最终行为由响应处理的顺序和规则定义综合决定。当详情页URL同时匹配了详情规则和更宽泛的翻页规则时Scrapy会把它分配给先定义且带callback的规则但如果我的详情规则allow写得太严比如要求URL里必须有某个数字格式错误URL就会被宽松规则接走。排查和修复的过程不算优雅我把每个站点详情页URL的样本拉出来逐一对着规则调试最后把宽松翻页规则全部加上了restrict_xpaths并且让详情规则尽量简洁准确。经验是CrawlSpider多个Rules的顺序优先级并不像直觉那么好写完之后一定要抓真实页面测一轮确认每个站点都进了对应回调再放开跑全量。6.3 多Spider共享Pipeline时的隐性问题最后一个值得说的坑共享Pipeline中如果某个管道内部持有了状态而多个spider同时使用这个管道实例就可能出现状态串扰。我的StoragePipeline一开始用了一个实例变量self.seen_count做统计在多spider并行时发现统计数字互相覆盖A站的数量被B站的请求冲掉了。排查后发现Scrapy的Pipeline对每个spider会实例化一次但不同spider的实例在同一个进程里共享同一个管道类的静态变量如果我用的是类变量而不是实例变量就会串。修复很简单把统计逻辑改成实例变量或干脆换成按spider的名字动态维护字典。但这个问题提醒了我一个原则共享Pipeline里的任何状态都必须按spider维度隔离不能假设同一时间只有一个spider在跑。同样适用于日志标签、告警频率控制这类隐性状态。这套单工程多spider的架构用下来最大感受是当采集目标从一两个网站变成十几个网站、每个网站还有一堆子站点时复杂度不会均匀地加在所有代码上而是集中在规则编排和管道分流这两个地方。把筛选规则的粒度做细把来源打标做扎实把管道的状态隔离做好整个系统就是稳定的。将来如果你要在这个基础上做全站更新检测或增量采集只要在Pipeline前面再加一层指纹对比即可骨架基本不用动。

关于本文作者

来自尧图内容编辑团队

尧图内容编辑团队 内容团队

尧图内容编辑团队

本文由尧图网络内容编辑团队执笔。团队由资深项目经理、前端工程师与设计师组成,所有内容均来自亲手交付的真实项目,先讲清问题、再给出可落地的解法。尧图深耕北京网站建设十年,服务过京华建材集团、智造科技等各行业客户,把一线经验沉淀为可复用的行业观察。

  • 十年建站经验,覆盖建材、制造、服务、文创等
  • 项目经理把关选题与事实准确性
  • 工程师与设计师联合撰写专业细节
  • 统一编辑规范,保证文风与排版一致
  • 每月复盘转化数据,迭代选题方向

延伸阅读

相关资讯与近期热门内容

深度阅读推荐

建站决策前值得细读的三篇

网站改版的5个关键决策
2024-08-12

网站改版的5个关键决策

什么时候该改版、改到什么程度、如何避免流量掉光,京华建材集团改版复盘给出答案。

获取专属建站方案

看完文章,把您的行业与预算告诉我们,免费获取一份量身定制的官网建设方案与报价。

立即免费咨询