Scrapy SEP-014 深度解析:CrawlSpider v2 的解耦设计——Matcher、Request Extractor 与 Processor 的分离思想
SEP-014 是 Scrapy 增强提案(Scrapy Enhancement Proposal,SEP)中专门针对 CrawlSpider 的一次重构方案,它针对规则爬虫三大痛点——回调难以持久化、链接抽取与处理紧耦合、无法从命令行直接爬取指定 URL——提出了把"匹配、抽取、处理、分发"四个职责彻底拆分的 v2 架构。虽然该提案在 r2632 版本中因使用率不足而被弃用,但其核心思想(规则即匹配条件、链接处理流水线化)最终以演化形态沉淀进了今天 Scrapy 的 CrawlSpider 实现。读完本文,你既能完整掌握 SEP-014 的 API 设计与实现草图,也能对照 scrapy/spiders/crawl.py 中的现行代码,理解 Scrapy 最终为何选择了另一条实现路线。
SEP-014 的来历与要解决的三个缺陷
SEP 存放于 sep/ 目录,该目录 README 说明这些提案大多从旧的 Trac Wiki 迁移而来。sep/sep-014.rst 的元信息表明:提案名为 CrawlSpider v2,由 Insophia Team 于 2010-01-22 创建,2010-02-04 更新,状态为 Final. Partially implemented but discarded because of lack of use in r2632(部分实现但因使用率低而在 r2632 被弃用)。
提案开篇列出了当时 CrawlSpider 的三大缺陷:
- Request 的 callback 难以持久化。回调以函数引用形式存在于请求对象中,队列序列化(如磁盘调度器)无法可靠保存它;
- Link Extractor 不灵活且难以维护,链接的处理/过滤逻辑(如 canonicalize 规范化)与抽取逻辑紧耦合在一起;
- 无法从命令行直接爬取一个 URL,因为 Spider 不知道该 URL 应该使用哪个回调。
提案给出的总体修改方向是四点 API 变更:
- 分离 Rule-LinkExtractor-Callback 三者的功能;
- 把 LinkExtractor 的功能拆分为 Request Extractor(返回 Request 对象,而不仅仅是链接 URL)与 Request Processor(对 Request 做过滤或改写);
- 将"确定 response 回调"与"抽取新请求"两个过程解耦;
- 回调由 Matcher 对象对 request/response 对象做匹配来确定,而不是像旧式设计那样由提取规则附带决定。
五大核心组件
SEP-014 把整个规则爬虫拆成五个组件,职责链条为:RulesManager(定回调)→ RequestGenerator(生成请求)→ 内部依次调用 RequestExtractor(抽链接)和 RequestProcessor(链式处理链接)。
Matcher 对象:匹配请求/响应
Matcher 负责判断给定的 request 或 response 是否满足任意条件,它直接拿到 request/response 对象,因此可以访问其全部属性。提案指出,在旧版 CrawlSpider 中,Rule 对象既负责确定 extractor 的回调、又携带 URL 正则,而新的 Matcher 只保留"模式/条件"这一份职责,决定哪个 request/response 应该执行某个动作。
Request Extractor:从 Response 产出 Request
Request Extractor 接收 response 对象,决定后续跟随哪些请求。提案将其定位为 LinkExtractor 的增强:LinkExtractor 返回的是 URL(链接),而 Request Extractor 直接返回 Request 对象,把"链接→请求"的构造提前到抽取阶段完成。
Request Processor:对请求链式加工
Request Processor 接收请求对象,可以对其执行任意动作,如过滤(filtering)或就地修改(modifying on the fly)。旧的 LinkExtractor 把 canonicalize 这类链接处理内置其中,而 Request Processor 可以复用、并且串联成流水线依次应用。
Request Generator:解耦 _request_to_follow()
Request Generator 是对 CrawlSpider 的 _request_to_follow() 方法的解耦:它接收 response 对象,依次应用 Request Extractors 和 Request Processors,产出新的请求。
Rules Manager:用 Matcher 确定回调
新的 Rule 由 Matcher 对象加 callback 组成。旧的 Legacy Rules 用于执行链接抽取并给生成的 Request 挂上回调;新 Rules 只用于为给定 response 确定回调。提案认为这"打开了许多可能性",例如按 URL 确定回调,并且由于回调是在 response 与 Rules 匹配时就确定的,Request 队列因此可以被持久化(直接解决缺陷 1)。
使用示例
基本爬取
#!python
#
# Basic Crawling
#
class SampleSpider(CrawlSpider):
rules = [
# 分发器采用 first-match(首个匹配)策略
Rule(UrlRegexMatch(r"product\.html\?id=\d+"), "parse_item", follow=False),
# 若第一个参数是字符串,默认会被包装成 UrlRegexMatch
Rule(r".+", "parse_page"),
]
request_extractors = [
# 爬取所有链接,寻找商品和图片
SgmlRequestExtractor(),
]
request_processors = [
# 规范化所有请求的 URL
Canonicalize(),
]
def parse_item(self, response):
# 从 response 中解析并抽取 item
pass
def parse_page(self, response):
# 抽取所有页面的图片
pass
三个类属性各司其职:rules 决定回调分发(首条匹配生效,follow=False 表示匹配到 parse_item 后不再继续抽取该页链接);request_extractors 决定从页面哪里抽链接;request_processors 决定链接抽取后如何被清洗/过滤。
自定义 Processor 与外部回调
#!python
#
# Using external callbacks
#
# 自定义 Processor
def filter_today_links(requests):
# 只爬取今天的链接
today = datetime.datetime.today().strftime("%Y-%m-%d")
return [r for r in requests if today in r.url]
# 定义在 spider 之外的回调
def my_external_callback(response):
# 处理 item
pass
class SampleSpider(CrawlSpider):
rules = [
# 分发器采用 first-match 策略
Rule(UrlRegexMatch(r"/news/(.+)/"), my_external_callback),
]
request_extractors = [
RegexRequestExtractor(r"/sections/.+"),
RegexRequestExtractor(r"/news/.+"),
]
request_processors = [
# 规范化所有请求的 URL
Canonicalize(),
filter_today_links,
]
这个例子展示了提案的两个关键灵活性:回调可以是外部函数而不必是 spider 方法(对应缺陷 3 中"命令行直接爬取 URL"的场景);Processor 既可以是类(Canonicalize())也可以是普通函数(filter_today_links),且多个 processor 串行应用。
提案的包结构
提案将实现标记为 Work-in-progress,规划的包结构如下:
contrib_exp
|- crawlspider/
|- spider.py
|- CrawlSpider
|- rules.py
|- Rule
|- CompiledRule
|- RulesManager
|- reqgen.py
|- RequestGenerator
|- reqproc.py
|- Canonicalize
|- Unique
|- ...
|- reqext.py
|- SgmlRequestExtractor
|- RegexRequestExtractor
|- ...
|- matchers.py
|- BaseMatcher
|- UrlMatcher
|- UrlRegexMatcher
|- ...
放在 contrib_exp(contributor experimental)下,也说明它当时是以实验性扩展的形态推进的。
实现草图:Matcher、Extractor 与 Processor
Request/Response Matcher
#!python
"""
Request/Response Matchers
Perform evaluation to Request or Response attributes
"""
class BaseMatcher(object):
"""Base matcher. Returns True by default."""
def matches_request(self, request):
"""Performs Request Matching"""
return True
def matches_response(self, response):
"""Performs Response Matching"""
return True
class UrlMatcher(BaseMatcher):
"""Matches URL attribute"""
def __init__(self, url):
"""Initialize url attribute"""
self._url = url
def matches_url(self, url):
"""Returns True if given url is equal to matcher's url"""
return self._url == url
def matches_request(self, request):
"""Returns True if Request's url matches initial url"""
return self.matches_url(request.url)
def matches_response(self, response):
"""Returns True if Response's url matches initial url"""
return self.matches_url(response.url)
class UrlRegexMatcher(UrlMatcher):
"""Matches URL using regular expression"""
def __init__(self, regex, flags=0):
"""Initialize regular expression"""
self._regex = re.compile(regex, flags)
def matches_url(self, url):
"""Returns True if url matches regular expression"""
return self._regex.search(url) is not None
设计上注意两点:基类 BaseMatcher 默认返回 True,意味着任何 Matcher 都可以通过继承定制到任意请求/响应属性(meta、headers 等);UrlRegexMatcher 继承 UrlMatcher 并只重写 matches_url,体现了"URL 匹配是通用的单点扩展位"。
Request Extractor
#!python
#
# Requests Extractor
# Extractors receive response and return list of Requests
#
class BaseSgmlRequestExtractor(FixedSGMLParser):
"""Base SGML Request Extractor"""
def __init__(self, tag="a", attr="href"):
"""Initialize attributes"""
FixedSGMLParser.__init__(self)
self.scan_tag = tag if callable(tag) else lambda t: t = tag
self.scan_attr = attr if callable(attr) else lambda a: a = attr
self.current_request = None
def extract_requests(self, response):
"""Returns list of requests extracted from response"""
return self._extract_requests(response.body, response.url, response.encoding)
def _extract_requests(self, response_text, response_url, response_encoding):
"""Extract requests with absolute urls"""
self.reset()
self.feed(response_text)
self.close()
base_url = self.base_url if self.base_url else response_url
self._make_absolute_urls(base_url, response_encoding)
self._fix_link_text_encoding(response_encoding)
return self.requests
def _make_absolute_urls(self, base_url, encoding):
"""Makes all request's urls absolute"""
for req in self.requests:
url = req.url
# make absolute url
url = urljoin_rfc(base_url, url, encoding)
url = safe_url_string(url, encoding)
# replace in-place request's url
req.url = url
def _fix_link_text_encoding(self, encoding):
"""Convert link_text to unicode for each request"""
for req in self.requests:
req.meta.setdefault("link_text", "")
req.meta["link_text"] = str_to_unicode(req.meta["link_text"], encoding)
def reset(self):
"""Reset state"""
FixedSGMLParser.reset(self)
self.requests = []
self.base_url = None
def unknown_starttag(self, tag, attrs):
"""Process unknown start tag"""
if "base" == tag:
self.base_url = dict(attrs).get("href")
if self.scan_tag(tag):
for attr, value in attrs:
if self.scan_attr(attr):
if value is not None:
req = Request(url=value)
self.requests.append(req)
self.current_request = req
def unknown_endtag(self, tag):
"""Process unknown end tag"""
self.current_request = None
def handle_data(self, data):
"""Process data"""
current = self.current_request
if current and not "link_text" in current.meta:
current.meta["link_text"] = data.strip()
class SgmlRequestExtractor(BaseSgmlRequestExtractor):
"""SGML Request Extractor"""
def __init__(self, tags=None, attrs=None):
"""Initialize with custom tag & attribute function checkers"""
# defaults
tags = tuple(tags) if tags else ("a", "area")
attrs = tuple(attrs) if attrs else ("href",)
tag_func = lambda x: x in tags
attr_func = lambda x: x in attrs
BaseSgmlRequestExtractor.__init__(self, tag=tag_func, attr=attr_func)
class XPathRequestExtractor(SgmlRequestExtractor):
"""SGML Request Extractor with XPath restriction"""
def __init__(self, restrict_xpaths, tags=None, attrs=None):
"""Initialize XPath restrictions"""
self.restrict_xpaths = tuple(arg_to_iter(restrict_xpaths))
SgmlRequestExtractor.__init__(self, tags, attrs)
def extract_requests(self, response):
"""Restrict to XPath regions"""
hxs = HtmlXPathSelector(response)
fragments = (
"".join(html_frag for html_frag in hxs.select(xpath).extract())
for xpath in self.restrict_xpaths
)
html_slice = "".join(html_frag for html_frag in fragments)
return self._extract_requests(html_slice, response.url, response.encoding)
实现上这是一个基于 SGML 事件流解析器的抽取器:unknown_starttag 中识别 <base> 标签获得 base URL,命中 scan_tag/scan_attr 就立即构造 Request;handle_data 捕获链接文本写入 meta["link_text"];解析完成后 _make_absolute_urls 用 urljoin_rfc 就地改写为绝对 URL,并修正 link_text 编码。子类 SgmlRequestExtractor 把 tag/attr 泛化为集合匹配,默认扫描 ("a", "area") 的 href;XPathRequestExtractor 则先用 HtmlXPathSelector 把页面切片到指定 XPath 区域再抽取。
Request Processor
#!python
#
# Request Processors
# Processors receive list of requests and return list of requests
#
"""Request Processors"""
class Canonicalize(object):
"""Canonicalize Request Processor"""
def __call__(self, requests):
"""Canonicalize all requests' urls"""
for req in requests:
# replace in-place
req.url = canonicalize_url(req.url)
yield req
class Unique(object):
"""Filter duplicate Requests"""
def __init__(self, *attributes):
"""Initialize comparison attributes"""
self._attributes = attributes or ["url"]
def _requests_equal(self, req1, req2):
"""Attribute comparison helper"""
for attr in self._attributes:
if getattr(req1, attr) != getattr(req2, attr):
return False
# all attributes equal
return True
def _request_in(self, request, requests_seen):
"""Check if request is in given requests seen list"""
for seen in requests_seen:
if self._request_in(request, seen):
return True
# request not seen
return False
def __call__(self, requests):
"""Filter seen requests"""
# per-call duplicates filter
requests_seen = set()
for req in requests:
if not self._request_in(req, requests_seen):
yield req
# registry seen request
requests_seen.add(req)
class FilterDomain(object):
"""Filter request's domain"""
def __init__(self, allow=(), deny=()):
"""Initialize allow/deny attributes"""
self.allow = tuple(arg_to_iter(allow))
self.deny = tuple(arg_to_iter(deny))
def __call__(self, requests):
"""Filter domains"""
processed = (req for req in requests)
if self.allow:
processed = (
req for req in requests if url_is_from_any_domain(req.url, self.allow)
)
if self.deny:
processed = (
req
for req in requests
if not url_is_from_any_domain(req.url, self.deny)
)
return processed
class FilterUrl(object):
"""Filter request's url"""
def __init__(self, allow=(), deny=()):
"""Initialize allow/deny attributes"""
_re_type = type(re.compile("", 0))
self.allow_res = [
x if isinstance(x, _re_type) else re.compile(x) for x in arg_to_iter(allow)
]
self.deny_res = [
x if isinstance(x, _re_type) else re.compile(x) for x in arg_to_iter(deny)
]
def __call__(self, requests):
"""Filter request's url based on allow/deny rules"""
# TODO: filter valid urls here?
processed = (req for req in requests)
if self.allow_res:
processed = (
req for req in requests if self._matches(req.url, self.allow_res)
)
if self.deny_res:
processed = (
req
for req in requests
if not self._matches(req.url, self.deny_res)
)
return processed
def _matches(self, url, regexs):
"""Returns True if url matches any regex in given list"""
return any(r.search(url) for r in regexs)
四个内置 Processor 的契约是"接收请求列表,返回请求列表"(生成器或迭代器),因此可以自由串联:Canonicalize 就地规范化 URL(对应旧 LinkExtractor 内建的 canonicalize);Unique 按可配置的属性去重,默认按 url 比较,且是单次调用内的去重;FilterDomain 与 FilterUrl 分别按域名、URL 正则做 allow/deny 过滤——这两者的 allow/deny 参数形态,与今天 scrapy/linkextractors/lxmlhtml.py 中 LxmlLinkExtractor 的 allow_domains/deny_domains/allow/deny 参数如出一辙,可以看出提案思路对现行抽取器设计的直接影响。值得强调的是,FilterDomain/FilterUrl 中调用的 url_is_from_any_domain 就是当前仓库 scrapy/utils/url.py 中真实存在的函数(第 23 行定义),说明提案引用的工具函数在今天的代码库中仍在服役。
Rule、CompiledRule 与 RulesManager
#!python
#
# Dispatch Rules classes
# Manage Rules (Matchers + Callbacks)
#
class Rule(object):
"""Crawler Rule"""
def __init__(
self, matcher, callback=None, cb_args=None, cb_kwargs=None, follow=True
):
"""Store attributes"""
self.matcher = matcher
self.callback = callback
self.cb_args = cb_args if cb_args else ()
self.cb_kwargs = cb_kwargs if cb_kwargs else {}
self.follow = follow
#
# Rules Manager takes list of Rule objects and normalize matcher and callback
# into CompiledRule
#
class CompiledRule(object):
"""Compiled version of Rule"""
def __init__(self, matcher, callback=None, follow=False):
"""Initialize attributes checking type"""
assert isinstance(matcher, BaseMatcher)
assert callback is None or callable(callback)
assert isinstance(follow, bool)
self.matcher = matcher
self.callback = callback
self.follow = follow
#!python
#
# Handles rules matcher/callbacks
# Resolve rule for given response
#
class RulesManager(object):
"""Rules Manager"""
def __init__(self, rules, spider, default_matcher=UrlRegexMatcher):
"""Initialize rules using spider and default matcher"""
self._rules = tuple()
# compile absolute/relative-to-spider callbacks"""
for rule in rules:
# prepare matcher
if isinstance(rule.matcher, BaseMatcher):
matcher = rule.matcher
else:
# matcher not BaseMatcher, check for string
if isinstance(rule.matcher, basestring):
# instance default matcher
matcher = default_matcher(rule.matcher)
else:
raise ValueError(
"Not valid matcher given %r in %r" % (rule.matcher, rule)
)
# prepare callback
if callable(rule.callback):
callback = rule.callback
elif not rule.callback is None:
# callback from spider
callback = getattr(spider, rule.callback)
if not callable(callback):
raise AttributeError(
"Invalid callback %r can not be resolved" % callback
)
else:
callback = None
if rule.cb_args or rule.cb_kwargs:
# build partial callback
callback = partial(callback, *rule.cb_args, **rule.cb_kwargs)
# append compiled rule to rules list
crule = CompiledRule(matcher, callback, follow=rule.follow)
self._rules += (crule,)
def get_rule(self, response):
"""Returns first rule that matches response"""
for rule in self._rules:
if rule.matcher.matches_response(response):
return rule
RulesManager.__init__ 完成了"编译"工作:字符串 matcher 被默认包装为 UrlRegexMatcher(这解释了基本示例中 Rule(r".+", ...) 的写法);字符串 callback 通过 getattr(spider, name) 解析为 spider 方法;cb_args/cb_kwargs 通过 functools.partial 预绑定。get_rule(response) 遍历规则表,返回第一条 matcher 命中的规则,即首条匹配策略。
RequestGenerator 与 CrawlSpider v2
#!python
#
# Request Generator
# Takes response and generate requests using extractors and processors
#
class RequestGenerator(object):
def __init__(self, req_extractors, req_processors, callback):
self._request_extractors = req_extractors
self._request_processors = req_processors
self.callback = callback
def generate_requests(self, response):
"""
Extract and process new requests from response
"""
requests = []
for ext in self._request_extractors:
requests.extend(ext.extract_requests(response))
for proc in self._request_processors:
requests = proc(requests)
for request in requests:
yield request.replace(callback=self.callback)
#!python
#
# Spider
#
class CrawlSpider(InitSpider):
"""CrawlSpider v2"""
request_extractors = []
request_processors = []
rules = []
def __init__(self):
"""Initialize dispatcher"""
super(CrawlSpider, self).__init__()
# wrap rules
self._rulesman = RulesManager(self.rules, spider=self)
# generates new requests with given callback
self._reqgen = RequestGenerator(
self.request_extractors, self.request_processors, self.parse
)
def parse(self, response):
"""Dispatch callback and generate requests"""
# get rule for response
rule = self._rulesman.get_rule(response)
if rule:
# dispatch callback if set
if rule.callback:
output = iterate_spider_output(rule.callback(response))
for req_or_item in output:
yield req_or_item
if rule.follow:
for req in self._reqgen.generate_requests(response):
yield req
整条数据流至此清晰:parse(response) 先经 RulesManager.get_rule 按 URL 匹配确定该响应的回调并执行,再按 rule.follow 决定是否调用 RequestGenerator;RequestGenerator 聚合所有 Extractor 的抽取结果,串行穿过 Processor 流水线,最后用 request.replace(callback=self.callback) 把回调重置为统一的 parse——这样被持久化的队列里只有 parse 这一稳定入口,真正的业务回调等到 response 回来时才由规则表重新确定。这正是对缺陷 1(回调难以持久化)的正面回答。
对照现行实现:SEP-014 的思想如何演化进今天的 CrawlSpider
Scrapy 仓库中最终采用的 CrawlSpider(scrapy/spiders/crawl.py)并没有实现 v2 的 Matcher 机制,而是走了另一条更简洁的路线,但两条路线的对应关系非常清晰,值得逐点对照。官方文档中的讲解见 docs/topics/spiders.rst 的 "Crawling rules" 小节。
Rule 语义的取舍。 现行 Rule 的签名为 Rule(link_extractor, callback, cb_kwargs, follow, process_links, process_request, errback)(scrapy/spiders/crawl.py 第 63-95 行):
- SEP 中的
Request Extractor对应现行的link_extractor:每条规则自带一个LinkExtractor(若省略则用模块级默认实例_default_link_extractor = LinkExtractor())。区别在于现行LinkExtractor(实现于 scrapy/linkextractors/lxmlhtml.py 的LxmlLinkExtractor)返回的是Link对象而非Request,且把allow/deny/allow_domains/deny_domains/deny_extensions/restrict_xpaths/restrict_css/restrict_text等过滤选项内置其中——从源码结构看,这其实是把 SEP 中RegexRequestExtractor与FilterDomain/FilterUrl的能力重新合并回抽取器,用更少的概念换取易用性; - SEP 中的
Request Processor流水线对应现行的process_links与process_request两个钩子:process_links接收整批链接做过滤(相当于批量 Processor),process_request接收(request, response)返回改写后的Request或None(相当于单请求 Processor,返回None即过滤丢弃)。二者的默认值是恒等函数_identity/_identity_process_request; - SEP 中的 Matcher(按 response URL 匹配确定回调) 则被整体放弃,取而代之的是规则索引方案:
_build_request在生成请求时写入meta["rule"] = rule_index(scrapy/spiders/crawl.py 第 133-139 行),响应回来时_callback通过self._rules[response.meta["rule"]]找回原始规则再分发。这样回调归属在请求创建时就被规则固化(且rule索引可序列化),同样规避了"回调不可持久化"的问题,但语义从 SEP 的"按 URL 正则决定回调"变成了"按生成该请求的规则决定回调"。docs/topics/spiders.rst 也明确警告:rulemeta 键被CrawlSpider依赖,把它从一个规则生成的请求复制到另一个规则生成的请求会导致响应分发到错误的回调; - 回调解析的"编译"逻辑一脉相承:SEP 的
RulesManager用getattr(spider, name)把字符串回调解析为方法、用partial预绑定参数;现行的Rule._compile(spider)用_get_method完成同样的字符串→方法解析(callback、errback、process_links、process_request 四项),并在CrawlSpider.__init__中对self.rules逐条copy.copy后编译,避免污染类属性。
入口回调的演化。 SEP v2 依赖外部调用 parse 完成分发;现行实现则把 start_urls 的处理独立为 parse_start_url(response, **kwargs),由 _parse 经 parse_with_rules 调用(scrapy/spiders/crawl.py 第 117-126 行)。parse_with_rules 是统一入口:先执行 callback(支持异步生成器与 Awaitable),再把结果交给可覆写的 process_results 后逐条 yield,最后按 follow 与全局开关 _follow_links 产出 _requests_to_follow 的结果。_requests_to_follow 内部用 seen 集合做跨规则的去重(第 141-154 行)——这一点比 SEP 中 Unique 的"单次调用内去重"更进一步,等价于把 SEP 示例里 Rule(r".+", ...) 与 Rule(r"product\.html...", ...) 并存时可能产生的重复请求问题在实现层直接消解。
follow 的默认值差异。 现行 Rule 中 follow = follow if follow is not None else not callback:不给 callback 则默认继续跟随,给了 callback 则默认不跟随。这与 SEP v2 中 Rule.__init__ 的 follow=True 默认值不同,与 CompiledRule 的 follow=False 断言默认值也不同,属于最终落地的行为约定,写规则时应显式理解该默认值。
全局开关的废弃。 CrawlSpider.from_crawler 中读取 CRAWLSPIDER_FOLLOW_LINKS 设置并提示其已废弃,官方建议改用规则级的 follow=False(scrapy/spiders/crawl.py 第 222-233 行)。从废弃信息看,Scrapy 团队的选择与 SEP-014 的精神一致:把行为下沉到规则本身,而非全局开关。
为什么 v2 被弃用,以及它对今天写爬虫的启示
SEP-014 的状态说明(Final,但 "Partially implemented but discarded because of lack of use in r2632")提示了它的结局:v2 的 Matcher + Request Extractor + Processor 三件套表达力确实更强(回调可按任意 response 属性匹配、处理器可任意串联、支持外部函数回调),但它要求用户管理 request_extractors/request_processors/rules 三个类属性并引入 Matcher 这一新抽象,学习成本明显高于"每条 Rule 自带一个 LinkExtractor + 两个钩子"的现行设计。从 scrapy/spiders/crawl.py 的现行结构看,Scrapy 把 v2 最有价值的两点——回调归属可序列化(meta["rule"]) 与 链接处理钩子化(process_links/process_request)——以更小的 API 面保留了下来,而把 URL 过滤能力收编进 LinkExtractor 的参数体系(对照 scrapy/linkextractors/lxmlhtml.py 中 LxmlLinkExtractor 的 allow/deny/allow_domains/deny_domains/deny_extensions 参数,其语义正是 SEP 中 FilterUrl/FilterDomain 的原型)。
对今天的开发者而言,SEP-014 仍有一份实际参考手册的价值:当你需要在 CrawlSpider 中做跨规则的链接去重、自定义 URL 规范化(对应 Canonicalize)或按属性批量过滤链接时,现行 Rule 的 process_links/process_request 钩子就是 SEP "Request Processor 流水线"的落点;而当你的回调确实需要按响应 URL 模式动态确定(而非按生成规则确定)时,v2 的 Matcher 思路提醒你:可以放弃 CrawlSpider 的规则分发,改用普通 Spider 在回调里自行按 response.url 路由,因为这才是 Matcher 机制在现行 API 约束下的等价替代。
最后需要说明版本边界:SEP-014 的代码草图基于 2010 年的 Python 2 / Twisted 时代 API(basestring、HtmlXPathSelector、FixedSGMLParser、urljoin_rfc 等),这些名称在今天的仓库中已不存在或已被替换,草图仅作为设计文档阅读;现行行为以 scrapy/spiders/crawl.py、scrapy/linkextractors/lxmlhtml.py 与 docs/topics/spiders.rst 为准。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0627
Hy4-previewHy4 preview 是由腾讯混元团队研发的新一代混合专家(MoE)旗舰模型。模型总参数量 770B,每个 token 激活 49B,主干共包含78层,第一层采用标准 FFN,其余 77 层均为 MoE 结构,每层包含 256 个路由专家与 1 个共享专家,每个 token 激活 top-8 路由专家及共享专家。主干之外原生内置 1 层 MTP(总参数量 10B,激活 0.7B)以支持投机解码。Python00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
GLM-5.3-FlashGLM-5.3-Flash (320B-A18B),是GLM-5系列的首个原生多模态模型。320B总参数,能力超过GLM-5.2Jinja00
Spark-X2.5-4BSpark-X2.5-4B 旨在让强大的 AI 更实用、更高效、更易获得。在广泛日常任务中表现强劲,涵盖对话、写作、翻译、推理、编码、工具调用以及智能体工作流,并在同等规模的开源模型中取得领先成绩。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00