Etsy 是全球最大的手工、复古和工艺品交易市场之一,其每个搜索页都是一份结构化的实时卖家清单,包含商品标题、标价、店铺名称和星级评分。对于从事手工品领域市场调研、价格追踪或竞品分析的人而言,这份公开列表数据是你无需账号就能读取的最清晰需求信号之一。卖家可以观察同类商品竞品的定价策略;买家可以追踪某个品类的趋势;分析师可以绘制某个细分市场随时间的变化曲线。
本指南将展示如何用 Python 抓取 Etsy 商品列表。你将构建一个小型、可运行的爬虫,通过 Crawling API 获取 Etsy 搜索页,为每条列表解析一条干净的记录,处理多页结果,并将结果导出为 JSON 和 CSV。整个演示仅限于公开列表数据:任何人无需登录即可在 Etsy 搜索页上看到的标题、价格、店铺名、评分和链接。
你将构建什么
一个 Python 脚本,接受 Etsy 搜索查询,通过 Crawling API 检索每个渲染后的结果页,并为每张列表卡片提取结构化记录。本文以搜索词 clothes 作为贯穿全文的示例(与旧版演示使用的查询相同),并从每张卡片中提取以下字段:
- Title 列表卡片上显示的商品名称。
- Price 标价,即 Etsy 在卡片上渲染的货币金额。
- Shop 列表背后的卖家店铺名称。
- Rating 平均星级评分(当列表显示时)。
- Link 该列表详情页的 URL。
为什么普通请求在 Etsy 上失效
如果你将普通 HTTP 客户端指向 Etsy 搜索 URL,很少能得到你所需的列表。有两件事在阻碍你。首先,Etsy 大量依赖 JavaScript:它发送一个轻量骨架,并在页面脚本运行时填充列表卡片,因此初始 HTML 通常缺少大部分网格内容。其次,Etsy 会迅速标记自动化流量。数据中心 IP 段和不像真实浏览器的请求模式,在你到达任何结果之前就会遭遇 CAPTCHA 挑战、中间页面或直接封锁。
因此,一个可用的 Etsy 爬虫在单次请求中需要两样东西:能渲染页面的浏览器,以及 Etsy 认为是真实买家的 IP。你可以自己用无头浏览器和一个轮换住宅代理池来实现,但维护这套方案本身就是大部分工作量。Crawling API 将两者合并为一次调用:你发送搜索 URL,它在可信住宅 IP 后渲染页面,处理轮换和 CAPTCHA 解决,然后返回供你解析的完整 HTML。
前提条件
在编写任何代码前,你需要准备好以下几项,都不费时。
基础 Python 知识。 你应当能够编写和运行 Python 脚本,并使用 pip 安装包。如果你对这门语言还不熟悉,官方 Python 文档或任何入门课程都涵盖了本教程所假设的程度。
Python 3.8 或更高版本。 通过 python --version(或 python3 --version)确认你的版本。如果尚未安装,请从 python.org 下载,并确保 Python 已加入系统 PATH。
Crawlbase 账号与 token。 注册免费账号,打开控制台,复制你的 token。Etsy 使用 JavaScript 渲染其列表,因此本爬虫使用 JavaScript token。免费层包含 1,000 次请求,无需信用卡,完全够用于构建和测试。请像保护密码一样保管 token,切勿提交到版本控制。
搭建项目
创建虚拟环境以隔离项目依赖,然后安装爬虫所需的三个库。crawlbase 是 Crawling API 的官方客户端,beautifulsoup4 解析返回的 HTML,让你能通过 CSS 选择器提取每张列表卡片中的字段,pandas 使 CSV 导出只需一行代码。
python --version python -m venv etsy_env source etsy_env/bin/activate pip install crawlbase beautifulsoup4 pandas
在 Windows 上,用 etsy_env\Scripts\activate 代替 source 那行来激活环境。库安装完毕后,创建本指南其余部分将构建的脚本文件:
touch etsy_scraper.py
了解 Etsy 搜索页
Etsy 的搜索页位于以查询为键的稳定 URL 上:搜索 clothes 的 URL 是 https://www.etsy.com/search?q=clothes,通过追加 &page= 参数来翻页。页面以网格布局展示列表卡片,每张卡片对应一件商品,包含相同的几个字段:标题、价格、店铺名、星级评分和列表详情页链接。
编写选择器之前,在浏览器中打开一个搜索页,右键点击一张列表卡片,选择"检查"。Etsy 将每条结果包裹在搜索结果容器下的一个列表项中,并在卡片内部暴露标题、价格和评分。这些就是你的目标元素。Etsy 的类名会不时变化,下面的选择器在撰写时有效;如果某个字段开始返回空,请检查实时页面并相应调整。
步骤 1:获取渲染后的搜索页
从获取完整页面开始。导入 CrawlingAPI 类,用你的 token 初始化,设置搜索 URL,然后请求它。在解析前检查状态码可以让失败信号响亮而不是静默。
from crawlbase import CrawlingAPI api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) def crawl(page_url): options = {"ajax_wait": "true", "page_wait": 3000} response = api.get(page_url, options) if response["status_code"] == 200: return response["body"].decode("latin1") print(f"Request failed: {response['status_code']}") return None if __name__ == "__main__": search_url = "https://www.etsy.com/search?q=clothes" html = crawl(search_url) print(html[:500] if html else "No HTML returned")
两个等待选项对于随页面加载填充的网格至关重要。ajax_wait 告知 API 等待异步内容加载完成,page_wait 在加载后再增加固定的毫秒数停顿,让延迟渲染的卡片在页面被捕获前出现。正文解码为 latin1,因为 Etsy 页面混入了严格 UTF-8 解码可能出错的字符。运行脚本,你应当看到真实的列表标记,而不是挑战页骨架。这在编写任何选择器之前就确认了渲染可用。
这一次 crawl 调用隐藏了难点:Etsy 需要在可信 IP 后渲染页面,而两个 _wait 选项只有在请求本身成功通过后才能起作用。Crawling API 接受你的 token,在真实浏览器中运行搜索页,在服务端轮换住宅 IP,处理 CAPTCHA 解决,然后交付完整的 HTML。你无需自己运行无头浏览器集群和代理池。先在免费的 1,000 次请求层用它请求一个 Etsy 搜索 URL 试试。
步骤 2:用 BeautifulSoup 解析列表卡片
拿到渲染后的 HTML 后,将其载入 BeautifulSoup,找到每张列表卡片,并按选择器提取各字段。Etsy 将结果分组到搜索结果容器下的有序列表中,每张卡片对应一个列表项。每张卡片内,标题、价格、店铺和评分位于各自的元素中,列表链接就是卡片的锚点。用 try/except 包裹每张卡片,这样一条格式异常的列表不会导致整次运行崩溃。
from bs4 import BeautifulSoup CONTAINER = "div.search-listings-group div[data-search-results-container] ol li" def text_of(card, selector): el = card.select_one(selector) return el.get_text(strip=True) if el else None def parse_link(card): a = card.select_one("a.listing-link[href]") return a["href"] if a else None def scrape_listings(html): soup = BeautifulSoup(html, "html.parser") cards = soup.select(CONTAINER) results = [] for card in cards: try: title = text_of(card, "div.v2-listing-card__info h3.v2-listing-card__title") if not title: continue results.append({ "title": title, "price": text_of(card, "div.n-listing-card__price p.lc-price span.currency-value"), "shop": text_of(card, "div.v2-listing-card__info p.v2-listing-card__shop span"), "rating": text_of(card, "div.shop-name-with-rating span.larger_review_stars > div"), "link": parse_link(card), }) except Exception as e: print(f"Skipped a card: {e}") return results
text_of 辅助函数在卡片内查询一个元素,缺失时返回 None 而不是对 .get_text() 调用抛出异常。这使提取具有弹性,即使字段缺失也能正常工作,而这在列表中很常见,因为并非每条列表都显示评分。标题来自 h3.v2-listing-card__title,价格来自价格块内的 span.currency-value,店铺名来自列表卡片的店铺元素,评分来自评星块。容器选择器以及标题、价格和评分选择器直接移植自旧版演示;店铺和链接是本版本新增的两个字段。
Etsy 列表卡片的类名(标题截断、价格 span、评分块)会重新生成并无通知地更改。将上面的选择器视为起始模板,而非固定契约。当某个字段对每张卡片都返回 None 时,在浏览器开发者工具中重新检查实时搜索页并更新选择器。定期进行选择器维护对任何生产爬虫来说都是正常的。
步骤 3:处理结果页面的分页
一个搜索页只是一部分样本;完整结果集跨越多页。Etsy 将搜索结果分成编号页,通过在搜索 URL 后追加 &page=N 来翻页。要获取完整数据集,先从第一页读取总页数,然后依次遍历每页。请求之间的短暂延迟控制了爬取节奏,避免在紧密循环中频繁请求 Etsy。
import time PAGINATION = 'div[data-appears-component-name="search_pagination"] nav ul.search-pagination li:nth-last-child(3) a' def get_total_pages(html): soup = BeautifulSoup(html, "html.parser") el = soup.select_one(PAGINATION) try: return int(el.get_text(strip=True)) if el else 1 except ValueError: return 1 def scrape_all_pages(query, max_pages=5): search_url = f"https://www.etsy.com/search?q={query}" first = crawl(search_url) if not first: return [] total = min(get_total_pages(first), max_pages) all_listings = scrape_listings(first) print(f"page 1: {len(all_listings)} listings (of {total} pages)") for page in range(2, total + 1): html = crawl(f"{search_url}&page={page}") if not html: break found = scrape_listings(html) all_listings.extend(found) print(f"page {page}: {len(found)} listings") time.sleep(2) return all_listings
get_total_pages 从分页控件读取页数:旧版选择器定位倒数第三个分页链接,即"下一页"和"最后一页"箭头之前的最后一个编号页。如果控件缺失或文本不是数字,则回退到 1,因此单页结果也能正常工作。max_pages 上限在开发过程中限制了测试运行的范围,而两次请求之间的 time.sleep(2) 控制了爬取节奏,避免被标记为快速连续流量。
步骤 4:组装脚本并导出 JSON 和 CSV
现在将 fetch、parse 和分页逻辑串联成一个可运行的脚本,然后将记录写入 JSON 和 CSV,以便加载到 notebook 或电子表格中。JSON 保留了代码所需的结构;用 pandas 写出的 CSV 可直接在任何电子表格工具中打开。
import json import time import pandas as pd from crawlbase import CrawlingAPI from bs4 import BeautifulSoup api = CrawlingAPI({"token": "YOUR_CRAWLBASE_TOKEN"}) CONTAINER = "div.search-listings-group div[data-search-results-container] ol li" PAGINATION = 'div[data-appears-component-name="search_pagination"] nav ul.search-pagination li:nth-last-child(3) a' def crawl(page_url): options = {"ajax_wait": "true", "page_wait": 3000} response = api.get(page_url, options) if response["status_code"] == 200: return response["body"].decode("latin1") 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 parse_link(card): a = card.select_one("a.listing-link[href]") return a["href"] if a else None def scrape_listings(html): soup = BeautifulSoup(html, "html.parser") results = [] for card in soup.select(CONTAINER): try: title = text_of(card, "div.v2-listing-card__info h3.v2-listing-card__title") if not title: continue results.append({ "title": title, "price": text_of(card, "div.n-listing-card__price p.lc-price span.currency-value"), "shop": text_of(card, "div.v2-listing-card__info p.v2-listing-card__shop span"), "rating": text_of(card, "div.shop-name-with-rating span.larger_review_stars > div"), "link": parse_link(card), }) except Exception as e: print(f"Skipped a card: {e}") return results def get_total_pages(html): soup = BeautifulSoup(html, "html.parser") el = soup.select_one(PAGINATION) try: return int(el.get_text(strip=True)) if el else 1 except ValueError: return 1 def scrape_all_pages(query, max_pages=5): search_url = f"https://www.etsy.com/search?q={query}" first = crawl(search_url) if not first: return [] total = min(get_total_pages(first), max_pages) listings = scrape_listings(first) print(f"page 1: {len(listings)} listings (of {total} pages)") for page in range(2, total + 1): html = crawl(f"{search_url}&page={page}") if not html: break found = scrape_listings(html) listings.extend(found) print(f"page {page}: {len(found)} listings") time.sleep(2) return listings def export(rows, name="etsy_listings"): with open(f"{name}.json", "w", encoding="utf-8") as f: json.dump(rows, f, indent=2, ensure_ascii=False) pd.DataFrame(rows).to_csv(f"{name}.csv", index=False) print(f"Saved {len(rows)} listings to {name}.json and {name}.csv") def main(): rows = scrape_all_pages("clothes", max_pages=5) if rows: export(rows) if __name__ == "__main__": main()
用 python etsy_scraper.py 运行完整脚本。它获取每个渲染后的搜索页,每条列表解析成一行,遍历结果页面直到 max_pages,并写出 etsy_listings.json 和 etsy_listings.csv。从 pandas DataFrame 构建 CSV 意味着列自动按字典键排列,两个导出文件永远保持一致。
输出结果示例
你将得到一个干净的列表记录,按页面顺序排列,可写入 JSON、CSV 或数据库。
[ { "title": "Women's Christian Sweatshirt, Bible Verse Shirts, Faith Based Tshirts", "price": "13.85", "shop": "FaithfulThreadsCo", "rating": "4.8", "link": "https://www.etsy.com/listing/1234567890/womens-christian-sweatshirt" }, { "title": "Thankful Super Soft Sweatshirt, Thanksgiving Sweatshirt, Friendsgiving Shirt", "price": "29.99", "shop": "CozyFallPrints", "rating": "4.9", "link": "https://www.etsy.com/listing/9876543210/thankful-super-soft-sweatshirt" } ]
价格以 Etsy 在卡片上渲染的原始货币值呈现,因此如果你需要用于分析的数字,可以在去除千位分隔符后将其转换为浮点数。如果你更倾向于将数据存储在可查询的格式中而不是平面文件,同样的行字典可以直接通过简短的 sqlite3 插入语句写入 SQLite 表,旧版演示也将其作为 CSV 的替代方案介绍了一遍。
跨查询扩展
一个搜索查询只是演示;真实的研究任务跨越多个查询。Etsy 为任何查询字符串提供搜索页,因此要比较细分市场,只需维护一份查询列表,对每个查询运行分页爬虫,并按查询名称为输出命名。控制请求节奏,不要以全速爬取所有内容。
QUERIES = ["clothes", "ceramic mug", "leather wallet"] def scrape_queries(queries, max_pages=3): everything = {} for query in queries: print(f"scraping: {query}") everything[query] = scrape_all_pages(query, max_pages=max_pages) time.sleep(3) return everything
按查询为输出建立索引,可将每个结果集分开存储,这正是比较品类时所需的。若要追踪随时间的趋势,可按计划运行任务并为每次导出加上日期戳,然后对比连续快照观察价格和评分的变化。关于如何将这类信息流转化为定价数据集的深入介绍,请参见如何将网络爬取用于价格情报。
保持不被封锁
即使有渲染加持,Etsy 仍会监测爬虫特征流量。养成几个习惯有助于保持运行健康,这些习惯同样适用于任何有较强反爬保护的商业目标。
- 控制请求节奏。 在页面和查询之间加入延迟,而不是以全速爬取所有内容。将较重的任务安排在非高峰时段,以减轻对 Etsy 服务器的压力。
- 依赖 IP 轮换。 住宅 IP 池将请求分散到众多真实用户地址上,使单个地址不会触发速率限制。Crawling API 已为你处理这一切;如果你自己搭建技术栈,这部分是关键所在。
- 只保留你需要的数据。 存储你的项目所用的列表字段,丢弃其余内容。定期重新检查选择器,使爬虫能跟上标记变化的步伐。
关于避免封锁的完整策略手册,请参见如何在不被封锁的情况下抓取网站;关于解析侧,使用 BeautifulSoup in Python指南深入介绍了该库。如果你正在构建完整的研究工作流,如何自动化电商产品研究展示了像本文这样的列表信息流的适用场景。
抓取 Etsy 是否合法?
抓取 Etsy 是否被允许,取决于 Etsy 的服务条款、你所在的司法管辖区,以及你对数据的用途。Etsy 的条款对自动化访问设有限制,因此无论工具多么谨慎,爬取都可能违反这些条款。本文中的代码并不改变这一点,它只是让技术部分可行。请阅读 Etsy 的服务条款和 robots.txt,并将两者视为采集内容的边界。对于商业或竞争用途,法律情况更为复杂,就你的具体情况咨询法律专家是明智之举。
以下几条值得坚守。只采集公开数据:任何人无需账号即可在搜索页上看到的列表标题、价格、店铺名、评分和列表链接。保持足够低的请求量,以免给 Etsy 的服务器造成压力,并尊重卖家隐私:避免个人数据,包括任何超出公开店铺名称的买家或卖家可识别信息。不要转载卖家拥有的商品照片或描述,不要触碰登录墙后的任何内容,也不要尝试绕过任何你没有权限通过的身份验证或挑战。
本指南刻意将范围限定在 Etsy 公开搜索列表,因为这是让工作具备合理性的边界。对于授权或批量访问,Etsy 为开发者提供官方开放 API,当你需要大量数据、有保障的结构或商业权利时,这才是正确工具。如果你的项目需要超出公开列表数据的内容,正确路径是官方 API 或与 Etsy 签订数据协议,而不是更巧妙的爬虫。
核心要点
- Etsy 搜索页是公开的列表信息流。 每个查询的结果页包含标题、价格、店铺和评分,这些数据对市场调研和价格追踪很有价值。
- 你需要同时具备渲染能力和可信 IP。 Etsy 以客户端方式填充列表网格,并屏蔽机器人流量,因此 Crawling API 在一次调用中通过住宅 IP 渲染页面。
- BeautifulSoup 完成提取工作。 循环处理搜索结果列表项,将标题、价格、店铺、评分和链接映射到当前选择器,并预期这些选择器会漂移。
- 处理分页并导出为 JSON 和 CSV。 读取页数,带延迟地遍历每个结果页,写出两种文件,使数据可加载到代码或电子表格中。
- 只处理公开数据。 遵守 Etsy 的服务条款和 robots.txt,对授权或批量数据优先考虑官方 Etsy 开放 API,永远不要触碰账号、个人信息或卖家拥有的媒体。
常见问题
为什么普通请求从 Etsy 返回不了列表数据?
两个原因。Etsy 在页面加载时以客户端方式填充大部分列表网格,因此原始请求通常得到一个缺少大多数卡片的骨架。此外,Etsy 会挑战或封锁不像真实浏览器的流量。通过可信 IP 的 Crawling API 渲染页面解决了这两个问题,这就是为什么这里的爬虫在设置了 ajax_wait 和 page_wait 的情况下通过它路由请求。
如何抓取特定的 Etsy 搜索查询?
每个 Etsy 搜索都有自己的 URL,以查询为键,例如 https://www.etsy.com/search?q=clothes。将 q 值替换为你需要的查询,然后通过追加 &page=N 来翻页。要覆盖多个查询,维护一份查询列表,对每个查询循环运行分页爬虫,并在请求之间加入短暂延迟。
能从 Etsy 列表卡片中提取哪些字段?
本爬虫从每个搜索结果卡片中提取商品标题、价格(Etsy 渲染的货币值)、店铺名、星级评分和列表链接。旧版演示还提到了 Etsy 上可获取的商品描述和图片;你可以通过检查卡片并在解析步骤中添加匹配的选择器来提取这些内容。
抓取 Etsy 时如何处理分页?
从第一个结果页的分页控件中读取总页数,然后从第 2 页循环到该总页数,每次在搜索 URL 后追加 &page=N。这里的爬虫用 max_pages 参数限制计数,使测试运行保持有界,并在请求之间睡眠,避免在紧密循环中频繁请求 Etsy。
如何保存抓取的 Etsy 数据?
脚本同时写出 JSON 文件(保留代码所需的结构)和从 pandas DataFrame 构建的 CSV(可直接在电子表格中打开)。如果你需要可查询的存储而不是平面文件,同样的行字典也可以通过简短的 sqlite3 插入语句写入 SQLite 表。
抓取 Etsy 时如何避免被封锁?
保持每个 IP 的请求速率较低,在页面和查询之间加入延迟,并通过轮换住宅 IP 路由,使单个地址不会触发速率限制。Crawling API 为你管理轮换、可信 IP 池和 CAPTCHA 处理;如果你自己搭建技术栈,那部分是值得投入的关键。关注状态码,当你开始看到挑战时及时降速。
大规模爬取任何站点,无需与基础设施对抗。
Crawlbase 负责处理代理、指纹和 CAPTCHA,让你的团队专注于交付数据流水线,而非维护爬取管道。1,000 次请求免费,无需信用卡。
