首页
/ Scrapling 自适应抓取(Adaptive Scraping)深度解析:让爬虫在网页结构变更后自动重定位元素

Scrapling 自适应抓取(Adaptive Scraping)深度解析:让爬虫在网页结构变更后自动重定位元素

2026-09-06 18:43:56作者:凤尚柏Louis

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!')

这里有两点值得注意:

  1. adaptive_domain 参数的作用:Scrapling 默认把 archive.orgstackoverflow.com 视为两个不同站点,会各自隔离 adaptive 数据。显式传入 adaptive_domain='stackoverflow.com' 是告诉 Scrapling"把这两个 URL 当作同一个网站处理",这样在快照页面上 auto_save 保存的数据,可以在正式域名的页面上被 adaptive 检索复用。
  2. URL 迁移场景:文档特别指出,adaptive_domain 参数最主要的用途是处理"网站换域名/换 URL 的同时改了结构"的情形——此时用它可以让新 URL 继续复用旧 URL 保存的 adaptive 数据;否则 Scrapling 会把新地址当作一个全新网站,弃用旧数据。如果两次请求的 URL 本来就相同(常见情况),则不需要该参数。

adaptive 逻辑在 SelectorFetcher 两类入口上行为一致,下文以 Selector 类为主展开。

工作原理:保存阶段与匹配阶段

自适应抓取分为两个阶段(两阶段模型):

  1. Save Phase(保存阶段):把元素的独特属性存入数据库;
  2. Match Phase(匹配阶段):之后再抓取该网站时,用这些属性与页面中的所有元素做相似度比对。

完整的处理逻辑如下(与文档描述一致):

  1. 用任意方式选中一个元素后,Scrapling 提取它的独特属性(具体字段见下文);
  2. 把属性写入已配置的数据库(默认是 SQLite);
  3. 由于元素的一切特征(class、id、路径……)都可能被网站方修改或删除,没有任何单一特征可以当作数据库主键。存储系统因此依赖两样东西:
    • 当前网站的域名。使用 Selector 类时在初始化时通过 url 参数传入;使用 Fetcher 时则自动从请求 URL 中解析;
    • 一个 identifier,用于日后查询该元素的属性。identifier 通常无需手动指定(CSS/XPath 方式下会自动使用选择器字符串)。
  4. 之后网站结构发生变化时,只要启用 adaptive,Scrapling 就会取回该元素的独特属性,把页面上的所有元素逐一与之比对,基于"综合相似度"计算得分;
  5. 相似度得分最高的元素被返回。

参与比对的独特属性

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.dbSelector 初始化时若启用了 adaptive 且未指定 storage/storage_args,就会用该默认路径和当前 url 构造一个 SQLiteStorageSystem 实例(parser.py)。

另外两点来自源码的事实细节:

  • 线程安全SQLiteStorageSystem 使用 RLock 加锁、check_same_thread=False 连接,并开启 PRAGMA journal_mode=WAL(Write-Ahead Logging)以提升并发性能,注释明确说明其为 Scrapy 这类多线程框架而优化(storage.py);
  • 自定义存储Selectorstorage 参数要求传入一个继承自 StorageSystemMixin 且被 lru_cache 装饰过的类,否则抛出 ValueErrorparser.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"时才会造成问题,因为保存是覆盖写、匹配只认最新数据。

storagestorage_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),其行为等价于:

  1. 选择器直接命中元素:若同时传了 auto_save=True,则把结果中的第一个元素identifier or selector 为键保存(覆盖写);
  2. 未命中且 adaptive=True:先 retrieve(identifier or selector) 取回存档,命中存档后调用 relocate(element_data, percentage) 做相似度匹配;若匹配成功且又带了 auto_save=True,还会把新找到的元素重新保存——这意味着元素每经历一次成功重定位,存档就会刷新为最新结构下的属性,形成滚动维护;
  3. 未命中且未开 adaptive:若你误传了 auto_saveadaptive 而全局未启用,会打 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 而不是抛出 IndexErrortest_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 命名策略与"只存第一个元素"这一限制,就能在真实项目中把它用得稳、查得快。

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