Scrapling 自适应抓取(Adaptive Scraping)深度解析:让爬虫在网页结构变更后自动重定位元素
Adaptive Scraping(自适应抓取,早期版本中称为 automatch)是 Scrapling 框架中最具防御性的能力之一:它让爬虫在目标网站改版、调整 DOM 结构、重命名 class/id 之后,依然能够凭借之前保存的"元素指纹"找到同一语义的节点,从而免去频繁的维护成本。本文基于 Scrapling 仓库中的功能文档、scrapling/parser.py 的核心实现与配套测试,完整讲解自适应抓存的原理、两种使用方式、存储系统细节、相似度评分机制以及常见问题的排查方法。
为什么需要自适应抓取
以文档中给出的典型页面为例,一个商品列表页面初始结构如下:
<div class="container">
<section class="products">
<article class="product" id="p1">
<h3>Product 1</h3>
<p class="description">Description 1</p>
</article>
<article class="product" id="p2">
<h3>Product 2</h3>
<p class="description">Description 2</p>
</article>
</section>
</div>
要抓取第一个商品,通常会写一个选择器:
page.css('#p1')
而一旦网站运营方实施结构性改版,例如变成:
<div class="new-container">
<div class="product-wrapper">
<section class="products">
<article class="product new-class" data-id="p1">
<div class="product-info">
<h3>Product 1</h3>
<p class="new-description">Description 1</p>
</div>
</article>
<article class="product new-class" data-id="p2">
<div class="product-info">
<h3>Product 2</h3>
<p class="new-description">Description 2</p>
</div>
</article>
</section>
</div>
</div>
外层包裹、id 被换成 data-id、class 追加了 new-class,原来的选择器 #p1 直接失效,爬虫代码需要人工维护。自适应抓取正是为了解决这类问题而设计的:
- 第一次选中某个元素时,你可以启用
adaptive特性并保存它的"独特属性"(unique properties); - 之后再次用同一个选择器查找而元素已不存在时,Scrapling 会从存储中取出这些属性,对页面上所有元素逐一计算相似度,返回相似度得分最高的那个元素。
这个能力不局限于 CSS/XPath 选择:它对任何选取方式找到的元素都适用(后面"手动方式"一节会展示)。
真实场景验证:用 Wayback Machine 对比 10 年前后的 StackOverflow
文档中给出了一个非常直观的真实世界示例:用 The Web Archive 的 Wayback Machine 抓取 2010 年 1 月的 StackOverflow 快照,并与当前站点对比,验证同一个选择器能否在两个年代截然不同的页面中定位到同一个"Questions"按钮(该选择器由 Chrome 开发者工具生成):
from scrapling import Fetcher
selector = '#hmenus > div:nth-child(1) > ul > li:nth-child(1) > a'
old_url = "https://web.archive.org/web/20100102003420/http://stackoverflow.com/"
new_url = "https://stackoverflow.com/"
Fetcher.configure(adaptive = True, adaptive_domain='stackoverflow.com')
page = Fetcher.get(old_url, timeout=30)
element1 = page.css(selector, auto_save=True)[0]
# Same selector but used in the updated website
page = Fetcher.get(new_url)
element2 = page.css(selector, adaptive=True)[0]
if element1.text == element2.text:
print('Scrapling found the same element in the old and new designs!')
这里有两点值得注意:
adaptive_domain参数的作用:Scrapling 默认把archive.org与stackoverflow.com视为两个不同站点,会各自隔离 adaptive 数据。显式传入adaptive_domain='stackoverflow.com'是告诉 Scrapling"把这两个 URL 当作同一个网站处理",这样在快照页面上auto_save保存的数据,可以在正式域名的页面上被adaptive检索复用。- URL 迁移场景:文档特别指出,
adaptive_domain参数最主要的用途是处理"网站换域名/换 URL 的同时改了结构"的情形——此时用它可以让新 URL 继续复用旧 URL 保存的 adaptive 数据;否则 Scrapling 会把新地址当作一个全新网站,弃用旧数据。如果两次请求的 URL 本来就相同(常见情况),则不需要该参数。
adaptive 逻辑在 Selector 与 Fetcher 两类入口上行为一致,下文以 Selector 类为主展开。
工作原理:保存阶段与匹配阶段
自适应抓取分为两个阶段(两阶段模型):
- Save Phase(保存阶段):把元素的独特属性存入数据库;
- Match Phase(匹配阶段):之后再抓取该网站时,用这些属性与页面中的所有元素做相似度比对。
完整的处理逻辑如下(与文档描述一致):
- 用任意方式选中一个元素后,Scrapling 提取它的独特属性(具体字段见下文);
- 把属性写入已配置的数据库(默认是 SQLite);
- 由于元素的一切特征(class、id、路径……)都可能被网站方修改或删除,没有任何单一特征可以当作数据库主键。存储系统因此依赖两样东西:
- 当前网站的域名。使用
Selector类时在初始化时通过url参数传入;使用 Fetcher 时则自动从请求 URL 中解析; - 一个
identifier,用于日后查询该元素的属性。identifier 通常无需手动指定(CSS/XPath 方式下会自动使用选择器字符串)。
- 当前网站的域名。使用
- 之后网站结构发生变化时,只要启用
adaptive,Scrapling 就会取回该元素的独特属性,把页面上的所有元素逐一与之比对,基于"综合相似度"计算得分; - 相似度得分最高的元素被返回。
参与比对的独特属性
Scrapling 依赖的独特属性清单(文档原文列举):
- 元素自身的:标签名、文本内容、属性(名称与值)、兄弟节点(只取标签名)、路径(只取标签名序列);
- 父元素的:标签名、属性(名称与值)、文本内容。
需要强调的是,比对不是精确相等判断,而是基于"相似度",且值本身的顺序也会计入比较(例如 class 名书写的先后顺序)。
从源码层面可以印证这一机制。scrapling/core/storage.py 中定义了存储抽象基类 StorageSystemMixin(要求实现 save/retrieve 两个抽象方法),以及默认实现 SQLiteStorageSystem。其建表语句如下(storage.py):
CREATE TABLE IF NOT EXISTS storage (
id INTEGER PRIMARY KEY,
url TEXT,
identifier TEXT,
element_data TEXT,
UNIQUE (url, identifier)
)
可以看到,记录的确是以 (url, identifier) 二元组作为唯一键存储的——url 存的是经 tld 库解析后的基础域名(_get_base_url 方法,storage.py),identifier 则是查询键;element_data 是元素属性字典序列化的 JSON。写入使用 INSERT OR REPLACE,即同键覆盖写:新的保存会直接替换旧数据,adaptive 匹配时只使用最新一次保存的属性。
同时,源码中的默认数据库文件路径定义为(parser.py):
__DEFAULT_DB_FILE__ = str(Path(__file__).parent / "elements_storage.db")
即默认库文件是包目录下的 scrapling/elements_storage.db。Selector 初始化时若启用了 adaptive 且未指定 storage/storage_args,就会用该默认路径和当前 url 构造一个 SQLiteStorageSystem 实例(parser.py)。
另外两点来自源码的事实细节:
- 线程安全:
SQLiteStorageSystem使用RLock加锁、check_same_thread=False连接,并开启PRAGMA journal_mode=WAL(Write-Ahead Logging)以提升并发性能,注释明确说明其为 Scrapy 这类多线程框架而优化(storage.py); - 自定义存储:
Selector的storage参数要求传入一个继承自StorageSystemMixin且被lru_cache装饰过的类,否则抛出ValueError(parser.py)。StorageSystemMixin._get_hash提供了基于 SHA-256(附加原始长度以进一步降低碰撞概率)的 identifier 哈希工具,供自定义存储系统安全地使用(storage.py)。
相似度评分是怎么算的
Selector.relocate() 会对页面上的每一个节点调用 __calculate_similarity_score 打分——注意源码注释特意说明:即使已经出现 100% 的得分也不会提前终止,因为页面上可能存在并列高分的元素(parser.py)。
评分逻辑(__calculate_similarity_score)大致为"逐项加分 / 检查项总数 × 100":
| 比对项 | 计分方式 |
|---|---|
| 标签名 | 完全相等得 1 分 |
| 文本内容 | SequenceMatcher 文本相似度(0~1) |
| 属性字典(名+值) | 键相似度 ×0.5 + 值相似度 ×0.5(__calculate_dict_diff) |
class/id/href/src 四个关键属性 |
各自单独再做一次字符串相似度,帮助在"结构完全改变"时仍命中 |
| 路径(标签名序列) | SequenceMatcher 相似度 |
| 父元素标签名 | SequenceMatcher 相似度 |
| 父元素属性 | 字典差相似度 |
| 父元素文本 | SequenceMatcher 相似度 |
| 兄弟节点标签序列 | SequenceMatcher 相似度 |
最终得分 round((score / checks) * 100, 2),即一个 0~100 的百分比。relocate 取最高分那一组元素,只有当最高分不低于 percentage 阈值(默认 40)时才返回,否则打一条 warning("found no element above the X% threshold (top score: Y%)")并返回空列表(parser.py)。文档也特别提醒:百分比的计算只依赖页面结构,不要随意调整 percentage,除非你清楚自己在做什么。
调试时还有一个实用技巧:把日志级别调到 DEBUG,relocate 会输出"最高得分"以及"Top 5 最佳匹配元素",方便确认是不是选错了节点(parser.py)。
如何启用 adaptive 特性
自适应特性作用于"已找到的任何元素",以参数形式加入 CSS/XPath 选择方法中。第一步是全局启用它——在初始化 Selector 时传 adaptive=True,或在所用 Fetcher 上开启:
from scrapling import Selector, Fetcher
page = Selector(html_doc, adaptive=True)
# OR
Fetcher.adaptive = True
page = Fetcher.get('https://example.com')
使用 Selector 类时,建议通过 url 参数传入网站地址,Scrapling 会据此按域名隔离各元素的保存数据。如果不传 URL,保存时会用字符串 default 顶替 URL 字段——这只有在"同一 identifier 被用于不同网站却又没传 URL"时才会造成问题,因为保存是覆盖写、匹配只认最新数据。
storage 与 storage_args 两个参数则控制数据库连接本身;默认使用库自带的 SQLite 类(见上文实现细节)。
Selector 构造函数的相关签名(parser.py)供参考:
Selector(
content, # HTML 字符串或 bytes
url="", # 站点地址,用于按域名隔离 adaptive 数据
adaptive=False, # 全局开关,优先级高于一切自适应相关参数
_storage=None, # 内部复用(子元素继承父级 storage)
storage=SQLiteStorageSystem, # 必须是 lru_cache 包装的 StorageSystemMixin 子类
storage_args=None, # 传给存储类的参数字典;为空时用默认库文件与 url
**_,
)
从源码结构看,adaptive 是"硬开关":子 Selector 对象(如 .css() 返回的每个元素、.parent 等)会通过内部转换函数继承父级 adaptive 状态与 _storage 实例(parser.py),因此整个选取链路上的自适应行为是一致的;而一旦在实例化时禁用,运行时调用 save/retrieve 会直接抛出 RuntimeError,提示必须新建实例(parser.py)。
启用后,主要有两种使用方式。
方式一:CSS/XPath 选择方式
先对页面上存在的元素使用 auto_save 参数完成保存:
element = page.css('#p1', auto_save=True)
当元素日后不再存在时,用同一个选择器加 adaptive 参数让库替你找回来:
element = page.css('#p1', adaptive=True)
在 css/xpath 方法中,identifier 会被自动设置为传入的方法选择器字符串(不显式传 identifier 时)。
源码中 xpath 方法展示了完整的执行分支(parser.py),其行为等价于:
- 选择器直接命中元素:若同时传了
auto_save=True,则把结果中的第一个元素以identifier or selector为键保存(覆盖写); - 未命中且
adaptive=True:先retrieve(identifier or selector)取回存档,命中存档后调用relocate(element_data, percentage)做相似度匹配;若匹配成功且又带了auto_save=True,还会把新找到的元素重新保存——这意味着元素每经历一次成功重定位,存档就会刷新为最新结构下的属性,形成滚动维护; - 未命中且未开
adaptive:若你误传了auto_save或adaptive而全局未启用,会打 warning 并忽略该参数。
此外,所有这些方法都支持显式传 identifier 参数自行命名——在某些场景下更有用,也可以配合 auto_save 用自定义键保存属性。例如(取自仓库测试 test_adaptive.py 的真实用例):
old_page.css("#p1, #p2", auto_save=True)[0] # 组合选择器,每个子选择器各自保存
relocated = new_page.css("#p1", adaptive=True)
relocated[0].attrib["data-id"] == "p1" # 改版后依然命中正确元素
测试还覆盖了失败边界:当新页面与存档完全不相似、最高分低于 percentage 阈值(用例中设为 95)时,adaptive + auto_save 会安静地返回空 Selectors 而不是抛出 IndexError(test_adaptive.py)。
方式二:手动方式(save / retrieve / relocate)
元素可以手动保存、取回与重定位,这使得任何方法找到的任何元素都能参与自适应,例如按文本找到的节点:
element = page.find_by_text('Tipping the Velvet', first_match=True)
用 save 方法保存其独特属性,identifier 需要手动指定(取一个有语义的名字):
page.save(element, 'my_special_element')
日后再取回并在新页面中重定位:
>>> element_dict = page.retrieve('my_special_element')
>>> page.relocate(element_dict, selector_type=True)
[<data='<a href="catalogue/tipping-the-velvet_99...' parent='<h3><a href="catalogue/tipping-the-velve...'>]
>>> page.relocate(element_dict, selector_type=True).css('::text').getall()
['Tipping the Velvet']
relocate 的签名(parser.py):
element:可以是存档字典(retrieve的返回值),也可以是HtmlElement/Selector对象(内部会自动转成属性字典);percentage:相似度阈值,默认 40;selector_type:传True时结果转换为Selectors对象(可以继续链式调用.css()、.getall()等);省略时返回lxml.etree原始元素列表:
>>> page.relocate(element_dict)
[<Element a at 0x105a2a7b0>]
也就是说,save + retrieve + relocate 三件套把"指纹"的存取与匹配完全交给了你,适配那些 css/xpath 覆盖不了的选择途径(按文本、按正则、按自定义函数过滤等)。
参数速查
| 位置 | 参数 | 默认值 | 说明 |
|---|---|---|---|
Selector(...) / Fetcher.configure(...) |
adaptive |
False |
全局启用自适应特性;实例级硬开关,优先级最高 |
Selector(...) |
url |
"" |
用于按域名隔离数据;缺省时以 default 作为 URL 字段 |
Selector(...) |
storage / storage_args |
SQLiteStorageSystem / 默认库文件 |
存储类必须是 lru_cache 装饰的 StorageSystemMixin 子类 |
Fetcher.configure(...) |
adaptive_domain |
无 | 指定统一的"站点域",用于快照/换域名等跨域复用存档 |
css() / xpath() |
adaptive |
False |
未命中时启用重定位 |
css() / xpath() |
auto_save |
False |
命中时保存(或刷新)第一个元素的属性 |
css() / xpath() |
identifier |
选择器字符串本身 | 存档键;建议跨选择器复用同一元素时显式指定 |
css() / xpath() / relocate() |
percentage |
40 |
相似度下限;源码注释提醒该值仅依赖页面结构,慎用 |
故障排查(Troubleshooting)
找不到匹配(No Matches Found)
# 1. Check if data was saved
element_data = page.retrieve('identifier')
if not element_data:
print("No data saved for this identifier")
# 2. Try with different identifier
products = page.css('.product', adaptive=True, identifier='old_selector')
# 3. Save again with new identifier
products = page.css('.new-product', auto_save=True, identifier='new_identifier')
排查顺序是:先用 retrieve 确认该 identifier 下确实有存档;若当初保存时用的是别的键(或 identifier 被显式改过),尝试用正确的旧键查询;确认无误后,用 auto_save=True 以新 identifier 重新保存一次,为新结构"重置指纹"。
匹配到了错误元素(Wrong Elements Matched)
# Use more specific selectors
products = page.css('.product-list .product', auto_save=True)
# Or save with more context
product = page.find_by_text('Product Name').parent
page.save(product, 'specific_product')
核心思路是提高存档时的上下文丰富度:用更具体的选择器,或者把目标元素向父级扩展一步(如示例中取 .parent),让存档里携带更多父级属性与路径信息——评分函数对父元素标签、属性、文本都单独计分,上下文越完整,歧义越小的元素越容易在匹配中胜出。同时把日志调到 DEBUG 查看 Top 5 匹配,可以快速判断是"整体改版导致得分下降"还是"页面存在多个相似节点导致误配"。
已知限制
- 只保存第一个元素:adaptive 的保存过程中,选择结果里只有第一个元素的独特属性会被保存。如果你的选择器在页面不同位置选中了多个元素,日后
adaptive重定位只会返回其中的第一个。 - 组合选择器除外:用逗号组合的多个 CSS 选择器(如
'#p1, #p2')不受上述限制——源码在css方法中会先按逗号拆分(split_selectors),把每个子选择器规范化后各自独立执行选择与保存(parser.py),因此每个子选择器都有自己的存档。
小结
Scrapling 的 adaptive 特性以"保存指纹 → 相似度匹配"的两阶段模型,把"网站改版导致选择器失效"这一爬虫最常见的维护痛点变成了可自愈的流程:auto_save 负责在元素健康时持续刷新指纹,adaptive 负责在元素消失时按域名 + identifier 取回指纹并在整个页面内做全量相似度比对;底层默认由线程安全、WAL 模式的 SQLite 存储(scrapling/elements_storage.db)支撑,且通过 StorageSystemMixin 抽象允许替换为自定义存储。理解 percentage 阈值、identifier 命名策略与"只存第一个元素"这一限制,就能在真实项目中把它用得稳、查得快。
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 StartedRust0624
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