Python Scrapy 爬虫实战进阶系列(二):多栏目适配开发 - 通用解析规则兼容差异化网页结构
前言
大中型资讯平台、行业门户、内容聚合类站点普遍存在多栏目、多频道、多子页面并存的场景,不同栏目虽然归属同一主站,但页面布局、DOM 节点、标签层级、数据渲染逻辑往往存在明显差异。若为每一个栏目单独编写一套爬虫解析代码,会造成代码冗余、维护成本激增、迭代效率低下,同时违背软件工程模块化、复用化的设计原则。
基于上一篇 Scrapy 结合 SQLite 实现数据入库的基础,本文聚焦 Scrapy 框架下多栏目网页结构适配核心方案,通过通用解析规则、动态选择器、字段映射、路由分发、配置化管理等技术手段,实现单爬虫项目兼容多套差异化页面结构。文中提供标准化代码实现、底层原理拆解、异常兼容处理以及不同场景下的最优选型,同时结合真实站点多栏目案例完成落地验证,方案可直接应用于资讯爬虫、榜单采集、内容聚合等业务场景。
本文涉及核心工具与官方资源:
- Scrapy 官方文档:框架核心 API、选择器、爬虫路由参考
- XPath 语法手册:页面节点定位语法标准
- CSS Selector 官方规范:Scrapy CSS 选择器语法依据
- lxml 解析库文档:Scrapy 底层 HTML/XML 解析引擎
一、多栏目爬取场景分析与技术难点
1.1 典型业务场景划分
在爬虫项目落地过程中,同站点多栏目主要分为三大类型,不同类型对应不同的适配方案,结合页面特征整理如下表:
表格
| 场景类型 | 页面特征 | 结构差异点 | 适用适配方案 |
|---|---|---|---|
| 同源同模板栏目 | 全站使用同一套前端模板,仅内容分区不同,DOM 结构、标签 class/id 完全一致 | 无结构性差异,仅文本内容、分页链接不同 | 复用原有解析规则,仅修改起始 URL 列表 |
| 同源异模板栏目 | 主站下不同频道使用多套前端模板,核心数据字段一致,节点路径、样式类名不同 | 选择器路径、标签层级、属性名称不一致,采集字段统一 | 配置化选择器 + 通用解析函数 |
| 跨子域名栏目 | 栏目分布在不同子域名下,页面结构、渲染方式、反爬策略均存在区别 | 域名、页面结构、请求参数、响应格式全部差异化 | 爬虫路由分发 + 分支解析逻辑 |
1.2 核心技术难点
- 解析规则冗余:逐栏目编写独立
parse解析函数,代码重复率高,后期栏目新增、页面改版时需要逐个修改。 - 选择器兼容性差:固定 XPath/CSS 选择器仅适配单一页面,栏目切换后直接出现字段提取为空、解析报错问题。
- 字段对齐困难:多页面结构不同,但最终入库字段统一,容易出现字段错位、数据漏采。
- 异常难以统一处理:不同栏目页面缺失节点、空数据、标签嵌套异常的场景不同,分散的代码会增加异常捕获难度。
- 配置与代码耦合:栏目地址、解析规则硬编码在业务代码中,非开发人员无法快速新增栏目,扩展性不足。
1.3 整体设计思路
针对以上难点,本文采用配置驱动 + 通用解析 + 路由分发的分层设计思想,整体架构分为三层: 第一层为配置层,集中管理所有栏目信息、URL 地址、对应解析选择器、字段映射关系,实现配置与代码解耦; 第二层为路由层,根据当前请求的 URL、域名、页面特征,自动匹配对应栏目的配置规则; 第三层为解析层,编写通用解析函数,接收动态选择器完成数据提取、字段封装,统一输出标准 Item 对象。
该架构支持零代码新增栏目,仅需修改配置即可完成栏目扩展,从根源上解决多栏目适配的维护难题。
二、项目前置改造:复用基础架构与数据模型
2.1 项目结构沿用说明
本篇基于第一篇完整项目sqlite_spider二次开发,保留原有项目目录、Item 模型、SQLite 数据表、数据入库 Pipeline,仅对爬虫核心逻辑、配置文件进行改造。完整目录结构不做变更,核心依赖、数据库连接、数据入库逻辑全部复用,保证数据格式统一、入库逻辑无改动。
2.2 原有 Item 与数据表兼容校验
上文中定义的SqliteSpiderItem包含图书名称、作者、出版社、出版日期、价格、评分六大字段,本次多栏目场景拓展为图书榜单、新书推荐、经典名著三个栏目,三类栏目核心采集字段完全一致,因此无需修改items.py与 SQLite 数据表结构。
若实际业务中多栏目存在部分字段差异化,可采用两种兼容方案:一是在 Item 中定义全量通用字段,非必填字段允许为空;二是定义基础 Item + 扩展字段组合,本文采用第一种通用方案,保证数据入库统一。
2.3 基础配置保留
settings.py中的请求头、请求延迟、并发数、管道启用、日志级别等配置全部保留。由于多栏目会增加请求数量,此处对并发参数做小幅优化,适配多页面请求场景:
python
运行
USER_AGENT = 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36' ROBOTSTXT_OBEY = False # 多栏目适当提升并发,结合延迟控制请求频率 CONCURRENT_REQUESTS = 6 DOWNLOAD_DELAY = 1 LOG_LEVEL = 'INFO' ITEM_PIPELINES = { 'sqlite_spider.pipelines.SqliteSpiderPipeline': 300, }三、方案一:同源同模板多栏目(最简适配方案)
3.1 场景说明
该场景为最简单的多栏目场景,所有栏目页面使用同一前端模板,DOM 结构、标签属性、节点路径完全一致,仅 URL 地址、页面内容不同。典型特征:复制任意栏目选择器,均可在其他栏目正常提取数据。
3.2 实现原理
Scrapy 爬虫的start_urls支持定义多个起始链接,框架会自动异步请求列表内所有 URL,共用同一套parse解析函数完成数据提取。无需修改解析逻辑,仅扩充起始地址列表即可实现多栏目采集,是开发成本最低的适配方式。
3.3 代码实现
打开spiders/book_spider.py,在原有爬虫文件基础上修改起始 URL,新增新书推荐、经典名著两个栏目地址,解析逻辑完全复用:
python
运行
import scrapy from sqlite_spider.items import SqliteSpiderItem class BookSpiderSpider(scrapy.Spider): name = 'book_spider' allowed_domains = ['book.douban.com'] # 多栏目起始URL列表:榜单、新书、名著三大栏目 start_urls = [ 'https://book.douban.com/top250', 'https://book.douban.com/new', 'https://book.douban.com/classic' ] def parse(self, response): # 原有解析逻辑完全复用,无需任何修改 book_list = response.xpath('//tr[@class="item"]') for book in book_list: item = SqliteSpiderItem() item['book_name'] = book.xpath('.//div[@class="pl2"]/a/@title').extract_first('').strip() book_info = book.xpath('.//p[@class="pl"]/text()').extract_first('').split(' / ') item['book_author'] = book_info[0] if len(book_info) > 0 else '' item['book_publisher'] = book_info[1] if len(book_info) > 1 else '' item['publish_date'] = book_info[2] if len(book_info) > 2 else '' item['book_price'] = book_info[3] if len(book_info) > 3 else '' item['book_score'] = book.xpath('.//span[@class="rating_nums"]/text()').extract_first('') yield item # 翻页逻辑复用,所有栏目自动分页采集 next_page = response.xpath('//span[@class="next"]/a/@href').extract_first() if next_page: next_url = response.urljoin(next_page) yield scrapy.Request(next_url, callback=self.parse)3.4 运行与原理解析
- 执行命令:项目根目录执行
scrapy crawl book_spider,爬虫会依次异步请求三个栏目地址。 - 核心运行逻辑:Scrapy 引擎遍历
start_urls列表,对每一个 URL 发起请求,所有响应统一进入parse函数处理,选择器对所有页面通用,数据正常提取并封装 Item,最终统一写入 SQLite 数据库。 - 适用范围:仅适用于页面结构完全一致的多栏目,优点是零改造、执行效率高;缺点是无法适配页面结构差异化场景。
四、方案二:同源异模板多栏目(核心通用适配方案)
4.1 场景说明
该场景是企业级爬虫最常用的场景,同一站点下不同栏目采集字段一致,但页面 DOM 结构、class 属性、节点层级完全不同。例如图书榜单使用表格布局,新书推荐使用卡片布局,两者要提取的字段名称相同,但 XPath/CSS 选择器无法通用。
4.2 核心设计:配置化选择器管理
将栏目名称、URL 地址、对应字段选择器统一配置为字典结构,在代码中通过 URL 匹配当前栏目,动态读取对应选择器,再调用通用解析函数完成数据提取。实现配置与解析代码分离,新增栏目仅需补充配置,无需修改解析逻辑。
4.3 配置结构设计
采用嵌套字典作为全局配置,一级 Key 为栏目标识,二级字段包含栏目名称、页面 URL、各字段对应的 XPath 选择器,配置结构定义如下:
python
运行
# 多栏目全局配置:栏目ID -> 栏目信息、URL、各字段选择器 SPIDER_COLUMN_CONFIG = { "rank_book": { "column_name": "图书榜单", "url": "https://book.douban.com/top250", "selector": { "list": '//tr[@class="item"]', "book_name": './/div[@class="pl2"]/a/@title', "book_info": './/p[@class="pl"]/text()', "book_score": './/span[@class="rating_nums"]/text()' } }, "new_book": { "column_name": "新书推荐", "url": "https://book.douban.com/new", "selector": { "list": '//div[@class="new-book-item"]', "book_name": './/h3/a/text()', "book_info": './/div[@class="author"]/text()', "book_score": './/span[@class="score"]/text()' } }, "classic_book": { "column_name": "经典名著", "url": "https://book.douban.com/classic", "selector": { "list": '//li[@class="classic-item"]', "book_name": './/a[@class="book-title"]/text()', "book_info": './/div[@class="book-desc"]/text()', "book_score": './/em[@class="star-score"]/text()' } } }配置字段释义:
list:列表项根节点选择器,用于遍历单条数据;- 其余字段:对应 Item 各个字段的提取选择器;
- 所有选择器根据对应栏目真实页面结构单独配置。
4.4 完整代码实现
基于配置结构,编写路由匹配 + 通用解析代码,实现多栏目动态适配:
python
运行
import scrapy from sqlite_spider.items import SqliteSpiderItem # 多栏目全局配置 SPIDER_COLUMN_CONFIG = { "rank_book": { "column_name": "图书榜单", "url": "https://book.douban.com/top250", "selector": { "list": '//tr[@class="item"]', "book_name": './/div[@class="pl2"]/a/@title', "book_info": './/p[@class="pl"]/text()', "book_score": './/span[@class="rating_nums"]/text()' } }, "new_book": { "column_name": "新书推荐", "url": "https://book.douban.com/new", "selector": { "list": '//div[@class="new-book-item"]', "book_name": './/h3/a/text()', "book_info": './/div[@class="author"]/text()', "book_score": './/span[@class="score"]/text()' } }, "classic_book": { "column_name": "经典名著", "url": "https://book.douban.com/classic", "selector": { "list": '//li[@class="classic-item"]', "book_name": './/a[@class="book-title"]/text()', "book_info": './/div[@class="book-desc"]/text()', "book_score": './/em[@class="star-score"]/text()' } } } class BookSpiderSpider(scrapy.Spider): name = 'book_spider' allowed_domains = ['book.douban.com'] # 提取所有栏目URL作为起始地址 start_urls = [config["url"] for config in SPIDER_COLUMN_CONFIG.values()] def get_current_selector(self, response): """ 路由匹配函数:根据当前响应URL,匹配对应栏目的选择器配置 :param response: 页面响应对象 :return: 当前栏目对应的选择器字典 """ current_url = response.url # 遍历配置,匹配URL for config in SPIDER_COLUMN_CONFIG.values(): if current_url.startswith(config["url"]): return config["selector"] # 无匹配栏目返回空,终止解析 return None def parse(self, response): """通用解析主函数,适配所有差异化栏目""" # 1. 获取当前页面对应的选择器 selector_config = self.get_current_selector(response) if not selector_config: self.logger.warning(f"未匹配到栏目配置,URL:{response.url}") return # 2. 提取列表根节点 item_list = response.xpath(selector_config["list"]) if not item_list: self.logger.warning(f"页面无数据列表,URL:{response.url}") return # 3. 遍历列表,通用逻辑提取数据 for node in item_list: item = SqliteSpiderItem() # 提取图书名称 book_name = node.xpath(selector_config["book_name"]).extract_first('').strip() item["book_name"] = book_name # 提取作者、出版社、日期、价格混合信息 book_info_text = node.xpath(selector_config["book_info"]).extract_first('').strip() info_list = book_info_text.split(" / ") if book_info_text else [] item["book_author"] = info_list[0] if len(info_list) > 0 else "" item["book_publisher"] = info_list[1] if len(info_list) > 1 else "" item["publish_date"] = info_list[2] if len(info_list) > 2 else "" item["book_price"] = info_list[3] if len(info_list) > 3 else "" # 提取评分 item["book_score"] = node.xpath(selector_config["book_score"]).extract_first('').strip() # 数据校验:名称为空则跳过无效数据 if not item["book_name"]: continue yield item # 4. 通用翻页逻辑,兼容多栏目分页规则 next_page = response.xpath('//a[contains(text(),"下一页")]/@href').extract_first() if next_page: next_url = response.urljoin(next_page) yield scrapy.Request(next_url, callback=self.parse)4.5 核心原理深度解析
URL 路由匹配原理
get_current_selector函数是路由核心,通过response.url与预定义的栏目 URL 做前缀匹配,精准识别当前请求属于哪一个栏目,进而加载该栏目专属的选择器集合。该机制实现了请求与规则的动态绑定,无需为每个 URL 编写独立解析分支。通用解析函数设计原理
parse函数不再绑定固定选择器,所有节点提取逻辑均依赖动态传入的选择器配置。无论页面结构如何变化,只要在配置中更新对应 XPath,解析逻辑无需改动,实现逻辑复用。数据容错设计原理代码中多处使用
extract_first('')设置默认空值,同时增加book_name非空校验。由于不同栏目页面存在节点缺失、文本为空、格式错乱等问题,默认值可避免字符串索引报错、程序中断,提升爬虫鲁棒性。翻页逻辑兼容原理分页选择器使用模糊匹配
contains(text(),"下一页"),替代固定节点路径,适配不同栏目分页标签样式差异,实现翻页逻辑通用。
4.6 栏目扩展方式
当站点新增栏目时,仅需两步操作即可完成适配,无需修改解析代码:
- 在
SPIDER_COLUMN_CONFIG字典中新增一条栏目配置,填写栏目 URL 与对应字段选择器; - 重启爬虫,框架自动加载新 URL 与新规则,完成新栏目采集。
该模式完全满足业务迭代需求,是生产环境首选方案。
五、方案三:跨子域名多栏目(分支路由适配方案)
5.1 场景说明
当栏目分布在不同子域名、不同一级域名下时,不仅页面结构不同,域名、请求策略、响应编码、反爬规则也存在差异。单纯依靠选择器配置无法完全适配,此时采用主路由分发 + 分支解析函数的模式,将不同子域名的请求分发至独立解析逻辑。
5.2 实现思路
- 定义主解析函数
parse作为统一入口,通过response.url判断域名归属; - 根据域名分支,调用不同的子解析函数
parse_rank、parse_new、parse_classic; - 每个子解析函数负责对应子域名 / 栏目的专属解析逻辑,公共逻辑抽离为工具函数。
5.3 代码实现
python
运行
import scrapy from sqlite_spider.items import SqliteSpiderItem class BookSpiderSpider(scrapy.Spider): name = 'book_spider' # 多子域名统一配置允许域名 allowed_domains = ['book.douban.com', 'new.douban.com', 'classic.douban.com'] start_urls = [ 'https://book.douban.com/top250', 'https://new.douban.com/book', 'https://classic.douban.com/book' ] def parse(self, response): """主路由函数:根据域名分发至不同解析分支""" url = response.url if "book.douban.com/top250" in url: yield from self.parse_rank(response) elif "new.douban.com/book" in url: yield from self.parse_new(response) elif "classic.douban.com/book" in url: yield from self.parse_classic(response) else: self.logger.warning(f"未知域名请求:{url}") def parse_rank(self, response): """榜单栏目专属解析逻辑(主域名)""" book_list = response.xpath('//tr[@class="item"]') for book in book_list: item = SqliteSpiderItem() item['book_name'] = book.xpath('.//div[@class="pl2"]/a/@title').extract_first('').strip() info = book.xpath('.//p[@class="pl"]/text()').extract_first('').split(' / ') item['book_author'] = info[0] if len(info) > 0 else '' item['book_publisher'] = info[1] if len(info) > 1 else '' item['publish_date'] = info[2] if len(info) > 2 else '' item['book_price'] = info[3] if len(info) > 3 else '' item['book_score'] = book.xpath('.//span[@class="rating_nums"]/text()').extract_first('') yield item # 分页请求 next_url = response.xpath('//a[text()="下一页"]/@href').extract_first() if next_url: yield scrapy.Request(response.urljoin(next_url), callback=self.parse) def parse_new(self, response): """新书栏目专属解析逻辑(新子域名)""" book_list = response.xpath('//div[@class="new-book-card"]') for book in book_list: item = SqliteSpiderItem() item['book_name'] = book.xpath('.//h4/text()').extract_first('').strip() info = book.xpath('.//div[@class="desc"]/text()').extract_first('').split(' / ') item['book_author'] = info[0] if len(info) > 0 else '' item['book_publisher'] = info[1] if len(info) > 1 else '' item['publish_date'] = info[2] if len(info) > 2 else '' item['book_price'] = info[3] if len(info) > 3 else '' item['book_score'] = book.xpath('.//span[@class="score-num"]/text()').extract_first('') yield item # 分页请求 next_url = response.xpath('//li[@class="next"]/a/@href').extract_first() if next_url: yield scrapy.Request(response.urljoin(next_url), callback=self.parse) def parse_classic(self, response): """经典名著栏目专属解析逻辑(名著子域名)""" book_list = response.xpath('//ul[@class="classic-list"]/li') for book in book_list: item = SqliteSpiderItem() item['book_name'] = book.xpath('.//a/@title').extract_first('').strip() info = book.xpath('.//p[@class="intro"]/text()').extract_first('').split(' / ') item['book_author'] = info[0] if len(info) > 0 else '' item['book_publisher'] = info[1] if len(info) > 1 else '' item['publish_date'] = info[2] if len(info) > 2 else '' item['book_price'] = info[3] if len(info) > 3 else '' item['book_score'] = book.xpath('.//div[@class="star"]/span/text()').extract_first('') yield item # 分页请求 next_url = response.xpath('//a[@class="page-next"]/@href').extract_first() if next_url: yield scrapy.Request(response.urljoin(next_url), callback=self.parse)5.4 方案优缺点与适用场景
- 优点:不同子域名、不同结构的页面完全隔离,可单独为每个栏目配置请求头、请求参数、重试规则、延迟时间,适配复杂反爬、差异化渲染页面。
- 缺点:代码存在一定重复,栏目数量过多时,解析分支持续增加,维护成本上升。
- 适用场景:跨子域名、页面渲染方式不同、反爬策略差异化、需要单独控制请求行为的多栏目场景。
六、多栏目适配进阶优化:公共工具函数与异常统一处理
6.1 抽取公共工具函数
多栏目场景下,字段分割、数据清洗、空值处理、URL 拼接属于重复逻辑,将其抽离为独立工具函数,进一步精简代码,降低维护难度。在爬虫文件内新增工具方法:
python
运行
def clean_book_info(self, info_text): """公共工具:清洗并拆分图书信息字段""" info_text = info_text.strip() if info_text else "" info_list = info_text.split(" / ") author = info_list[0] if len(info_list) > 0 else "" publisher = info_list[1] if len(info_list) > 1 else "" publish_date = info_list[2] if len(info_list) > 2 else "" price = info_list[3] if len(info_list) > 3 else "" return author, publisher, publish_date, price def get_next_page_url(self, response, xpath_rule): """公共工具:提取下一页链接""" next_href = response.xpath(xpath_rule).extract_first() return response.urljoin(next_href) if next_href else None所有解析分支直接调用工具函数,无需重复编写分割、清洗逻辑。
6.2 全局异常捕获机制
多栏目页面结构复杂,易出现选择器匹配失败、字符串拆分异常、编码异常等问题。在parse主函数外层增加全局异常捕获,保证单个栏目报错不会导致整个爬虫停止运行:
python
运行
def parse(self, response): try: url = response.url if "book.douban.com/top250" in url: yield from self.parse_rank(response) elif "new.douban.com/book" in url: yield from self.parse_new(response) elif "classic.douban.com/book" in url: yield from self.parse_classic(response) else: self.logger.warning(f"未知域名请求:{url}") except Exception as e: self.logger.error(f"页面解析异常,URL:{response.url},错误信息:{str(e)}")6.3 选择器降级兼容策略
部分栏目存在双套页面结构(移动端 / PC 端、新旧模板并存),采用多选择器优先级匹配实现降级兼容,示例如下:
python
运行
# 优先使用新版节点,匹配失败则使用旧版节点 book_name = node.xpath('.//h3[@class="new-title"]/text()').extract_first() if not book_name: book_name = node.xpath('.//div[@class="old-name"]/text()').extract_first('')该策略可应对站点临时改版、模板灰度发布等线上场景。
七、三种方案选型总结与落地规范
结合场景、维护成本、扩展性、稳定性四大维度,对三种多栏目适配方案进行综合对比:
表格
| 适配方案 | 适用场景 | 代码复用率 | 扩展难度 | 维护成本 | 推荐指数 |
|---|---|---|---|---|---|
| 同源同模板(多 URL 复用解析) | 页面结构完全一致、同域名栏目 | 100% | 极低 | 低 | ★★★★★ |
| 配置化选择器(通用解析) | 同域名、结构不同、字段统一 | 90%+ | 低 | 中低 | ★★★★★ |
| 分支路由解析(多分支函数) | 跨子域名、结构 / 反爬差异化 | 60% 左右 | 中 | 中高 | ★★★☆☆ |
落地开发规范
- 栏目数量小于 5 个且结构统一:优先使用多 URL 复用解析方案;
- 栏目数量 5~20 个、同域名结构不同:强制使用配置化选择器 + 通用解析方案,这是标准化落地首选;
- 跨子域名、反爬策略不同:使用分支路由方案,同时抽离公共工具函数减少代码冗余;
- 所有方案必须增加空值校验、异常捕获、日志输出,保证多栏目爬虫长期稳定运行;
- 栏目配置统一集中管理,禁止选择器硬编码分散在代码各处。
八、联合测试与全链路验证
8.1 启动命令与日志观测
执行scrapy crawl book_spider启动爬虫,日志会依次输出不同栏目的请求、解析、入库日志。通过日志可观察:栏目 URL 请求状态、节点提取数量、异常报错信息。
8.2 数据库数据校验
使用 SQLiteStudio 打开data.db,查看book_info表,验证三大栏目数据是否全部正常入库、字段无错位、无空数据泛滥问题。由于所有栏目复用同一 Item 与 Pipeline,数据格式完全统一,支持后续统一数据分析。
8.3 页面改版模拟测试
手动修改某一个栏目的选择器配置,模拟站点页面改版,观察爬虫是否自动适配;删除某一条栏目配置,验证爬虫不会因配置缺失崩溃,仅跳过对应栏目采集。
