如何使用 Scrapeless Scraping 浏览器构建 Etsy 爬虫:2026 年综合指南(Node.js)
Scraping and Proxy Management Expert
主要收获:
- Scrapeless Scraping Browser 作为强大的 AI 浏览器基础设施,通过自动指纹识别、住宅代理和 CAPTCHA 解决,突破 Etsy 的 DataDome 反机器人层。
- 一个 CONFIG 块中提供四种发现模式 — 产品 URL、类别 URL、关键词搜索(可选扩展)和商铺 URL。更换输入相同的流程。
- 八个结构化过滤器(促销、免运费、可定制、发货至、最小/最大价格、状态、排序)可与使用搜索或类别 URL 的任何发现模式组合。
- 输出模式涵盖 每个产品 30 多个字段,包括
variations、breadcrumbs、listedDate、reviews[].photos和独特的商品信号(isBestseller、isStarSeller、isFreeShipping、inStock、favoritesCount、每个评价子评分)。 - 一个可配置的重试循环(默认
maxRetries: 10,逐渐回退 3 秒 → 47 秒)在尝试之间轮换到新会话和住宅 IP,自动吸收短暂的 403 错误。
引言:使用反检测云浏览器大规模抓取 Etsy
Etsy 是电子商务情报的金矿:商铺主比较销售价格、为机器学习项目提供情感训练语料,以及为代发货商找到细分市场,所有信息均源于相同的列表页面。官方 Etsy API 访问受到限制,审批周期较长,第三方数据转售商成本高昂,而定制的抓取工具需要持续维护,以应对 DataDome 和 Etsy 前端频繁的 CSS 类名变更。
本指南讲解了一份基于 Scrapeless Scraping Browser 的单一 TypeScript 文件,处理所有复杂部分:反检测云浏览器、住宅代理、带有评价和商铺元数据的逐产品丰富,以及多查询扩展技术,从单一基础关键词中获取比 Etsy 每次搜索上限通常允许的更多结果。相同的抓取工具支持四种独立的发现模式 — 输入 产品 URL、类别 URL、关键词搜索 或 商铺 URL — 每一输出行都携带相同的丰富 30 字段模式,无论列表是如何被发现的。
你可以用它做什么
Etsy 数据是一个多功能资产,推动从产品研究到高级 AI 分析的高影响商业应用。以下是五种实际商业用途,均可通过相同代码库实现,通常只需更改配置:
- 代发货研究和产品查找 — 关键词搜索模式。 在
"macramé plant hanger"上运行抓取程序,设置expandStrategy: "keywords",搜索["boho", "modern", "minimalist"],设置maxProducts: 200并按favoritesCount × rating对输出进行排名。过滤isStarSeller: true且 favoritesCount 远高于中值的商铺 — 这些就是你的代发货候选者。将结果 CSV 文件导入 Shopify 或私人供应商列表。这是人们抓取 Etsy 的最常见原因,也是最快能够转化为收入的方式。 - 竞争对手价格监控 — 产品 URL(直接 URL)模式。 在
startUrls中保持竞争对手列表 URL 的清单,每晚运行抓取程序。存储每个 JSON 快照,并记录其scrapedAt时间戳,比较price、originalPrice、discountPercent和inStock。价格跌幅超过 10%?发送 Slack 警报。inStock由true变为false?标记为供应信号。以这种方式构建的完整价格历史是每个竞争情报仪表盘的核心。 - 关键词和趋势研究 — 类别 URL 模式与过滤器。 将
categoryUrl指向特定 Etsy 类别(例如/c/bags-and-purses/wallets-and-money-clips/wallets),应用过滤器组合(filters.onSale: true、filters.condition: "new"、filters.orderBy: "date_desc"),提取几百个列表中的tags和materials,对其频率进行计数,并按使用每个标签的列表的favoritesCount总和排序。最近创建的列表中出现但旧列表中未出现的标签就是你的上升子细分市场。 - 用于机器学习和市场研究的评价聚合 — 关键词或类别模式。 在一条垂直交易路径(例如手工蜡烛或个性化珠宝)中抓取
reviews[],将reviews[].text输入情感分类器,而在存在时将itemQuality/shipping/customerService子评分用作监督训练标签。每条评价中的照片(reviews[].photos[])如果需要视觉训练数据则提供一个平行的图像语料库。 - 商店性能基准测试 — 商店-URL模式. 将
shopUrl指向竞争对手的商店页面(例如https://www.etsy.com/shop/TexasValleyLeather),设置maxPagesPerQuery: 5以分页其完整目录,抓取器将枚举该商店当前销售的每个商品。通过shop.totalSales、shop.openedYear、rating、reviewsCount和isStarSeller比较同一类别中的卖家。
为什么选择 Scrapeless
Scrapeless 抓取浏览器 为你的抓取器提供了一个生产级的云浏览器,能够开箱即用地清除 Etsy 的 DataDome 检查 — 不需要隐身插件,不需要指纹调优,不需要维护代理轮换脚本。通过 WebSocket 端点使用 Puppeteer 或 Playwright 连接,让基础设施处理反机器人层。
开箱即用,你将获得:
- 抗检测指纹识别,在长时间会话中表现稳健
- 住宅代理,覆盖195个以上国家(针对美国、英国、德国的定价分别)
- 自动解决 CAPTCHA,当 Etsy 提供时
- 会话录制,用于调试选择器回归问题
- 支持 CDP 基础框架的 WebSocket 端点,如 Puppeteer 和 Playwright — 无需学习 SDK
- AI 代理就绪:无缝集成如 Scrapeless MCP 服务器 等工具,赋予你的 AI 代理“在网络上的眼睛和手”。
集成只需一行更改:将 puppeteer.connect() 指向 Scrapeless URL,而不是本地浏览器。其余代码保持完全不变 — 标准 CDP,标准选择器,标准工作流程。所有 DataDome 复杂性在服务器端处理,不在你的代码库中。
在 app.scrapeless.com 的免费计划中获取你的 API 密钥。
前置条件与安装
Node.js 18 或更新版本。一个 Scrapeless API 密钥(免费层覆盖本指南中的所有内容)。一些 Puppeteer 的熟悉程度会有所帮助。不需要本地 Chrome — 浏览器在 Scrapeless 的云中运行。
bash
mkdir etsy-scrapeless-browserless && cd etsy-scrapeless-browserless
npm init -y
npm install puppeteer-core dotenv cheerio
npm install -D tsx typescript @types/node @types/cheerio
puppeteer-core 驱动云浏览器;cheerio 在每个页面加载完成后在服务器端解析渲染的 HTML。将浏览器端滚动与 Node 端解析分开,使每个提取器都可类型化且可以单元测试,针对保存的 HTML 夹具进行测试。
.env 文件:
SCRAPELESS_API_KEY=your_key_here
第一步 — 连接到抓取浏览器
一个连接助手用于整个抓取器。使用令牌、国家和 TTL 构建 WSS URL,然后交给 puppeteer.connect。
ts
import "dotenv/config";
import puppeteer, { type Browser, type Page } from "puppeteer-core";
import * as cheerio from "cheerio";
// 助手 — 拉取页面的完整 HTML 并使用 cheerio 解析。调用者
// 负责首先运行任何浏览器端的滚动 / waitForFunction
// 以便懒加载区域能够被填充。之后,解析保持在 Node 中:
// 类型化,无需字符串化评估主体,无需 `__name` tsx 陷阱,易于
// 针对保存的 HTML 夹具单元测试。
async function parseWithCheerio(page: Page): Promise<cheerio.CheerioAPI> {
const html = await page.content();
return cheerio.load(html);
}
type ScraperInput = {
proxyCountry: string; // 例如 "US", "GB", "DE"
sessionTTL: number; // 秒,允许 60–900;600 是安全的默认值
};
function connectionURL(sessionName: string, cfg: ScraperInput): string {
const token = process.env.SCRAPELESS_API_KEY;
if (!token) throw new Error("SCRAPELESS_API_KEY 未在 .env 中设置");
// 默认情况下,Scrapeless 在会话期间固定住宅 IP,
// 因此在一次 puppeteer.connect 内部的每次页面导航使用相同的出站 IP。
// 打开一个新会话(新连接)会分配一个新 IP,
// 这正是重试循环依赖于此以绕过被标记的 IP。
const qs = new URLSearchParams({
token,
proxyCountry: cfg.proxyCountry,
sessionTTL: String(cfg.sessionTTL),
sessionName,
sessionRecording: "true",
// 让 Scrapeless 控制整个桌面指纹 — UA、屏幕、时区
// 和语言。无需手动设置视口 / 设置用户代理。
fingerprint: JSON.stringify({ platform: "Windows" }),
});
return `wss://browser.scrapeless.com/api/v2/browser?${qs.toString()}`;
}
async function openBrowser(sessionName: string, cfg: ScraperInput): Promise<Browser> {
return puppeteer.connect({
browserWSEndpoint: connectionURL(sessionName, cfg),
defaultViewport: null,
});
}
这就是整个特定于Scrapeless的表面积——一个WSS URL和一个puppeteer.connect。在扩大这一点之前,有一点值得了解:单个puppeteer.connect会话在其生命周期内绑定到单个住宅IP(通过在同一浏览器句柄上连续三次访问api.ipify.org验证——每次都是相同的IP)。打开一个新的会话会刷新IP。这是第8步中的重试循环建立的基础——如果这个会话的IP上的请求被阻止,我们就关闭会话,打开一个新的会话,获取一个新的IP并重试。
Scrapeless Scraping Browser在连接层拥有浏览器指纹——用户代理、屏幕大小、时区和语言都通过WSS URL上的fingerprint: { platform: "Windows" }查询参数处理。无需手动调用setViewport或setUserAgent。第8步中的重试循环吸收了上面的瞬时阻塞。
唯一的浏览器端设置是一个一行的tsx兼容性存根:
ts
async function prepPage(page: Page): Promise<void> {
// 存根tsx注入的__name助手,以便page.evaluate函数体
// 不会在浏览器上下文中崩溃,提示"__name未定义"。
await page.evaluateOnNewDocument(
"(function(){ globalThis.__name = function(f){ return f; }; })()",
);
}
会话热身
在导航到搜索或商店页面之前,爬虫会先加载一次Etsy主页以建立有效的浏览器会话。没有这一步,/search和/shop端点在冷会话中返回403:
ts
const ETSY_COUNTRY_PATHS: Record<string, string> = {
US: "", DE: "de/", GB: "uk/", FR: "fr/", IT: "it/", ES: "es/",
NL: "nl/", CA: "ca/", AU: "au/", JP: "jp/", IN: "in/",
};
async function warmUpSession(page: Page, proxyCountry: string): Promise<void> {
const path = ETSY_COUNTRY_PATHS[proxyCountry] ?? "";
try {
await page.goto(`https://www.etsy.com/${path}`, {
waitUntil: "domcontentloaded",
timeout: 30000,
});
} catch {
// 超时或网络错误都没关系——到那时Cookies已经设置好了。
}
await dismissEtsyConsent(page);
await delay(1500);
}
特定于国家的路径很重要:使用DE代理访问etsy.com/de/返回200并设置正确的区域会话Cookies,而使用DE代理访问etsy.com/返回403且会话保持锁定。经过在美国(64个列表)、德国(60个列表)和英国(61个列表)的验证——当热身匹配代理国家时,这三个国家的搜索结果都在第一次尝试中返回。爬虫在第一次调用collectSearchResults之前,每个浏览器会话调用一次warmUpSession。
第2步——四种发现模式
爬虫接受四种独立方式来查找列表,均在同一CONFIG块中。选择与上游问题匹配的,exactly设置startUrls、shopUrl、categoryUrl或searchQuery中的一个。如果设置了多个,则优先级为shopUrl → categoryUrl → searchQuery → startUrls。
产品URL模式(直接URL)——已知列表,每晚重新抓取,竞争对手快照:
ts
const CONFIG: ScraperInput = {
startUrls: [
"https://www.etsy.com/listing/547491922/leather-walletwalletman-leather",
"https://www.etsy.com/listing/1022283131/personalized-slim-wallet-fathers-day",
],
maxProducts: 2,
// ...其他默认设置
};
类别URL模式——使用结构化过滤器进行整个类别抓取:
ts
const CONFIG: ScraperInput = {
categoryUrl: "https://www.etsy.com/c/bags-and-purses/wallets-and-money-clips/wallets",
filters: {
onSale: true,
freeShipping: true,
minPrice: 20,
maxPrice: 60,
orderBy: "most_relevant",
},
maxPagesPerQuery: 2,
maxProducts: 20,
};
关键词搜索模式——小众发现、趋势研究、批量列表提取:
ts
const CONFIG: ScraperInput = {
searchQuery: "leather wallet",
expandStrategy: "keywords", // "none" | "keywords" | "prices"
expandKeywords: ["mens", "womens", "vintage"], // 在扩展为关键词时附加到基础上
maxProducts: 20,
};
商店URL模式——为基准测试/竞争对手分析列出特定商店中的每个列表:
ts
const CONFIG: ScraperInput = {
shopUrl: "https://www.etsy.com/shop/TexasValleyLeather",
maxPagesPerQuery: 5,
maxProducts: 40,
};
这四种模式都在第4–6步中输入相同的每个列表丰富管道,并在第8步中发出相同的30字段模式。
结构化过滤器
八个可选过滤器键与searchQuery或categoryUrl组合。设置适用的,其他的可以不设置:
| 键 | 值 | 效果 |
|---|---|---|
onSale |
true |
仅当前标记为促销的列表 |
freeShipping |
true |
仅向代理国家免费发货的列表 |
customizable |
true |
仅个性化列表 |
shipsTo |
ISO 代码,例如"US" |
必须发货到该国 |
minPrice / maxPrice |
数字 | 价格范围(Etsy的原生过滤器) |
condition |
"new" | "vintage" |
Etsy 状态过滤器 |
orderBy |
"最相关" | "最近日期" | "价格升序" | "价格降序" | "最高评价" |
结果排序 |
分页控制
设置 maxPagesPerQuery: N 以明确迭代 ?page=1..N 在每个发现 URL 上。没有它,抓取器会滚动到目标的初始页面,并在收集到 maxProducts 个独特列表后停止。当您想要可预测的广泛抓取时,请使用明确的分页(例如,“抓取该类别的前 5 页,即使这是 200+ 个列表”)。
第 3 步 — 多查询扩展(Etsy 的“结果上限”解决方法)
Etsy 的消费者界面在大多数细分市场未耗尽之前就限制了分页,并且在高请求量下,每个 IP 的速率限制会迅速生效 — 任何单个关键字只能显示一个排名切片。要耗尽一个细分市场,可以沿一个轴(关键字或价格区间)拆分基本查询,并通过 listingId 去重结果。
对于“皮革钱包”,关键字扩展看起来像这样:
ts
function searchUrlForQuery(query: string, page = 1, priceMin?: number, priceMax?: number) {
const params = new URLSearchParams({ q: query });
if (page > 1) params.set("page", String(page));
if (priceMin !== undefined) params.set("min", String(priceMin));
if (priceMax !== undefined) params.set("max", String(priceMax));
return `https://www.etsy.com/search?${params.toString()}`;
}
type ExpandStrategy = "keywords" | "prices" | "none";
function multiQueryExpand(
base: string,
cfg: { expandStrategy: ExpandStrategy; expandKeywords: string[]; priceBuckets: [number, number][] }
) {
if (cfg.expandStrategy === "keywords") {
const queries = [base, ...cfg.expandKeywords.map((k) => `${k} ${base}`)];
return queries.map((q) => searchUrlForQuery(q));
}
if (cfg.expandStrategy === "prices") {
return cfg.priceBuckets.map(([min, max]) => searchUrlForQuery(base, 1, min, max));
}
return [searchUrlForQuery(base)];
}
["男士", "女士", "复古"] 针对“皮革钱包”产生四个搜索。运行它们,收集列表 URL,通过 URL 中埋藏的数字 ID(/listing/1051861316/...)去重。将 maxProducts 设置得足够高(几十到几百),以真正涵盖所有的变体 — 如果目标很小,抓取器将在第一个有结果的查询后短路,完全跳过去重工作。
价格分桶的工作方式也相同 — 不同的桶产生不同的排名切片,因为 Etsy 的“最佳匹配”受其他结果集中的价格影响。
第 4 步 — 从每个搜索收集列表 URL
向下滚动结果侧边栏,以触发懒加载的卡片,然后获取每个 div.listing-link 内部的每个 a[href*="/listing/"] 链接(当 Etsy 进行 A/B 测试类名时,以 [data-listing-id] 作为后备)。
ts
type SearchHit = { listingId: string | null; url: string; title: string | null; rank: number };
const delay = (ms: number) => new Promise((r) => setTimeout(r, ms));
async function collectSearchResults(page: Page, searchUrl: string, target: number, pageTimeoutMs = 60000): Promise<SearchHit[]> {
await page.goto(searchUrl, { waitUntil: "domcontentloaded", timeout: 60000 });
await dismissEtsyConsent(page);
await delay(2000);
// 滚动几次以触发懒加载的卡片。每次滚动都进行一次
// cheerio 快照,以计算卡片数量 — 一旦我们
// 拥有足够多的卡片,就停止滚动。
for (let i = 0; i < 6; i++) {
const $peek = await parseWithCheerio(page);
if ($peek("[data-listing-id], div.listing-link").length >= target) break;
await page.evaluate(() => window.scrollBy(0, 1200));
await delay(900);
}
// 使用 cheerio 解析稳定的 DOM — 没有 `page.evaluate` 回合,
// 没有字符串化的函数体,只是直接的选择器遍历。
const $ = await parseWithCheerio(page);
let cards = $("div.listing-link");
if (cards.length === 0) cards = $("[data-listing-id]");
const hits: SearchHit[] = [];
const seen = new Set<string>();
cards.each((_, card) => {
const link = $(card).find('a[href*="/listing/"]').first();
if (!link.length) return;
const href = link.attr("href") || "";
const absolute = href.startsWith("http") ? href : `https://www.etsy.com${href.startsWith("/") ? "" : "/"}${href}`;
const url = absolute.split("?")[0];
if (!url || seen.has(url)) return;
seen.add(url);
const titleEl = $(card).find("h3").first();
const title = titleEl.length ? titleEl.text().trim() : (link.attr("title") || null);
const idMatch = url.match(/\/listing\/(\d+)/);
const listingId = idMatch ? idMatch[1] : null;
hits.push({ listingId, url, title, rank: hits.length + 1 });
});
return hits;
}
滚动保持在 page.evaluate 中,因为这是一个实时 DOM 操作(触发 Etsy 的懒加载),但所有的 解析 都通过 cheerio 在 page.content() 快照中运行。这是步骤 6-7 中所有六个数据提取器使用的相同模式。
dismissEtsyConsent 调用用于非美国会话,在页面渲染之前,Etsy 会显示一个“Cookie 和隐私”入口。该函数会寻找标签为“接受全部”/“拒绝全部”/几个语言的对应按钮并点击。
第 5 步 — 浏览每个商品
与 Google 地图不同,Etsy 的 /listing/<id>/ URL 直接导航时会渲染完整面板,因此无需点击步骤 — 抓取程序直接调用 page.goto(listingUrl)。不过,DataDome 在此子树的大量新代理 IP 上返回 HTTP 403,因此导航包装器会检查响应状态,对 403/429 快速失败,如果 h1 永远未出现则抛出异常 — 这些条件都会触发外部重试循环以用新的住宅 IP 打开新会话。
ts
const resp = await page.goto(hit.url, { waitUntil: "domcontentloaded", timeout: 60000 });
const status = resp?.status() ?? 0;
if (status === 403 || status === 429) {
throw new Error(`被阻止: HTTP ${status} 在 ${hit.url}`);
}
await dismissEtsyConsent(page);
try {
await page.waitForSelector("h1", { timeout: 15000 });
} catch {
// 15秒后没有 h1 几乎总是意味着 DataDome 挑战或重定向页面。
// 抛出异常使重试循环打开一个新会话(= 新的住宅 IP)。
throw new Error(`在 ${hit.url} 没有 h1 — 可能是机器人挑战页面`);
}
await delay(1500);
然后通过分块滚动整个页面来触发延迟加载。描述、材料和运输部分都在滚动时加载。
第 6 步 — 提取概览字段
提取器具有一个两阶段结构,下面每个提取器都重复:浏览器端(滚动 + waitForFunction 以激活延迟加载区域)→ Node 端(通过 page.content() 获取页面的 HTML 然后使用 cheerio 进行解析)。这种分割在需要时为我们提供实时 DOM 行为,而在不需要时则提供类型化的、可测试的服务器端解析。
ts
async function extractOverview(page: Page): Promise<Partial<EtsyProduct>> {
// 浏览器端:分块滚动以便描述 / 材料 / 运输
// 延迟加载,然后等待购买框超出“加载”占位符后再激活。
await page.evaluate(`(function() {
var step = 500, total = document.body.scrollHeight, current = 0;
var iv = setInterval(function() {
current += step;
window.scrollTo(0, current);
if (current >= total) clearInterval(iv);
}, 200);
})()`);
await delay(3500);
try {
await page.waitForFunction(
// 等待任何合理的价格包装包含数值
// (不是 Etsy 短暂显示的“加载”占位符)。比仅依赖购买框包装
// 更广泛的选择网可以提高在价格首先渲染为 .currency-value
// 的慢速商品中的命中率。
`(function(){
var sels = [
"[data-selector='price-only'] span.currency-value",
"div[data-buy-box-region='price'] span.currency-value",
"p[class*='price'] span.currency-value",
"span.currency-value"
];
for (var i = 0; i < sels.length; i++) {
var el = document.querySelector(sels[i]);
if (el && /^\\s*\\$?\\d/.test((el.textContent || '').trim())) return true;
}
return false;
})()`,
{ timeout: 15000 },
);
} catch { /* 提取器仍然尝试其下面的最好 */ }
// Node 端:一次性拉取渲染的 HTML,并使用 cheerio 解析。
const $ = await parseWithCheerio(page);
const title = $("h1").first().text().trim() || null;
// 价格 — 三步级联。 (1) 首选 Etsy 标记为当前价格的显式 `[data-selector='price-only']`
// 子树; (2) 退而求其次,选择第一个祖先不是删除线 / 原价
// 包装的 `span.currency-value`; (3) 最后退路,使用正文文本正则表达式。
const isOriginalPriceWrapper = (el: any) =>
$(el).closest("[class*='strikethrough'], [class*='original'], s, .wt-text-strikethrough").length > 0;
let price: string | null = null;
const priceOnly = $("[data-selector='price-only'] span.currency-value").first();
if (priceOnly.length && /^\d/.test(priceOnly.text().trim())) {
price = priceOnly.text().trim();
}
if (!price) {
$("span.currency-value").each((_, el) => {
if (price) return false;
if (isOriginalPriceWrapper(el)) return;
const t = $(el).text().trim();
if (t && /^\d/.test(t)) price = t;
});
}
if (!price) {
const bodyPriceMatch = $("body").text().match(/(?:现在\s+)?价格:?\s*([$£€]?[\d.,]+)/i);
if (bodyPriceMatch) price = bodyPriceMatch[1].trim();
}
// 评分 — 任何提到“星星”、“评分”或“满分”的 aria-label。
let rating: number | null = null;
$("[aria-label*='star' i], [aria-label*='rating' i]").each((_, n) => {
if (rating !== null) return false;
const a = $(n).attr("aria-label") || "";
const rm = a.match(/(\d+(?:\.\d+)?)\s*(?:out|star|rating)/i);
if (rm) rating = parseFloat(rm[1]);
});
zh
// 状态徽章 — 正则表达式处理页面主体。
const bodyText = $("body").text();
const isBestseller = /畅销书/i.test(bodyText);
const isFreeShipping = /免运费/i.test(bodyText);
const isStarSeller = /星级卖家/i.test(bodyText);
const inStock = !/缺货|售罄/i.test(bodyText);
// 商店边栏。
const shopLink = $("a[href*='/shop/']").first();
const shopName = shopLink.text().trim() || null;
const shopUrl = (shopLink.attr("href") || "").split("?")[0] || null;
return { title, _price_raw: price, rating, isBestseller, isFreeShipping, isStarSeller, inStock,
shop: { name: shopName, url: shopUrl } } as Partial<EtsyProduct>;
}
_price_raw 前缀是一种约定:下游的 enrichProduct 会通过 extractNumber 处理它,然后在最终 JSON 输出之前删除原始字符串字段。这个代码片段是简略的——完整的 extractOverview 在 index.ts 中还会提取 currency(货币),discountPercent(折扣百分比),reviewsCount(评论数),favoritesCount(收藏数),description(描述),materials(材料),itemDetails(商品详情),shippingFrom(发货地点),processingTime(处理时间),tags(标签),listedDate(上市日期)及其余的商店字段。整个过程都遵循相同的 cheerio 优先模式,只是选择器更多。
一个小型的货币容忍的 extractNumber 辅助函数将 "$24.99","24,99 €" 或 "1,234" 转换为一个干净的数字——Etsy 根据代理国家以当地格式提供价格,你不希望你的数字字段是字符串。
第七步 — 评论、图片、商店边栏、变体、面包屑、相关搜索
评论。 Etsy 的评论卡片位于 div[data-review-region] 中(div[class*='review-card'] 和 div[class*='review-item'] 作为 DOM 修订的后备)。滚动到评论区域,然后将每张卡片映射到作者 / 评分 / 文本 / 日期以及三个子评分。
ts
async function extractReviews(page: Page, max: number): Promise<EtsyReview[]> {
// 浏览器端:滚动到评论区域,以便懒加载的评论卡片能渲染出来。
for (let i = 0; i < 8; i++) {
const found = await page.evaluate(
`!!document.querySelector('[data-reviews-section], div#reviews, div[class*="reviews"]')`,
);
if (found) break;
await page.evaluate(() => window.scrollBy(0, 700));
await delay(700);
}
await delay(1500);
// 节点端:使用 cheerio 解析现在已经加载的页面。
const $ = await parseWithCheerio(page);
const out: EtsyReview[] = [];
$(
"div[data-review-region], div[class*='review-card'], div[class*='review-item'], li[class*='review']",
).each((_, card) => {
if (out.length >= max) return false;
const $card = $(card);
const cardText = $card.text();
// 跳过包含许多评论的聚合容器。
if (cardText.length > 6000) return;
const author = $card.find("a[href*='/people/'], strong, p[class*='name']").first().text().trim() || null;
// 评分:首先是 `data-rating` 属性,然后是 aria-label。
let rating: number | null = null;
const $starEl = $card.find("[aria-label*='星' i], [data-rating]").first();
if ($starEl.length) {
const dr = $starEl.attr("data-rating");
if (dr) rating = parseFloat(dr);
else {
const rm = ($starEl.attr("aria-label") || "").match(/(\d+(?:\.\d+)?)/);
if (rm) rating = parseFloat(rm[1]);
}
}
// 文本:专用选择器,然后卡片中的最长段落。
let text: string | null =
$card.find("p[class*='review-text'], div[class*='review-text'], div[id*='review-content']")
.first().text().trim() || null;
if (!text) {
let longest = "";
$card.find("p").each((_, p) => {
const pt = $(p).text().trim();
if (pt.length > longest.length && pt.length > 20) longest = pt;
});
text = longest || null;
}
// 日期:首先是专用元素,然后是月份名称或数字正则表达式。
let date: string | null =
$card.find("span[class*='date'], time, p[class*='date']").first().text().trim() || null;
if (!date) {
const dm = cardText.match(/(\w{3,9}\s+\d{1,2},?\s+\d{4}|\d{1,2}\/\d{1,2}\/\d{2,4})/);
if (dm) date = dm[1];
}
// 子评分 — 卡片内部标记的行。匹配标签,解析相邻数字。
const subRating = (label: string): number | null => {
let val: number | null = null;
$card.find("span, div, li").each((_, row) => {
if (val !== null) return false;
const t = $(row).text().toLowerCase();
if (t.includes(label) && t.length < 60) {
const sm = t.match(/(\d+(?:\.\d+)?)/);
if (sm) val = parseFloat(sm[1]);
}
});
return val;
};
// 评论者上传的照片 — 与商品图片相同的 `il_fullxfull` 升级。
const photos: string[] = [];
const photoSeen = new Set<string>();
$card.find("img[src*='etsystatic'], img[data-src*='etsystatic']").each((_, img) => {
if (photos.length >= 6) return false;
let psrc = $(img).attr("src") || $(img).attr("data-src") || "";
if (!psrc) return;
psrc = psrc.replace(/il_\d+xN/, "il_fullxfull").replace(/_\d+x\d+./, "_1024x1024.");
if (!photoSeen.has(psrc)) { photoSeen.add(psrc); photos.push(psrc); }
});
out.push({
author, rating, text, date,
itemQuality: subRating("物品质量"),
shipping: subRating("运输"),
customerService: subRating("客户服务"),
photos,
});
});
return out;
}
四个卡片选择器的替代方案涵盖了 Etsy 正在进行的 A/B 修订 — `[data-review-region]` 是当前热门选择器;`[class*='review-card']`、`[class*='review-item']` 和 `li[class*='review']` 是旧的和新的变体,仍会根据帐户和列表出现。顶部的 cardText 长度保护跳过意外匹配的包裹元素,这些元素会将一批评论连接为一体并返回。
**图片。** Etsy 默认提供缩略图。通过替换 URL 中的大小后缀将其升级为全分辨率:`il_75x75` → `il_fullxfull`,或 `_300x300.jpg` → `_1024x1024.jpg`。同一图像,分辨率更高,无需额外请求。
```ts
async function extractImages(page: Page, max: number): Promise<string[]> {
const $ = await parseWithCheerio(page);
const urls: string[] = [];
const seen = new Set<string>();
$("img[src*='etsystatic'], img[data-src*='etsystatic']").each((_, img) => {
if (urls.length >= max) return false;
let src = $(img).attr("src") || $(img).attr("data-src") || "";
if (!src) return;
// 尽可能将缩略图大小后缀升级到全分辨率。
src = src.replace(/il_\d+xN/, "il_fullxfull").replace(/_\d+x\d+\./, "_1024x1024.");
if (!seen.has(src)) { seen.add(src); urls.push(src); }
});
return urls;
}
选择器中的 img[data-src*='etsystatic'] 模式很重要 — Etsy 将画廊缩略图延迟加载到 data-src 后,并且在进入视口之前不会填充 src。
变体、面包屑、相关搜索。 在评论和图片之后运行三个额外的提取器,每个提取器都包裹在自己的 try/catch 中,以便未命中的选择器降级为一个空数组而不是导致行中断:
extractVariations(page)— 提取卖家展示的尺码/颜色/个性化选项,作为{name, options[]}[]列表。 填充自[data-selector*='variation']子树中的<select>元素。extractBreadcrumbs(page)— 从带有ref=breadcrumb_listing的锚标签中获取类别路径(例如["首页", "包和钱包", "钱包和钱夹", "钱包"])。Etsy 没有将这些包裹在<nav aria-label="breadcrumb">中 — 它们是带有 ref 参数的普通链接。extractRelatedSearches(page)— Etsy 在列表页面底部呈现的“探索相关搜索”链接。提取器重新滚动到页面底部,等待懒加载的标签部分,然后读取链接文本。Etsy 进行 A/B 测试,仅图像的 chips(没有文本)与带文本标签的 chips,对此字段预期在大约一半的列表中填充。
上市日期和店铺级总数 在 extractOverview 中与基本字段一起提取。listedDate 解析 Etsy 在物品详情旁显示的“上市日期 Mon DD, YYYY”字符串 — 请注意,这反映的是最近重新上市/自动续订日期,而不是原始创建日期。shop.reviewsCountShop 仅在 Etsy 明确将数量区分为商店级别时填充(许多列表布局未呈现 — null 是诚实的答案)。
评论者上传的照片 存在于每个评论卡片中。 extractReviews 现在通过与商品图片相同的 il_fullxfull 升级,为每个评论捕获最多 6 张照片,为下游代码提供了并行图像语料库进行视觉分析或评论验证。
第 8 步 — 弹性每个产品的丰富与错误处理
抓取一个列表是简单的。连续抓取一百个时,瞬时故障开始出现 — Etsy 偶尔会提供过期缓存,h1 不会填充,单个代理请求超时。三个防御性层级在规模上处理这些问题:
每个产品一个新浏览器。 在收集到搜索结果后,为每个富化打开一个新的 Scrapeless Scraping Browser 会话。状态不会在产品之间泄漏,会话级别的错误不会影响其余运行。每个新会话都会分配一个新的住宅 IP,因此当 DataDome 在一个 IP 上返回 403 时,下一次尝试会落在另一个 IP 上。
最多 cfg.maxRetries 次重试尝试(默认 10 次),并有逐渐增加的回退。在干净的运行中,大多数产品在第 1 次尝试中成功;在坏 IP 运行中,可能需要 3–6 次尝试才能在干净的住宅 IP 上顺利进行。高重试预算是 50% 命中率和 100% 命中率之间的区别。
分类错误分类法。 categorizeError(err) 将每个原始失败(HTTP 403/404/429、ERR_SSL_*、ERR_TUNNEL_*、缺少 h1、导航超时、WSS 握手)映射到八种 ScrapeErrorKind 值之一,并带有 retryable: boolean 标志。可重试的错误进入退避循环;不可重试的错误(例如过期列表上的 HTTP 404)则立即中断。当所有尝试耗尽时,产品会携带 error: { kind, message, attempts } 以便下游代码能够确切知道为什么一行返回为空。
ts
// 在主循环中,对每个搜索结果 h(由 i 索引)执行一次:
const MAX_ATTEMPTS = cfg.maxRetries; // 默认值 10
let p: EtsyProduct | null = null;
let lastError: ScrapeErrorInfo | null = null;
let attemptsUsed = 0;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
attemptsUsed = attempt;
let eb;
try {
eb = await openBrowser(`etsy-enrich-${i}-${attempt}-${Date.now()}`, cfg);
} catch (e: any) {
lastError = categorizeError(new Error(`openBrowser 失败: ${e?.message ?? e}`));
log(` 尝试 ${attempt}/${MAX_ATTEMPTS} — ${lastError.kind}: ${lastError.message.slice(0, 120)}`);
if (!lastError.retryable) break;
if (attempt < MAX_ATTEMPTS) await delay(Math.max(cfg.retryInitialBackoffMs, 3000));
continue;
}
try {
p = await enrichProduct(eb, h, cfg);
if (p.title) { lastError = null; break; }
lastError = categorizeError(new Error(`在 ${h.url} 上没有 h1 — title 为 null`));
} catch (e: any) {
lastError = categorizeError(e);
log(` 尝试 ${attempt}/${MAX_ATTEMPTS} — ${lastError.kind}: ${lastError.message.slice(0, 120)}`);
if (!lastError.retryable) break; // 不可重试: 404 等。快速失败。
} finally {
await eb.close().catch(() => {});
}
if (attempt < MAX_ATTEMPTS) {
// 可配置的增量退避。默认(3000、1500、500)产生
// 尝试间隔为 5s、8s、12s、17s、23s、30s、38s、47s、57s。
const backoff = cfg.retryInitialBackoffMs
+ attempt * (cfg.retryBackoffLinearMs + attempt * cfg.retryBackoffQuadraticMs);
await delay(backoff);
}
}
if (!p || !p.title) {
p = emptyProduct(h.url);
p.rank = h.rank;
if (lastError) {
p.error = { kind: lastError.kind, message: lastError.message.slice(0, 200), attempts: attemptsUsed };
}
}
products.push(p);
// 产品间暂停(可配置)—— 打破连续的列表
// 导航,以便会话模式不会看起来像数据机器人。
if (i < hits.length - 1) await delay(cfg.interProductDelayMs);
在大规模操作中几个重要的细节:会话名称包括产品索引 i 和 Date.now(),以便新会话不会在产品间冲突;openBrowser 被单独包裹在其自己的 try/catch 中,以便 WSS 握手失败不会跳过重试;eb.close() 被忽略为 .catch(() => {}),因为在关闭它时会话已经死亡;增量退避的增长速度足够缓慢,以便容易的产品快速完成,但难的产品能获得 DataDome 对标记 IP 强制的几秒窗口;而产品间的暂停显著降低了相关阻塞的可能性。
八种错误类型
每个失败的产品都携带一个 error: { kind, message, attempts } 对象。kind 字段告诉下游代码如何反应而无需解析自由格式的消息:
kind |
触发 | 可重试 |
|---|---|---|
blocked |
HTTP 403 或 429 — DataDome 或速率限制 | ✅ 是 |
not-found |
HTTP 404 — 列表已删除或从未存在 | ❌ 否(快速失败) |
tls |
ERR_SSL_* / ERR_CERT_* — 瞬态代理故障 |
✅ 是 |
network |
ERR_TUNNEL / ERR_CONNECTION_* / ERR_ABORTED |
✅ 是 |
no-h1 |
页面加载但 <h1> 从未出现 — 软挑战页面 |
✅ 是 |
timeout |
导航超时超过 pageTimeoutMs |
✅ 是 |
open-browser |
WSS 握手到 Scrapeless 失败 | ✅ 是 |
unknown |
其他任何情况 | ✅ 是(默认) |
每个参数都可以调整
所有重试和节奏值都存储在 ScraperInput 中——没有任何硬编码。当您需要在更严格的计划上实现可预测的吞吐量或在更难的目标上进行更积极的重试时,调整它们:
| 配置字段 | 默认值 | 角色 |
|---|---|---|
maxRetries |
10 |
每个产品在放弃之前的总尝试次数 |
retryInitialBackoffMs |
3000 |
增长退避公式的基础 |
retryBackoffLinearMs |
1500 |
线性项 |
retryBackoffQuadraticMs |
500 |
二次项 |
interProductDelayMs |
3000 |
连续产品丰盛之间的暂停 |
pageTimeoutMs |
60000 |
page.goto 超时 |
h1TimeoutMs |
15000 |
waitForSelector("h1") 超时 |
postLoadDelayMs |
1500 |
h1 出现后提取之前的延迟 |
您得到的结果
每个产品返回一个扁平的 JSON 对象。这样设计的目的是为了同一个抓取器可以满足所有下游用例,而无需进行第二次传递。
来自此确切模板上运行的“皮钱包”搜索的真实首次结果:
json
{
"listingId": "547491922",
"title": "皮革钱包•钱包•男士皮革钱包•极简钱包•个性化钱包•皮革周年纪念•瘦身皮革钱包•男士钱包",
"url": "https://www.etsy.com/listing/547491922/leather-walletwalletman-leather",
"rank": 1,
"price": 5.52,
"originalPrice": 68.99,
"currency": "¥",
"discountPercent": 92,
"inStock": false,
"rating": 4.9,
"reviewsCount": 929,
"favoritesCount": 850,
"isBestseller": false,
"isFreeShipping": false,
"isStarSeller": true,
"tags": ["伴娘礼物", "伴郎礼物", "婚礼礼物", "订婚礼物"],
"materials": [],
"shop": {
"name": "TexasValleyLeather",
"url": "https://www.etsy.com/shop/TexasValleyLeather",
"location": null,
"totalSales": null,
"openedYear": null,
"reviewsCountShop": null
},
"images": [
"https://i.etsystatic.com/15980284/r/il/2456a5/3164786673/il_fullxfull.3164786673_roeh.jpg",
"... 4 更多 URL"
],
"variations": [
{ "name": "个性化选项", "options": ["是的,添加雕刻", "不,谢谢"] },
{ "name": "颜色选项", "options": ["栗色", "黑色", "棕色"] }
],
"breadcrumbs": ["首页", "包包与钱包", "钱包与零钱夹", "钱包"],
"relatedSearches": ["男士皮革钱包", "时尚男士钱包", "定制瘦身皮革双折钱包"],
"listedDate": "2026年4月15日",
"priceBucket": null,
"reviews": [
{
"author": "Liz",
"rating": 0,
"text": "描述一致且快速发货。谢谢!",
"date": "2026年4月12日",
"itemQuality": null,
"shipping": null,
"customerService": null,
"photos": []
},
"... 9 更多评论"
],
"error": null,
"scrapedAt": "2026-04-16T17:09:48.919Z"
}
抓取公开可用的数据以进行价格监控和研究通常是合法的,前提是您遵循Etsy的使用条款,并避免抓取个人用户数据。使用Scrapeless可以确保您的抓取活动尊重服务器资源,通过管理速率保持合适。
Scrapeless如何处理Etsy的DataDome保护?
与标准代理不同,Scrapeless管理整个浏览器指纹和TLS握手。这使得您的抓取器与真实用户无差别,从而允许您在没有手动隐匿配置的情况下绕过DataDome的复杂机器人检测。
问题1:抓取Etsy是否需要代理?
是的。如果没有住宅代理,DataDome会迅速标记数据中心流量——指纹和IP信誉组合通常会被归入拒绝桶,直接导航请求到/listing/页面会返回HTTP 403,并显示JavaScript挑战页面。Scrapeless抓取浏览器内置住宅代理——每个会话通过所选国家的不同住宅IP路由,经过连续的新会话测试,返回不同的出站IP(api.ipify.org探测)。
问题2:我如何查看抓取器在过去运行中的操作?
该模板中的每个会话都在WSS URL上设置了sessionRecording: "true",因此Scrapeless会保存云浏览器访问的每一页的完整视频重放——滚动位置、DOM状态和网络活动。在**app.scrapeless.com** → 抓取浏览器 → 会话中找到重放,并通过抓取器每次尝试记录的sessionName值进行匹配(例如etsy-enrich-3-2-1713198231047)。
如果仪表板显示“重放不可用 - 请启用'网络录制'以查看会话录制”,请在您的Scrapeless账户设置页面上打开网络录制开关。所有计划都是免费的;默认关闭。一旦启用,所有未来的会话将自动录制——在录制关闭时运行的过去会话无法追溯恢复。
重播是调试为何某行返回title: null的最快方式。打开会话,滑动时间线到page.goto触发的时刻,您将看到服务器是返回了实际的列表、DataDome挑战还是过时的URL重定向。
问题3:为什么评论有时通过内部端点加载而不是页面?
更新的Etsy列表在页面渲染后通过内部POST请求加载一些评论批次。抓取器通过向评论区域滚动并等待来处理此问题——在解析器运行时,卡片已经在DOM中。对于评论数以千计的产品,您将获得前~30条(或您设置的maxReviews值)。深入获取需要直接拦截GraphQL端点,超出了这里的范围。
问题4:关于地区和货币重定向呢?
Etsy通过IP重定向到本地化版本(例如,德国IP访问etsy.de,法国IP访问etsy.fr)。每个地区的价格和货币字符串不同。抓取器的extractNumber帮助程序可以处理1,234.56(en-US)和1.234,56(de-DE)格式。如果您希望在多个运行中保持一致的美元定价,请将proxyCountry: "US"固定。
问题5:我如何按价格、促销、免运费或状态进行筛选?
设置任意组合的八个filters.*键。它们与searchQuery和categoryUrl模式组合,并直接编码到Etsy的URL中:
ts
const CONFIG: ScraperInput = {
categoryUrl: "https://www.etsy.com/c/bags-and-purses/wallets-and-money-clips/wallets",
filters: {
onSale: true, // → &is_on_sale=1
freeShipping: true, // → &free_shipping=1
customizable: true, // → &is_personalizable=1
shipsTo: "US", // → &ships_to=US (ISO国家代码)
minPrice: 20, // → &min=20
maxPrice: 60, // → &max=60
condition: "vintage", // "new" | "vintage" (→ &explicit=vintage)
orderBy: "price_asc", // "most_relevant" | "date_desc" | "price_asc" | "price_desc" | "highest_reviews"
},
// ...
};
有两个注意事项值得了解:在/search?q=... URL上的filters.minPrice / filters.maxPrice对DataDome敏感(过滤搜索URL比未过滤的更容易得到403),因此expandStrategy: "prices"现在运行一个单一的广泛搜索,并通过priceBucket在客户端标记结果——相同的用户意图,没有URL过滤阻止。在categoryUrl上,价格过滤正常工作。
问题6:我可以调整重试、超时和速率吗?
可以。每个重试和速率值都是ScraperInput中的一个CONFIG字段:
| 字段 | 默认值 | 角色 |
|---|---|---|
maxRetries |
10 |
每个产品放弃前的总尝试次数 |
retryInitialBackoffMs |
3000 |
增长退避公式的基础 |
retryBackoffLinearMs |
1500 |
线性项 |
retryBackoffQuadraticMs |
500 |
二次项(产生5秒→57秒的进展) |
interProductDelayMs |
3000 |
连续产品增强之间的暂停 |
pageTimeoutMs |
60000 |
page.goto 超时 |
h1TimeoutMs |
15000 |
waitForSelector("h1") 超时 |
postLoadDelayMs |
1500 |
h1 出现后的延迟,在提取之前 |
更严格的 Scrapeless 计划受益于更低的 interProductDelayMs 和更低的 maxRetries;对于更难对抗的反机器人目标,则受益于更高的这两项值。
Q7:我可以预期哪些失败类别?
每个耗尽重试的产品都会携带一个结构化的 error: { kind, message, attempts } 字段。八种分类的类型:
blocked— 来自 DataDome 的 HTTP 403/429 或速率限制(可重试)not-found— HTTP 404,过期或已删除的列表(不可重试 — 快速失败)tls—ERR_SSL_*/ERR_CERT_*代理 TLS 问题(可重试)network—ERR_TUNNEL/ERR_CONNECTION_*/ERR_ABORTED(可重试)no-h1— 页面加载但<h1>从未出现,可能是软 DD 挑战页面(可重试)timeout— 导航超时超过(可重试)open-browser— WSS 握手到 Scrapeless 失败(可重试)unknown— 其他任何情况(默认可重试)
下游代码可以将 kind: "not-found" 视为 "丢弃此 URL,永远不要重新排队" ,将 kind: "blocked" 视为 "在 DataDome 的 IP 声誉窗口重置后在下一个小时再试一次"。
在Scrapeless,我们仅访问公开可用的数据,并严格遵循适用的法律、法规和网站隐私政策。本博客中的内容仅供演示之用,不涉及任何非法或侵权活动。我们对使用本博客或第三方链接中的信息不做任何保证,并免除所有责任。在进行任何抓取活动之前,请咨询您的法律顾问,并审查目标网站的服务条款或获取必要的许可。



