🎯 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ụ thu thập dữ liệu Etsy với Scrapeless Scraping Browser: 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 Gác hoạt động như một hạ tầng trình duyệt AI mạnh mẽ, vượt qua lớp chống bot DataDome của Etsy nhờ vào việc quét dấu vân tay tự động, proxy dân cư và giải 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ó tùy chọn mở rộng) và URL cửa hàng. Hoán đổi đầu vào, cùng một ống dẫn.
  • Tám bộ lọc có cấu trúc (đang bán, 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, tình trạng, sắp xếp theo) có thể 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.
  • Lược đồ đầu ra bao gồm 30+ trường cho mỗi sản phẩm bao gồm biến thể, dấu bánh mì, ngày niêm yết, đánh giá[].hình ảnh và các tín hiệu tiếp thị độc đáo (isBestseller, isStarSeller, isFreeShipping, inStock, favoritesCount, các điểm phụ cho mỗi đánh giá).
  • Một vòng lặp thử lại có thể cấu hình được (mặc định maxRetries: 10, quay lại tự động từ 3 giây → 47 giây) được 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 lỗi tạm thời 403.

Giới thiệu: Thu thập dữ liệu Etsy quy mô lớn với trình duyệt đám mây không bị phát hiện

Etsy là một mỏ vàng cho thông tin thương mại điện tử: giá cả so sánh giữa các nhà bán hàng cho chủ cửa hàng, tập hợp dữ liệu đào tạo cảm xúc cho các dự án ML và khám phá ngách cho các dropshipper đều xuất phát từ các trang niêm yết giống nhau. API chính thức của Etsy đã hạn chế truy cập và có chu kỳ phê duyệt dài, các nhà cung cấp dữ liệu bên thứ ba tốn kém và một trình scraper tùy chỉnh yêu cầu duy trì liên tục để đối phó với DataDome và những thay đổi thường xuyên về tên lớp CSS trên giao diện 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 Gác xử lý mọi phần khó khăn ngay từ đầu: 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, cùng với kỹ thuật mở rộng nhiều truy vấn cho phép hiển thị nhiều kết quả hơn từ một từ khóa cơ bản so với giới hạn trên mỗi tìm kiếm của Etsy. Cùng một trình scraper hỗ trợ bốn chế độ khám phá độc lập — cung cấp cho nó một URL sản phẩm, URL danh mục, tìm kiếm từ khóa hoặc URL cửa hàng — và mỗi hàng đầu ra đều mang theo cùng một lược đồ 30 trường phong phú, bất kể cách niêm yết được phát hiện.


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 tiên tiến. 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 sự 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 scraper trên "macramé plant hanger" với expandStrategy: "keywords" qua ["boho", "modern", "minimalist"], đặ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 mức trung bình — đó là các ứng viên của bạn cho dropshipping. Đưa tệp 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 khiến mọi người thu thập dữ liệu từ Etsy và là cách nhanh nhất để chuyển thành doanh thu.
  2. Theo dõi giá cả đối thủchế độ URL sản phẩm (URL trực tiếp). Giữ một danh sách các URL niêm yết của đối thủ trong startUrls và chạy trình scraper hàng đêm. Lưu mỗi bức tranh JSON với dấu thời gian scrapedAt của nó và so sánh price, originalPrice, discountPercentinStock giữa các lần chạy. Giảm giá lớn hơn 10%? Cảnh báo Slack. inStock chuyển từ true sang false? Tín hiệu về cung. Lịch sử giá đầy đủ 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 intel đối thủ.
  3. Nghiên cứu từ khóa và xu hướngchế độ URL danh mục với các bộ lọc. Chỉ định categoryUrl tại một danh mục Etsy cụ thể (ví dụ: /c/bags-and-purses/wallets-and-money-clips/wallets), áp dụng các tổ hợp bộ lọc (filters.onSale: true, filters.condition: "new", filters.orderBy: "date_desc"), lấy tagsmaterials qua vài trăm niêm yết, đếm 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 từng thẻ. Những thẻ xuất hiện trong các niêm yết mới được tạo nhưng KHÔNG xuất hiện trong các niêm yết cũ là các ngách phụ đang nổi lên 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[] qua 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 cảm xúc và sử dụng các điểm phân loại 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 từng đánh giá (reviews[].photos[]) cung cấp cho bạn một tập ảnh song song nếu bạn cần dữ liệu đào tạo trực quan.
  5. Chấm điểm hiệu suất cửa hàngchế độ shop-URL. Đặt shopUrl vào 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à bộ 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 các người bán trong cùng một danh mục theo 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 bộ thu thập dữ liệu của bạn một trình duyệt đám mây chất lượng sản xuất có thể vượt qua các kiểm tra DataDome của Etsy ngay lập tức — không cần plugin giấu diếm, không cần điều chỉnh dấu vân tay, không cần kịch bản xoay vòng 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.

Ngay khi bạn bắt đầu, bạn sẽ có:

  • Phát hiện chống lại việc đánh dấu dấu vân tay giữ vững trong các phiên làm việc dài
  • Proxy dân cư tại hơn 195 quốc gia (giá mục tiêu cho US, GB, DE tách biệt)
  • Giải CAPTCHAs tự động khi Etsy đưa ra
  • Ghi lại phiên làm việc để gỡ lỗi các vấn đề với lựa chọn sau này
  • Các điểm cuối WebSocket hỗ trợ các khung dựa trên CDP như Puppeteer và Playwright — không cần học SDK
  • AI Agent Ready: Tích hợp liền mạch với các công cụ như Scrapeless MCP Server để cấp cho các AI agent của bạn "đôi mắt và đôi tay" trên web.

Tích hợp này chỉ cần thay đổi một dòng: chỉ cần chỉ định puppeteer.connect() vào một URL Scrapeless thay vì trình duyệt cục bộ. Phần còn lại của mã vẫn giữ nguyên — CDP tiêu chuẩn, các bộ chọn 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 sống trên phía máy chủ, ngoài mã nguồn của bạn.

Nhận khóa API của bạn trong gói miễn phí tại app.scrapeless.com.


Điều kiện tiên quyết & Cài đặt

Node.js phiên bản 18 trở lên. Một khóa API Scrapeless (gói miễn phí bao gồm mọi thứ trong hướng dẫn này). Một chút quen với Puppeteer sẽ hữu ích. Không cần Chrome cục bộ — 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 kết xuất trên máy chủ sau khi mỗi trang đã hoàn tất tải. Việc tách phân trang ở phía trình duyệt khỏi phân tích ở phía Node giúp mỗi bộ thu thập dữ liệu được kiểu hóa và có thể kiểm tra đơn vị với các tình huống HTML đã lưu giữ.

.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 trợ giúp kết nối cho toàn bộ bộ thu thập dữ liệu. Tạo một URL WSS với token, 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 HTML đầy đủ của trang và phân tích nó với cheerio. Người gọi
// có trách nhiệm thực hiện bất kỳ cuộn trang ở phía trình duyệt / waitForFunction
// trước tiên để các vùng lười không bị khô. Sau đó, việc phân tích giữ lại trong Node:
// kiểu hóa, không có các thân evaluate 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 tình huống 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, 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 không được thiết lập trong .env");
  // Scrapeless giữ địa chỉ IP dân cư trong suốt thời gian của một phiên mặc định,
  // vì vậy mọi điều hướng trang bên 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ẽ tạo một IP mới,
  // điều này là điều mà vòng lặp thử lại dựa vào để định tuyến 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 toàn bộ dấu vân tay máy tính để bàn — UA, màn hình, múi giờ
    // và ngôn ngữ. Không cần setViewport / setUserAgent thủ công.
    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 biết trước khi bạn mở rộng điều này: một phiên puppeteer.connect duy nhất gắn liền với một IP dân cư duy nhất trong suốt vòng đời của nó (được xác nhận 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ẽ tạo ra một IP mới. Đó là nền tảng mà vòng lặp thử lại trong Bước 8 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, nhận 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ởi tham số truy vấn fingerprint: { platform: "Windows" } trên URL WSS. Không cần các lệnh gọi setViewport hoặc setUserAgent thủ công. Vòng lặp thử lại trong Bước 8 hấp thụ các chặn tạm thời ở phía trên.

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

ts Copy
async function prepPage(page: Page): Promise<void> {
  // Stub trình trợ giúp __name được bổ sung bởi tsx để các khối hàm page.evaluate
  // không bị chí mạng với "__name is not defined" trong ngữ cảnh trình duyệt.
  await page.evaluateOnNewDocument(
    "(function(){ globalThis.__name = function(f){ return f; }; })()",
  );
}

Khởi động phiên

Trước khi điều hướng đến trang tìm kiếm hoặc cửa hàng, bộ 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 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 {
    // Lỗi timeout hoặc mạng không vấn đề — cookie sẽ được thiết lập vào lúc đó.
  }
  await dismissEtsyConsent(page);
  await delay(1500);
}

Đường dẫn theo quốc gia cụ thể rất 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 vẫn bị khóa. Được xác nhận qua 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 quá trình khởi động phù hợp với quốc gia proxy. Bộ thu thập dữ liệu gọi warmUpSession một lần cho mỗi phiên trình duyệt trước khi gọi collectSearchResults lần đầu tiên.


Bước 2 — Bốn Chế Độ Khám Phá

Bộ 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ả đều trên cùng một khối CONFIG. Chọn cái nào phù hợp với câu hỏi nguồn và đặt chính xác một trong startUrls, shopUrl, categoryUrl hoặc searchQuery. Nếu có nhiều hơn một cái được đặt, quyền ưu tiên là shopUrlcategoryUrlsearchQuerystartUrls.

Chế độ URL sản phẩm (direct-URL) — danh sách đã biết, thu thập lại hàng đêm, ảnh chụp đối thủ:

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 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ó 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 từ khóa — khám phá 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"], // được 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 benchmarking / đối thủ:

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

Tất cả bốn chế độ đều cung cấp cùng một quy trình làm phong phú theo từng danh sách trong các Bước 4–6 và phát hành cùng một lược đồ 30 trường trong Bước 8.

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

Tám khóa bộ lọc tùy chọn kết hợp với searchQuery hoặc categoryUrl. Đặt những cái nào áp dụng, để những cái còn lại trống:

Khóa Giá trị Hiệu ứ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ố Khoảng giá (bộ lọc gốc của Etsy)
condition "new" | "vintage" Bộ lọc tình trạng của Etsy
orderBy "liên quan nhất" | "ngày giảm dần" | "giá tăng dần" | "giá giảm dần" | "đá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 có điều này, trình thu thập thông tin sẽ cuộn trang ban đầu đến đích và dừng lại ngay khi thu thập được maxProducts danh sách độc nhất. Sử dụng phân trang rõ ràng khi bạn muốn quét rộng có thể dự đoán được (ví dụ: "thu thập 5 trang đầu tiên của danh mục này ngay cả khi có hơn 200 danh sách").


Bước 3 — Mở rộng nhiều truy vấn (giải pháp cho "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 trước khi hầu hết các ngách bị cạn kiệt, và giới hạn tốc độ theo IP bắt đầu nhanh chóng dưới khối lượng yêu cầu cao — bất kỳ từ khóa đơn lẻ nào cũng chỉ bao giờ hiện lên một lát xếp hạng. Để khai thác một ngách, 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ỏ trùng lặp kết quả bằng listingId.

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

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á" | "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á") {
    return cfg.priceBuckets.map(([min, max]) => searchUrlForQuery(base, 1, min, max));
  }
  return [searchUrlForQuery(base)];
}

["nam", "nữ", "cổ điển"] đối với "ví da" sản xuất 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ố nhúng 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 quá nhỏ, bộ thu thập thông tin sẽ ngắt quãng ngay 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.

Phân nhóm giá hoạt động theo cách tương tự — các nhóm khác nhau hiện lên các lát xếp hạng khác nhau vì "phù hợp tốt nhất" của Etsy bị ảnh hưởng bởi giá liên quan đến các thứ 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 bên 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] là phương án dự phòng khi Etsy A/B thử nghiệm 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 vài lần để kích hoạt các thẻ tải lười. Mỗi lượt đi sẽ
  // chụp một ảnh snapshot của DOM hiện tại để đếm thẻ — ngay khi chúng ta có đủ,
  // chúng ta dừng cuộn.
  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 đã ổn định bằng cheerio — không có vòng lặp `page.evaluate`,
  // không có nội dung hàm dạng chuỗi, chỉ đơn giản là duyệt theo bộ chọ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 ở page.evaluate vì đó là 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 đều chạy qua cheerio trên một bức ảnh chụp của page.content(). Đó là mẫu tương tự mà tất cả sáu bộ trích xuất làm phong phú trong Bước 6–7 sử dụng.
Cuộc gọi dismissEtsyConsent được thực hiện cho các phiên không phải ở Mỹ, nơi Etsy hiển thị một cửa sổ "Cookies and Privacy" trước khi trang được tải. Hàm này tìm bất kỳ nút nào được gán nhãn "Chấp nhận tất cả" / "Từ chối tất cả" / tương đương trong một vài ngôn ngữ và nhấp vào đó.

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ị toàn bộ bảng điều khiển ngay cả khi điều hướng trực tiếp, vì vậy không cần bước nhấp chuột nào — trình thu thập dữ liệu gọi page.goto(listingUrl) trực tiếp. Tuy nhiên, DataDome trả về HTTP 403 trên một phần đáng kể các IP proxy mới cho nhánh này, vì vậy lớp bọc điều hướng kiểm tra mã 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 — từng điều kiện đó 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 gần như luôn có nghĩa là một thách thức của DataDome hoặc trang chuyển hướng.
  // Ném lỗi để vòng lặp thử lại mở 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 thách thức bot`);
}
await delay(1500);

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

Bước 6 — Trích xuất các trường tổng quan

Trình trích xuất có cấu trúc hai pha lặp lại trong mỗi trình trích xuất phía dưới: bên trình duyệt (cuộn + waitForFunction để bổ sung các vùng lười) → bên Node (lấy HTML của trang một lần qua page.content() rồi phân tích với cheerio). Sự phân tách đó mang lại cho chúng ta hành vi giống như DOM trực tiếp khi cần và phân tích phía máy chủ có kiểu hình, có thể kiểm tra khi 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ả / vật liệu / vận chuyển
  // được tải lười, sau đó chờ buy-box được bổ sung qua "Đang tải"
  // chỗ giữ chỗ.
  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ờ đến khi bất cứ bao bọc giá nào có giá trị số
      // (không phải là chỗ giữ chỗ "Đang tải" mà Etsy hiển thị tạm thời). Bám
      // sát hơn là bám vào bao bọc buy-box nâng cao khả năng thành công trên các
      // danh sách chậm mà giá được hiển thị 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 { /* trình trích xuất vẫn cố gắng tốt nhất bên dưới */ }

  // Bên Node: kéo HTML đã 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 rõ ràng `[data-selector='price-only']`
  // nhánh 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 không phải là bị gạch chéo / bao bọc giá gốc;
  // (3) regex văn bản cơ thể khi hết cách.
  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 cứ aria-label nào đề cập đến "sao", "điểm" hoặc "trên".
  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*(?:trên|sao|điểm)/i);
    if (rm) rating = parseFloat(rm[1]);
  });

Here is the translation of the provided English text into Vietnamese:

javascript Copy
// Biểu tượng trạng thái — sử dụng regex cho 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);

// Sidecar cửa hàng.
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: `enrichProduct` hạ nguồn truyền nó qua `extractNumber` và sau đó xóa trường chuỗi gốc trước khi JSON cuối cùng được phát ra. Đoạn mã này đã được tóm tắt — toàn bộ `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. Mô hình cheerio đầu tiên giống nhau, chỉ là nhiều bộ chọn hơn.

Một trợ giúp nhỏ `extractNumber` chịu sự không nhạy cảm với tiền tệ chuyển đổi `"$24.99"`, `"24,99 €"` hoặc `"1,234"` thành một số sạch — Etsy phục vụ giá cả 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 mình là chuỗi.

## Bước 7 — Đánh giá, Hình ảnh, Sidecar Cửa hàng, Biến thể, Breadcrumbs, Tìm kiếm Liên quan

**Đánh giá.** Thẻ đánh giá của Etsy sống trong `div[data-review-region]` (với `div[class*='review-card']` và `div[class*='review-item']` là các phương án dự phòng cho các sửa đổi DOM). Cuộn vào vùng đánh giá, rồi ánh xạ mỗi thẻ thành tác giả / đánh giá / văn bản / ngày cộng với ba đánh giá phụ.

```ts
async function extractReviews(page: Page, max: number): Promise<EtsyReview[]> {
  // Phía trình duyệt: cuộn vào vùng đá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);

  // Phía Node: phân tích trang đã được làm đầy bằng 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 thùng chứa tổng hợp giữ nhiều đánh giá cùng một 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]);
      }
    }

    // Văn bản: bộ chọn dành riêng, 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à tên tháng hoặc regex 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];
    }

    // Đánh giá phụ — các hàng gán nhãn bên trong thẻ. Khớp nhãn, phân tích số liền kề.
    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 — cùng `il_fullxfull` nâng cấp 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;

Feel free to ask if you need further assistance!
psrc = psrc.replace(/il_\d+xN/, "il_fullxfull").replace(/_\d+x\d+./, "_1024x1024.");
if (!photoSeen.has(psrc)) { photoSeen.add(psrc); photos.push(psrc); }
});

Copy
out.push({
  author, rating, text, date,
  itemQuality: subRating("chất lượng sản phẩm"),
  shipping: subRating("vận chuyển"),
  customerService: subRating("dịch vụ khách hàng"),
  photos,
});

});

return out;
}

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

**Hình ảnh.** Etsy phục vụ thumbnail theo mặc định. Nâng cấp chúng lên độ phân giải đầy đủ bằng cách thay thế hậu tố kích thước trong URL: `il_75x75` → `il_fullxfull`, hoặc `_300x300.jpg` → `_1024x1024.jpg`. Hình ảnh giống nhau, nhưng có độ phân giải cao hơn nhiều, không có yêu cầu bổ sung.

```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;
    // Nâng cấp hậu tố kích thước thumbnail 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 selector rất quan trọng — Etsy tải ảnh thumbnail chậm và không điền src cho đến khi chúng vào viewport.

Biến thể, breadcrumb, tìm kiếm liên quan. Ba extractor bổ sung chạy sau đánh giá và hình ảnh, mỗi cái được bọc trong try/catch của riêng nó nên một selector bị bỏ lỡ sẽ giảm xuống một mảng trống thay vì phá vỡ 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ủ", "Túi & Ví", "Ví & Kẹp Tiền", "Ví"]) từ các thẻ liên kết chứa ref=breadcrumb_listing trong href của chúng. Etsy không bọc những thứ này trong <nav aria-label="breadcrumb"> — chúng là liên kết đơn giản với tham số ref.
  • extractRelatedSearches(page) — liên kết "Khám phá tìm kiếm liên quan" mà Etsy hiển thị ở dưới cùng của các trang danh sách. Bộ trích xuất lại cuộn đến chân trang và chờ đợi cho phần thẻ tải chậm trước khi đọc văn bản liên kết. Etsy A/B thử nghiệm các thẻ chỉ có hình ảnh (không có văn bản) so với thẻ có nhãn văn bản, vì vậy hãy mong đợi trường này sẽ được lấp đầy ở khoảng một nửa số danh sách.

Ngày được liệt kê 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 "Được liệt kê vào thứ Hai, ngày DD, YYYY" mà Etsy hiển thị gần thông tin về sản phẩm — lưu ý rằng điều này phản ánh ngày cập nhật/relist gần nhất, không phải ngày tạo gốc. shop.reviewsCountShop chỉ được lấp đầy khi Etsy rõ ràng phân biệt số lượng này ở cấp shop (nhiều bố cục danh sách không hiển thị nó — giá trị null là câu trả lời trung thực ở đó).

Hình ảnh được tải lên bởi người đánh giá nằm trong mỗi thẻ đánh giá. extractReviews hiện đã capture 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ã bên dưới một tập hợp hình ảnh song song cho phân tích hình ảnh hoặc xác minh đánh giá.

Bước 8 — Tăng cường Kiên cố theo Sản phẩm & Xử lý Lỗi

Việc trích xuất một danh sách là khá đơn giản. Trích xuất hàng trăm trong một hàng là nơi các lỗi tạm thời bắt đầu xuất hiện — Etsy đôi khi phục vụ một bộ nhớ cache cũ, h1 không được điền, một yêu cầu proxy đơn lẻ đã hết thời gian. Ba lớp phòng ngừa xử lý điều này ở quy mô:

Trình duyệt mới cho mỗi sản phẩm. Sau khi các kết quả tìm kiếm được thu thập, mở một phiên trình duyệt Scraping 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à một lỗi ở cấp phiên không làm hỏng phần còn lại của quá trình. Mỗi phiên mới triển khai một IP dân cư mới, vì vậy khi DataDome trả về mã 403 trên một IP, lần thử tiếp theo sẽ dừng ở một IP khác.

Tối đa cfg.maxRetries lần thử lại (mặc định 10) với một khoảng thời gian tăng lên. Trong một lần chạy sạch, hầu hết các sản phẩm đều 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 lần thử trước khi phiên đó di chuyển đế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 theo danh mục. categorizeError(err) ánh xạ mỗi lỗi thô (HTTP 403/404/429, ERR_SSL_*, ERR_TUNNEL_*, thiếu h1, thời gian chờ điều hướng, bắt tay WSS) đến một trong tám giá trị ScrapeErrorKind với cờ retryable: boolean. Các lỗi có thể thử lại sẽ đi vào vòng lặp backoff; những lỗi không thể thử lại (ví dụ: HTTP 404 trên một danh sách cũ) sẽ dừng lại ngay lập tức. Khi tất cả các nỗ lực đều thất bại, sản phẩm sẽ được gửi với error: { kind, message, attempts } đã được điền để mã phía dưới có thể biết chính xác lý do tại sao một dòng trở về 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 đánh chỉ mục 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(`    nỗ lực ${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(`    nỗ lực ${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 chóng.
  } finally {
    await eb.close().catch(() => {});
  }
  if (attempt < MAX_ATTEMPTS) {
    // Backoff tăng trưởng có thể cấu hình. Các giá trị mặc định (3000, 1500, 500) tạo ra
    // khoảng cách 5s, 8s, 12s, 17s, 23s, 30s, 38s, 47s, 57s giữa các nỗ lực.
    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 ngừng giữa các sản phẩm (có thể cấu hình) — phân tách các điều hướng danh sách 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 vài chi tiết quan trọng khi mở rộng: tên phiên bao gồm chỉ mục sản phẩm iDate.now() để các phiên mới không bị xung đột giữa các sản phẩm; openBrowser được bao bọc trong một try/catch riêng biệt để một lần bắt tay WSS không thành công không bỏ qua việc thử lại; eb.close() được bỏ qua bằng .catch(() => {}) vì phiên đã chết trước khi bạn đóng nó; backoff tăng trưởng đủ chậm để những sản phẩm dễ dàng hoàn thành nhanh nhưng những sản phẩm khó có thể nhận được khoảng thời gian hàng chục giây mà DataDome áp dụng cho các IP bị đánh dấu; và thời gian ngừng giữa các sản phẩm giảm rõ rệt cơ hội bị chặn chung.

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ã phía 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ần suất ✅ có
not-found HTTP 404 — danh sách đã bị xóa hoặc chưa bao giờ tồn tại ❌ không (thất bại nhanh)
tls ERR_SSL_* / ERR_CERT_* — sự 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 nhẹ ✅ có
timeout Thời gian chờ điều hướng vượt quá pageTimeoutMs ✅ có
open-browser Bắt tay WSS với Scrapeless thất bại ✅ có
unknown Các lỗi khác ✅ có (mặc định)

Mọi điều chỉnh đều có thể tùy chỉnh

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

Trường CONFIG 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 backoff tăng trưởng
retryBackoffLinearMs 1500 Hạng số học
retryBackoffQuadraticMs 500 Hạng số bậc hai
interProductDelayMs 3000 Thời gian tạm dừng giữa các lần làm giàu 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ễ 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 rãi có chủ đích, để cùng một bộ thu thập dữ liệu phục vụ mọi trường hợp sử dụng phía dưới mà không cần một lần đi qua thứ hai.

Kết quả thực tế đầu tiên 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í Tối Giản•Ví Cá Nhân Hóa•Kỷ Niệm Da•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 Phù 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 more URLs"
  ],
  "variations": [
    { "name": "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", "Vàng"] }
  ],
  "breadcrumbs": ["Trang Chủ", "Túi & Ví", "Ví & Kẹp Tiền", "Ví"],
  "relatedSearches": ["Ví Da Cho Nam", "Ví Nam Thanh Lịch", "Ví Da Bifold Mỏng Tùy Chỉnh"],
  "listedDate": "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": "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"
}

Trợ giúp kết nối tương tự, phân loại thử lại và mẫu phiên trên mỗi mục tiêu sẽ được áp dụng trong toàn bộ danh mục Scrapeless: hãy kết hợp hướng dẫn này với Scrapeless MCP Server để kết nối dữ liệu Etsy trực tiếp vào bề mặt công cụ của một tác nhân AI, hoặc với danh sách tốt nhất về các tác nhân AI để có bối cảnh về cách đường ống đó gắn vào các quy trình tự động hóa rộng lớn hơn.

Ghim proxyCountry để phù hợp với thị trường mà bạn muốn có giá, giữ sessionRecording: "true" để bất kỳ hàng null nào cũng có thể được phát lại từ đầu đến cuối, coi các trường thiếu (materials, shop.location, reviews[].itemQuality) là nullable thay vì lỗi dữ liệu thiếu, và để giảm dần phản hồi bất thường 403. Đó là toàn bộ sách hướng dẫn.


Sẵn sàng để Xây Dựng Đường Ống Dữ Liệu Powered by AI của Bạn?

Tham gia cộng đồng của chúng tôi để nhận một gói miễn phí và kết nối với các nhà phát triển đang xây dựng đường ống trí tuệ Etsy: Discord · Telegram.

Đăng ký tại app.scrapeless.com để nhận thời gian chạy Trình Duyệt Scraping miễn phí — lên đến 100 giờ chạy trình duyệt trong bản dùng thử miễn phí — và điều chỉnh các mẫu ở trên cho các danh mục, cửa hàng và từ khóa Etsy mà đường ống của bạn cần.


Câu Hỏi Thường Gặp

Việc xé dữ liệu từ Etsy có hợp pháp không?

Việc xé dữ liệu công khai để giám sát giá cả và nghiên cứu thường 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 xé dữ liệu người dùng cá nhân. Sử dụng Scrapeless đảm bảo hoạt động xé 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ý tiến độ.

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

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

Q1: Có cần proxy để xé dữ liệu từ Etsy không?

Có. Không có proxy dân cư, DataDome nhanh chóng đánh dấu lưu lượng truy cập trung tâm dữ liệu — sự kết hợp của dấu vân tay và uy tín IP thường đưa vào danh mục từ chối và các yêu cầu điều hướng trực tiếp tới các trang /listing/ trở về HTTP 403 với một trang thách thức JavaScript. Trình Duyệt Scraping của Scrapeless đi kèm với các proxy dân cư tích hợp sẵn — mỗi phiên đều đi qua một IP dân cư khác nhau trong quốc gia đã chọn, được xác minh trong quá trình kiểm tra bằng cách các phiên mới lần lượt trả về các IP đầu ra khác nhau (api.ipify.org kiểm tra).

Q2: Làm thế nào tôi có thể xem những gì công cụ xé dữ liệu đã làm 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 lại một video toàn bộ kiểu phát lại 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 phát lại tại app.scrapeless.comTrình Duyệt ScrapingCác Phiên, và ghép nối theo giá trị sessionName mà công cụ xé 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ị "Phát lại không khả dụng - Vui lòng bật 'Ghi lại Web' để xem các bản ghi của phiên", hãy bật công tắc Ghi lại Web trên trang cài đặt tài khoản Scrapeless của bạn. Nó miễn phí trên tất cả các gói; chỉ là tắt theo mặc định. Khi được bật, tất cả các phiên trong tương lai sẽ tự động ghi lại — các phiên trong quá khứ đã chạy khi ghi lại đã tắt sẽ không thể phục hồi được sau.

Các video phát lại là cách nhanh nhất để gỡ lỗi lý do tại sao một hàng lại trở về với title: null. Mở phiên, lướt qua dòng thời gian đến khoảnh khắc page.goto kích hoạt, và bạn sẽ thấy máy chủ có trả về một danh sách thực hay không, một thách thức DataDome hay một chuyển hướng URL lỗi thời.

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 tạo. Công cụ xé dữ liệu xử lý điều này bằng cách cuộn vào khu vực đánh giá và chờ đợi — khi phân tích viên chạy, 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 đặt trên maxReviews). Đi sâu hơn yêu cầu phải chặn trực tiếp điểm cuối GraphQL, điều này nằm ngoài phạm vi ở đây.

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

Etsy chuyển hướng theo IP tới các phiên bản địa phương (etsy.de từ một IP Đứ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 công cụ xé 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á đồng USD nhất quán trong các lần chạy, hãy ghim proxyCountry: "US".

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

Đặt bất 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 vào URL của Etsy:

ts Copy
const CONFIG: ScraperInput = {
```vi
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",   // "mới" | "cổ điển" (→ &explicit=vintage)
    orderBy: "price_asc",   // "phù hợp nhất" | "ngày_desc" | "price_asc" | "price_desc" | "cao nhất đánh giá"
  },
  // ...
};

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 được lọc bị chặn 403 nhiều hơn so với những URL không được lọc), vì vậy expandStrategy: "prices" giờ đây thực hiện một tìm kiếm rộng duy nhất và gán thẻ cho kết quả ở phía khách hàng qua priceBucket — cùng ý định người dùng, không có khối URL-filter. 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ử, thời gian chờ và nhịp độ không?

Có. Mỗi giá trị thử lại và nhịp độ 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 bỏ cuộc
retryInitialBackoffMs 3000 Cơ sở của công thức tăng dần thời gian chờ
retryBackoffLinearMs 1500 Tham số tuyến tính
retryBackoffQuadraticMs 500 Tham số bậc hai (thời gian tiến độ từ 5 giây → 57 giây)
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 trễ sau khi h1 xuất hiện, trước khi lấy dữ liệu

Các kế hoạch Scrapeless chặt chẽ hơn được hưởng lợi từ việc giảm interProductDelayMs + giảm maxRetries; các mục tiêu chống bot khó hơn được hưởng lợi từ giá trị cao hơn ở cả hai.

Q7: Tôi có thể mong đợi các loại thất bại nào?

Mỗi sản phẩm vượt quá số lần thử đề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ần suất (có thể thử lại)
  • not-found — HTTP 404, danh sách cũ hoặc đã bị xóa (không thể thử lại — thất bại nhanh)
  • tlsERR_SSL_* / ERR_CERT_* sự cố TLS proxy (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ử thách 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 (có thể thử lại theo mặc định)

Mã hạ lưu có thể coi kind: "not-found" là "bỏ URL này, khô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ờ tiếp theo khi cửa sổ danh tiếng IP của DataDome được đặt lại".

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