Scrapy SEP-020 深度解读:Bulk Item Loader —— 用模式识别自动填充 Item Loader 的提案与实践
【免费下载链接】scrapyScrapy, a fast high-level web crawling & scraping framework for Python.项目地址: https://gitcode.com/GitHub_Trending/sc/scrapy
导读
本文围绕 Scrapy 官方 Enhancement Proposal(SEP)体系中的SEP-020: Bulk Item Loader展开。它提出:既然 Item Loader 解决了"如何填充 Item"的便捷性问题,那么同样可以有一个"批量"层面的工具,自动识别页面中结构规整的标记模式(如<dl>定义列表、<table>表格等),把"字段名 + 字段值"一次性地灌入 Item Loader,从而免去逐字段硬编码 XPath。读完本文,你将完整理解该提案的设计动机、工作方式、核心代码实现与扩展思路,同时掌握它与现代 Scrapy(scrapy.loader)之间的 API 映射关系。
背景说明:SEP 目录(sep/)存放 Scrapy Enhancement Proposals(Scrapy 增强提案),其中多数由旧 Trac Wiki 迁移而来,参见 sep/README.rst。SEP-020 标注为Draft(草案)状态,作者 Steven Almeroth,创建于 2012-02-24,并在页首注明"This SEP has been migrated from the Wiki"。因此文中代码属于历史提案示例,需结合当时与当前的 API 差异理解。
一、提案的立足点:Item Loader 之上再加一层"批量"
SEP-020 的出发点非常明确。官方文档在 docs/topics/loaders.rst 中写道:
Item Loaders provide a convenient mechanism for populating scraped items(Item Loader 为填充爬取到的 Item 提供了便捷机制)。
Item 提供"装数据的容器",而 Item Loader 提供"填充这个容器的机制"(详见 docs/topics/loaders.rst)。SEP-020 顺着这一层抽象继续向上延伸,提出Bulk Item Loader(批量 Item Loader):
Just as Item Loaders "provide a convenient mechanism for populating scraped Items", the Bulk Item Loader provides a convenient mechanism for populating Item Loaders.
也就是说:当面对成批的、同构的"字段名/字段值"结构时,程序员不应该手写几十行add_xpath,而应当让 Loader 自己"看懂"页面的标记结构并自动填充。
二、设计动机(Rationale):哪些 HTML 模式适合自动解析
现实中存在许多天然适合自动化解析的标记模式,提案点名了两类:
<table>表格:<tr>代表一行,<tr>内的<td>代表一个个字段——天然对应"一条数据库记录"。- 定义列表
<dl>:这是提案认为尤其适合自动填充 Item Loader 的模式。<dt>(definition term)放字段名,紧随其后的<dd>(definition description)放字段值。
提案给出了经典的 W3C HTML 4.01 风格示例:
<div class="geeks"> <dl> <dt> hacker <dd> a clever programmer <dt> nerd <dd> technically bright but socially inept person </dl> </div>对应地,W3C 的 "The ingredients"(配料)、"The procedure"(步骤)类页面结构正好是一组组dt/dd键值对,这正是本提案意欲自动化处理的典型场景。该示例页即是提案示例 Spider 抓取的http://www.w3.org/TR/html401/struct/lists.html(详见下文"示例 Spider"部分)。
在这一结构里,每个<dt>承载字段name,它后面紧跟的<dd>承载字段value。
三、工作方式:从"逐个硬编码"到"给定一个种子点"
SEP 明确指出:没有批量加载器时,程序员需要把所有条目逐一硬编码;有了批量加载器,只需提供一个"种子点"(seed point),Loader 即可根据模式自动完成余下的工作。
3.1 Before:手工逐字段 XPath
传统写法需要针对每个字段拼接 XPath 并单独调用一次add_xpath:
xpath = '//div[@class="geeks"]/dl/dt[contains(text(),"%s")]/following-sibling::dd[1]//text()' gl = XPathItemLoader(response=response, item=dict()) gl.default_output_processor = Compose(TakeFirst(), lambda v: v.strip()) gl.add_xpath("hacker", xpath % "hacker") gl.add_xpath("nerd", xpath % "nerd")这段代码的问题很明显:字段每多一个,就要多写一行几乎一样的调用;而且 XPath 本身还是用%字符串格式化拼出来的,维护性差、易出错。
3.2 After:一行触发批量解析
在 BulkItemLoader 的帮助下,同样的逻辑被压缩成两步:
bil = BulkItemLoader(response=response) bil.parse_dl('//div[@class="geeks"]/dl')只要给出定义列表的 XPath 位置(即"种子点"),parse_dl就会自动遍历其中的所有dt/dd键值对并填充 Loader。
四、核心代码提案逐行拆解
这是 SEP-020 给出的"仅覆盖基础功能的可运行示例",也是整个提案的代码骨架。注意其中 import 的是历史路径scrapy.contrib.loader(旧版模块位置),在现代 Scrapy 中对应scrapy.loader:
from scrapy.contrib.loader import XPathItemLoader from scrapy.contrib.loader.processor import MapCompose class BulkItemLoader(XPathItemLoader): """Item loader based on specified pattern recognition""" default_item_class = dict base_xpath = "//body" ignore = () def _get_label(self, entity): """Pull the text label out of selected markup :param entity: Found markup :type entity: Selector """ label = " ".join(entity.xpath(".//text()").extract()) label = label.encode("ascii", "xmlcharrefreplace") if label else "" label = label.strip(" ") if " " in label else label label = label.strip(":") if ":" in label else label label = label.strip() return label def _get_entities(self, xpath): """Retrieve the list of selectors for a given sub-pattern :param xpath: The xpath to select :type xpath: String :return: The list of selectors :rtype: list """ return self.selector.xpath(self.base_xpath + xpath) def parse_dl(self, xpath="//dl"): """Look for the specified definition list pattern and store all found values for the enclosed terms and descriptions. :param xpath: The xpath to select :type xpath: String """ for term in self._get_entities(xpath + "/dt"): label = self._get_label(term) if label and label not in self.ignore: value = term.xpath("following-sibling::dd[1]//text()") if value: self.add_value( label, value.extract(), MapCompose(lambda v: v.strip()) )4.1 类级配置:三个可调参数
| 属性 | 默认值 | 作用 |
|---|---|---|
default_item_class | dict | 当实例化时未显式传入 item 时,用该工厂自动创建容器。这里直接用dict,即最终得到一个普通字典而非 Scrapy Item |
base_xpath | "//body" | 所有传入的 XPath 都会先拼接此前缀。相当于给后续所有parse_*方法设一个"根作用域",便于整体限定在页面某区域 |
ignore | () | 一个标签元组(黑名单),_get_label得到的标签若命中该集合则被跳过,不会写入 Loader |
这种"子类级默认配置 + 方法级参数覆盖"的设计,与现代 Item Loader 的处理器声明方式异曲同工:现代的 Item Loader 通过default_input_processor、default_output_processor、default_selector_class等类属性定制行为(参见 scrapy/loader/init.py 中ItemLoader的 docstring 与类属性定义,default_item_class: type = Item、default_selector_class = Selector)。
4.2_get_label(entity):从标记中提取"干净"的字段名
这是字段名清洗的核心方法,按顺序执行一系列处理:
" ".join(entity.xpath(".//text()").extract()):取出该节点下所有文本并拼接——字段名内部可能被多个文本节点(如内联标签、换行)切碎,需要先合并。label.encode("ascii", "xmlcharrefreplace"):若文本含非 ASCII 字符,将其转成&#NNN;形式的 XML 字符引用(如 表示不间断空格),从而保证字段名是纯 ASCII、可安全用作字典键。若label为空则不转换。- 若包含
 ,则用strip(" ")剥掉首尾的字符引用。 - 若包含
:,则用strip(":")剥掉冒号(如 W3C 示例页中的'The ingredients:'→'The ingredients')。 - 最后再做一次
strip()清理空白。
4.3_get_entities(xpath):批量取出子模式的选择器列表
return self.selector.xpath(self.base_xpath + xpath)把类级base_xpath前缀与方法传入的 XPath 拼接后执行查询,返回 Selector 列表。self.selector来自 Item Loader 的selector/response机制(详见下文现代实现映射)。
4.4parse_dl(xpath="//dl"):核心批量解析入口
工作流程是清晰的三步循环:
- 先查
xpath + "/dt"拿到所有dt节点(列表); - 对每个
dt用_get_label提取并清洗出字段名label; - 过滤:
label非空 且 不在ignore黑名单中; - 用相对 XPath
following-sibling::dd[1]//text()取该dt紧随其后的第一个dd内的全部文本节点; - 若取到值,就调用
self.add_value(label, value.extract(), MapCompose(lambda v: v.strip()))——把字段名作为 key、dd内所有文本节点作为原始值列表写入 Loader,并由输入处理器MapCompose(lambda v: v.strip())对每个值做strip()清洗(extract()返回列表,正好对应 Item Loader "内部以列表收集数据"的语义,见 docs/topics/loaders.rst 中"Collected data is internally stored as lists"的说明)。
这里复用 Item Loader 的add_value,意味着所有已有的字段处理器、上下文机制都被天然继承,这是该提案能"站在 Item Loader 肩膀上"的根本原因。
五、示例 Spider:抓取 W3C 列表页面
提案给出了一个配套示例 Spider,运行后可以验证parse_dl在真实页面上的输出。
5.1 Spider 代码
from scrapy.spider import BaseSpider from scrapy.contrib.loader.bulk import BulkItemLoader class W3cSpider(BaseSpider): name = "w3c" allowed_domains = ["w3.org"] start_urls = ("http://www.w3.org/TR/html401/struct/lists.html",) def parse(self, response): el = BulkItemLoader(response=response) el.parse_dl("//dl[2]") item = el.load_item() from pprint import pprint pprint(item)注意,示例中的from scrapy.contrib.loader.bulk import BulkItemLoader表明该 BulkItemLoader 原计划作为一个独立的bulk子模块对外暴露(对应scrapy.contrib.loader.bulk.py文件)。示例里调用el.parse_dl("//dl[2]")只指定了页面中的第二个<dl>作为种子点,最后用load_item()产出最终字典。
关于示例用到的旧版 API 与现代对应关系(提案写于 2012 年,彼时 Scrapy 0.17):
| 提案中的历史 API | 现代 Scrapy 中的对应 |
|---|---|
scrapy.spider.BaseSpider | scrapy.Spider |
scrapy.contrib.loader.XPathItemLoader | scrapy.loader.ItemLoader(XPath/CSS 方法已合并,见下方说明) |
scrapy.contrib.loader.processor.MapCompose | itemloaders.processors.MapCompose(以及TakeFirst、Compose、Join等) |
.extract() | .getall()(单值可用.get()) |
现代scrapy.loader.ItemLoader(源码见 scrapy/loader/init.py)继承自itemloaders.ItemLoader,其 docstring 明确说明:当以response实例化时,会用default_selector_class(默认Selector)自动构造选择器,无需显式创建XPathItemLoader这类子类——因为现代ItemLoader同时提供add_xpath、add_css、add_value等方法。若按现代写法改写上述 Spider 的实例化部分,等价于:
from scrapy.loader import ItemLoader el = ItemLoader(response=response) # 自动以 response 构造 Selector由于本次目标是示范parse_dl这类自定义方法,最贴合的做法是让自定义的BulkItemLoader继承scrapy.loader.ItemLoader而非历史同名类;这也体现了提案思想在现代代码库中落地时只需替换基类即可。
5.2 实际运行日志与输出
提案贴出了 2012-11-19 的运行日志,完整展示了当时 Scrapy 0.17.0 的启动过程:依次启用的 extensions(LogStats、TelnetConsole、CloseSpider、WebService、CoreStats、SpiderState)、downloader middlewares(HttpAuthMiddleware、DownloadTimeoutMiddleware、UserAgentMiddleware、RetryMiddleware 等)、spider middlewares,最终请求http://www.w3.org/TR/html401/struct/lists.html返回 200 并缓存命中。去除噪声后的核心结果是:页面中dl[2]内的键值对全部被自动解析成了一个字典:
{'Notes': [u'The recipe may be improved by adding raisins.'], 'The ingredients': [u'', u'100 g. flour', u'', u'10 g. sugar', u'', u'1 cup water', u'', u'2 eggs', u'', u'salt, pepper', u''], 'The procedure': [u'', u'Mix dry ingredients thoroughly.', u'', u'Pour in wet ingredients.', u'', u'Mix for 10 minutes.', u'', u'Bake for one hour at 300 degrees.', u'']}这份输出清晰印证了几个实现细节:
- 字段名中的冒号被
_get_label正确剥掉:页面中原文是The ingredients:、The procedure:、Notes:,输出中已无冒号; - 每个字段的值保持列表形态(如
'The ingredients'含 10 个元素,其中空字符串来自dd内text()多节点 + 换行空白),这正符合 Item Loader"内部以列表收集、输出处理器负责合并"的约定; - 空字符串元素尚未被过滤,说明若要更干净的结果,可在输出处理器层追加
Compose(TakeFirst(), ...)之类过滤步骤(对应提案 Before 示例中的default_output_processor用法)。
六、Notes:可扩展的其他解析器与"嵌入式智能"
SEP-020 在文末明确指出,parse_dl只是起点,同样的思路可以轻易扩展出更多解析器:
parse_table():配合"哪一列是 key、哪一列是 value"的列位指定,将<table>/<tr>/<td>结构批量入库;parse_ul():配合 key/value 分隔符的指定,解析无序列表;parse_ol():同parse_ul,针对有序列表;parse():直接指定 key/value 对应的标签,做通用解析。
提案更进一步抛出embedded intelligence(嵌入式智能)的概念:如果再做一点"什么内容该放哪里"的引导(bootstrapping),甚至可以让一个通用解析器"直接走出去把所有上述结构都抓下来"——即让框架具备对页面语义结构的自适应识别能力,而不再依赖逐字段编程。
这正是该提案在思想层面最有价值的遗产:把"结构模式识别"从业务代码中抽离成可复用、可组合的 Loader 策略。SEP 中提到的parse_table/parse_ul等在未来往往不再是硬编码字段的 XPath,而是声明式的"模式 + 命名规则"。
七、与当前仓库的结合:现代实现映射与落地建议
7.1 提案代码与现代scrapy.loader的对应关系
当前仓库的 Item Loader 实现在 scrapy/loader/init.py,要点如下,可直接用于验证或迁移 SEP-020 的代码:
class ItemLoader(itemloaders.ItemLoader):Scrapy 的 Loader 是对第三方库 itemloaders(仓库说明见 docs/topics/loaders.rst)的封装,叠加了对 ScrapyResponse/Selector的自动支持;__init__中:若只传response而未传selector,会用default_selector_class(response)自动构造 Selector(见 scrapy/loader/init.py),这正是 SEP 示例中BulkItemLoader(response=response)能直接用self.selector的原因;- 类属性
default_item_class = Item、default_selector_class = Selector(见 scrapy/loader/init.py),与 SEP 中default_item_class = dict的用意一致(现代默认产出Item,而 SEP 示例选dict以求最简输出)。
7.2 处理器从何而来
SEP 提案中的MapCompose、Compose、TakeFirst在旧版路径scrapy.contrib.loader.processor下;现代用法是在 Loader 定义或 Item Field 元数据中声明_in/_out后缀属性,处理器从itemloaders.processors导入,例如:
from itemloaders.processors import TakeFirst, MapCompose, Join详见 docs/topics/loaders.rst 中 "Input and Output processors" 一节(处理器接收迭代对象、Loader 内部按列表收集数据、输入处理器逐元素 map、输出处理器作用于整体)。SEP-020 的parse_dl把MapCompose(lambda v: v.strip())作为第三个参数传给add_value,等价于为本次收集动态指定了一个内联输入处理器——现代 Item Loader 的add_value同样接受该参数,因此该方法在现代代码中依旧成立。
7.3 在 2.0+ 语法下重写的"可运行版"参考
结合上述映射,把 SEP-020 的核心类迁移到当前仓库 API 后大致如下(继承 scrapy/loader/init.py 中的ItemLoader,并把extract()换为getall()、XPathItemLoader换为ItemLoader):
from scrapy.loader import ItemLoader from itemloaders.processors import MapCompose class BulkItemLoader(ItemLoader): """Item loader based on specified pattern recognition""" default_item_class = dict # 或以自定义 Item 类替换 base_xpath = "//body" ignore = () def _get_label(self, entity): label = " ".join(entity.xpath(".//text()").getall()) label = label.encode("ascii", "xmlcharrefreplace") if label else "" label = label.strip(" ") if " " in label else label label = label.strip(":") if ":" in label else label return label.strip() def _get_entities(self, xpath): return self.selector.xpath(self.base_xpath + xpath) def parse_dl(self, xpath="//dl"): for term in self._get_entities(xpath + "/dt"): label = self._get_label(term) if label and label not in self.ignore: value = term.xpath("following-sibling::dd[1]//text()") if value: self.add_value( label, value.getall(), MapCompose(lambda v: v.strip()) )用的时候依然是两个调用:
bil = BulkItemLoader(response=response) bil.parse_dl('//div[@class="geeks"]/dl') # 或 '//body//dl' 等任意种子点 item = bil.load_item()7.4 需要警惕的两个边界
从代码与 SEP 状态出发,落地时有两点须注意:
- SEP 仍为草案:SEP-020 状态是Draft,BulkItemLoader 并未以独立内置模块的形式进入现代 Scrapy 主包(当前仓库
scrapy/loader/下不存在bulk.py),因此它应被视作设计模式参考/用户侧扩展模板,而非官方开箱功能。使用时应按 §7.3 的方式在项目内自行实现。 - 结果需要后处理:如 §5.2 所示,
parse_dl的原始输出是包含空串的列表值,字段名清洗也依赖页面格式(冒号、 等)。在实际爬虫中,通常要配合default_output_processor = Compose(TakeFirst(), lambda v: v.strip())(即 SEP 中 Before 示例的组合)或自定义输出处理器,才能得到干净的单值字段——这正好再次印证了 Item Loader 体系中"输入处理器负责清洗逐条值、输出处理器负责汇总"的分工。
八、小结:一份值得借鉴的"结构自动化"设计蓝图
SEP-020 虽以Draft收场,且代码停留在 2012 年的 API 快照上,但它的设计思想在今日依然成立且具有直接的工程参考价值:
- 抽象层级递进:Item → Item Loader → Bulk Item Loader,每一层都在减少重复劳动;
parse_dl用"一个种子点 + 一条模式规则"替代 N 条add_xpath,是"约定优于配置"在抓取层的朴素实践。 - 复用而非重写:批量解析完全建立在 Item Loader 的
selector、add_value、处理器与上下文机制之上,没有另起炉灶,这是该方案能被现代scrapy.loader.ItemLoader平滑承接的关键。 - 面向未来的嵌入式智能:
parse_table/parse_ul/parse与embedded intelligence的设想,本质是把"结构化标记 → 键值对"的识别从手写 XPath 中解放出来——今天你可以用同一个BulkItemLoader模式,把内容站点中形形色色的dl、table列表页"一次解析,处处复用"。
若你的爬虫正反复面对结构统一的键值对型页面(规格表、属性列表、词汇表、数据字典等),不妨以 SEP-020 为蓝图,在 scrapy/loader/init.py 提供的ItemLoader之上扩展一个属于自己的BulkItemLoader,再配合 docs/topics/loaders.rst 中的输入/输出处理器约定完成清洗——你会发现,距离"给一个种子点,整页结构化数据自动到手"并不遥远。
【免费下载链接】scrapyScrapy, a fast high-level web crawling & scraping framework for Python.项目地址: https://gitcode.com/GitHub_Trending/sc/scrapy
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考