🎯 Trình duyệt đám mây tùy chỉnh, chống phát hiện được hỗ trợ bởi Chromium tự phát triển, thiết kế dành cho trình thu thập dữ liệu webtác nhân AI. 👉Dùng thử ngay
Quay lại blog

Cách xây dựng một công cụ trích xuất dữ liệu từ Etsy với Trình duyệt ScrapeLess: Hướng dẫn toàn diện 2026 (Node.js)

James Thompson
James Thompson

Scraping and Proxy Management Expert

16-Apr-2026

Những điểm chính:

  • Trình duyệt Scraping Không Biến Hình hoạt động như một hạ tầng trình duyệt AI mạnh mẽ, vượt qua lớp chống bots DataDome của Etsy với việc tạo dấu vân tay tự động, proxy dân cư và giải mã CAPTCHA.
  • Bốn chế độ khám phá từ một khối CONFIG — URL sản phẩm, URL danh mục, tìm kiếm từ khóa (có thể mở rộng) và URL cửa hàng. Đổi đầu vào, cùng một quy trình.
  • Tám bộ lọc có cấu trúc (đang giảm giá, miễn phí vận chuyển, có thể tùy chỉnh, vận chuyển đến, giá tối thiểu/tối đa, điều kiện, sắp xếp theo) kết hợp với bất kỳ chế độ khám phá nào sử dụng URL tìm kiếm hoặc danh mục.
  • Sơ đồ đầu ra bao gồm 30+ trường cho mỗi sản phẩm bao gồm biến thể, mảnh hình, ngày niêm yết, đánh giá[].hình ảnh và các tín hiệu tiếp thị độc đáo (làBánChạy, làNgườiBánSao, làVậnChuyểnMiễnPhí, cònHàng, sốLượngYêuThích, điểm phụ theo đánh giá).
  • Một vòng lặp thử lại có thể cấu hình (mặc định maxRetries: 10, thời gian tăng dần 3 s → 47 s) chuyển sang một phiên mới và IP dân cư giữa các lần thử, tự động hấp thụ các phản hồi 403 tạm thời.

Giới thiệu: Scraping Etsy ở Quy Mô Với Trình Duyệt Đám Mây Chống Phát Hiện

Etsy là một kho báu cho thông tin thương mại điện tử: giá cả của người bán tương tự cho chủ cửa hàng, tập dữ liệu huấn luyện tâm lý cho các dự án ML và khám phá ngách cho dropshippers đều đến từ cùng một trang niêm yết. API chính thức của Etsy có quyền truy cập hạn chế và chu trình phê duyệt dài, các nhà bán dữ liệu bên thứ ba rất tốn kém và một trình thu thập tùy chỉnh cần bảo trì liên tục chống lại DataDome và những thay đổi tên lớp CSS thường xuyên trên giao diện của Etsy.

Hướng dẫn này đi qua một tệp TypeScript duy nhất được xây dựng trên Trình duyệt Scraping Không Biến Hình xử lý mọi phần khó khăn ở phía trước: trình duyệt đám mây chống phát hiện, proxy dân cư, làm giàu theo từng sản phẩm với đánh giá và siêu dữ liệu cửa hàng và kỹ thuật mở rộng nhiều truy vấn mà đưa ra nhiều kết quả hơn từ một từ khóa cơ sở duy nhất hơn mức trần tìm kiếm từng lượt của Etsy thường cho phép. Trình thu thập này hỗ trợ bốn chế độ khám phá độc lập — cho nó một URL sản phẩm, một URL danh mục, một tìm kiếm từ khóa hoặc một URL cửa hàng — và mỗi hàng đầu ra mang cùng một sơ đồ phong phú 30-trường bất kể cách niêm yết được khám phá.


Những Gì Bạn Có Thể Làm Với Nó

Dữ liệu Etsy là một tài sản đa năng, thúc đẩy các ứng dụng thương mại có tác động lớn từ nghiên cứu sản phẩm đến phân tích AI nâng cao. Dưới đây là năm ứng dụng thương mại thực tế, tất cả đều có thể đạt được từ cùng một mã nguồn, thường chỉ với một thay đổi trong cấu hình:

  1. Nghiên cứu dropshipping và tìm kiếm sản phẩmchế độ tìm kiếm từ khóa. Chạy trình thu thập dữ liệu qua "móc treo cây macramé" với expandStrategy: "keywords" trên ["boho", "hiện đại", "tối giản"], đặt maxProducts: 200 và xếp hạng đầu ra theo favoritesCount × rating. Lọc đến các cửa hàng nơi isStarSeller: true và favoritesCount cao hơn hẳn mức trung bình — đó là những ứng viên cho dropshipping của bạn. Đưa file CSV kết quả vào Shopify hoặc danh sách nhà cung cấp riêng. Đây là lý do phổ biến nhất mà mọi người thu thập dữ liệu từ Etsy và cũng là cách nhanh nhất để chuyển thành doanh thu.
  2. Giám sát giá đối thủchế độ URL sản phẩm (URL trực tiếp). Giữ danh sách các URL niêm yết của đối thủ trong startUrls và chạy trình thu thập hàng đêm. Lưu mỗi ảnh chụp JSON với thời gian scrapedAt và so sánh price, originalPrice, discountPercentinStock giữa các lần chạy. Giá giảm hơn 10%? Cảnh báo Slack. inStock chuyển từ true sang false? Đánh dấu là tín hiệu cung cấp. Lịch sử giá cả đầy đủ mà bạn xây dựng theo cách này là cốt lõi của mỗi bảng điều khiển phân tích đối thủ.
  3. Nghiên cứu từ khóa và xu hướngchế độ URL danh mục với bộ lọc. Chỉ định categoryUrl đến một danh mục cụ thể của Etsy (ví dụ: /c/bags-and-purses/wallets-and-money-clips/wallets), áp dụng các kết hợp bộ lọc (filters.onSale: true, filters.condition: "new", filters.orderBy: "date_desc"), kéo tagsmaterials từ vài trăm niêm yết, tính tần suất chúng và sắp xếp theo tổng số favoritesCount trên các niêm yết sử dụng mỗi thẻ. Những thẻ xuất hiện trong các niêm yết mới tạo nhưng KHÔNG có trong những thẻ cũ hơn là các ngách phụ đang nổi của bạn.
  4. Tổng hợp đánh giá cho ML và nghiên cứu thị trườngchế độ từ khóa hoặc danh mục. Thu thập reviews[] từ hàng ngàn niêm yết trong một lĩnh vực (nến thủ công, chẳng hạn, hoặc trang sức cá nhân hóa), đưa reviews[].text vào một bộ phân loại tâm lý và sử dụng các đánh giá phụ itemQuality / shipping / customerService làm nhãn đào tạo có giám sát khi chúng có mặt. Hình ảnh theo đánh giá (reviews[].photos[]) cung cấp cho bạn một tập hợp hình ảnh song song nếu bạn cần dữ liệu đào tạo trực quan.
  5. So sánh hiệu suất cửa hàngchế độ shop-URL. Chỉ định shopUrl đến trang cửa hàng của đối thủ (ví dụ: https://www.etsy.com/shop/TexasValleyLeather), đặt maxPagesPerQuery: 5 để phân trang toàn bộ danh mục của họ và công cụ thu thập dữ liệu sẽ liệt kê mọi sản phẩm mà cửa hàng đó hiện đang bán. So sánh những người bán trong cùng một thể loại bằng cách sử dụng shop.totalSales, shop.openedYear, rating, reviewsCountisStarSeller.

Tại sao chọn Scrapeless

Trình duyệt thu thập dữ liệu Scrapeless cung cấp cho công cụ thu thập dữ liệu của bạn một trình duyệt đám mây có độ tin cậy sản xuất, giúp vượt qua các kiểm tra DataDome của Etsy ngay từ đầu — không cần plugin tàng hình, không cần điều chỉnh dấu vân tay, không cần kịch bản luân chuyển proxy phải bảo trì. Kết nối qua một điểm cuối WebSocket sử dụng Puppeteer hoặc Playwright và để hạ tầng xử lý lớp chống bot.

Chỉ với một cái nhìn ban đầu, bạn nhận được:

  • Dấu vân tay chống phát hiện hoạt động tốt trong các phiên kéo dài
  • Proxy dân cư ở hơn 195 quốc gia (nhắm đến giá cả riêng ở Mỹ, Anh, Đức)
  • Giải quyết CAPTCHA tự động khi Etsy phục vụ một
  • Ghi lại phiên để gỡ lỗi các sai lệch chọn lọc sau này
  • Điểm cuối WebSocket hỗ trợ các framework dựa trên CDP như Puppeteer và Playwright — không cần học SDK nào
  • Chuẩn bị AI Agent: Tích hợp liền mạch với các công cụ như Máy chủ Scrapeless MCP để cấp cho các AI agent của bạn "đôi mắt và đôi tay" trên web.

Việc tích hợp chỉ cần một thay đổi duy nhất: chỉ định puppeteer.connect() đến một URL Scrapeless thay vì trình duyệt địa phương. Phần còn lại của mã vẫn giữ nguyên — CDP tiêu chuẩn, chọn lọc tiêu chuẩn, quy trình làm việc tiêu chuẩn. Tất cả độ phức tạp của DataDome nằm ở phía máy chủ, ngoài mã nguồn của bạn.

Nhận khóa API của bạn trên kế hoạch miễn phí tại app.scrapeless.com.


Yêu cầu & Cài đặt

Node.js 18 hoặc mới hơn. Một khóa API Scrapeless (mức miễn phí bao gồm mọi thứ trong hướng dẫn này). Một chút quen thuộc với Puppeteer sẽ có lợi. Không cần Chrome địa phương — trình duyệt chạy trên đám mây của Scrapeless.

bash Copy
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 điều khiển trình duyệt đám mây; cheerio phân tích HTML đã được render ở phía máy chủ sau khi mỗi trang đã tải xong. Việc tách biệt cuộn ở bên trình duyệt và phân tích ở bên Node giúp mọi extractor được kiểu hóa và có thể kiểm tra đơn vị với các fixtures HTML đã lưu.

.env:

Copy
SCRAPELESS_API_KEY=your_key_here

Bước 1 — Kết nối với Trình duyệt Thu thập dữ liệu

Một công cụ trợ giúp kết nối cho toàn bộ công cụ thu thập dữ liệu. Xây dựng một URL WSS với mã thông báo, quốc gia và TTL, sau đó truyền nó cho puppeteer.connect.

ts Copy
import "dotenv/config";
import puppeteer, { type Browser, type Page } from "puppeteer-core";
import * as cheerio from "cheerio";

// Trợ giúp — lấy toàn bộ HTML của trang và phân tích nó bằng cheerio. Người gọi
// có trách nhiệm thực hiện bất kỳ cuộn bên trình duyệt / waitForFunction
// trước tiên để các vùng lười biếng được hydrate. Sau đó, việc phân tích diễn ra ở Node:
// có kiểu, không có mã eval dưới dạng chuỗi, không có bẫy tsx `__name`, dễ dàng
// kiểm tra đơn vị với các fixtures HTML đã lưu.
async function parseWithCheerio(page: Page): Promise<cheerio.CheerioAPI> {
  const html = await page.content();
  return cheerio.load(html);
}

type ScraperInput = {
  proxyCountry: string;   // ví dụ "US", "GB", "DE"
  sessionTTL: number;     // giây, cho phép từ 60–900; 600 là mặc định an toàn
};

function connectionURL(sessionName: string, cfg: ScraperInput): string {
  const token = process.env.SCRAPELESS_API_KEY;
  if (!token) throw new Error("SCRAPELESS_API_KEY chưa được đặt trong .env");
  // Scrapeless giữ IP dân cư trong suốt thời gian phiên bởi
  // mặc định, vì vậy mỗi lần điều hướng trang trong một puppeteer.connect sử dụng
  // cùng một IP ra ngoài. Mở một phiên mới (kết nối mới) sẽ thay đổi IP, 
  // điều này là điều mà vòng lặp thử lại dựa vào để điều hướng xung quanh một IP bị đánh dấu.
  const qs = new URLSearchParams({
    token,
    proxyCountry: cfg.proxyCountry,
    sessionTTL: String(cfg.sessionTTL),
    sessionName,
    sessionRecording: "true",
    // Để Scrapeless sở hữu hoàn toàn dấu vân tay trên máy tính để bàn — UA, màn hình, múi giờ
    // và ngôn ngữ. Không cần thiết lập thủ công viewport / setUserAgent.
    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,
  });
}

Đó là toàn bộ diện tích bề mặt đặc thù của Scrapeless — một URL WSS và một puppeteer.connect. Một điều đáng lưu ý trước khi bạn mở rộng điều này: một phiên puppeteer.connect duy nhất được gắn với một địa chỉ IP dân cư duy nhất trong suốt thời gian sống của nó (được xác minh bằng cách truy cập api.ipify.org ba lần liên tiếp trên cùng một tay cầm trình duyệt — cùng một IP mỗi lần). Mở một phiên mới sẽ sử dụng một IP mới. Đây là nền tảng mà vòng lặp thử nghiệm trong Bước 8 được xây dựng — nếu một yêu cầu trên IP của phiên này bị chặn, chúng tôi sẽ đóng phiên, mở một phiên mới, lấy một IP mới và thử lại.

Trình duyệt Scrapeless Scraping sở hữu dấu vân tay trình duyệt ở lớp kết nối — UA, kích thước màn hình, múi giờ và ngôn ngữ đều được xử lý bằng tham số truy vấn fingerprint: { platform: "Windows" } trên URL WSS. Không cần gọi setViewport hoặc setUserAgent thủ công. Vòng lặp thử nghiệm trong Bước 8 hấp thụ các chặn tạm thời ở trên.

Thiết lập duy nhất ở phía trình duyệt là một đoạn stub tương thích tsx một dòng:

ts Copy
async function prepPage(page: Page): Promise<void> {
  // Stub the tsx-injected __name helper so page.evaluate function bodies
  // don't crash with "__name is not defined" inside the browser context.
  await page.evaluateOnNewDocument(
    "(function(){ globalThis.__name = function(f){ return f; }; })()",
  );
}

Khởi động phiên

Trước khi điều hướng đến một trang tìm kiếm hoặc cửa hàng, trình thu thập dữ liệu tải trang chính của Etsy một lần để thiết lập một phiên trình duyệt hợp lệ. Nếu không có bước này, các điểm cuối /search/shop sẽ trả về 403 trên một phiên lạnh:

ts Copy
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 {
    // Timeout or network error is fine — the cookies are set by then.
  }
  await dismissEtsyConsent(page);
  await delay(1500);
}

Đường dẫn cụ thể theo quốc gia là quan trọng: một proxy DE truy cập etsy.com/de/ trả về 200 và thiết lập cookie phiên khu vực chính xác, trong khi etsy.com/ với một proxy DE trả về 403 và phiên sẽ bị khóa. Đã được xác minh trên US (64 danh sách), DE (60 danh sách) và GB (61 danh sách) — cả ba đều trả về kết quả tìm kiếm ở lần thử đầu tiên khi khởi động khớp với quốc gia proxy. Trình thu thập dữ liệu gọi warmUpSession một lần cho mỗi phiên trình duyệt trước cuộc gọi collectSearchResults đầu tiên.


Bước 2 — Bốn Chế Độ Phát Hiện

Trình thu thập dữ liệu chấp nhận bốn cách độc lập để tìm danh sách, tất cả trong cùng một khối CONFIG. Chọn cách phù hợp với câu hỏi upstream và thiết lập chính xác một trong startUrls, shopUrl, categoryUrl, hoặc searchQuery. Nếu có hơn một tùy chọn được thiết lập, thứ tự ưu tiên là shopUrlcategoryUrlsearchQuerystartUrls.

Chế độ URL sản phẩm (direct-URL) — các danh sách đã biết, tái thu thập dữ liệu hàng đêm, hình ảnh cạnh tranh:

ts Copy
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,
  // ...các thiết lập mặc định khác
};

Chế độ URL danh mục — thu thập toàn bộ danh mục với các bộ lọc cấu trúc:

ts Copy
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,
};

Chế độ tìm kiếm theo từ khóa — phát hiện ngách, nghiên cứu xu hướng, kéo danh sách khối lượng:

ts Copy
const CONFIG: ScraperInput = {
  searchQuery: "leather wallet",
  expandStrategy: "keywords",                   // "none" | "keywords" | "prices"
  expandKeywords: ["mens", "womens", "vintage"], // thêm vào cơ sở khi mở rộng = từ khóa
  maxProducts: 20,
};

Chế độ URL cửa hàng — liệt kê mọi danh sách trong một cửa hàng cụ thể để phân tích tham chiếu / cạnh tranh:

ts Copy
const CONFIG: ScraperInput = {
  shopUrl: "https://www.etsy.com/shop/TexasValleyLeather",
  maxPagesPerQuery: 5,
  maxProducts: 40,
};

Cả bốn chế độ đều cung cấp cùng một quy trình làm giàu mỗi danh sách trong Các Bước 4–6 và phát ra cùng một sơ đồ 30 trường trong Bước 8.

Bộ lọc có cấu trúc

Tám chìa khóa bộ lọc tùy chọn kết hợp với searchQuery hoặc categoryUrl. Thiết lập bất kỳ cái nào áp dụng, bỏ qua những cái còn lại:

Chìa Khóa Giá Trị Tác Động
onSale true Chỉ danh sách hiện đang được đánh dấu giảm giá
freeShipping true Chỉ danh sách giao hàng miễn phí đến quốc gia proxy
customizable true Chỉ danh sách có thể cá nhân hóa
shipsTo mã ISO ví dụ "US" Phải giao hàng đến quốc gia đó
minPrice / maxPrice số Phạm vi giá (bộ lọc nội bộ của Etsy)
condition "new" | "vintage" Bộ lọc trạng thái của Etsy
orderBy "liên quan nhất" | "ngày_desc" | "giá_tăng" | "giá_giảm" | "đánh giá_cao nhất" Sắp xếp kết quả

Kiểm soát phân trang

Đặt maxPagesPerQuery: N để lặp lại rõ ràng ?page=1..N trên mỗi URL khám phá. Nếu không, trình thu thập dữ liệu sẽ cuộn trang đầu tiên tới mục tiêu và dừng lại ngay khi thu thập được maxProducts danh sách duy nhất. Sử dụng phân trang rõ ràng khi bạn muốn quét rộng một cách có thể dự đoán (ví dụ: "thu thập 5 trang đầu tiên của danh mục này ngay cả khi đó là 200+ danh sách").


Bước 3 — Mở rộng truy vấn đa (cách ly "giới hạn kết quả" của Etsy)

Giao diện người tiêu dùng của Etsy giới hạn phân trang nhiều trước khi hầu hết các ngách được khai thác, và việc giới hạn theo IP sẽ nhanh chóng xuất hiện dưới khối lượng yêu cầu cao — bất kỳ từ khóa nào chỉ bao giờ hiện một phần xếp hạng. Để khai thác một ngách, hãy chia truy vấn cơ sở dọc theo một trục (từ khóa hoặc nhóm giá) và loại bỏ các kết quả trùng lặp theo listingId.

Với "ví da da", một mở rộng từ khóa trông như sau:

ts Copy
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 = "từ khóa" | "giá cả" | "không có";

function multiQueryExpand(
  base: string,
  cfg: { expandStrategy: ExpandStrategy; expandKeywords: string[]; priceBuckets: [number, number][] }
) {
  if (cfg.expandStrategy === "từ khóa") {
    const queries = [base, ...cfg.expandKeywords.map((k) => `${k} ${base}`)];
    return queries.map((q) => searchUrlForQuery(q));
  }
  if (cfg.expandStrategy === "giá cả") {
    return cfg.priceBuckets.map(([min, max]) => searchUrlForQuery(base, 1, min, max));
  }
  return [searchUrlForQuery(base)];
}

["nam", "nữ", "cổ điển"] với "ví da" tạo ra bốn tìm kiếm. Chạy chúng, thu thập các URL danh sách, loại bỏ trùng lặp theo ID số được chôn trong URL (/listing/1051861316/...). Đặt maxProducts đủ cao (một vài chục đến vài trăm) để thực sự trải rộng trên tất cả các biến thể — nếu mục tiêu nhỏ, trình thu thập dữ liệu sẽ ngừng lại sau truy vấn đầu tiên có kết quả, bỏ qua hoàn toàn công việc loại bỏ trùng lặp.

Nhóm giá hoạt động tương tự — các nhóm khác nhau đem lại các phần xếp hạng khác nhau vì "kết quả tốt nhất" của Etsy bị ảnh hưởng bởi giá so với những cái khác trong tập kết quả.


Bước 4 — Thu thập URL danh sách từ mỗi tìm kiếm

Cuộn thanh kết quả đủ để kích hoạt các thẻ tải lười, sau đó lấy mọi liên kết a[href*="/listing/"] bên trong một div.listing-link (với [data-listing-id] như phương án dự phòng khi Etsy thử nghiệm A/B tên lớp).

ts Copy
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);

  // Cuộn một vài lần để kích hoạt thẻ tải lười. Mỗi lần cuộn mất
  // một snapshot cheerio của DOM hiện tại để đếm thẻ—ngay khi chúng ta có
  // đủ, chúng ta dừng cuộn lại.
  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);
  }

  // Phân tích DOM đã lắng xuống với cheerio—không cần vòng lặp `page.evaluate`,
  // không có thân hàm được chuỗi hóa, chỉ có sự di chuyển chọn đơn giản.
  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;
}

Cuộn vẫn trên page.evaluate vì đó là một hành động DOM trực tiếp (kích hoạt tải lười của Etsy), nhưng mọi phần của phân tích chạy qua cheerio trên một snapshot page.content(). Đó là mẫu giống nhau được sử dụng bởi tất cả sáu bộ thu thập thông tin trong Bước 6–7.
Cuộc gọi dismissEtsyConsent được thực hiện cho các phiên ngoài Hoa Kỳ, nơi Etsy hiển thị một cửa sổ "Cookies và Quyền riêng tư" trước khi trang được tải. Hàm này tìm bất kỳ nút nào có nhãn "Chấp nhận tất cả" / "Từ chối tất cả" / tương đương trong một số ngôn ngữ và nhấp vào nó.

Bước 5 - Điều hướng đến Mỗi Danh sách

Khác với Google Maps, các URL /listing/<id>/ của Etsy hiển thị đầy đủ bảng ngay cả khi điều hướng trực tiếp, vì vậy không cần bước nhấp vào — bộ thu thập gọi page.goto(listingUrl) trực tiếp. Tuy nhiên, DataDome trả về HTTP 403 cho một phần đáng kể của các IP proxy mới trong nhánh này, do đó, bộ bao bọc điều hướng kiểm tra trạng thái phản hồi, nhanh chóng thất bại với 403/429 và ném ra lỗi nếu h1 không bao giờ xuất hiện — mỗi điều kiện này kích hoạt vòng lặp thử lại bên ngoài để mở một phiên mới trên một IP dân cư mới.

ts Copy
const resp = await page.goto(hit.url, { waitUntil: "domcontentloaded", timeout: 60000 });
const status = resp?.status() ?? 0;
if (status === 403 || status === 429) {
  throw new Error(`bị chặn: HTTP ${status} trên ${hit.url}`);
}
await dismissEtsyConsent(page);
try {
  await page.waitForSelector("h1", { timeout: 15000 });
} catch {
  // Không có h1 sau 15 giây hầu như luôn có nghĩa là một trang kiểm tra hoặc chuyển hướng DataDome.
  // Ném ra lỗi để vòng lặp thử lại mở một phiên mới (= IP dân cư mới).
  throw new Error(`không có h1 trên ${hit.url} — có khả năng là trang kiểm tra bot`);
}
await delay(1500);

Sau đó, kích hoạt tải chậm bằng cách cuộn toàn bộ trang theo từng khối. Mô tả, nguyên liệu và phần vận chuyển đều tải khi cuộn.

Bước 6 - Trích xuất Các Trường Tổng quan

Bộ trích xuất có cấu trúc hai pha lặp lại trong mọi bộ trích xuất bên dưới: bên trình duyệt (cuộn + waitForFunction để làm đầy các vùng tải chậm) → bên Node (lấy HTML của trang một lần qua page.content() sau đó phân tích với cheerio). Cách phân tách đó mang lại cho chúng ta hành vi DOM sống khi chúng tôi cần và phân tích phía máy chủ có kiểu, có thể kiểm tra khi chúng tôi không cần.

ts Copy
async function extractOverview(page: Page): Promise<Partial<EtsyProduct>> {
  // Bên trình duyệt: cuộn theo từng khối để mô tả / nguyên liệu / vận chuyển
  // tải chậm, sau đó đợi cho hộp mua hàng được làm đầy sau chỗ giữ chỗ "Đang tải".
  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(
      // Chờ cho đến khi bất kỳ bao bọc giá cả nào khả thi chứa giá trị số
      // (không phải chỗ giữ chỗ "Đang tải" mà Etsy hiển thị ngắn gọn). Casting một
      // lưới rộng hơn chỉ bao gồm hộp mua hàng cải thiện tỷ lệ thành công trên các
      // danh sách chậm nơi giá sẽ được chèn vào .currency-value trước.
      `(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 { /* bộ trích xuất vẫn cố gắng hết sức bên dưới */ }

  // Bên Node: lấy HTML đã được hiển thị một lần và phân tích với cheerio.
  const $ = await parseWithCheerio(page);

  const title = $("h1").first().text().trim() || null;

  // Giá — chuỗi ba bước. (1) ưu tiên cây con rõ ràng `[data-selector='price-only']`
  // mà Etsy đánh dấu là giá hiện tại; (2) quay lại giá đầu tiên
  // `span.currency-value` mà tổ tiên của nó KHÔNG phải là các bao bọc giá gốc / bị gạch chéo; (3) phương pháp regex văn bản cơ thể cuối cùng.
  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(/(?:Giá ngay\s+)?Giá:?\s*([$£€]?[\d.,]+)/i);
    if (bodyPriceMatch) price = bodyPriceMatch[1].trim();
  }

  // Đánh giá — bất kỳ aria-label nào đề cập đến "ngôi sao", "đánh giá" hoặc "trong số đây".
  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*(?:trong|ngôi sao|đánh giá)/i);
    if (rm) rating = parseFloat(rm[1]);
  });

// Thẻ trạng thái — regex nội dung trang.
const bodyText = $("body").text();
const isBestseller = /Bestseller/i.test(bodyText);
const isFreeShipping = /free shipping/i.test(bodyText);
const isStarSeller = /Star Seller/i.test(bodyText);
const inStock = !/out of stock|sold out/i.test(bodyText);

// Cửa hàng bên cạnh.
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>;
}

Tiền tố _price_raw là một quy ước: hàm enrichProduct tiếp theo xử lý nó thông qua extractNumber và sau đó xóa trường chuỗi thô trước khi JSON cuối cùng được phát ra. Đoạn mã này đã được rút gọn - chức năng đầy đủ extractOverview trong index.ts cũng lấy currency, discountPercent, reviewsCount, favoritesCount, description, materials, itemDetails, shippingFrom, processingTime, tags, listedDate và các trường cửa hàng còn lại. Cách thứ tự dùng cheerio tương tự ở các nơi khác, chỉ là có thêm nhiều bộ chọn hơn.

Một hàm hỗ trợ extractNumber dung lượng nhỏ, có khả năng xử lý tiền tệ, biến "$24.99", "24,99 €", hoặc "1,234" thành một con số sạch — Etsy phục vụ giá theo định dạng địa phương tùy thuộc vào quốc gia proxy và bạn không muốn các trường số của bạn là chuỗi.

Đánh giá. Thẻ đánh giá của Etsy sống trong div[data-review-region] (với div[class*='review-card']div[class*='review-item'] là những phương án dự phòng cho các thay đổi trong DOM). Cuộn vào khu vực đánh giá, sau đó ánh xạ mỗi thẻ đến tác giả / đánh giá / nội dung / ngày cộng với ba chỉ số phụ.

ts Copy
async function extractReviews(page: Page, max: number): Promise<EtsyReview[]> {
  // Mặt trước: cuộn vào khu vực đánh giá để các thẻ đánh giá lười biếng được hiển thị.
  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);

  // Mặt sau: phân tích trang đã được làm nóng với 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();
    // Bỏ qua các container tổng hợp mà chứa nhiều đánh giá cùng lúc.
    if (cardText.length > 6000) return;

    const author = $card.find("a[href*='/people/'], strong, p[class*='name']").first().text().trim() || null;

    // Đánh giá: thuộc tính `data-rating` trước, sau đó là aria-label.
    let rating: number | null = null;
    const $starEl = $card.find("[aria-label*='star' 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]);
      }
    }

    // Nội dung: bộ chọn riêng biệt, sau đó là đoạn văn dài nhất trong thẻ.
    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;
    }

    // Ngày: phần tử riêng biệt trước, sau đó là biểu thức chính quy tháng hoặc số.
    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];
    }

    // Chỉ số phụ — hàng được đánh dấu bên trong thẻ. Khớp nhãn, phân tích số bên cạnh.
    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;
    };

    // Ảnh do người đánh giá tải lên — nâng cấp `il_fullxfull` như hình ảnh danh sách.
    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.");
      nếu (!photoSeen.has(psrc)) { photoSeen.add(psrc); photos.push(psrc); }
    });

    out.push({
      tác giả, đánh giá, văn bản, ngày,
      chất lượng sản phẩm: subRating("chất lượng sản phẩm"),
      vận chuyển: subRating("vận chuyển"),
      dịch vụ khách hàng: subRating("dịch vụ khách hàng"),
      photos,
    });
  });

  return out;
}

Bốn lựa chọn chọn thẻ bài bao gồm các chỉnh sửa A/B liên tục của Etsy — [data-review-region] là lựa chọn hiện tại nổi bật; [class*='review-card'], [class*='review-item']li[class*='review'] là các biến thể cũ và mới mà vẫn xuất hiện tùy theo tài khoản và danh sách. Bảo vệ chiều dài cardText ở trên bỏ qua các phần tử bao bọc mà vô tình khớp và sẽ trả về một loạt đánh giá được nối lại thành một.

Hình ảnh. Etsy phục vụ hình thu nhỏ mặc định. Nâng cấp chúng lên độ phân giải đầy đủ bằng cách thay thế phần hậu tố kích thước trong URL: il_75x75il_fullxfull, hoặc _300x300.jpg_1024x1024.jpg. Cùng một hình ảnh, độ phân giải cao hơn nhiều, không cần yêu cầu bổ sung.

ts Copy
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;
    // Nâng cấp phần hậu tố kích thước hình thu nhỏ lên độ phân giải đầy đủ khi có thể.
    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;
}

Mẫu img[data-src*='etsystatic'] trong bộ chọn là rất quan trọng — Etsy tải hình thu nhỏ của thư viện một cách lười biếng sau data-src và không làm đầy src cho đến khi chúng vào vùng nhìn.

Biến thể, breadcrumbs, tìm kiếm liên quan. Ba bộ thu thập bổ sung chạy sau đánh giá và hình ảnh, mỗi bộ được bao bọc trong try/catch riêng để một bộ chọn bị bỏ lỡ không làm hỏng hàng:

  • extractVariations(page) — lấy các tùy chọn kích thước / màu sắc / cá nhân hóa mà người bán cung cấp, dưới dạng danh sách {name, options[]}[]. Lấp đầy từ các phần tử <select> bên trong các cây con [data-selector*='variation'].
  • extractBreadcrumbs(page) — lấy dấu vết danh mục (ví dụ: ["Trang chính", "Túi & Ví", "Ví & Kẹp tiền", "Ví"]) từ các thẻ liên kết có chứa ref=breadcrumb_listing trong href của chúng. Etsy không bọc chúng trong một <nav aria-label="breadcrumb"> — chúng là liên kết đơn giản với một tham số ref.
  • extractRelatedSearches(page) — liên kết "Khám phá tìm kiếm liên quan" mà Etsy hiển thị ở cuối các trang danh sách. Bộ thu thập cuộn lại đến chân trang và chờ phần thẻ tải lười trước khi đọc văn bản liên kết. Etsy A/B-tests các thẻ chỉ hình ảnh (không có văn bản) so với các thẻ có nhãn văn bản, vì vậy hãy mong đợi trường này được lấp đầy trên khoảng một nửa số danh sách.

Ngày niêm yết và tổng số cấp độ cửa hàng được lấy bên trong extractOverview cùng với các trường cơ bản. listedDate phân tích chuỗi "Niêm yết vào Thứ Hai DD, YYYY" mà Etsy hiển thị gần chi tiết mặt hàng — lưu ý rằng điều này phản ánh ngày làm mới/cập nhật gần đây nhất, không phải ngày tạo ban đầu. shop.reviewsCountShop chỉ được lấp đầy khi Etsy rõ ràng xác định số đó là cấp độ cửa hàng (nhiều bố cục danh sách không hiển thị nó - null là câu trả lời trung thực ở đây).

Hình ảnh do người đánh giá tải lên sống bên trong mỗi thẻ đánh giá. extractReviews giờ đây ghi lại tối đa 6 hình ảnh mỗi đánh giá thông qua cùng một nâng cấp il_fullxfull được sử dụng cho hình ảnh danh sách, cung cấp cho mã phía dưới một tập hợp hình ảnh song song cho phân tích trực quan hoặc xác minh đánh giá.

Bước 8 — Tăng cường và Xử lý Lỗi Từng Sản Phẩm Chắc Chắn

Việc thu thập một danh sách là rất đơn giản. Thu thập hàng trăm cái liền kề là nơi các lỗi tạm thời bắt đầu xuất hiện — Etsy thỉnh thoảng phục vụ một bộ nhớ đệm cũ, h1 không được làm đầy, một yêu cầu proxy đơn lẻ hết thời gian. Ba lớp bảo vệ xử lý những điều này ở quy mô:

Trình duyệt mới cho mỗi sản phẩm. Sau khi thu thập được các điểm tìm kiếm, mở một phiên Scrapeless Scraping Browser mới cho mỗi lần tăng cường. Trạng thái không bị rò rỉ giữa các sản phẩm và lỗi ở cấp phiên không làm hỏng phần còn lại của phiên chạy. Mỗi phiên mới sẽ sử dụng một IP dân cư mới, vì vậy khi DataDome trả về 403 trên một IP, lần thử tiếp theo sẽ đến trên một IP khác.

Tối đa cfg.maxRetries nỗ lực thử lại (mặc định là 10) với một độ trễ ngày càng tăng. Trong một lần chạy sạch, hầu hết các sản phẩm thành công trong lần thử 1; trong một lần chạy IP xấu, có thể mất 3–6 nỗ lực trước khi phiên hạ cánh trên một IP dân cư sạch. Một ngân sách thử lại cao là sự khác biệt giữa tỷ lệ thành công 50% và 100%.
Phân loại lỗi. categorizeError(err) ánh xạ mọi thất bại thô (HTTP 403/404/429, ERR_SSL_*, ERR_TUNNEL_*, h1-missing, thời gian chờ duyệt, bắt tay WSS) tới một trong tám giá trị ScrapeErrorKind với cờ retryable: boolean. Các lỗi có thể thử lại sẽ đưa vào vòng lặp quay lại; những lỗi không thể thử lại (ví dụ như HTTP 404 trên một danh sách đã cũ) sẽ ngắt quãng ngay lập tức. Khi tất cả lần thử đều hết, sản phẩm sẽ được xuất ra với error: { kind, message, attempts } được điền, để mã bên dưới có thể biết chính xác lý do tại sao một hàng dữ liệu quay trở lại trống.

ts Copy
// Bên trong vòng lặp chính, một lần cho mỗi kết quả tìm kiếm h (được chỉ số bởi i):
const MAX_ATTEMPTS = cfg.maxRetries;   // mặc định 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 thất bại: ${e?.message ?? e}`));
    log(`    lần thử ${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(`không có h1 trên ${h.url} — tiêu đề là null`));
  } catch (e: any) {
    lastError = categorizeError(e);
    log(`    lần thử ${attempt}/${MAX_ATTEMPTS} — ${lastError.kind}: ${lastError.message.slice(0, 120)}`);
    if (!lastError.retryable) break;   // không thể thử lại: 404, v.v. Thất bại nhanh.
  } finally {
    await eb.close().catch(() => {});
  }
  if (attempt < MAX_ATTEMPTS) {
    // Quay lại tăng dần có thể cấu hình. Mặc định (3000, 1500, 500) mang lại
    // 5s, 8s, 12s, 17s, 23s, 30s, 38s, 47s, 57s giữa các lần thử.
    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);
// Thời gian tạm dừng giữa các sản phẩm (có thể cấu hình) — ngắt quãng các lần duyệt niêm yết liên tiếp
// để mẫu phiên không trông giống như bot với DataDome.
if (i < hits.length - 1) await delay(cfg.interProductDelayMs);

Một số chi tiết quan trọng khi quy mô: tên phiên bao gồm chỉ số sản phẩm iDate.now() để đảm bảo rằng các phiên mới không bị chồng chéo lên nhau qua các sản phẩm; openBrowser được bao bọc trong khối try/catch riêng của nó để một lần bắt tay WSS thất bại không nhảy qua lần thử lại; eb.close() được ngắt với .catch(() => {}) vì phiên đã chết trước khi bạn đóng nó; quay lại tăng dần phát triển đủ chậm để các sản phẩm dễ dàng hoàn thành nhanh nhưng các sản phẩm khó nhận được khoảng thời gian hàng chục giây mà DataDome yêu cầu cho các IP bị đánh dấu; và thời gian tạm dừng giữa các sản phẩm giảm thiểu rõ rệt khả năng chặn tương quan.

Tám loại lỗi

Mỗi sản phẩm thất bại mang một đối tượng error: { kind, message, attempts }. Trường kind cho mã bên dưới biết cách phản ứng mà không cần phân tích thông điệp tự do:

kind Kích hoạt Có thể thử lại
blocked HTTP 403 hoặc 429 — DataDome hoặc giới hạn tỉ lệ ✅ có
not-found HTTP 404 — danh sách đã bị xóa hoặc chưa từng tồn tại ❌ không (thất bại nhanh)
tls ERR_SSL_* / ERR_CERT_* — trục trặc proxy tạm thời ✅ có
network ERR_TUNNEL / ERR_CONNECTION_* / ERR_ABORTED ✅ có
no-h1 Trang đã tải nhưng <h1> không bao giờ xuất hiện — trang thách thức mềm ✅ có
timeout Thời gian chờ duyệt vượt quá pageTimeoutMs ✅ có
open-browser Bắt tay WSS đến Scrapeless thất bại ✅ có
unknown Bất cứ điều gì khác ✅ có (mặc định)

Mọi điều chỉnh đều có thể tinh chỉnh

Tất cả các giá trị thử lại và tốc độ đều nằm trên ScraperInput — không gì là mã cứng. Điều chỉnh chúng khi bạn cần thông lượng có thể dự đoán trên một kế hoạch chặt chẽ hơn hoặc cố gắng thử lại mạnh mẽ hơn cho một mục tiêu khó khăn hơn:

Trường CẤU HÌNH Mặc định Vai trò
maxRetries 10 Tổng số lần thử cho mỗi sản phẩm trước khi từ bỏ
retryInitialBackoffMs 3000 Cơ sở của công thức quay lại tăng dần
retryBackoffLinearMs 1500 Hạng mục tuyến tính
retryBackoffQuadraticMs 500 Hạng mục bậc hai
interProductDelayMs 3000 Thời gian tạm dừng giữa các lần làm phong phú sản phẩm liên tiếp
pageTimeoutMs 60000 Thời gian chờ page.goto
h1TimeoutMs 15000 Thời gian chờ waitForSelector("h1")
postLoadDelayMs 1500 Thời gian tạm dừng sau khi h1 xuất hiện, trước khi trích xuất

Những gì bạn nhận được

Một đối tượng JSON phẳng cho mỗi sản phẩm. Rộng nhằm mục đích, do đó cùng một bộ thu thập dữ liệu cung cấp cho mọi trường hợp sử dụng bên dưới mà không cần phải xử lý lần thứ hai.

Kết quả thực từ một tìm kiếm "ví da da" chạy trên mẫu chính xác này:

json Copy
{
  "listingId": "547491922",
json Copy
{
  "title": "Ví Da•Ví•Ví Da Nam•Ví Đơn Giản•Ví Cá Nhân•Ví Da Kỷ Niệm•Ví Da Mỏng•Ví Nam",
  "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": ["Quà tặng cho cô dâu", "Quà tặng cho phù rể", "Quà cưới", "Quà đính hôn"],
  "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 khác"
  ],
  "variations": [
    { "name": "Cách Cá Nhân Hóa", "options": ["Có, thêm khắc", "Không, cảm ơn"] },
    { "name": "Tùy Chọn Màu", "options": ["Nâu", "Đen", "Be"] }
  ],
  "breadcrumbs": ["Trang Chủ", "Túi & Ví", "Ví & Kẹp Tiền", "Ví"],
  "relatedSearches": ["Ví Nam Da", "Ví Đơn Giản Cho Nam", "Ví Da Bifold Tùy Chỉnh"],
  "listedDate": "Ngày 15 tháng 4, 2026",
  "priceBucket": null,
  "reviews": [
    {
      "author": "Liz",
      "rating": 0,
      "text": "Như đã mô tả và giao hàng nhanh chóng. Cảm ơn bạn!",
      "date": "Ngày 12 tháng 4, 2026",
      "itemQuality": null,
      "shipping": null,
      "customerService": null,
      "photos": []
    },
    "... 9 đánh giá khác"
  ],
  "error": null,
  "scrapedAt": "2026-04-16T17:09:48.919Z"
}

Lấy dữ liệu công khai để theo dõi giá và nghiên cứu về cơ bản là hợp pháp, miễn là bạn tôn trọng Điều khoản Sử dụng của Etsy và tránh thu thập dữ liệu cá nhân của người dùng. Sử dụng Scrapeless đảm bảo hoạt động thu thập dữ liệu của bạn tôn trọng tài nguyên máy chủ thông qua việc quản lý tốc độ.

Scrapeless xử lý bảo vệ DataDome của Etsy như thế nào?

Khác với các proxy tiêu chuẩn, Scrapeless quản lý toàn bộ dấu vân tay trình duyệt và quy trình bắt tay TLS. Điều này làm cho trình thu thập dữ liệu của bạn trở nên không thể phân biệt được với một người dùng thực, cho phép bạn vượt qua khả năng phát hiện bot tinh vi của DataDome mà không cần cấu hình ẩn.

Q1: Có cần proxy để thu thập dữ liệu từ Etsy không?

Có. Nếu không có proxy dân cư, DataDome sẽ đánh dấu lưu lượng truy cập từ trung tâm dữ liệu rất nhanh — sự kết hợp giữa dấu vân tay và độ tin cậy IP thường bị loại bỏ và các yêu cầu điều hướng trực tiếp tới các trang /listing/ sẽ trả về HTTP 403 với một trang thách thức JavaScript. Trình thu thập dữ liệu Scrapeless được trang bị sẵn các proxy dân cư — mỗi phiên làm việc qua một IP dân cư khác nhau trong quốc gia đã chọn, được xác minh trong các thử nghiệm bằng cách liên tục mở các phiên mới trả về các IP đầu ra riêng biệt (api.ipify.org).

Q2: Làm thế nào tôi có thể xem những gì trình thu thập dữ liệu đã thực hiện trong một lần chạy trước?

Mỗi phiên trong mẫu này đặt sessionRecording: "true" trên URL WSS, vì vậy Scrapeless lưu video kiểu replay đầy đủ của mỗi trang mà trình duyệt đám mây đã chạm vào — vị trí cuộn, trạng thái DOM và hoạt động mạng. Tìm các video replay tại app.scrapeless.comScraping BrowserSessions, và khớp với giá trị sessionName mà trình thu thập dữ liệu ghi lại cho mỗi lần thử (ví dụ: etsy-enrich-3-2-1713198231047).

Nếu bảng điều khiển hiển thị "Replay Unavailable — Please enable 'Web Recording' to view the session recordings", hãy bật công tắc Web Recording trên trang cài đặt tài khoản Scrapeless của bạn. Nó miễn phí cho mọi gói; chỉ tắt theo mặc định. Khi đã kích hoạt, tất cả các phiên trong tương lai sẽ được ghi lại tự động — những phiên đã chạy khi ghi lại tắt sẽ không thể phục hồi được sau này.

Replay là cách nhanh nhất để gỡ lỗi lý do tại sao một hàng được trả về với title: null. Mở phiên, cuộn đến thời điểm page.goto được kích hoạt, và bạn sẽ thấy máy chủ đã trả về một danh sách thực sự, thách thức DataDome hay một chuyển hướng URL cũ.

Q3: Tại sao đôi khi các đánh giá lại tải qua các điểm cuối nội bộ thay vì trang?

Các danh sách Etsy mới hơn tải một số lô đánh giá qua các yêu cầu POST nội bộ sau khi trang đã được hiển thị. Trình thu thập dữ liệu xử lý điều này bằng cách cuộn vào vùng đánh giá và đợi — đến khi bộ phân tích hoạt động, các thẻ đã có trong DOM. Đối với các sản phẩm có hàng ngàn đánh giá, bạn sẽ nhận được khoảng ~30 cái đầu tiên (hoặc bất cứ điều gì bạn thiết lập maxReviews thành). Đi sâu hơn yêu cầu phải chặn điểm cuối GraphQL trực tiếp, điều này nằm ngoài phạm vi ở đây.

Q4: Còn chuyển hướng theo khu vực và tiền tệ thì sao?

Etsy chuyển hướng theo IP đến các phiên bản địa phương (etsy.de từ một IP của Đức, etsy.fr từ Pháp). Giá cả và chuỗi tiền tệ khác nhau theo khu vực. Trợ giúp extractNumber của trình thu thập dữ liệu xử lý cả định dạng 1,234.56 (en-US) và 1.234,56 (de-DE). Nếu bạn muốn giá USD nhất quán giữa các lần chạy, hãy gán proxyCountry: "US".

Q5: Làm thế nào tôi có thể lọc theo giá, bán hàng, miễn phí vận chuyển hoặc điều kiện?

Đặt bất kỳ sự kết hợp nào của tám khóa filters.*. Chúng kết hợp với chế độ searchQuerycategoryUrl và được mã hóa trực tiếp trên URL của Etsy:

ts Copy
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 (mã quốc gia 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"
  },
  // ...
};

Hai lưu ý cần biết: filters.minPrice / filters.maxPrice trên URL /search?q=... nhạy cảm với DataDome (các URL tìm kiếm đã lọc bị đánh dấu 403 quyết liệt hơn so với các URL chưa lọc), vì vậy expandStrategy: "prices" bây giờ chạy một tìm kiếm rộng duy nhất và gán nhãn kết quả ở phía khách hàng qua priceBucket — cùng một ý định người dùng, không có khối URL-lọc. Trên categoryUrl, bộ lọc giá hoạt động bình thường.

Q6: Tôi có thể điều chỉnh số lần thử lại, thời gian chờ và tốc độ không?

Có. Mỗi giá trị thử lại và tốc độ là một trường CONFIG trên ScraperInput:

Trường Mặc định Vai trò
maxRetries 10 Tổng số lần thử trên mỗi sản phẩm trước khi từ bỏ
retryInitialBackoffMs 3000 Cơ sở của công thức lùi lại tăng dần
retryBackoffLinearMs 1500 Hạng mục tuyến tính
retryBackoffQuadraticMs 500 Hạng mục bậc hai (tăng dần từ 5 giây → 57 giây)
interProductDelayMs 3000 Thời gian dừng giữa các nâng cấp sản phẩm liên tiếp
pageTimeoutMs 60000 Thời gian chờ page.goto
h1TimeoutMs 15000 Thời gian chờ cho waitForSelector("h1")
postLoadDelayMs 1500 Thời gian trì hoãn sau khi h1 xuất hiện, trước khi trích xuất

Các kế hoạch Scrapeless nghiêm ngặt hơn được hưởng lợi từ interProductDelayMs thấp hơn + số lần thử lại tối đa thấp hơn; các mục tiêu chống bot khó khăn hơn được hưởng lợi từ giá trị cao hơn ở cả hai.

Q7: Tôi có thể kỳ vọng những loại thất bại nào?

Mỗi sản phẩm hết số lần thử lại đều có một trường cấu trúc error: { kind, message, attempts }. Tám loại được phân loại:

  • blocked — HTTP 403/429 từ DataDome hoặc giới hạn tỷ lệ (có thể thử lại)
  • not-found — HTTP 404, danh sách lỗi thời hoặc đã bị xóa (không thể thử lại — thất bại nhanh)
  • tlsERR_SSL_* / ERR_CERT_* sự cố proxy TLS (có thể thử lại)
  • networkERR_TUNNEL / ERR_CONNECTION_* / ERR_ABORTED (có thể thử lại)
  • no-h1 — trang đã tải nhưng <h1> không bao giờ xuất hiện, có thể là trang thách thức DD mềm (có thể thử lại)
  • timeout — thời gian chờ điều hướng vượt quá (có thể thử lại)
  • open-browser — bắt tay WSS với Scrapeless thất bại (có thể thử lại)
  • unknown — bất cứ điều gì khác (mặc định có thể thử lại)

Mã downstream có thể coi kind: "not-found" là "bỏ qua URL này, đừng bao giờ đưa nó vào hàng đợi lại" và kind: "blocked" là "thử lại cái này trong giờ tới khi cửa sổ uy tín IP của DataDome được reset".

Tại Scrapless, chúng tôi chỉ truy cập dữ liệu có sẵn công khai trong khi tuân thủ nghiêm ngặt các luật, quy định và chính sách bảo mật trang web hiện hành. Nội dung trong blog này chỉ nhằm mục đích trình diễn và không liên quan đến bất kỳ hoạt động bất hợp pháp hoặc vi phạm nào. Chúng tôi không đảm bảo và từ chối mọi trách nhiệm đối với việc sử dụng thông tin từ blog này hoặc các liên kết của bên thứ ba. Trước khi tham gia vào bất kỳ hoạt động cạo nào, hãy tham khảo ý kiến ​​cố vấn pháp lý của bạn và xem xét các điều khoản dịch vụ của trang web mục tiêu hoặc có được các quyền cần thiết.

Bài viết phổ biến nhất

Danh mục