Scrapling 实战:网页改版后,自适应定位能恢复多少数据?

用 Scrapling 0.5 对比固定选择器与自适应定位,复现列表漏采、包装层误匹配和干扰卡片命中,并用业务字段验证结果。

亿牛云技术团队2026年10月25日10 分钟阅读

自适应定位可以帮助你重新寻找元素,但返回了节点,并不意味着整个列表已经恢复。

这次使用 Scrapling 0.5,在同一组已知商品数据上改变页面结构。原版采集完整;改类名后,自适应结果只有第一件商品;增加包装层时,虽然返回三个节点,但商品 ID 全部为空。

这组 2026 年 10 月 29 日的本地结果,适合用来理解适用边界,不能据此推断所有站点的恢复效果。整个实验没有调用 LLM。

先定义正确的业务数据

实验只采集前三件商品,价格按页面显示保留为字符串。

ID名称价格
p1Atlas19.50
p2Beacon24.00
p3Compass31.25

结果正确必须同时满足:没有漏 ID、没有额外 ID、名称和价格对应正确。只比较数量,无法发现错层节点和推荐卡片。

示例源码仓库为 16yun-cn/www-16yun-cn-v3,完整实验位于 scripts/blog-ai-crawling/scrapling_adaptive.py,页面由同目录 serve.py 提供。

五种页面变体改变了什么

每种变体都从原版保存一份独立的定位信息,避免前一轮结果影响后一轮。

变体页面变化要观察的问题
original保持 .product-card基线是否完整
renamed商品改为 .item-tile原选择器失效后能恢复几条
wrapped改类名,并增加 section 包装层返回的是否仍是商品节点
reordered改类名,并反转商品顺序组合变化下恢复哪些商品
distractor商品改类名,推荐卡片沿用旧类名原选择器有命中时是否触发自适应

reordered 同时改变类名和顺序,不是单独的排序实验。wrapped 也是组合变化。解读结果时必须保留这个条件。

获取与本文一致的示例版本

先准备 Git 和已能访问公开仓库的 GitHub CLI。下面使用仓库标识获取固定版本示例。

gh repo clone 16yun-cn/www-16yun-cn-v3 ai-crawling-examples -- --filter=blob:none --sparse
cd ai-crawling-examples
git checkout --detach bc6828e62c417b1a3459199811fd4e2164137a5f
git sparse-checkout set scripts/blog-ai-crawling

这个固定提交包含本文运行过的脚本和依赖锁;稀疏检出只取运行示例需要的目录及仓库根文件,不要求启动官网。

后文安装命令从这个 checkout 的根目录执行。代码摘录按完整示例的文件和行号标注,省略的 import、变量初始化及清理步骤仍在原文件中。

你可以先阅读代码摘录理解行为,再执行完整脚本;不要将多段摘录当作一个可直接粘贴运行的独立文件。

固定环境并运行

本次使用 Python 3.12.13、Scrapling 0.5。依赖锁文件与同系列 Crawl4AI 示例共享,环境独立于网站。

LAB_ENV="$(mktemp -d)"
uv venv --python 3.12 "$LAB_ENV/venv"
uv pip sync --python "$LAB_ENV/venv/bin/python" \
  scripts/blog-ai-crawling/requirements.lock
"$LAB_ENV/venv/bin/python" \
  scripts/blog-ai-crawling/scrapling_adaptive.py \
  --output "$LAB_ENV/results"

脚本自动启动本地 HTTP 服务,使用 Fetcher 读取页面,不需要运行浏览器或配置模型。

报告写入 scrapling-report.json,包含每种变体的固定结果、自适应结果、完整正确标记,以及缺失和意外 ID。

保存原版定位信息

下面是脚本中保存基线的核心调用。storage 为每种变体分配独立临时数据库。

对应示例:scripts/blog-ai-crawling/scrapling_adaptive.py:25-28。以下仅摘录相关语句,已为阅读展开换行。

original = Fetcher.get(base + "/products?variant=original")
saved = Selector(
    original.html_content,
    url=base + "/products",
    adaptive=True,
    storage_args=storage,
)
baseline = records(
    saved.css(".product-card", auto_save=True, identifier="cards")
)
assert baseline == expected

初始化时的 adaptive=True 允许保存和恢复信息;查询时的参数决定本次是否尝试重新定位。两处作用不同,不能漏掉保存步骤后就期待恢复。

实验保持 Selector 的 URL 和 identifier 不变,以明确复用哪份定位信息。

对同一改版页面比较两种查询

页面重新获取后,固定查询与自适应查询都使用相同的原始选择器。

对应示例:scripts/blog-ai-crawling/scrapling_adaptive.py:30-32。以下仅摘录相关语句,已为阅读展开换行。

changed = Selector(
    response.html_content,
    url=base + "/products",
    adaptive=True,
    storage_args=storage,
)
fixed = records(changed.css(".product-card"))
adaptive = records(
    changed.css(".product-card", adaptive=True, identifier="cards")
)

这里的 records 读取节点自身的 data-id,再读取内部名称和价格。若节点层级不对,ID 就会暴露问题,而不是被静默补成猜测值。

为什么读取节点自身的 ID

记录转换对应 scripts/blog-ai-crawling/scrapling_adaptive.py:14-15。下面展开同一实现,便于看清 ID 与子字段的来源。

def records(elements):
    return [
        {
            "id": item.attrib.get("data-id"),
            "name": item.css("h2::text").get(),
            "price": item.css(".price::text").get(),
        }
        for item in elements
    ]

名称与价格来自后代节点,ID 则来自当前节点的属性。这个差别使包装层错误能够被发现:外层 section 包含商品文字,却不拥有商品身份。

如果为了得到一个非空 ID,临时改成在任意后代寻找 data-id,就改变了本次检查的含义。你需要先确认新节点结构,再决定新的提取规则。

实际结果:列表完整性没有自动恢复

本次完整输出如下。

变体固定查询自适应查询业务结果
originalp1、p2、p3p1、p2、p3完整且字段正确
renamed空p1漏 p2、p3
wrapped空三个 ID 为空的节点返回错误层级
reordered空p1漏 p2、p3
distractorad1ad1采到推荐卡片,漏全部目标商品

包装层变体尤其容易误判:名称和价格仍然能从后代节点读取,但 data-id 在内部 article 上,返回节点自身没有这个属性。

如果只看“返回三条”,这一轮会被错误标记为成功。

报告如何表示漏采

下面摘录 scrapling-report.json 的 renamed 结果,只保留自适应结果及相关判定字段。

{
  "adaptive": [
    {"id": "p1", "name": "Atlas", "price": "19.50"}
  ],
  "adaptive_complete_and_correct": false,
  "missing_ids": ["p2", "p3"],
  "unexpected_ids": []
}

虽然唯一返回的 p1 字段正确,完整性仍然是 false。没有额外记录,也不能抵消两条目标记录缺失。

这份报告用于观察行为,脚本不会因为自适应查询的返回值非空,就把这一变体改写为成功。

从源码解释两个边界

核对 D4Vinci/Scrapling 提交 0336400 的 scrapling/parser.py:662-680,可以看到两个与本次结果直接相关的分支。

原选择器有结果时,会直接返回。启用自动保存时保存的是 elements[0]。只有原选择器没有命中,且启用了自适应查询,才会读取保存信息并重新定位。

因此,用列表选择器保存信息,不应理解为保存了一份完整的列表恢复契约。第一条锚点的相似度匹配,不能保证找回所有兄弟记录。

同样,推荐卡片沿用旧类名时,查询已经有结果,自适应分支不会被触发。框架并不知道业务上你要的是“可售商品”,而不是“任何保留旧类名的卡片”。

Issue #259 报告过旧版 0.4.7 中列表只恢复第一条的现象。本次在 0.5 的受控页面中观察到相关行为;我们没有把该 Issue 的版本或站点条件直接当成本次测试结果。

用业务字段检查,而不是只看节点数量

示例将记录排序后与已知预期比较,顺序变化不会被误判为漏采。

对应示例:scripts/blog-ai-crawling/scrapling_adaptive.py:34-41。以下是便于说明的等价或精简写法。

canonical = lambda rows: sorted(rows, key=lambda row: row["id"] or "")
complete_and_correct = canonical(adaptive) == canonical(expected)
missing_ids = sorted(
    {row["id"] for row in expected}
    - {row["id"] for row in adaptive}
)

固定测试站拥有完整答案,所以可以逐项比较。真实业务通常需要组合检查:必填字段、身份唯一性、允许的取值,以及与已知分页总量或历史数据的核对。

类型正确也不够。999.00 是合法的价格字符串,但推荐卡片 ad1 并不是目标商品。

不要在缺 ID 时自动生成一个新 ID 来让校验通过;那会把定位错误变成看似有效的数据。

明确修复当前选择器

示例页面仍保留稳定的商品属性和容器,可以直接修复查询。

对应示例:scripts/blog-ai-crawling/scrapling_adaptive.py:45-47。以下仅摘录相关语句,已为阅读展开换行。

renamed = Fetcher.get(base + "/products?variant=renamed")
stable = records(renamed.css("main#products > article[data-id]"))
assert stable == expected

在 renamed 变体中,这个选择器得到完整的 p1、p2、p3,以及正确的名称、价格。

它没有被证明能覆盖所有变体。增加包装层后,直接子元素关系也会变化,需要再次检查页面契约。

选择器修复是针对已知页面的明确修改,不应被包装成自适应算法已经成功修复了页面。

按错误类型选择处理方式

不同的失败需要不同处理,不能都归结为“调一下自适应阈值”。

可能看到的现象意味着什么下一步
原查询空,自适应也空当前条件未恢复目标保留页面,检查保存信息及页面变化
只返回 p1找回一个候选,列表不完整重新核对列表定位与记录范围
返回三个节点但 ID 为空节点层级违反提取约定检查外层包装与内部商品节点
返回 ad1匹配到了错误业务对象修正作用区域和业务属性条件
ID 正确但字段不一致记录存在,字段提取仍有错误核对字段定位及页面原值

首行和末行是排查时需要考虑的情况,不是本次已观察到的输出。本次实验实际发现的是漏采、错层和干扰节点,没有声称这些变体覆盖了全部错误类型。

修复 renamed 后,应先确认原版仍然正确,再检查修复声称覆盖的变体。若你进一步修改包装层规则,需要单独验证它,不应沿用 renamed 的通过结果。

什么时候停止自动输出

测试夹具知道所有预期商品,可以直接比较完整记录。真实采集若没有总量答案,也应先定义哪些错误需要停止导出,哪些可以保留为待处理记录。

例如,身份字段缺失时,你无法可靠去重或更新已有商品。此时应暴露错误,而不是猜测身份;推荐卡片混入时,也不能仅按价格格式有效就接受。

这些决定属于你的数据契约。自适应定位不会自动替你判断错误数据进入下游后的影响。

不要在误匹配后立即保存新锚点并继续运行,否则后续排查会更难区分原始基线与错误匹配。本例为每个变体使用独立存储,保留了这种可比较性。

如何放进实际采集任务

稳定的业务属性可作为优先定位依据;自适应结果可以用于辅助找回候选元素。候选结果仍应通过字段和完整性检查,异常时保留页面和报告,修正定位规则。

Scrapling 还提供浏览器获取、Spider 调度和代理相关能力,但本次没有验证暂停恢复、代理轮换、大规模并发或反爬效果,不能从这组选择器实验推导这些能力的表现。

如果页面需要点击和翻页,可继续阅读 Stagehand 配置与实现。该篇把实际跑通的确定性流程,与供你配置的模型调用明确分开。

需要企业代理方案?

我们可根据目标站点、并发规模与稳定性目标提供定制方案。