Scrapling 自适应抓取(Adaptive Scraping):当网站改版后,让选择器自动找回元素
【免费下载链接】Scrapling🕷️ An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl!项目地址: https://gitcode.com/GitHub_Trending/sc/Scrapling
本文基于 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">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.org与stackoverflow.com是两个不同域名,自适应数据会按域名隔离存储; - 通过
Fetcher.configure(adaptive_domain='stackoverflow.com')显式指定统一域名,Scrapling 就会把两次抓取的数据归到同一个“网站”下,而不是相互隔离; - 该参数的设计初衷还覆盖了一种更常见的真实场景:网站改版的同时更换了域名。此时可以用
adaptive_domain让新旧 URL 共用同一份已存储的自适应数据,否则 Scrapling 会把新域名当作全新网站,旧数据被废弃。
在真实生产场景中代码是一样的,只是两次请求使用同一个 URL,因此不需要adaptive_domain。上述示例同时演示了Selector类与Fetcher类两种入口,两者的自适应逻辑完全相同。
三、工作原理:保存阶段与匹配阶段
自适应抓取分为两个阶段:
- 保存阶段(Save Phase):存储元素的唯一属性;
- 匹配阶段(Match Phase):之后据此寻找属性相似的元素。
整体流程(文档中的“少技术细节版”描述)是:
通过下文的任一方式告诉 Scrapling 保存某元素的唯一属性;
Scrapling 使用其配置的数据库(默认 SQLite)保存每个元素的唯一属性;
由于网站运营方可以修改或删除元素的一切内容,元素上没有任何一项可以单独充当数据库唯一键。为此存储系统依赖两样东西组合定位:
- 当前网站的域名(使用
Selector类时需通过url参数传入;使用抓取器时会自动从 URL 取得); - 一个用于查询该元素属性的identifier(不需要时它会由选择器自动生成)。
两者共同用于之后从数据库中检索元素的唯一属性。
- 当前网站的域名(使用
当网站结构变化后,你启用
adaptive让 Scrapling 查找元素。它取出该元素的唯一属性,用页面上所有元素逐一与之比对,计算相似度得分;返回与目标元素相似度得分最高者。
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还支持storage与storage_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,会把重定位到的元素再次保存(用新页面结构刷新指纹); - 若初始化时未启用
adaptive,adaptive或auto_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 方法 的工作过程:
- 入参可以是字典、
HtmlElement或Selector,内部统一经element_to_dict转成指纹字典; - 用预编译的 XPath
.//*遍历页面全部节点,对每个候选节点调用__calculate_similarity_score计算得分(源码注释说明:即使某个元素已达 100% 也不提前终止,因为页面上可能存在同分的其他元素,全部要收进score_table); - 取得最高分后,只有最高分 ≥ percentage(默认 40)时才返回该得分档的全部节点;否则记录 warning(提示“top score 是多少,如果这就是你要的元素可以调低 percentage”)并返回空列表;
- 在 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,参考 自定义存储教程)。
掌握这套机制后,配合 选择器文档 与 核心类文档 中的元素查询能力,即可构建出对网站结构变更具有韧性的抓取管道。
【免费下载链接】Scrapling🕷️ An adaptive Web Scraping framework that handles everything from a single request to a full-scale crawl!项目地址: https://gitcode.com/GitHub_Trending/sc/Scrapling
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考