:多栏目适配开发 - 通用解析规则兼容差异化网页结构)
前言大中型资讯平台、行业门户、内容聚合类站点普遍存在多栏目、多频道、多子页面并存的场景不同栏目虽然归属同一主站但页面布局、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[classitem]) for book in book_list: item SqliteSpiderItem() item[book_name] book.xpath(.//div[classpl2]/a/title).extract_first().strip() book_info book.xpath(.//p[classpl]/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[classrating_nums]/text()).extract_first() yield item # 翻页逻辑复用所有栏目自动分页采集 next_page response.xpath(//span[classnext]/a/href).extract_first() if next_page: next_url response.urljoin(next_page) yield scrapy.Request(next_url, callbackself.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[classitem], book_name: .//div[classpl2]/a/title, book_info: .//p[classpl]/text(), book_score: .//span[classrating_nums]/text() } }, new_book: { column_name: 新书推荐, url: https://book.douban.com/new, selector: { list: //div[classnew-book-item], book_name: .//h3/a/text(), book_info: .//div[classauthor]/text(), book_score: .//span[classscore]/text() } }, classic_book: { column_name: 经典名著, url: https://book.douban.com/classic, selector: { list: //li[classclassic-item], book_name: .//a[classbook-title]/text(), book_info: .//div[classbook-desc]/text(), book_score: .//em[classstar-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[classitem], book_name: .//div[classpl2]/a/title, book_info: .//p[classpl]/text(), book_score: .//span[classrating_nums]/text() } }, new_book: { column_name: 新书推荐, url: https://book.douban.com/new, selector: { list: //div[classnew-book-item], book_name: .//h3/a/text(), book_info: .//div[classauthor]/text(), book_score: .//span[classscore]/text() } }, classic_book: { column_name: 经典名著, url: https://book.douban.com/classic, selector: { list: //li[classclassic-item], book_name: .//a[classbook-title]/text(), book_info: .//div[classbook-desc]/text(), book_score: .//em[classstar-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, callbackself.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[classitem]) for book in book_list: item SqliteSpiderItem() item[book_name] book.xpath(.//div[classpl2]/a/title).extract_first().strip() info book.xpath(.//p[classpl]/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[classrating_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), callbackself.parse) def parse_new(self, response): 新书栏目专属解析逻辑新子域名 book_list response.xpath(//div[classnew-book-card]) for book in book_list: item SqliteSpiderItem() item[book_name] book.xpath(.//h4/text()).extract_first().strip() info book.xpath(.//div[classdesc]/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[classscore-num]/text()).extract_first() yield item # 分页请求 next_url response.xpath(//li[classnext]/a/href).extract_first() if next_url: yield scrapy.Request(response.urljoin(next_url), callbackself.parse) def parse_classic(self, response): 经典名著栏目专属解析逻辑名著子域名 book_list response.xpath(//ul[classclassic-list]/li) for book in book_list: item SqliteSpiderItem() item[book_name] book.xpath(.//a/title).extract_first().strip() info book.xpath(.//p[classintro]/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[classstar]/span/text()).extract_first() yield item # 分页请求 next_url response.xpath(//a[classpage-next]/href).extract_first() if next_url: yield scrapy.Request(response.urljoin(next_url), callbackself.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[classnew-title]/text()).extract_first() if not book_name: book_name node.xpath(.//div[classold-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 页面改版模拟测试手动修改某一个栏目的选择器配置模拟站点页面改版观察爬虫是否自动适配删除某一条栏目配置验证爬虫不会因配置缺失崩溃仅跳过对应栏目采集。