Zoro.com是一家大型工业用品零售商,拥有数百万种产品:工具、设备、紧固件、安全装备和MRO零件,每个列表都包含品牌、价格、Zoro编号和库存状态。对于在工业领域进行价格追踪、目录研究或竞争对手分析的人而言,这些列表是清晰的公开信号,这也是经销商、采购团队和分析师持续关注它们的原因。

本指南将向您展示如何使用Python抓取Zoro产品数据。您将构建一个小型可运行的爬虫,通过Crawling API获取Zoro搜索和产品页面,为每个商品解析干净的记录,处理分页,并将结果导出为JSON和CSV格式。整个演练范围限定于公开目录数据:任何人无需登录即可在搜索或产品页面上看到的品牌、标题、价格、Zoro编号和库存状态。

您将构建什么

一个Python脚本,接受一个Zoro搜索URL,通过Crawling API获取渲染后的页面,并为每个产品提取结构化记录。我们以工具箱搜索作为示例(与旧版演练使用的查询相同),并从每个列表中提取以下字段:

  • 品牌 产品卡片上显示的制造商名称。
  • 标题 列表中的产品名称。
  • 价格 产品显示价格(如有)。
  • Zoro编号 产品URL中的SKU标识符,例如G6893443。
  • 库存状态 库存情况,如有货或缺货。
  • 链接 产品详情页的绝对URL。

为什么普通请求在Zoro上会失败

如果您将普通HTTP客户端指向Zoro搜索URL,很少能得到您想要的产品网格。有两个原因对您不利。首先,Zoro在客户端渲染大部分列表:页面先发送一个轻量级外壳,随着JavaScript运行填充产品卡片,因此初始HTML通常缺少您想解析的商品。其次,Zoro会标记自动化流量。不像真实浏览器的数据中心IP范围和请求模式,在您接触到列表之前就会遇到挑战页面或直接被封锁。

因此,一个有效的Zoro爬虫在一次请求中需要两样东西:一个渲染页面的浏览器,以及一个Zoro视为真实购物者的IP。您可以自己用无头浏览器和轮换住宅代理池来实现,但保持这套栈健康运行才是大部分工作量所在。Crawling API将两者折叠进一次调用:您发送URL,它在受信任的住宅IP背后渲染页面,处理轮换和CAPTCHA解决,并返回完整HTML供您解析。

前提条件

在编写任何代码之前,您需要准备几样东西,每样都不费多少时间。

Python基础。 您应该能够编写和运行Python脚本,并用pip安装包。如果您是Python新手,Python网络爬虫指南涵盖了本教程所假设的基础知识。

Python 3.8或更高版本。python --version(或python3 --version)确认版本。如果没有,请从python.org安装,并确保Python已在系统PATH中。

Crawlbase账号和令牌。 注册免费账号,打开仪表盘,从账号文档页面复制您的令牌。免费层包含1,000次请求且无需绑卡,足够构建和测试这个爬虫。由于Zoro是JavaScript渲染的,这些请求请使用您的JavaScript令牌。请像对待密码一样对待令牌,不要将其放入版本控制。

搭建项目

创建虚拟环境以隔离项目依赖,然后安装爬虫所需的两个库。crawlbase是Crawling API的官方客户端,beautifulsoup4解析返回的HTML,让您可以通过CSS选择器从产品卡片中提取每个字段。

bash
python --version

python -m venv zoro_env
source zoro_env/bin/activate

pip install crawlbase beautifulsoup4

在Windows上,用zoro_env\Scripts\activate代替source命令激活环境。安装好两个库后,创建本指南后续内容所需的脚本文件:

bash
touch zoro_scraper.py

了解Zoro搜索页面

Zoro搜索使用由q查询参数构建的稳定URL,例如https://www.zoro.com/search?q=tool+box,并通过附加page参数进行分页,如&page=2。页面展示一个产品卡片网格,每个商品一张,每张卡片包含相同的几个字段:品牌名称、标题、价格、缩略图和进入产品详情页的链接。

在编写选择器之前,在浏览器中打开一个Zoro搜索页面,右键点击一张产品卡片,选择"检查"。从DOM浏览器中,您可以读出每个字段的CSS选择器:

  • 品牌名称 在类名为brand-name<span>内。
  • 产品标题 在类名为product-title<div>内。
  • 价格 在类名为price<div>内。
  • 产品URL 在嵌套于div.product-title内的<a>href中。
  • 产品图片 在带有data-za="product-image"<img>src中。

产品卡片本身位于section[data-za="product-cards-list"]容器内,每张卡片都是一个div.search-product-card,这是循环的目标。Zoro编号不是卡片上的独立元素:它是产品URL中的SKU段(如/i/G6893443/中的G6893443),因此您从链接中提取它,而不是直接选取。

第1步:获取渲染后的搜索页面

首先获取完整页面。导入CrawlingAPI类,用您的令牌初始化,设置搜索URL,然后发起请求。在解析前检查状态码,让失败可见而非静默。

python
from crawlbase import CrawlingAPI

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})

def crawl(page_url):
    options = {"ajax_wait": "true", "page_wait": 5000}
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['status_code']}")
    return None

if __name__ == "__main__":
    search_url = "https://www.zoro.com/search?q=tool+box"
    html = crawl(search_url)
    print(html[:500] if html else "No HTML returned")

两个等待选项对于随页面加载填充的网格至关重要。ajax_wait指示API等待异步内容加载完毕,page_wait固定等待若干毫秒(这里是5000,因为Zoro的列表页面加载可能较慢),确保延迟渲染的卡片在页面被捕获之前出现。运行脚本,您应该看到真实的列表标记,而非挑战页面的外壳。这在您编写任何选择器之前就能确认渲染正常工作。

Crawlbase Crawling API

Zoro的产品网格需要在受信任IP背后渲染的页面,一次调用完成。Crawling API接受您的令牌,在真实浏览器中运行搜索页面(使用您刚刚设置的ajax_waitpage_wait选项),在服务端轮换住宅IP并处理CAPTCHA解决,然后交给您完整HTML。您无需自己维护无头浏览器集群和代理池。先在免费的1,000次请求层对一个搜索URL试试。

第2步:用BeautifulSoup解析产品卡片

手握渲染后的HTML,将其加载进BeautifulSoup,找到每张产品卡片,并通过其选择器提取每个字段。每张卡片位于section[data-za="product-cards-list"]容器内,作为div.search-product-card。从卡片上读取品牌、标题、价格、图片和链接,然后从链接中提取Zoro编号。用try/except包裹每张卡片,确保一个格式异常的列表不会导致整个运行崩溃。

python
import re
from bs4 import BeautifulSoup

BASE = "https://www.zoro.com"

def text_of(card, selector):
    el = card.select_one(selector)
    return el.get_text(strip=True) if el else None

def zoro_number(url):
    match = re.search(r"/i/(G\d+)/", url)
    return match.group(1) if match else None

def scrape_listings(html):
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select('section[data-za="product-cards-list"] > div.search-product-card')
    results = []
    for card in cards:
        try:
            anchor = card.select_one("div.product-title a")
            href = anchor["href"] if anchor else ""
            link = BASE + href if href.startswith("/") else href
            img = card.select_one('img[data-za="product-image"]')
            results.append({
                "brand": text_of(card, "span.brand-name"),
                "title": text_of(card, "div.product-title"),
                "price": text_of(card, "div.price"),
                "zoro_number": zoro_number(link),
                "availability": text_of(card, "div.availability"),
                "image_url": img["src"] if img else None,
                "link": link,
            })
        except Exception as e:
            print(f"Skipped a card: {e}")
    return results

text_of辅助函数在卡片内查询一个元素,当元素缺失时返回None而非对空值调用.get_text()抛出异常。这使提取在字段缺失时保持稳定,这种情况很常见,因为并非每张卡片都显示价格或库存徽章。zoro_number辅助函数用一个小正则表达式从产品URL中提取带G前缀的SKU,链接被规范化为绝对URL,因为Zoro提供的是相对href

选择器会漂移

当Zoro重构其标记时,brand-nameproduct-titleprice等类名可能会发生变化,而data-za="product-cards-list"属性等结构性标记则往往更持久。请将上述选择器视为起始模板而非合同。当所有卡片的某个字段都返回None时,在浏览器开发者工具中重新检查实时搜索页面并更新选择器。定期进行选择器维护是任何生产级爬虫的正常操作。

第3步:处理分页并导出JSON和CSV

一个搜索页面只是演示;真实的工作需要遍历整个结果集。Zoro使用page参数进行分页,因此附加&page=N并循环直到某页返回无卡片。现在将获取、解析和分页循环整合为一个可运行的脚本,然后将记录同时写入JSON和CSV,以便加载进笔记本或电子表格。

python
import csv
import json
import re
import time
from crawlbase import CrawlingAPI
from bs4 import BeautifulSoup

api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"})
BASE = "https://www.zoro.com"
FIELDS = ["brand", "title", "price", "zoro_number", "availability", "image_url", "link"]

def crawl(page_url):
    options = {"ajax_wait": "true", "page_wait": 5000}
    response = api.get(page_url, options)
    if response["status_code"] == 200:
        return response["body"].decode("utf-8")
    print(f"Request failed: {response['status_code']}")
    return None

def text_of(card, selector):
    el = card.select_one(selector)
    return el.get_text(strip=True) if el else None

def zoro_number(url):
    match = re.search(r"/i/(G\d+)/", url)
    return match.group(1) if match else None

def scrape_listings(html):
    soup = BeautifulSoup(html, "html.parser")
    cards = soup.select('section[data-za="product-cards-list"] > div.search-product-card')
    results = []
    for card in cards:
        try:
            anchor = card.select_one("div.product-title a")
            href = anchor["href"] if anchor else ""
            link = BASE + href if href.startswith("/") else href
            img = card.select_one('img[data-za="product-image"]')
            results.append({
                "brand": text_of(card, "span.brand-name"),
                "title": text_of(card, "div.product-title"),
                "price": text_of(card, "div.price"),
                "zoro_number": zoro_number(link),
                "availability": text_of(card, "div.availability"),
                "image_url": img["src"] if img else None,
                "link": link,
            })
        except Exception as e:
            print(f"Skipped a card: {e}")
    return results

def scrape_all_pages(search_url, max_pages=5):
    all_rows = []
    for page in range(1, max_pages + 1):
        page_url = f"{search_url}&page={page}"
        print(f"Scraping page {page}...")
        html = crawl(page_url)
        if not html:
            break
        rows = scrape_listings(html)
        if not rows:
            print("No more products. Stopping.")
            break
        all_rows.extend(rows)
        time.sleep(2)
    return all_rows

def export(rows, name="zoro_listings"):
    with open(f"{name}.json", "w", encoding="utf-8") as f:
        json.dump(rows, f, indent=2, ensure_ascii=False)
    with open(f"{name}.csv", "w", newline="", encoding="utf-8") as f:
        writer = csv.DictWriter(f, fieldnames=FIELDS)
        writer.writeheader()
        writer.writerows(rows)
    print(f"Saved {len(rows)} products to {name}.json and {name}.csv")

def main():
    search_url = "https://www.zoro.com/search?q=tool+box"
    rows = scrape_all_pages(search_url, max_pages=3)
    export(rows)

if __name__ == "__main__":
    main()

python zoro_scraper.py运行完整脚本。它遍历最多max_pages个搜索页面,每个产品解析一行,并同时写入zoro_listings.jsonzoro_listings.csv。当某页返回无卡片时,scrape_all_pages循环提前停止,页面之间的time.sleep(2)控制了运行节奏。共享的FIELDS列表使CSV列顺序与字典键保持同步,确保两种导出格式永远不会产生偏差。

输出示例

您将得到一个干净的产品记录列表,按页面顺序排列,可随时写入JSON、CSV或数据库。

json
[
  {
    "brand": "Apex Tool Group",
    "title": "3 Drawer Tool Box",
    "price": "$149.99 /ea",
    "zoro_number": "G6893443",
    "availability": "In Stock",
    "image_url": "https://www.zoro.com/static/cms/product/prev/KDT83151xx1200.jpg",
    "link": "https://www.zoro.com/apex-tool-group-3-drawer-tool-box-83151/i/G6893443/"
  },
  {
    "brand": "Dewalt",
    "title": "Rolling Tool Box, Plastic, Black, 28 in W",
    "price": "$43.19 /ea",
    "zoro_number": "G3778857",
    "availability": "In Stock",
    "image_url": "https://www.zoro.com/static/cms/product/prev/Z1wK0zqcpEx-.JPG",
    "link": "https://www.zoro.com/dewalt-rolling-tool-box-plastic-black-28-in-w-dwst28100/i/G3778857/"
  }
]

抓取单个产品页面

搜索爬虫给您带来列表网格。当您需要更深入的细节(完整描述、规格表、每张图片)时,跟随每条记录中的link进入产品页面并解析它。Zoro在产品页面上提供以下元素:

  • 产品标题 在带有data-za="product-name"<h1>中。
  • 价格 在带有data-za="product-price"<div>中。
  • 描述div.product-description div.description-text中。
  • 规格div.product-details-info内的<table>中的行,每行两个<td>单元格。
  • 产品图片 div.product-images内带有类名product-image<img>标签。
python
import re
from bs4 import BeautifulSoup

def clean(value):
    return re.sub(r"\s+", " ", value).strip() if value else None

def scrape_product_page(product_url):
    html = crawl(product_url)
    if not html:
        return {}
    soup = BeautifulSoup(html, "html.parser")

    title = soup.select_one('h1[data-za="product-name"]')
    price = soup.select_one('div[data-za="product-price"]')
    desc = soup.select_one("div.product-description div.description-text")

    specs = {}
    for row in soup.select("div.product-details-info table tr"):
        cells = row.find_all("td")
        if len(cells) == 2:
            specs[clean(cells[0].text)] = clean(cells[1].text)

    images = [img["src"] for img in soup.select("div.product-images img.product-image") if img.get("src")]

    return {
        "title": clean(title.text) if title else None,
        "price": clean(price.text) if price else None,
        "zoro_number": zoro_number(product_url),
        "description": clean(desc.text) if desc else None,
        "specifications": specs,
        "image_urls": images,
        "url": product_url,
    }

这复用了列表爬虫中相同的crawlzoro_number辅助函数,因此您可以向它传入搜索结果中任何linkclean辅助函数将连续空白折叠为单个空格,这在Zoro的规格表和多行描述中很重要。规格以扁平键值字典的形式返回,每个两单元格表格行对应一个条目,这正是能干净地加载到DataFrame或数据库列集中的数据格式。

保持不被封锁

即使处理了渲染,Zoro仍会监控爬虫形态的流量。以下几个习惯能让抓取运行保持健康,它们适用于任何防御严密的商业目标。

  • 控制请求节奏。 在页面获取之间添加延迟,而不是全速爬取所有内容。将较重的任务安排在非高峰时段,以减轻Zoro服务器的负载。
  • 依赖轮换。 住宅IP池将请求分散到众多真实用户地址上,使单个地址不会触发速率限制。Crawling API为您处理这一切;如果您自己搭建,这是最需要做对的部分。
  • 只保留您需要的数据。 存储项目使用的目录字段,丢弃其余内容。定期检查选择器以确保爬虫能跟上标记变化。

更广泛的防封锁操作手册请参阅如何在不被封锁的情况下抓取网站。如果价格追踪是您的目标,网络爬取在价格情报中的应用指南展示了如何将这些快照转化为趋势数据源,而电商网络爬虫概述则涵盖了跨零售网站的更广泛模式。对于类似的MRO和办公用品目标,请参阅如何抓取Office Depot

抓取Zoro合法吗?

抓取Zoro是否被允许,取决于Zoro的服务条款、您所在的司法管辖区以及您对数据的使用方式。Zoro的条款对自动化访问有所限制,因此无论您的工具多么谨慎,抓取行为都可能违反这些条款。这里的代码并不改变这一点,它只是让技术部分得以实现。请阅读Zoro的服务条款及其robots.txt,将两者都视为您采集范围的边界。对于商业或竞争性使用,法律层面更为复杂,咨询法律专家就您的具体情况给出建议是明智之举。

有几条值得坚守的底线。仅采集公开数据:任何人无需账号即可在搜索或产品页面上看到的品牌、标题、价格、Zoro编号、库存状态和列表链接。将请求量控制在不对Zoro服务器造成压力的水平,避免个人数据,包括任何与可识别的买家、评论者或卖家相关的信息,超出页面上公开列出的内容。不要将Zoro受版权保护的产品摄影或描述大批量重新分发为您自己的内容。如果您计划商业性地使用这些数据,请取得许可或正式协议,而不要假定沉默就是同意。

本指南有意限定在公开目录页面范围内,因为这是保持工作可辩护的边界。它不涵盖登录后的任何内容、账号或订单数据、个人信息,或任何绕过身份验证或CAPTCHA(您本无权通过的)的尝试。如果Zoro为您的使用场景提供商业数据信息流、合作伙伴计划或官方API,这才是当您需要大量数据、有保障的结构或商业权利时的正确工具。如果您的项目需要超出公开目录数据的内容,官方渠道或数据协议才是正确路径,而不是更聪明的爬虫。

回顾

核心要点

  • Zoro列表是公开目录数据。 每个搜索和产品页面包含品牌、标题、价格、Zoro编号和库存状态,这就是为什么它们对价格追踪和工业用品研究如此有用。
  • 您需要同时具备渲染能力和受信任的IP。 Zoro在客户端填充其产品网格并对机器人流量发起挑战,因此Crawling API在一次调用中,通过ajax_waitpage_wait在住宅IP背后渲染页面。
  • BeautifulSoup完成提取。 循环div.search-product-card卡片,将品牌、标题、价格、库存状态和链接映射到当前选择器,并用一个小正则表达式从URL中提取Zoro编号。
  • 分页并导出为JSON和CSV。 附加&page=N并循环直到某页返回无卡片;共享字段列表使JSON和CSV导出保持同步。
  • 坚守公开数据。 遵守Zoro的服务条款和robots.txt,控制请求节奏,对于获得许可或批量数据优先选择官方信息流,永远不要涉及账号、订单或个人信息。

常见问题

为什么普通请求从Zoro获取不到产品?

两个原因。Zoro在页面加载时在客户端填充其大部分产品网格,因此原始请求通常得到一个缺少列表的外壳。此外,Zoro还会挑战或封锁不像真实浏览器的流量。通过Crawling API在受信任IP背后渲染页面可以解决两者,这就是为什么这里的爬虫通过它路由请求,并使用ajax_wait和5000毫秒的page_wait

我可以从Zoro搜索页面提取哪些字段?

从每张产品卡片中,您可以读取品牌(span.brand-name)、标题(div.product-title)、价格(div.price)、缩略图(img[data-za="product-image"])和产品链接。Zoro编号是该链接中带G前缀的SKU,您可以用一个短正则表达式提取出来。产品页面额外提供完整描述、规格表和更大的图片集。

如何处理Zoro的分页?

Zoro使用page查询参数进行分页。在搜索URL后附加&page=2&page=3等,并循环,当某页返回无产品卡片或达到您设置的页数上限时停止。在请求之间添加短暂延迟,避免全速爬取。

如何获取产品的Zoro编号?

Zoro编号是产品URL中的SKU段,即/i/G6893443/中的G6893443。爬虫用一个针对链接的正则表达式来提取它,而非选取单独的元素,因此搜索结果记录和单个产品页面都可以获得该值。

我可以将抓取的数据存储为CSV而非JSON吗?

可以。脚本同时写入两种格式:JSON用于嵌套结构,CSV用于电子表格。共享的FIELDS列表驱动CSV标题和列顺序,使其与字典键保持一致。如果您愿意,也可以直接将记录加载进数据库。

如何避免在抓取Zoro时被封锁?

保持低频的每IP请求速率,在页面之间添加延迟,并通过轮换住宅IP路由,使单个地址不会触发速率限制。Crawling API为您管理轮换、受信任的IP池和CAPTCHA处理;如果您自己搭建,这是值得投入的部分。关注状态码,当开始遇到挑战时立即退缩。

开始构建

大规模爬取任何站点,无需与基础设施对抗。

Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。

自助开通 · 无需销售通话 · 提供企业级爬取量