首页
/ Scrapy SEP-003:嵌套 Item API(ItemField)设计提案——从设计思路到被 Item Loader 取代的完整复盘

Scrapy SEP-003:嵌套 Item API(ItemField)设计提案——从设计思路到被 Item Loader 取代的完整复盘

2026-09-04 15:18:30作者:谭伦延

本文基于 Scrapy 官方增强提案 SEP-003(sep/sep-003.rst),完整还原 2009 年 Scrapy 团队为"嵌套 Item"设计 ItemField API 的全部细节:设计前提、字段类型转换实现、默认值语义与边界争议;并结合当前仓库中的 scrapy/item.pySEP-008Item Loader 实现,说明该提案为何被废弃、其核心诉求最终以什么形态落地。读完你能理解嵌套数据的两种工程解法(声明式字段类型 vs 加载器),并掌握当前版本处理嵌套结构的正确姿势。

SEP-003 的定位:一个已废弃但有历史价值的提案

SEP-003 全称为 Nested items API (ItemField),由 Pablo Hoffman 于 2009-07-19 提出,其核心目标只有一个:让 Scrapy 的 Item 能够声明"字段值是另一个 Item"这类嵌套结构,例如 Product 内嵌 SupplierVariant 列表。

提案头部的状态栏给出了它的最终归宿:

Status: Obsolete by sep-008

即 SEP-003 从未进入 Scrapy 核心,而是与同期的 SEP-001(item 字段填充 API 对比)SEP-005(ItemBuilder API 详解) 一起,被 2009-08-11 提出的 SEP-008(Item Parsers)整体取代。从 SEP-008 头部的声明可以看到:

  • SEP-008 状态为 Final(implemented with variations)
  • Obsoletes: sep-001, sep-002, sep-003, sep-005
  • 最终实现时改名为 Item Loaders,并对方法名与语义做了小幅调整。

理解这一点很重要:本文所有代码都是提案阶段的 API 草案,依赖的 scrapy.item.fields.BaseFieldTextFieldUrlField 等类在当前仓库中已不存在,切勿照抄运行;它们的历史价值在于展示了 Scrapy 对嵌套数据处理的完整设计权衡。

设计前提:Item 必须先支持两条基本约定

SEP-003 开头列出了 ItemField 依赖的两条前置 API 约定(Prerequisites):

  1. 用 Item 实例作为第一个参数实例化必须返回副本item2 = MyItem(item1) 要返回第一个 item 实例的拷贝
  2. 支持关键字参数语法实例化item = Item(attr1=value1, attr2=value2)

当前 scrapy/item.py 中的 Item.__init__ 仍然实现了第 2 条(接受 *args, **kwargs 并对每个键值对执行 self[k] = v),第 1 条则对应至今保留的 Item.copy() 方法(scrapy/item.pyreturn self.__class__(self))。可见 SEP-003 对基础 Item 行为的假设,如今依然成立。

提案中的 ItemField 实现:类型转换与默认值陷阱

提案给出了 ItemField 的参考实现(原文完整代码):

#!python
from scrapy.item.fields import BaseField


class ItemField(BaseField):
    def __init__(self, item_type, default=None):
        self._item_type = item_type
        super(ItemField, self).__init__(default)

    def to_python(self, value):
        return (
            self._item_type(value) if not isinstance(value, self._item_type) else value
        )

    def get_default(self):
        # WARNING: returns default item instead of a copy - this must be
        # well documented, as Items are mutable objects and may lead to
        # unexpected behaviors # always returning a copy may not be desirable
        # either (see Supplier item, for example). this method can be
        # overridden to change this behaviour
        return self._default

三个要点:

  1. to_python 是隐式转换核心:赋值时若值不是目标 Item 类型,就尝试 self._item_type(value) 构造一个——这正是后文"可以传 dict、不能传字符串"能力的来源;
  2. get_default 的注释是全篇最值得警惕的设计警告:默认值直接返回 self._default 这个共享的可变对象而非副本。因为 Item 是可变对象,多个实例读到同一个默认 Item 后再修改,会互相污染。提案作者明确要求"这一点必须在文档中写清楚",同时承认"总是返回副本也未必合适(例如 Supplier 这种场景)",因此把覆写 get_default 作为逃生舱口留给了用户;
  3. 这套 to_python/get_default 的字段协议,与后来 Django 风格的"字段类型系统"思路同源——而最终 Scrapy 选择了更简单的方向(见文末对照)。

使用场景一:声明包含 ItemField 的嵌套 Item

提案给出了三层嵌套的示例(Supplier → Variant → Product,完整继承自原文):

#!python
from scrapy.item.models import Item
from scrapy.item.fields import ListField, ItemField, TextField, UrlField, DecimalField


class Supplier(Item):
    name = TextField(default="anonymous supplier")
    url = UrlField()


class Variant(Item):
    name = TextField(required=True)
    url = UrlField()
    price = DecimalField()


class Product(Variant):
    supplier = ItemField(Supplier, default=Supplier(name="default supplier"))
    variants = ListField(ItemField(Variant))

    # these ones are used for documenting default value examples
    supplier2 = ItemField(Supplier)
    variants2 = ListField(ItemField(Variant), default=[])

Product 继承自 Variant,说明提案设想字段类型体系支持普通的类继承复用;supplier 是单个 ItemField,variants 则是 ListField(ItemField(Variant))——字段类型可以嵌套组合

提案特别强调了一个反直觉的编译期失败:递归的 ItemField 定义不合法:

#!python
class Product(Item):
    variants = ItemField(Product)  # Fails to compile

原因是类体执行时 Product 还不存在,ItemField(Product) 无法引用一个尚未构造完成的类。想表达"树形自嵌套"时,这个提案直接走不通——这是此类"声明式自引用类型"方案的通病。

使用场景二:赋值的隐式实例化与类型校验

to_python 带来了一套"宽容输入 + 严格校验"的赋值语义(原文完整示例):

#!python
supplier = Supplier(name="Supplier 1", url="http://example.com")

p = Product()

# standard assignment
p["supplier"] = supplier
# this also works as it tries to instantiate a Supplier with the given dict
p["supplier"] = {"name": "Supplier 1", url: "http://example.com"}
# this fails because it can't instantiate a Supplier
p["supplier"] = "Supplier 1"
# this fails because url doesn't have the valid type
p["supplier"] = {"name": "Supplier 1", url: 123}

v1 = Variant()
v1["name"] = "lala"
v1["price"] = Decimal("100")

v2 = Variant()
v2["name"] = "lolo"
v2["price"] = Decimal("150")

# standard assignment
p["variants"] = [v1, v2]  # OK
# can also instantiate at assignment time
p["variants"] = [v1, Variant(name="lolo", price=Decimal("150"))]
# this also works as it tries to instantiate a Variant with the given dict
p["variants"] = [v1, {"name": "lolo", "price": Decimal("150")}]
# this fails because it can't instantiate a Variant
p["variants"] = [v1, "test"]
# this fails because 'coco' is not a valid value for price
p["variants"] = [v1, {"name": "lolo", "price": "coco"}]

归纳出的规则:

赋值内容 结果
已实例化的目标 Item(如 supplier 直接接受
可构造的 dict(字段类型均合法) 自动 to_python 转为 Item
无法构造的值(字符串)或字段类型不合法的 dict(url: 123price: "coco" 失败
ListField 列表 逐元素应用同样的实例化/校验逻辑

这正是该提案想解决的痛点:爬虫从 JSON/HTML 拿到的嵌套数据往往是"半生不熟"的 dict,ItemField 希望字段声明本身承担"dict → 结构化 Item"的转换与校验职责。

使用场景三:默认值的四种语义

提案用一段交互式示例钉死了默认值行为(原文完整示例):

#!python
p = Product()

p["supplier"]  # returns: Supplier(name='default supplier')
p["supplier2"]  # raises KeyError
p["supplier2"] = Supplier()
p["supplier2"]  # returns: Supplier(name='anonymous supplier')

p["variants"]  # raises KeyError
p["variants2"]  # returns []

p["categories"]  # raises KeyError
p.get("categories")  # returns None

p["numbers"]  # returns []

四条规则:

  1. 显式声明了 defaultsupplier):读取即得默认 Item;
  2. 未声明 defaultsupplier2):读取抛 KeyError,但赋值一个空构造的 Supplier() 后,其 name 字段会落到 TextField 自己的默认值 "anonymous supplier"——字段级默认值层层穿透
  3. variants2 显式给了 default=[],所以读取返回空列表而非 KeyError;
  4. 完全未定义的字段categories):[] 访问抛 KeyError.get() 返回 None,行为与标准 dict 一致;另外 numbers 这类 ListField 即使未声明 default,读取也返回 [](集合类型字段的固有默认语义)。

配合上文 get_default 的警告,完整的图景是:p["supplier"] 每次返回的都是同一个 Supplier 默认实例,跨实例共享且可变——这是提案遗留的最重要的使用陷阱。

使用场景四:访问与修改嵌套值,以及未决的 XXX 问题

#!python
p = Product(supplier=Supplier(name="some name", url="http://example.com"))
p["supplier"]["url"]  # returns 'http://example.com'
p["supplier"]["url"] = "http://www.other.com"  # works as expected
p["supplier"]["url"] = 123  # fails: wrong type for supplier url

p["variants"] = [v1, v2]
p["variants"][0]["name"]  # returns v1 name
p["variants"][1]["name"]  # returns v2 name

# XXX: decide what to do about these cases:
p["variants"].append(v3)  # works but doesn't check type of v3
p["variants"].append(1)  # works but shouldn't?

两层结论:

  • 点路径式的嵌套读写是流畅的p["supplier"]["url"] 可直接读改,且内层字段类型校验依然生效(url = 123 失败);
  • 提案以 XXX 标注了两处悬而未决的问题p["variants"].append(v3) 能追加但不做类型检查append(1) 竟然也"能工作"。这说明 ListField(ItemField(Variant)) 的校验只覆盖整体赋值路径,绕过 to_python 直接操作列表就绕开了全部防线——校验语义的"完整性漏洞"。

这个悬案后来没有答案,因为整个提案被 SEP-008 取代了。

对照当前实现:为什么 Scrapy 最终没有 ItemField

对照当前仓库的 Item 实现(scrapy/item.py),可以看到 Scrapy 最终走的是极简路线:

  • Field 现在只是一个 dict 的子类——字段元数据容器,不携带类型、默认值、转换逻辑:
class Field(dict[str, Any]):
    """Container of field metadata"""
  • ItemMetascrapy/item.py)只负责收集 Field 属性到 cls.fields
  • Item.__setitem__scrapy/item.py)只做字段名白名单校验,值类型完全不检查:
def __setitem__(self, key: str, value: Any) -> None:
    if key in self.fields:
        self._values[key] = value
    else:
        raise KeyError(f"{self.__class__.__name__} does not support field: {key}")

从源码结构看,这印证了 SEP 演进的取舍:SEP-003 想要的"字段声明即类型系统"被判定为过度设计,Scrapy 把 Item 降级为"带字段名约束的 dict",把数据清洗、类型转换、嵌套组装的职责整体移交给加载期组件。官方 Items 文档 也明确了这一边界:内置 Item 不做运行时类型强制;需要运行时类型校验时,使用 Pydantic 模型作为 item 类型(Pydantic 模型在运行时强制字段类型并抛出验证错误)。

而 SEP-003 真正想解决的问题——"从 response 中抽取嵌套数据并组装成结构"——由 SEP-008 的 Item Loader 接棒。当前 scrapy/loader/init.pyItemLoader(继承自 itemloaders 库)正是 SEP-008 "Alternative Public API Proposal" 的落地形态:add_value()/replace_value()/add_xpath()/add_css() 等 API,字段级 *_in/*_out 处理器,与 SEP-008 文档中的命名一一对应。

针对嵌套结构,当前版本的推荐做法是嵌套 Loader而非嵌套字段类型:docs/topics/loaders.rst 展示了用 loader.nested_xpath("//footer") 为页面中重复出现的块结构分别创建子加载器、各自收集值后再合并——这正是 SEP-003 中 Product/Supplier/Variant 场景的现代等价物,且不存在"默认值共享可变对象"和"append 绕过校验"两个历史坑。

小结:SEP-003 留下的三条设计经验

  1. 声明式类型字段 vs 加载期转换,是嵌套数据的两条路线。SEP-003 走前者(字段自带 to_python),SEP-008 走后者(Loader 负责转换),Scrapy 最终选择后者并保持 Item 的极简;
  2. 可变默认值必须是显式设计决策get_default 返回共享默认 Item 的警告,放在今天依然适用于任何"字段级默认值"设计——要么文档显著警告,要么返回副本,要么像现版 Item 一样干脆不设字段默认值;
  3. 校验必须覆盖所有写入路径。提案中 append 绕过类型检查的 XXX 未决问题,是所有"仅在 __setitem__ 处校验"方案的通病,也是理解最终 API 为何选择"Item 不校验、Loader/Pydantic 负责校验"分层的最好注脚。

如果想继续追溯完整演进链,建议依次阅读 SEP-001(RobustItem/ItemForm/ItemBuilder 三方案对比)、SEP-005(ItemBuilder 详细用法)、SEP-008(最终胜出的 Item Loader API 设计),以及 SEP 目录总览 sep/README.rst

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.12 K
2.72 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
528
588
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
906
1.83 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
854
1.34 K
docsdocs
暂无描述
Markdown
891
5.79 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.53 K
1.01 K
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.34 K
1.45 K
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
988
506
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
540
384