首页
/ Scrapling 自适应抓取(Adaptive Scraping):当网站改版后,让选择器自动找回元素

Scrapling 自适应抓取(Adaptive Scraping):当网站改版后,让选择器自动找回元素

2026-09-04 15:32:32作者:董宙帆

本文基于 Scrapling 官方文档 docs/parsing/adaptive.md 及其配套源码,完整讲解自适应抓取(adaptive scraping,早期称为 automatch)这一核心特性:它如何让爬虫在网站结构或设计改版后,无需人工维护选择器即可重新定位到同一元素。读完后你将掌握 auto_save / adaptive / identifier 等参数用法、save / retrieve / relocate 手动定位流程、相似度评分算法的源码原理,以及 SQLite 存储系统的隔离机制与自定义存储方案。

一、为什么需要自适应抓取

爬虫最脆弱的地方在于:你写死的选择器,往往绑定着网页当前的具体结构。假设你正在抓取这样一页商品列表:

<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>

你想抓取第一个商品(id="p1"),自然会选择:

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>

#p1 这个 ID 没了,选择器直接失效,代码必须返工。这正是 Scrapling 的 adaptive(自适应)特性要解决的问题:

首次选中某个元素并启用自适应时,Scrapling 会记住它的“特征指纹”;下次选择该元素时发现它不存在,Scrapling 就会在当前页面中搜索与该指纹相似度最高的元素——而且全程不依赖 AI。

最简用法如下:

from scrapling import Selector, Fetcher
# 改动发生之前
page = Selector(page_source, adaptive=True, url='example.com')
# 或者
Fetcher.adaptive = True
page = Fetcher.get('https://example.com')
# 然后
element = page.css('#p1', auto_save=True)
if not element:  # 某一天网站改版了?
    element = page.css('#p1', adaptive=True)  # Scrapling 依然能找到它!
# 你的其余代码……

需要注意的是,自适应逻辑对所有选择方式都生效,不只是 CSS/XPath 选择。

二、实战验证:同一选择器横跨 2010 年与现在

官方文档给出的真实场景是:找一个即将改版的网站几乎不可能,于是作者利用互联网档案馆(Wayback Machine)保存的 StackOverflow 2010 年快照(https://web.archive.org/web/20100102003420/http://stackoverflow.com/)与当前站点对比——用同一个选择器提取两个时代设计中的同一个按钮。

从 2010 版设计中提取 Questions 按钮,可以写一个像 #hmenus > div:nth-child(1) > ul > li:nth-child(1) > a 这样非常具体的选择器(它是由 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]
# 同一个选择器,用在改版后的网站上
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!')  # Spoiler: 它确实做到了

这里引入了一个文档中的新参数 adaptive_domain,它存在的原因值得展开:

  • 对 Scrapling 而言,archive.orgstackoverflow.com两个不同域名,自适应数据会按域名隔离存储;
  • 通过 Fetcher.configure(adaptive_domain='stackoverflow.com') 显式指定统一域名,Scrapling 就会把两次抓取的数据归到同一个“网站”下,而不是相互隔离;
  • 该参数的设计初衷还覆盖了一种更常见的真实场景:网站改版的同时更换了域名。此时可以用 adaptive_domain 让新旧 URL 共用同一份已存储的自适应数据,否则 Scrapling 会把新域名当作全新网站,旧数据被废弃。

在真实生产场景中代码是一样的,只是两次请求使用同一个 URL,因此不需要 adaptive_domain。上述示例同时演示了 Selector 类与 Fetcher 类两种入口,两者的自适应逻辑完全相同。

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

自适应抓取分为两个阶段:

  1. 保存阶段(Save Phase):存储元素的唯一属性;
  2. 匹配阶段(Match Phase):之后据此寻找属性相似的元素。

整体流程(文档中的“少技术细节版”描述)是:

  1. 通过下文的任一方式告诉 Scrapling 保存某元素的唯一属性;

  2. Scrapling 使用其配置的数据库(默认 SQLite)保存每个元素的唯一属性;

  3. 由于网站运营方可以修改或删除元素的一切内容,元素上没有任何一项可以单独充当数据库唯一键。为此存储系统依赖两样东西组合定位:

    • 当前网站的域名(使用 Selector 类时需通过 url 参数传入;使用抓取器时会自动从 URL 取得);
    • 一个用于查询该元素属性的 identifier(不需要时它会由选择器自动生成)。

    两者共同用于之后从数据库中检索元素的唯一属性。

  4. 当网站结构变化后,你启用 adaptive 让 Scrapling 查找元素。它取出该元素的唯一属性,用页面上所有元素逐一与之比对,计算相似度得分;

  5. 返回与目标元素相似度得分最高者。

3.1 元素指纹到底包含什么

文档强调:元素的所有属性都可能被改,比较不是精确匹配,而是“这些值有多相似”。从源码看,指纹的生成在 element_to_dict_StorageTools 工具类)中,保存的字段为:

字段 含义
tag 元素标签名
attributes 元素的属性名与值(会剔除空值)
text 元素直接文本
path 从根节点到该元素的标签名路径(仅标签名)
parent_name 父元素标签名
parent_attribs 父元素的属性(名与值)
parent_text 父元素的直接文本
siblings 兄弟元素列表(仅标签名,按顺序)
children 子元素列表(仅标签名,按顺序)

这与文档“唯一属性”一节完全对应:元素自身的标签名、文本、属性(名+值)、兄弟(仅标签名)、路径(仅标签名),加上父元素的标签名、属性、文本。此外,文档特别提醒连值的顺序也参与比较——例如 class 名此前的书写顺序与现在的书写顺序——这一点在源码中体现为兄弟/子元素以有序元组保存、以及后文评分算法对属性名/值分别做序列比对。

3.2 默认存储:按域名 + identifier 隔离的 SQLite

默认存储在 SQLiteStorageSystem 中实现,几个源码级细节值得注意:

  • 表结构为 storage (id, url, identifier, element_data, UNIQUE (url, identifier))——域名与 identifier 的组合是唯一定位键,保存是覆盖写(ON CONFLICT 更新),所以 adaptive 特性永远只使用最新一次保存的属性;
  • 域名提取由 StorageSystemMixin._get_base_url()storage.py)完成,它基于 tld 库从 URL 中解析出可注册域(fld/domain)。如果初始化时没有传 URL,default 会代替 URL 字段入库——这正是文档警告的隐患:若你在不同网站间复用了同一个 identifier 却从未传过 url,保存过程会互相覆盖数据;
  • 默认数据库文件由 parser.py 中的 __DEFAULT_DB_FILE__ 指定;类使用 RLock 加锁并开启 SQLite 的 WAL 日志模式,官方注释说明其面向 Scrapy 等线程化框架设计,是线程安全的。

四、使用方式一:CSS/XPath 选择方式

4.1 启用自适应

首先必须全局启用该特性——两种方式任选其一:

from scrapling import Selector, Fetcher
page = Selector(html_doc, adaptive=True)
# 或者
Fetcher.adaptive = True
page = Fetcher.get('https://example.com')

若使用 Selector 类,务必通过 url 参数传入网站 URL,Scrapling 会按域名隔离每个元素保存的属性(前文已说明不传 URL 时用 default 兜底的后果)。

除了这两个参数,Selector 还支持 storagestorage_args:前者传入存储类(默认是库内置的 SQLite 实现),后者是传给存储类的参数字典。除非你想编写自己的存储系统,否则一般用不到默认配置即可——自定义存储的完整教程见仓库中的 Writing your retrieval system

全局启用后,有两种主要使用方式。

4.2 auto_save 与 adaptive 参数

选择当前存在的元素时,用 auto_save 保存其特征:

element = page.css('#p1', auto_save=True)

当元素消失后,用同一个选择器adaptive 参数,库会替你找到它:

element = page.css('#p1', adaptive=True)

看似简单,但源码中发生的事很多。先看参数如何落库:identifier 默认就是你传入的选择器本身(见 css 方法identifier or selector),无需手动指定;你也可以显式传入 identifier 参数自行命名——这在“以后要换一个选择器却想找回同一元素”的场景中非常有用。

css 方法的内部实现细节(对使用者是透明但对排障很有价值):

  • css 并不直接执行 CSS,而是经 _css_to_xpath 转换后委托给 xpath 方法
  • 组合选择器(用逗号合并多个选择器)会被拆分,每个子选择器单独执行,且各自的 identifier 取各自的规范化选择器——这解释了后文“已知问题”中组合选择器不受“只存第一个元素”限制的原因;
  • xpath 方法的执行顺序是:先尝试常规 XPath 命中;命中且 auto_save=True 时保存 elements[0](第一个元素);未命中且 adaptive=True才调用 retrieve 取出指纹、再走 relocate 重定位,若重定位成功且 auto_save=True,会把重定位到的元素再次保存(用新页面结构刷新指纹);
  • 若初始化时未启用 adaptiveadaptiveauto_save 参数会被忽略并记录 warning(源码中两处 log.warning 可作行为佐证)。

所有选择方法都额外支持 percentage 参数,取值范围 0–100,默认 40:它是重定位的最低可接受相似度得分。文档同时提醒:得分计算完全取决于页面结构本身,“除非你确切知道自己在做什么,否则别动这个数字”。

五、使用方式二:手动保存与重定位

手动方式允许你保存任何方式找到的任何元素,再重定位它,全部发生在 adaptive 特性之内。例如你通过文本找到了一个元素:

element = page.find_by_text('Tipping the Velvet', first_match=True)

可以用 save 方法保存其唯一属性,但此时 identifier 必须自己设置——示例中使用 my_special_element;文档建议像命名变量一样使用有意义的 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']

retrieve 取出指纹字典、relocate 在当前页面中重定位。关于返回类型:

  • selector_type=True 时,结果转换为 Selectors 对象,可继续链式使用 .css() 等选择方法(如上例提取文本);
  • 省略 selector_type(默认 False)时,返回原始的 lxml.etree 元素列表:
>>> page.relocate(element_dict)
[<Element a at 0x105a2a7b0>]

5.1 relocate 的源码逻辑

relocate 方法 的工作过程:

  1. 入参可以是字典、HtmlElementSelector,内部统一经 element_to_dict 转成指纹字典;
  2. 用预编译的 XPath .//* 遍历页面全部节点,对每个候选节点调用 __calculate_similarity_score 计算得分(源码注释说明:即使某个元素已达 100% 也不提前终止,因为页面上可能存在同分的其他元素,全部要收进 score_table);
  3. 取得最高分后,只有最高分 ≥ percentage(默认 40)时才返回该得分档的全部节点;否则记录 warning(提示“top score 是多少,如果这就是你要的元素可以调低 percentage”)并返回空列表;
  4. 在 DEBUG 日志级别下,会额外打印 Top 5 得分节点,方便排查匹配偏差。

5.2 相似度评分算法

__calculate_similarity_score 是“什么都被考虑”这一说法的落点。它对候选元素逐项打分(score)并累计检查项数(checks),最终得分为 round(score / checks * 100, 2)。逐项看:

比较项 计分方式
标签名 完全相等计 1 分
文本 原文与候选文本的 SequenceMatcher 相似度比值(仅当原文有文本时)
属性整体 __calculate_dict_diff:属性键序列比值 × 0.5 + 属性值序列比值 × 0.5(所以顺序参与比较;双方都没有属性也计 1 分)
class / id / href / src 这四个关键属性各自单独再做一次序列相似度比较(注释说明:单独测试有助于应对“完全的结构化变更”)
标签路径 路径字符串序列的 SequenceMatcher 比值
父元素标签名 / 父属性 / 父文本 有父级信息时分别做序列相似度比较(父属性同样走 dict diff)
兄弟元素 兄弟标签有序元组的序列比值

可以看出:比较是逐项加权平均而非一票否决,标签、文本、属性、路径、父级、兄弟共同决定得分。这也解释了官方示例中为什么 2010 版与当代 StackOverflow 的同一按钮仍能超过默认 40% 阈值被找回——即使 class 全变了,标签、文本、路径结构与父级上下文的相似项仍然足够多。

save / retrieve 本身在 parser.py 中有两个值得注意的边界行为:

  • adaptive 未在全局启用就调用它们,会直接抛出 RuntimeError(提示需新建实例);
  • save 时若传入的是文本节点,会自动取其父元素再保存。

六、故障排查(Troubleshooting)

文档提供了两类典型故障的排查路径。

6.1 找不到匹配(No Matches Found)

# 1. 检查数据是否被保存
element_data = page.retrieve('identifier')
if not element_data:
    print("No data saved for this identifier")

# 2. 尝试使用不同的 identifier
products = page.css('.product', adaptive=True, identifier='old_selector')

# 3. 用新的 identifier 重新保存
products = page.css('.new-product', auto_save=True, identifier='new_identifier')

排查思路:先用 retrieve 确认该 identifier 下确实有指纹数据;若数据是旧结构留下的,可尝试指向旧保存记录的 identifier;最后以当前结构重新 auto_save 建立新基准。

6.2 匹配到了错误的元素(Wrong Elements Matched)

# 使用更具体的选择器
products = page.css('.product-list .product', auto_save=True)

# 或者保存时携带更多上下文
product = page.find_by_text('Product Name').parent
page.save(product, 'specific_product')

即:提高被保存元素的“上下文浓度”(更深层的选择器、带父级上下文),让指纹中携带更多区分度信息。

七、已知限制

文档明确指出一条需要牢记的限制:

adaptive 的保存过程中,只保存选择结果中第一个元素的唯一属性。如果你使用的选择器在页面不同位置选中了多个元素,之后重定位时只会返回第一个元素。唯一例外是组合 CSS 选择器(用逗号合并多个选择器)——因为此类选择器会被拆分、逐个单独执行(对应 css 方法中的 split_selectors 逻辑),每个子选择器分别保存与重定位。

结合源码可确认这一点:auto_save 触发时执行的是 self.save(elements[0], identifier or selector),即只取结果集首元素。测试覆盖见 tests/parser/test_adaptive.py

八、总结

Scrapling 的自适应抓取用“保存阶段 + 匹配阶段”的指纹比对模型,把“网站改版导致选择器失效”这一爬虫维护中最常见的问题,转化成了运行时的一次相似度检索:

  • 启用Selector(html, adaptive=True, url=...)Fetcher.adaptive = True / Fetcher.configure(adaptive=True, adaptive_domain=...)
  • 选择器流auto_save=True 首次保存(identifier 默认为选择器),元素消失后 adaptive=True 自动重定位,最低相似度阈值由 percentage 控制(默认 40);
  • 手动流save(element, identifier)retrieve(identifier)relocate(element_dict, selector_type=True),可作用于任意选择方式找到的元素;
  • 存储:默认 SQLite(WAL 模式、线程安全),按“域名 + identifier”唯一约束隔离与覆盖;域名缺省记为 default,跨域名/跨存档场景用 adaptive_domain 统一;也可通过 storage / storage_args 参数接入自定义存储系统(继承 StorageSystemMixin,参考 自定义存储教程)。

掌握这套机制后,配合 选择器文档核心类文档 中的元素查询能力,即可构建出对网站结构变更具有韧性的抓取管道。

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