← 全部文章

绕过聚合平台的汽车经销商库存抓取

经销商库存抓取的难点在门店独立站点,而非聚合平台。其核心在于客户端渲染价格、少数几套平台模板,以及伪装成有效数据的反爬拦截。

每个人起初都会先写聚合网站的爬虫。Cars.com、CarGurus、AutoTrader:三个站点,各配一个解析器,到周末你就能拿到看似覆盖了二手车市场的数据流。

然后有人会问:剩下的数据在哪里?

挑战所在

根据 NADA 2025 年中报告的统计,全美有 16,972 家特许轻型车经销商,这还不算根本没有统一统计口径的独立车商。针对 NADA 这一数据的二次引用,数字在 15,720 到 16,990 之间浮动,这也反映出该市场的统计现状。

这些实体门店各自运营着独立的网站。上面的库存代表着车商当前的现货及当天的定价,比同步到第三方聚合平台要早好几天。如果你在做二手车定价、残值预测,或是向车商销售竞品分析工具,独立门店网站上的数据才是你真正需要的。聚合平台上的数据只是滞后且经过滤的副本。

因此,团队开始转向独立门店网站,并按顺序发现了三件事。

这些网站并非独一无二。它们几乎全部运行在少数几家汽车经销商建站平台上:Dealer.com、DealerOn、Dealer Inspire、CDK、Reynolds、Sincro、Lotlinx(具体名单取决于统计标准)。任何提供此类数据服务的团队,都是按平台而不是按经销商来开发解析器的。Apify 的 经销商网站库存爬虫也明确说明了这点:先识别平台,再提取数据。用几个模板就能覆盖数万家门店。这是整个环节中的好消息。

价格通常不在你直接抓取的 HTML 中。这些平台在客户端渲染价格模块,而预估月供和优惠信息往往在随后的二次调用中才返回。普通的 HTTP 请求只能拿到年份、品牌、车型、里程和 VIN。价格字段返回为空。

接着是悄然破坏数据集的部分:在经销商网站上,抓取失败看起来和真实数据毫无二致。Bot 防护服务在拦截可疑请求时会返回 HTTP 200 及一个插页式页面。未渲染的价格模块会在 DOM 中留下 "Call for Price",而这也正是车商有时故意填写的文案。这两类数据落入数仓时看起来同样规范。

我们在另一个行业探讨过这种模式,即 请求被拦截看起来却像正常数据点。汽车行业的情况更棘手,因为“暂无报价”是一种合法的业务状态,而不是显而易见的异常。

按成本拆分带来的改变

能稳定运行的流水线不是按站点组织的,而是按单个请求的成本组织的。

成本高昂的请求是对某个门店网站的第一次请求:它必须运行浏览器,绕过经销商平台设置的所有防护,并带回一个 session。在此之后的所有请求都是廉价的 HTTP 调用,直接复用第一次请求获取的凭证。

import requests

FOURA = "https://api.foura.ai/api"
AUTH = {"Authorization": "Bearer pk_live_..."}

# First page of a rooftop: render it, clear the defense, keep the session.
first = requests.post(f"{FOURA}/browser", headers=AUTH, json={
    "url": "https://example-motors.com/used-inventory/index.htm",
    "unblocker": True,
    "timeout_ms": 45000,
}).json()

listings = first["body"]
jar      = "; ".join(f'{c["name"]}={c["value"]}' for c in first["cookies"])
agent    = first["userAgent"]
exit_id  = first["proxy"]     # opaque proxy ID, send it back to stay on the same exit

然后遍历该经销商的其余库存,无需再次为浏览器付费:

page = requests.post(f"{FOURA}/single", headers=AUTH, json={
    "method": "GET",
    "url": "https://example-motors.com/used-inventory/index.htm?start=20",
    "proxy": exit_id,
    "headers": [["Cookie", jar], ["User-Agent", agent]],
    "validate": {
        "status": {"accept": [200]},
        "data": {
            "accept": ["vehicle-card"],
            "fail": ["Just a moment", "Access Denied"]
        }
    }
}).json()

其中的两个细节比表面看起来更为关键。

Session 是作为一个整体传递的。通过验证获得的 clearance cookie 会与获取它的出口 IP 以及对应的 User-Agent 绑定。如果在其他地方或使用不同的 User-Agent 重放该 cookie,目标站点会要求重新进行验证。这就是为什么 cookie jar、User-Agent 字符串和 proxy ID 必须协同传递。这是我们看到开发者最常犯的错误:他们保留了 cookie,却更换了出口节点,然后疑惑为什么低成本路径不再有效。

validate 代码块彻底解决了静默失败(silent-failure)的问题。它让 request 明确定义了正常页面的特征:仅在列表网格渲染完成后才匹配特定标记,并在出现拦截页特征字符串时直接判定为失败。不符合这些规则的 response 不会被解析为价格为 null 的数据行。它属于明确分类的失败请求,且不会被计入成功。在汽车数据领域,应同时编写正向匹配标记和负向排除列表,因为“电话询价”本身存在歧义,而“车辆卡片未渲染”则具有明确的判定性。

如果尚不清楚特定平台需要哪条路径,Auto 可以通过单次调用探测出来,并返回有效的 session。应将其作为前期探测手段,而非生产环境的常规路由。一旦确定某个平台需要浏览器渲染而其他平台不需要,就应将各平台固定到直接引擎上,避免每晚付费让编排器重复探测已知的结果。

实际效果

以一个中等规模任务为例进行测算(基于行业基准的示例场景,非特定客户数据):4,000 家门店,每家约 180 辆二手车,每晚全量更新。

  • 仅需渲染 4,000 个页面,而非 720,000 个。 每家门店通过一次页面渲染建立 session,其余 716,000 个页面复用该 session 走低成本路径。决定每晚全量采集成本是否可承受的关键在于该比例,而非 parser。
  • 两种缺失价格被划分到不同的表中。 借助 validate 规则,“车商未发布价格”和“页面抓取失败”不再共用相同的数据行结构。模型只会接触到前者。
  • 按平台而非按车商编写 parser。 根据 response 识别平台类型,将 HTML 传递给对应的 parser 处理。支持范围内的平台接入新门店无需额外开发成本。
  • 车商更换平台时会立即触发显式报错。 平台识别失效后不会写入数据行,相关人员会直接收到工单提醒,避免连续六周静默产出错误价格。

需要注意的复杂场景:大型汽车经销商集团越来越倾向于脱离标准化平台构建自研网站,这仍然需要人工维护专用 parser 并承担后续维护成本。此外,各门店的数据新鲜度也存在差异。部分平台对库存页面设置了强缓存,导致无论采集频率多高,“当日价格”都可能滞后一天。如果数据模型默认所有门店的时间戳实时性一致,这种逻辑错误是无法通过升级采集基础设施来解决的。

核心结论

汽车数据的难点从来不是每个人都用来做基准测试的那三大聚合平台。而是那 17000 个小型站点,它们单个来看都不值得专门写一个爬虫,但组合起来却价值巨大。

这种模式不仅存在于汽车领域。药房、设备经销商、区域性食品杂货店、各类连锁加盟店:长尾部分只有在你把每一个站点都当成独立目标时,处理成本才会显得高昂。但它们通常并非如此。处理好第一个 request,让 session 支持复用,剩下的工作就不再是数据采集问题,而是转变成解析问题,而后者的成本要低得多。