🎯 カスタマイズ可能で検出回避型のクラウドブラウザ。自社開発のChromiumを搭載し、ウェブクローラーAIエージェント向けに設計されています。👉今すぐ試す
ブログに戻ります

EtsyスクレーパーをScrapeless Scraping Browserで構築する方法:包括的ガイド2026(Node.js)

James Thompson
James Thompson

Scraping and Proxy Management Expert

16-Apr-2026

主なポイント:

  • Scrapeless Scraping Browser は、EtsyのDataDomeの対ボット層を自動指紋認識、住宅プロキシ、およびCAPTCHA解決によりクリアする強力なAIブラウザインフラです。
  • 一つのCONFIGブロックからの四つの発見モード — 商品URL、カテゴリURL、キーワード検索(オプションの拡張付き)、およびショップURL。入力を入れ替えても同じパイプライン。
  • 八つの構造化フィルター(セール中、送料無料、カスタマイズ可能、配送先、最小/最大価格、状態、並べ替え)は、検索またはカテゴリURLを使用する任意の発見モードと組み合わせて使用できます。
  • 出力スキーマは、variationsbreadcrumbslistedDatereviews[].photos、およびユニークなマーチャンダイジング信号(isBestsellerisStarSellerisFreeShippinginStockfavoritesCount、および各レビューのサブスコア)を含む製品ごとに30以上のフィールドをカバーします。
  • 設定可能なリトライループ(デフォルトのmaxRetries: 10、成長バックオフ3秒→47秒)は、試行の間に新しいセッションと住宅IPに切り替わり、一時的な403エラーを自動的に吸収します。

はじめに:アンチ検出クラウドブラウザでのEtsyの大量スクレイピング

Etsyはeコマースインテリジェンスの金の鉱脈です:ショップオーナー向けの類似販売者価格、MLプロジェクト用の感情トレーニングコーパス、ドロップシッパーのためのニッチ発見がすべて同じリスティングページから流れています。公式のEtsy APIは制限されたアクセスがあり、承認サイクルが長く、第三者データリセラーはコストが高く、カスタムスクレイパーはDataDomeに対する継続的なメンテナンスが必要です。

このガイドでは、Scrapeless Scraping Browser に基づいて構築された単一のTypeScriptファイルを通じて、すべての難しい部分を前もって処理します:対検出クラウドブラウザ、住宅プロキシ、レビューとショップメタデータによる製品ごとの強化、そして単一の基本キーワードからEtsyの検索上限以上の結果を引き出すマルチクエリ拡張技術。この同じスクレイパーは、四つの独立した発見モードをサポートし、商品URLカテゴリURLキーワード検索、またはショップURLを与えると、リスティングがどのように発見されたかに関係なく、すべての出力行は同じ豊富な30フィールドのスキーマを持ちます。


何ができるか

Etsyデータは多用途な資産であり、製品研究から高度なAI分析までの高い影響力を持つ商業アプリケーションを推進します。以下は、同じコードベースから達成可能な実際の商業用途の五つです。ほとんどの場合、設定の変更だけで実現できます。

  1. ドロップシッピングリサーチと製品発見キーワード検索モード。 expandStrategy: "keywords" を使用して "macramé plant hanger" をスクレイピーし、["boho", "modern", "minimalist"]maxProducts: 200 を設定し、出力を favoritesCount × rating でランク付けします。 isStarSeller: true かつ favoritesCount が中央値を大きく上回るショップにフィルタリングします — これがあなたのドロップシッピング候補です。結果のCSVをShopifyやプライベートサプライヤーリストにドロップします。これが人々がEtsyをスクレイピングする最も一般的な理由であり、収益に変える最も迅速な方法です。
  2. 競合価格監視商品URL(直接URL)モード。 競合のリスティングURLのリストを startUrls に保持し、スクレイパーを毎晩実行します。それぞれのJSONスナップショットを scrapedAt タイムスタンプで保存し、実行間で priceoriginalPricediscountPercentinStock を差分します。価格が10%以上下落?Slackアラート。inStocktrue から false に変わる?供給信号としてフラグを立てます。この方法で構築する完全な価格履歴は、すべての競合インテリジェンスダッシュボードの核となります。
  3. キーワードおよびトレンドリサーチフィルターを使用したカテゴリURLモード。 特定のEtsyカテゴリ(例えば、/c/bags-and-purses/wallets-and-money-clips/wallets)を categoryUrl に向け、フィルターの組み合わせ(filters.onSale: truefilters.condition: "new"filters.orderBy: "date_desc")を適用し、数百のリスティングから tagsmaterials を引き出し、それらの頻度をカウントして各タグを使用するリスティングの favoritesCount の合計でソートします。最近作成されたリスティングに表示されるが古いリスティングには表示されないタグが成長するサブニッチです。
  4. MLおよびマーケットリサーチのためのレビュー集約キーワードまたはカテゴリモード。 (手作りキャンドルまたはパーソナライズされたジュエリーなど)縦列で数千のリスティングにわたって reviews[] をスクレイピングし、reviews[].text を感情分類器に投入し、存在する場合は itemQuality / shipping / customerService のサブ評価を監督されたトレーニングラベルとして使用します。各レビューの写真(reviews[].photos[])は、視覚的なトレーニングデータが必要な場合の並行画像コーパスを提供します。
  5. ショップパフォーマンスベンチマーキングショップURLモード. shopUrlを競合店舗のページ(例: https://www.etsy.com/shop/TexasValleyLeather)に設定し、maxPagesPerQuery: 5を設定して完全なカタログをページ区切りで取得します。スキャパーはそのショップが現在販売しているすべてのリスティングを列挙します。同じカテゴリ内の売り手をshop.totalSalesshop.openedYearratingreviewsCountisStarSellerによって比較します。

なぜScrapelessなのか

Scrapeless スクレイピングブラウザーは、あなたのスキャパーに生産グレードのクラウドブラウザーを提供し、EtsyのDataDomeチェックをそのままクリアします — ステルスプラグインなし、フィンガープリント調整なし、プロキシローテーションスクリプトの維持なし。PuppeteerまたはPlaywrightを使用してWebSocketエンドポイント経由で接続し、インフラストラクチャがアンチボット層を処理します。

すぐに得られるもの:

  • 検出防止フィンガープリンティングが長時間のセッションでも有効
  • 195以上の国での住宅用プロキシ(米国、英国、ドイツの料金は別々にターゲティング)
  • EtsyがCAPTCHAを提供した際の自動CAPTCHA解決
  • デバッグ用のセッション録画  — セレクターの回帰を後から確認
  • PuppeteerやPlaywrightなどのCDPベースのフレームワークをサポートするWebSocketエンドポイント — 学ぶべきSDKはなし
  • AIエージェント対応: **Scrapeless MCPサーバー**などのツールとシームレスに統合され、あなたのAIエージェントに「目と手」を提供します。

統合は1行の変更:puppeteer.connect()をローカルブラウザーではなくScrapelessのURLに向けるだけです。残りのコードはまったく同じ — 標準CDP、標準セレクター、標準ワークフロー。すべてのDataDomeの複雑さはサーバー側にあり、あなたのコードベースにはありません。

app.scrapeless.comの無料プランでAPIキーを取得してください。


前提条件とインストール

Node.js 18以上。ScrapelessのAPIキー(無料プランでこのガイドのすべてをカバー)。Puppeteerの基本的な知識があるとなお良い。ローカルChromeは不要 — ブラウザーは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はクラウドブラウザーを駆動し、cheerioは各ページが読み込まれた後にサーバー側でレンダリングされたHTMLを解析します。ブラウザー側のスクロールとNode側の解析を分離することで、すべてのエクストラクターが型付けされ、保存されたHTMLフィクスチャに対してユニットテスト可能に保たれます。

.env:

Copy
SCRAPELESS_API_KEY=your_key_here

ステップ1 — スクレイピングブラウザーに接続する

スキャパー全体のための1つの接続ヘルパー。トークン、国、TTLを持つWSS URLを構築し、その後puppeteer.connectに渡します。

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

// ヘルパー — ページの完全なHTMLを取得し、cheerioで解析します。呼び出し元は
// まずブラウザー側のスクロール/ waitForFunctionを実行して
// 遅延領域を水和する責任があります。その後、解析はNode側に保持されます:
// 型付けされており、文字列化された評価ボディなし、`__name` tsxの落とし穴なし、
// 保存されたHTMLフィクスチャに対して簡単にユニットテストが可能です。
async function parseWithCheerio(page: Page): Promise<cheerio.CheerioAPI> {
  const html = await page.content();
  return cheerio.load(html);
}

type ScraperInput = {
  proxyCountry: string;   // 例: "US", "GB", "DE"
  sessionTTL: number;     // 秒数、60〜900が許可されます;600が安全なデフォルト
};

function connectionURL(sessionName: string, cfg: ScraperInput): string {
  const token = process.env.SCRAPELESS_API_KEY;
  if (!token) throw new Error("SCRAPELESS_API_KEYが.envに設定されていません");
  // Scrapelessは、セッションのライフタイムのためにデフォルトで
  // 住宅のIPを固定しますので、puppeteer.connectの内部でページナビゲーションが
  // 同じアウトバウンドIPを使用します。新しいセッションを開く(新しい接続)は
  // 新しいIPをロールして、これはフラグ付きIPを回避するリトライループが依存していることです。
  const qs = new URLSearchParams({
    token,
    proxyCountry: cfg.proxyCountry,
    sessionTTL: String(cfg.sessionTTL),
    sessionName,
    sessionRecording: "true",
    // Scrapelessがデスクトップフィンガープリンティングを完全に所有させます — UA、スクリン、
    // タイムゾーン、言語。手動のsetViewport / 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,
  });
}

これがScrapeless専用の全ての表面積です — WSS URLが1つ、puppeteer.connectが1つです。これをスケールする前に知っておくべきことは、単一のpuppeteer.connectセッションはその生涯の間に単一の住宅IPにバインドされているということです(同じブラウザハンドルでapi.ipify.orgに3回連続してアクセスすると確認できます — 毎回同じIPです)。新しいセッションを開くと新しいIPが割り当てられます。これがステップ8の再試行ループの基盤となります — このセッションのIPでリクエストがブロックされた場合、セッションを閉じて新しいセッションを開き、新しいIPを取得して再試行します。

Scrapeless Scraping Browserは、接続層においてブラウザフィンガープリントを所有しています — UA、画面サイズ、タイムゾーン、言語はすべてWSS URLのfingerprint: { platform: "Windows" }クエリパラメータによって処理されます。手動でのsetViewportsetUserAgentの呼び出しは不要です。ステップ8の再試行ループは、上記の一時的なブロックを吸収します。

ブラウザ側のセットアップは1行のtsx互換スタブだけです:

ts Copy
async function prepPage(page: Page): Promise<void> {
  // tsxで注入された__nameヘルパーをスタブして、page.evaluate関数の本体が
  // ブラウザコンテキスト内で"__name is not defined"でクラッシュしないようにします。
  await page.evaluateOnNewDocument(
    "(function(){ globalThis.__name = function(f){ return f; }; })()",
  );
}

セッションのウォームアップ

検索やショップページに移動する前に、スクレイパーはEtsyのホームページを一度読み込んで有効なブラウザセッションを確立します。このステップを省略すると、/searchおよび/shopエンドポイントは、コールドセッションで403を返します:

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 {
    // タイムアウトまたはネットワークエラーは問題ありません — この時点でクッキーは設定されています。
  }
  await dismissEtsyConsent(page);
  await delay(1500);
}

国特有のパスが重要です:DEプロキシがetsy.com/de/にアクセスすると200が返され、正しい地域セッションクッキーが設定される一方で、DEプロキシでetsy.com/にアクセスすると403が返され、セッションはロックされたままです。これは、US(64件のリスティング)、DE(60件のリスティング)、GB(61件のリスティング)で確認されており、ウォームアップがプロキシの国と一致したときに、すべてが最初の試行で検索結果を返します。スクレイパーは、最初のcollectSearchResults呼び出しの前にブラウザセッションごとに1回warmUpSessionを呼び出します。


ステップ2 — 4つの発見モード

スクレイパーは、同じCONFIGブロック内で独立した4つの方法でリスティングを見つけることができます。上流の質問に合ったものを選び、startUrlsshopUrlcategoryUrl、またはsearchQueryのいずれか1つを正確に設定します。複数の設定があれば、優先順位はshopUrlcategoryUrlsearchQuerystartUrlsです。

製品URLモード(直接URL) — 既知のリスティング、夜間の再スクレイプ、競合他社のスナップショット:

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,
  // ...その他のデフォルト
};

カテゴリURLモード — 構造化されたフィルターを使用した全体カテゴリクロール:

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

キーワード検索モード — ニッチの発見、トレンド研究、ボリュームリスティングの引き出し:

ts Copy
const CONFIG: ScraperInput = {
  searchQuery: "leather wallet",
  expandStrategy: "keywords",                   // "none" | "keywords" | "prices"
  expandKeywords: ["mens", "womens", "vintage"], // 拡張時にベースに追加されます
  maxProducts: 20,
};

ショップURLモード — ベンチマーク/競争分析のために特定のショップのすべてのリスティングを列挙:

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

すべてのモードは、ステップ4〜6の同じリスティングごとの強化パイプラインに供給され、ステップ8で同じ30フィールドのスキーマを出力します。

構造化フィルター

8つのオプションフィルターキーは、searchQueryまたはcategoryUrlと組み合わせることができます。該当するものを設定し、他はオフのままにしてください:

キー 効果
onSale true 現在セール中のリスティングのみ
freeShipping true プロキシ国に無料で発送されるリスティングのみ
customizable true 個別にカスタマイズ可能なリスティングのみ
shipsTo ISOコード 例: "US" その国に発送しなければなりません
minPrice / maxPrice 数値 価格帯(Etsyのネイティブフィルター)
condition "new" | "vintage" Etsyの状態フィルター
orderBy "最も関連性が高い" | "日付の降順" | "価格の昇順" | "価格の降順" | "レビュー数が最も多い" 結果の並び替え

ページネーション制御

maxPagesPerQuery: N を設定して、各ディスカバリーURLで明示的に ?page=1..N を反復処理します。これがないと、スクレイパーはターゲットの初期ページをスクロールし、maxProducts のユニークリスティングが収集されると停止します。予測可能な広範囲を取得したい場合は、明示的なページネーションを使用します(例:「このカテゴリーの最初の5ページをスクレイプする。たとえそれが200以上のリスティングであっても」)。


ステップ3 — マルチクエリ拡張(Etsyの「結果上限」回避策)

Etsyの消費者UIは、ほとんどのニッチが完全に網羅される前にページネーションを制限し、高いリクエストボリュームの下でIPごとのレート制限をすぐに適用します — どの単一のキーワードも常に一つのランキングスライスしか表面化しません。ニッチを網羅するためには、ベースクエリを軸に沿って分割し(キーワードまたは価格バケット)、listingIdによって結果を重複削除します。

「レザー財布」の場合、キーワード拡張は以下のようになります。

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 = "キーワード" | "価格" | "なし";

function multiQueryExpand(
  base: string,
  cfg: { expandStrategy: ExpandStrategy; expandKeywords: string[]; priceBuckets: [number, number][] }
) {
  if (cfg.expandStrategy === "キーワード") {
    const queries = [base, ...cfg.expandKeywords.map((k) => `${k} ${base}`)];
    return queries.map((q) => searchUrlForQuery(q));
  }
  if (cfg.expandStrategy === "価格") {
    return cfg.priceBuckets.map(([min, max]) => searchUrlForQuery(base, 1, min, max));
  }
  return [searchUrlForQuery(base)];
}

["メンズ", "ウィメンズ", "ヴィンテージ"] を「レザー財布」に対して使用すると、4つの検索が生成されます。それらを実行し、リスティングURLを収集し、URLに埋め込まれている数値ID (/listing/1051861316/...) で重複を削除します。maxProductsを十分に高く設定して(数十から数百)、すべてのバリエーションに実際に広がるようにします — 対象が小さい場合、スクレイパーは結果がある最初のクエリの後にショートサーキットし、重複削除の作業を完全にスキップします。

価格バケットも同じように機能します — 異なるバケットは異なるランキングスライスを表示します。なぜなら、Etsyの「ベストマッチ」は、結果セット内の他の商品の価格に対する相対価格から影響を受けるからです。


ステップ4 — 各検索からリスティングURLを収集

結果のサイドバーを十分にスクロールして、レイジーロードされたカードをトリガーし、div.listing-link内のすべての a[href*="/listing/"] リンクを取得します([data-listing-id]をフォールバックとして、Etsyがクラス名をA/Bテストする場合)。

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

  // レイジーロードされたカードをトリガーするために、いくつかの回数スクロールします。各パスは、
  // 現在のDOMのcheerioスナップショットを取り、カードの数をカウントします。十分な数が
  // 確保できたら、スクロールを停止します。
  for (let i = 0; i < 6; i++) {
    const $peek = await parseWithCheerio(page);
    if ($peek("[data-listing-id], div.listing-link").length >= target) break;
    await page.evaluate(() => window.scrollBy(0, 1200));
    await delay(900);
  }

  // settled DOMをcheerioで解析します — `page.evaluate`の往復はなく、
  // 文字列化された関数本体もなく、ただ直線的なセレクタトラバーサルです。
  const $ = await parseWithCheerio(page);
  let cards = $("div.listing-link");
  if (cards.length === 0) cards = $("[data-listing-id]");
  const hits: SearchHit[] = [];
  const seen = new Set<string>();
  cards.each((_, card) => {
    const link = $(card).find('a[href*="/listing/"]').first();
    if (!link.length) return;
    const href = link.attr("href") || "";
    const absolute = href.startsWith("http") ? href : `https://www.etsy.com${href.startsWith("/") ? "" : "/"}${href}`;
    const url = absolute.split("?")[0];
    if (!url || seen.has(url)) return;
    seen.add(url);
    const titleEl = $(card).find("h3").first();
    const title = titleEl.length ? titleEl.text().trim() : (link.attr("title") || null);
    const idMatch = url.match(/\/listing\/(\d+)/);
    const listingId = idMatch ? idMatch[1] : null;
    hits.push({ listingId, url, title, rank: hits.length + 1 });
  });
  return hits;
}

スクロールは page.evaluate に留まりますが、これはライブDOMアクション(Etsyのレイジーロードをトリガーします)で、すべての解析page.content() スナップショットに対してcheerioを通じて実行されます。これは、ステップ6-7のすべての6つのエンリッチメントエクストラクターによって使用される同じパターンです。
dismissEtsyConsent呼び出しは、Etsyがページをレンダリングする前に「クッキーとプライバシー」のゲートを表示する非米国セッション用にあります。この関数は、いくつかの言語で「すべてを受け入れる」 / 「すべてを拒否する」 / 同等のラベルが付けられたボタンを探し、それをクリックします。

ステップ 5 — 各リストに移動する

Googleマップとは異なり、Etsyの/listing/<id>/ URLは直接ナビゲーションでも完全なパネルをレンダリングしますので、クリックスルーのステップは不要です — スクレイパーはpage.goto(listingUrl)を直接呼び出します。ただし、DataDomeはこのサブツリーに対して新しいプロキシIPの意味のある割合でHTTP 403を返しますので、ナビゲーションラッパーはレスポンスステータスをチェックし、403/429で早期に失敗し、h1が表示されない場合は例外をスローします — これらの条件のいずれかが発生すると、外側のリトライループが新しい住宅IPで新しいセッションを開きます。

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(`ブロックされました: HTTP ${status} on ${hit.url}`);
}
await dismissEtsyConsent(page);
try {
  await page.waitForSelector("h1", { timeout: 15000 });
} catch {
  // 15秒後にh1がない場合は、ほぼ確実にDataDomeのチャレンジまたはリダイレクトページです。
  // 例外をスローして、リトライループが新しいセッションを開けるようにします(=新しい住宅IP)。
  throw new Error(`h1が${hit.url}で見つかりませんでした — おそらくボットチャレンジページです`);
}
await delay(1500);

その後、ページ全体をチャンクごとにスクロールして遅延ロードをトリガーします。説明、材料、配送セクションはすべてスクロールでロードされます。

ステップ 6 — 概要フィールドを抽出

エクストラクターは、以下のすべてのエクストラクターで繰り返される2フェーズ構造を持っています: ブラウザ側(スクロール + waitForFunctionで遅延領域を水和)→ ノード側page.content()でページのHTMLを一度取得し、cheerioで解析)。この分割により、必要なときにはライブDOMの動作を得られ、不要なときには型が付けられ、テスト可能なサーバー側の解析が可能です。

ts Copy
async function extractOverview(page: Page): Promise<Partial<EtsyProduct>> {
  // ブラウザ側: チャンクでスクロールして説明/材料/配送が遅延ロードされ、
  // その後バイボックスが「読み込み中」プレースホルダを超えて水和されるのを待つ。
  await page.evaluate(`(function() {
    var step = 500, total = document.body.scrollHeight, current = 0;
    var iv = setInterval(function() {
      current += step;
      window.scrollTo(0, current);
      if (current >= total) clearInterval(iv);
    }, 200);
  })()`);
  await delay(3500);

  try {
    await page.waitForFunction(
      // いずれかの妥当な価格ラッパーが数値を含むまで待つ
      // (Etsyが一時的に表示する「読み込み中」プレースホルダではなく)。バイボックスラッパーだけでなく広いネットを引くことで、
      // 価格が最初に.currency-valueにレンダリングされる遅延リストでのヒット率が向上します。
      `(function(){
        var sels = [
          "[data-selector='price-only'] span.currency-value",
          "div[data-buy-box-region='price'] span.currency-value",
          "p[class*='price'] span.currency-value",
          "span.currency-value"
        ];
        for (var i = 0; i < sels.length; i++) {
          var el = document.querySelector(sels[i]);
          if (el && /^\s*\$?\d/.test((el.textContent || '').trim())) return true;
        }
        return false;
      })()`,
      { timeout: 15000 },
    );
  } catch { /* エクストラクターは下でベストを尽くそうとします */ }

  // ノード側: 一度レンダリングされたHTMLを取得し、cheerioで解析。
  const $ = await parseWithCheerio(page);

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

  // 価格 — 三段階のカスケード。(1) 現在の価格としてEtsyがマークした明示的な `[data-selector='price-only']`
  // サブツリーを優先する; (2) ストライクスルー/元の価格ラッパーではない最初の
  // `span.currency-value`にフォールバックする; (3) 最後の手段としてボディテキストの正規表現を使用する。
  const isOriginalPriceWrapper = (el: any) =>
    $(el).closest("[class*='strikethrough'], [class*='original'], s, .wt-text-strikethrough").length > 0;
  let price: string | null = null;
  const priceOnly = $("[data-selector='price-only'] span.currency-value").first();
  if (priceOnly.length && /^\d/.test(priceOnly.text().trim())) {
    price = priceOnly.text().trim();
  }
  if (!price) {
    $("span.currency-value").each((_, el) => {
      if (price) return false;
      if (isOriginalPriceWrapper(el)) return;
      const t = $(el).text().trim();
      if (t && /^\d/.test(t)) price = t;
    });
  }
  if (!price) {
    const bodyPriceMatch = $("body").text().match(/(?:今すぐ\s+)?価格:?\s*([$£€]?[\d.,]+)/i);
    if (bodyPriceMatch) price = bodyPriceMatch[1].trim();
  }

  // 評価 — "星"、"評価"、または"の中で"を言及する任意のaria-label。
  let rating: number | null = null;
  $("[aria-label*='star' i], [aria-label*='rating' i]").each((_, n) => {
    if (rating !== null) return false;
    const a = $(n).attr("aria-label") || "";
    const rm = a.match(/(\d+(?:\.\d+)?)\s*(?:中|星|評価)/i);
    if (rm) rating = parseFloat(rm[1]);
  });

Here is the translation of the provided English text to Japanese:

ja Copy
// ステータスバッジ — ページ本文を正規表現で処理する。
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);

// ショップサイドカー。
const shopLink = $("a[href*='/shop/']").first();
const shopName = shopLink.text().trim() || null;
const shopUrl = (shopLink.attr("href") || "").split("?")[0] || null;

return { title, _price_raw: price, rating, isBestseller, isFreeShipping, isStarSeller, inStock,
  shop: { name: shopName, url: shopUrl } } as Partial<EtsyProduct>;
}

_price_raw プレフィックスは慣習です。ダウンストリームの enrichProductextractNumber を通し、最終的な JSON が出力される前に生文字列フィールドが削除されます。このスニペットは省略されており、index.ts の完全な extractOverviewcurrencydiscountPercentreviewsCountfavoritesCountdescriptionmaterialsitemDetailsshippingFromprocessingTimetagslistedDate 及びその他のショップフィールドも取得します。セレクタは基本的には同じパターンですが、より多くのセレクタが含まれています。

小さな通貨対応の extractNumber ヘルパーは "$24.99""24,99 €"、または "1,234" をクリーンな数値に変換します — Etsy はプロキシ国に応じて地元のフォーマットで価格を提供し、数値フィールドを文字列にしてはいけません。

ステップ7 — レビュー、画像、ショップサイドカー、バリエーション、パンくずリスト、関連検索

レビュー。 Etsy のレビューカードは div[data-review-region] に存在し、(DOM の改訂のために div[class*='review-card']div[class*='review-item'] がフォールバックとして存在します)。レビュー領域にスクロールし、各カードを著者 / 評価 / テキスト / 日付および3つのサブ評価にマッピングします。

ts Copy
async function extractReviews(page: Page, max: number): Promise<EtsyReview[]> {
  // ブラウザサイド:レビュー領域にスクロールして、遅延読み込みのレビューカードを表示します。
  for (let i = 0; i < 8; i++) {
    const found = await page.evaluate(
      `!!document.querySelector('[data-reviews-section], div#reviews, div[class*="reviews"]')`,
    );
    if (found) break;
    await page.evaluate(() => window.scrollBy(0, 700));
    await delay(700);
  }
  await delay(1500);

  // Node サイド:今や水和されたページを cheerio で解析します。
  const $ = await parseWithCheerio(page);
  const out: EtsyReview[] = [];

  $(
    "div[data-review-region], div[class*='review-card'], div[class*='review-item'], li[class*='review']",
  ).each((_, card) => {
    if (out.length >= max) return false;
    const $card = $(card);
    const cardText = $card.text();
    // 一度に多くのレビューを保持する集約コンテナをスキップします。
    if (cardText.length > 6000) return;

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

    // 評価: `data-rating` 属性が最初、次に aria-label。
    let rating: number | null = null;
    const $starEl = $card.find("[aria-label*='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]);
      }
    }

    // テキスト:専用のセレクタ、次にカード内の最も長い段落。
    let text: string | null =
      $card.find("p[class*='review-text'], div[class*='review-text'], div[id*='review-content']")
        .first().text().trim() || null;
    if (!text) {
      let longest = "";
      $card.find("p").each((_, p) => {
        const pt = $(p).text().trim();
        if (pt.length > longest.length && pt.length > 20) longest = pt;
      });
      text = longest || null;
    }

    // 日付:最初に専用の要素、次に月名または数値の正規表現。
    let date: string | null =
      $card.find("span[class*='date'], time, p[class*='date']").first().text().trim() || null;
    if (!date) {
      const dm = cardText.match(/(\w{3,9}\s+\d{1,2},?\s+\d{4}|\d{1,2}\/\d{1,2}\/\d{2,4})/);
      if (dm) date = dm[1];
    }

    // サブ評価 — カード内のラベル付き行。ラベルを照合し、隣接する数値を解析します。
    const subRating = (label: string): number | null => {
      let val: number | null = null;
      $card.find("span, div, li").each((_, row) => {
        if (val !== null) return false;
        const t = $(row).text().toLowerCase();
        if (t.includes(label) && t.length < 60) {
          const sm = t.match(/(\d+(?:\.\d+)?)/);
          if (sm) val = parseFloat(sm[1]);
        }
      });
      return val;
    };

    // レビュアーがアップロードした写真 — リスト画像と同じ `il_fullxfull` アップグレード。
    const photos: string[] = [];
    const photoSeen = new Set<string>();
    $card.find("img[src*='etsystatic'], img[data-src*='etsystatic']").each((_, img) => {
      if (photos.length >= 6) return false;
      let psrc = $(img).attr("src") || $(img).attr("data-src") || "";
      if (!psrc) return;

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

out.push({
author, rating, text, date,
itemQuality: subRating("item quality"),
shipping: subRating("shipping"),
customerService: subRating("customer service"),
photos,
});
});

return out;
}

Copy
4つのカードセレクターの選択肢は、Etsyの進行中のA/B改訂をカバーしています。`[data-review-region]`が現在のホットセレクターです。`[class*='review-card']`、`[class*='review-item']`および`li[class*='review']`は、アカウントやリスティングによって表示される古いおよび新しいバリアントです。上部のcardTextの長さガードは、偶然に一致するラッパー要素をスキップし、1つのバッチとして連結されたすべてのレビューを返すことを防ぎます。

**画像。** Etsyはデフォルトでサムネイルを提供します。URLのサイズ接尾辞を置き換えることでフル解像度にアップグレードします:`il_75x75` → `il_fullxfull`、または`_300x300.jpg` → `_1024x1024.jpg`。同じ画像で、はるかに高い解像度、追加のリクエストは不要です。

```ts
async function extractImages(page: Page, max: number): Promise<string[]> {
  const $ = await parseWithCheerio(page);
  const urls: string[] = [];
  const seen = new Set<string>();
  $("img[src*='etsystatic'], img[data-src*='etsystatic']").each((_, img) => {
    if (urls.length >= max) return false;
    let src = $(img).attr("src") || $(img).attr("data-src") || "";
    if (!src) return;
    // 可能な場合にサムネイルサイズ接尾辞をフル解像度にアップグレードします。
    src = src.replace(/il_\d+xN/, "il_fullxfull").replace(/_\d+x\d+\./, "_1024x1024.");
    if (!seen.has(src)) { seen.add(src); urls.push(src); }
  });
  return urls;
}

img[data-src*='etsystatic']パターンは重要です。Etsyはギャラリーサムネイルをdata-srcの後ろで遅延読み込みし、ビューポートに入るまでsrcを埋めません。

バリエーション、パンくずリスト、関連検索。 レビューや画像の後に3つの追加の抽出機能が実行され、それぞれが独自のtry/catchでラップされるため、セレクターが見逃されると空の配列に昇格します:

  • extractVariations(page) — 売り手が示すサイズ/色/パーソナリゼーションオプションを{name, options[]}[]リストとして引き出します。[data-selector*='variation']のサブツリー内の<select>要素から取得します。
  • extractBreadcrumbs(page)hrefref=breadcrumb_listingを持つアンカータグからカテゴリーのトレイル(例:["ホームページ", "バッグ&財布", "財布&マネークリップ", "財布"])を取得します。Etsyはこれを<nav aria-label="breadcrumb">にラップせず、単なるリンクでrefパラメーターがあります。
  • extractRelatedSearches(page) — リストページの下部にEtsyがレンダリングする「関連検索を探索」リンクチップです。抽出機能はページの足に再スクロールし、遅延読み込みされたタグセクションを待ってからリンクテキストを読み取ります。Etsyは画像のみのチップ(テキストなし)とテキスト表示のチップをA/Bテストしているため、このフィールドはリストの約半分に現れることを期待できます。

リスト日付とショップレベルの合計は、基本フィールドと同時にextractOverview内で引き出されます。listedDateは、Etsyがアイテム詳細の近くに表示する「Mon DD, YYYYにリストされた」という文字列を解析します。これは、元の作成日ではなく、最も最近の再リスト/自動更新日を反映しています。shop.reviewsCountShopは、Etsyが明示的にショップレベルの数を識別する場合にのみ埋められます(多くのリスティングレイアウトでは表示されないため、nullが正直な回答です)。

レビュアーがアップロードした写真は各レビューカード内に存在します。extractReviewsは、リスティング画像に使用されるのと同じil_fullxfullアップグレードを介して、各レビューごとに最大6枚の写真をキャプチャし、下流のコードに視覚分析やレビュー検証のための並行した画像コーパスを提供します。

ステップ8 — 耐障害性のある製品別の強化とエラーハンドリング

1つのリスティングをスクレイピングするのは簡単です。100件を連続してスクレイピングするのは、一時的な失敗が現れるところです。Etsyは時折古いキャッシュを提供し、h1が埋まらず、単一のプロキシリクエストがタイムアウトします。これをスケールで処理するための3つの防御層があります:

製品ごとに新しいブラウザを開く。 検索ヒットが収集された後、各強化のために新しいScrapeless Scraping Browserセッションを開きます。状態は製品間でリークせず、セッションレベルのエラーが他の実行を汚染することはありません。各新しいセッションは新しい居住用IPをロールアウトするため、DataDomeが1つのIPで403を返すと、次の試行は異なるIPに着地します。

最大cfg.maxRetriesの再試行(デフォルト10回)。 クリーンランの場合、ほとんどの製品は1回目の試行で成功します。悪いIPランでは、クリーンな居住用IPにセッションが着地するまでに3~6回の試行がかかる場合があります。高いリトライ予算が50%のヒット率と100%の違いです。
エラーの分類タクソノミー。 categorizeError(err)は、生の失敗(HTTP 403/404/429、ERR_SSL_*ERR_TUNNEL_*、h1がない、ナビタイムアウト、WSSハンドシェイク)を8つのScrapeErrorKind値のいずれかにマッピングし、retryable: booleanフラグを付けます。再試行可能なエラーはバックオフループに供給され、非再試行可能なエラー(例:古いリストに対するHTTP 404)は即座にショートサーキットします。すべての試行が尽きると、製品はerror: { kind, message, attempts }が入力された状態で出荷され、下流のコードが行が空に戻った理由を正確に知ることができます。

ts Copy
// メインループ内、各検索ヒット h に対して一度ずつ(iでインデックス付け):
const MAX_ATTEMPTS = cfg.maxRetries;   // デフォルトは10
let p: EtsyProduct | null = null;
let lastError: ScrapeErrorInfo | null = null;
let attemptsUsed = 0;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
  attemptsUsed = attempt;
  let eb;
  try {
    eb = await openBrowser(`etsy-enrich-${i}-${attempt}-${Date.now()}`, cfg);
  } catch (e: any) {
    lastError = categorizeError(new Error(`openBrowserに失敗しました: ${e?.message ?? e}`));
    log(`    試行 ${attempt}/${MAX_ATTEMPTS} — ${lastError.kind}: ${lastError.message.slice(0, 120)}`);
    if (!lastError.retryable) break;
    if (attempt < MAX_ATTEMPTS) await delay(Math.max(cfg.retryInitialBackoffMs, 3000));
    continue;
  }
  try {
    p = await enrichProduct(eb, h, cfg);
    if (p.title) { lastError = null; break; }
    lastError = categorizeError(new Error(`no h1 on ${h.url} — title was null`));
  } catch (e: any) {
    lastError = categorizeError(e);
    log(`    試行 ${attempt}/${MAX_ATTEMPTS} — ${lastError.kind}: ${lastError.message.slice(0, 120)}`);
    if (!lastError.retryable) break;   // 非再試行可能: 404など。早く失敗する。
  } finally {
    await eb.close().catch(() => {});
  }
  if (attempt < MAX_ATTEMPTS) {
    // 設定可能な成長バックオフ。デフォルト(3000, 1500, 500)は
    // 5秒、8秒、12秒、17秒、23秒、30秒、38秒、47秒、57秒の間隔で試行間で待機します。
    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);
// 製品間のポーズ(設定可能) — 連続したリスティングナビゲーションを
// 中断し、セッションパターンがDataDomeにボット形状に見えないようにします。
if (i < hits.length - 1) await delay(cfg.interProductDelayMs);

スケールにおいて重要な詳細として:セッション名には製品インデックス iDate.now() が含まれているため、新しいセッションが製品間で衝突しません;openBrowserは独自のtry/catchでラップされているため、失敗したWSSハンドシェイクが再試行をスキップすることはありません;eb.close()は、セッションが既に死んでいるために.catch(() => {})で飲み込まれます;成長バックオフは十分に緩やかに成長し、簡単な製品は速く完了しますが、難しいものはDataDomeがフラグ付きIPに対して強いる数十秒のウィンドウを得ます;また、製品間のポーズは相関ブロックの可能性を測定可能に減少させます。

八つのエラータイプ

すべての失敗した製品は、error: { kind, message, attempts }オブジェクトを持ちます。kindフィールドは、下流のコードが自由形式のメッセージを解析せずにどのように反応するかを通知します:

kind トリガー 再試行可能
blocked HTTP 403 または 429 — DataDomeまたはレートリミット ✅ はい
not-found HTTP 404 — リストが削除されたか存在しない ❌ いいえ(早く失敗)
tls ERR_SSL_* / ERR_CERT_* — 一時的なプロキシの問題 ✅ はい
network ERR_TUNNEL / ERR_CONNECTION_* / ERR_ABORTED ✅ はい
no-h1 ページが読み込まれたが <h1> が表示されなかった — ソフトチャレンジページ ✅ はい
timeout ナビゲーションタイムアウトが pageTimeoutMs を超えた ✅ はい
open-browser ScrapelessへのWSSハンドシェイクに失敗 ✅ はい
unknown その他すべて ✅ はい(デフォルト)

すべてのノブは調整可能

すべての再試行と間隔の値はScraperInputにあり、何もハードコーディングされていません。より厳格なプランで予測可能なスループットが必要な場合や、より困難なターゲットでの攻撃的な再試行が必要な場合に調整します:

CONFIGフィールド デフォルト 役割
maxRetries 10 諦める前の製品ごとの総試行回数
retryInitialBackoffMs 3000 成長バックオフ式の基本
retryBackoffLinearMs 1500 線形項
retryBackoffQuadraticMs 500 二次項
interProductDelayMs 3000 連続する製品強化間のポーズ
pageTimeoutMs 60000 page.gotoタイムアウト
h1TimeoutMs 15000 waitForSelector("h1")タイムアウト
postLoadDelayMs 1500 h1が表示された後の遅延、抽出前

返されるもの

製品ごとの1つのフラットJSONオブジェクト。意図的に広くしているので、同じスクレイパーが二回目のパスなしであらゆる下流の使用ケースにフィードします。

この正確なテンプレートで実行された「レザー財布」検索の実際の最初の結果:

json Copy
{
  "listingId": "547491922",
json Copy
{
  "title": "革の財布•財布•男性用革財布•ミニマリスト財布•パーソナライズ財布•革の記念日•スリムレザー財布•男性用財布",
  "url": "https://www.etsy.com/listing/547491922/leather-walletwalletman-leather",
  "rank": 1,
  "price": 5.52,
  "originalPrice": 68.99,
  "currency": "¥",
  "discountPercent": 92,
  "inStock": false,
  "rating": 4.9,
  "reviewsCount": 929,
  "favoritesCount": 850,
  "isBestseller": false,
  "isFreeShipping": false,
  "isStarSeller": true,
  "tags": ["ブライズメイドギフト", "グルームズメンギフト", "ウェディングギフト", "エンゲージメントギフト"],
  "materials": [],
  "shop": {
    "name": "TexasValleyLeather",
    "url": "https://www.etsy.com/shop/TexasValleyLeather",
    "location": null,
    "totalSales": null,
    "openedYear": null,
    "reviewsCountShop": null
  },
  "images": [
    "https://i.etsystatic.com/15980284/r/il/2456a5/3164786673/il_fullxfull.3164786673_roeh.jpg",
    "... 4 more URLs"
  ],
  "variations": [
    { "name": "パーソナライズ", "options": ["はい、彫刻を追加", "いいえ、ありがとう"] },
    { "name": "カラーオプション", "options": ["チェスナット", "ブラック", "タン"] }
  ],
  "breadcrumbs": ["ホームページ", "バッグと財布", "財布とマネークリップ", "財布"],
  "relatedSearches": ["男性用革財布", "スリークメンズ財布", "カスタムスリムレザー二つ折り財布"],
  "listedDate": "2026年4月15日",
  "priceBucket": null,
  "reviews": [
    {
      "author": "Liz",
      "rating": 0,
      "text": "説明通りで、迅速に発送されました。ありがとうございます!",
      "date": "2026年4月12日",
      "itemQuality": null,
      "shipping": null,
      "customerService": null,
      "photos": []
    },
    "... 9 more reviews"
  ],
  "error": null,
  "scrapedAt": "2026年4月16日T17:09:48.919Z"
}

同じ接続ヘルパー、リトライタクソノミー、ターゲットごとのセッションパターンは、より広範なScrapelessカタログ全体にわたって適用されます。このガイドを**Scrapeless MCP Serverと組み合わせて、EtsyデータをAIエージェントのツールサーフェスに直接接続したり、最高のAIエージェントまとめ**でパイプラインがより広い自動化ワークフローにどうプラグインするかの文脈を得たりしてください。

proxyCountryを希望する市場の料金に合わせて設定し、sessionRecording: "true"を維持して、任意のnull行がエンドツーエンドで再生できるようにし、欠落フィールド(materialsshop.locationreviews[].itemQuality)を欠落データバグではなくnullableとして扱い、増加するバックオフを一時的な403を吸収できるようにします。これが全体のプレイブックです。


AI駆動型データパイプラインの構築に準備はできていますか?

私たちのコミュニティに参加して無料プランを取得し、Etsyインテリジェンスパイプラインを構築している開発者とつながりましょう: Discord · Telegram

app.scrapeless.comにサインアップして無料のScraping Browserランタイムを取得してください — 無料トライアルで最大100時間のブラウザ実行が可能です — そして、上記のパターンをあなたのパイプラインが必要とするEtsyカテゴリー、ショップ、キーワードに適応させてください。


よくある質問

Etsyのスクレイピングは合法ですか?

価格監視や研究のために公開されているデータをスクレイピングすることは一般的に合法ですが、Etsyの利用規約を尊重し、個人ユーザーデータのスクレイピングを避ける必要があります。Scrapelessを使用することで、管理されたペースを通じてサーバーリソースに対するスクレイピング活動が敬意を持たれることを保証します。

ScrapelessはEtsyのDataDome保護にどのように対処しますか?

標準的なプロキシとは異なり、ScrapelessはブラウザのフィンガープリントおよびTLSハンドシェイク全体を管理します。これにより、あなたのスクレイパーは実際のユーザーと区別がつかず、手動のステルス構成なしでDataDomeの高度なボット検出を回避できるようになります。

Q1: Etsyをスクレイピングするにはプロキシは必要ですか?

はい。居住用プロキシなしでは、DataDomeはデータセンターからのトラフィックをすぐにフラグします — フィンガープリントとIPレピュテーションの組み合わせは、通常拒否バケットにスコアされ、直接ナビゲーションリクエストが/listing/ページに対してHTTP 403でJavaScriptチャレンジページを返します。Scrapeless Scraping Browserには、居住用プロキシが組み込まれています — すべてのセッションは選択した国の異なる居住用IPを通るようにルーティングされ、テストで確認された連続的な新しいセッションが異なるアウトバウンドIPを返します(api.ipify.orgプローブ)。

Q2: 過去の実行でスクレイパーが何をしたかを見るにはどうすればよいですか?

このテンプレートのすべてのセッションはWSS URLでsessionRecording: "true"を設定しているため、Scrapelessはクラウドブラウザが触れたすべてのページのフルビデオスタイルの再生を保存します — スクロール位置、DOM状態およびネットワークアクティビティ。リプレイは**app.scrapeless.com** → Scraping BrowserSessionsで見つけられ、スクレイパーが各試行のたびにログするsessionName値(例: etsy-enrich-3-2-1713198231047)で一致させます。

ダッシュボードに「リプレイが利用できません — セッション録画を表示するには「Web Recording」を有効にしてください」と表示された場合は、Scrapelessアカウント設定ページでWeb Recordingトグルをオンにしてください。これはすべてのプランで無料で、デフォルトではオフになっています。これを有効にすると、すべての将来のセッションが自動的に録画されます — 録画がオフの状態で実行された過去のセッションは遡って回復できません。

リプレイは、なぜ1行がtitle: nullで戻ってきたのかをデバッグするための最も迅速な方法です。セッションを開き、page.gotoが発火した瞬間までタイムラインをスクラブすると、サーバーが実際のリスティング、DataDomeチャレンジ、または古いURLリダイレクトを返したかどうかがわかります。

Q3: レビューが時々ページではなく内部エンドポイントを介して読み込まれるのはなぜですか?

新しいEtsyリスティングは、ページがレンダリングされた後に内部POSTリクエストを介していくつかのレビューバッチを読み込みます。スクレイパーは、レビュー領域にスクロールし、待機することでこれに対処します — パーサーが実行される頃には、カードはDOMに存在しています。レビューが数千件ある製品の場合、最初の約30件(またはmaxReviewsで設定した件数)が取得されます。さらに深い情報を取得するには、GraphQLエンドポイントを直接傍受する必要がありますが、ここでは範囲外です。

Q4: 地域や通貨のリダイレクトについてはどうですか?

EtsyはIPによってローカライズされたバージョンにリダイレクトします(ドイツのIPからはetsy.deフランスのIPからはetsy.fr)。価格や通貨の文字列は地域によって異なります。スクレイパーのextractNumberヘルパーは、1,234.56(en-US)および1.234,56(de-DE)形式の両方を処理します。実行ごとに一貫したUSD価格が必要な場合は、proxyCountry: "US"を設定してください。

Q5: 価格、セール中、送料無料、または状態でフィルタリングするにはどうすればよいですか?

8つのfilters.*キーの任意の組み合わせを設定します。それはsearchQuerycategoryUrlモードと組み合わされ、EtsyのURLに直接エンコードされます:

ts Copy
const CONFIG: ScraperInput = {
```json
categoryUrl: "https://www.etsy.com/c/bags-and-purses/wallets-and-money-clips/wallets",
  filters: {
    onSale: true,           // → &is_on_sale=1
    freeShipping: true,     // → &free_shipping=1
    customizable: true,     // → &is_personalizable=1
    shipsTo: "US",          // → &ships_to=US (ISO国コード)
    minPrice: 20,           // → &min=20
    maxPrice: 60,           // → &max=60
    condition: "vintage",   // "new" | "vintage" (→ &explicit=vintage)
    orderBy: "price_asc",   // "most_relevant" | "date_desc" | "price_asc" | "price_desc" | "highest_reviews"
  },
  // ...
};

注意すべき二つの注意点: /search?q=... URLの filters.minPrice / filters.maxPrice は DataDome に敏感です(フィルタ付きの検索 URL は未フィルタのものより積極的に403エラーになります)。そのため、expandStrategy: "prices"は単一の広範な検索を実行し、クライアント側で priceBucketを介して結果をバケツタグ付けします — 同じユーザーの意図を持ちながら、URLフィルタブロックは回避されます。categoryUrlでは価格フィルタは通常通り動作します。

Q6: リトライ、タイムアウト、ペーシングを調整できますか?

はい。すべてのリトライおよびペーシングの値は ScraperInput の CONFIG フィールドです:

フィールド デフォルト 役割
maxRetries 10 諦める前の各製品に対する合計試行回数
retryInitialBackoffMs 3000 成長バックオフの基準
retryBackoffLinearMs 1500 線形項
retryBackoffQuadraticMs 500 二次項(5秒→57秒の進行を生成)
interProductDelayMs 3000 連続した製品強化間の一時停止
pageTimeoutMs 60000 page.goto のタイムアウト
h1TimeoutMs 15000 waitForSelector("h1") のタイムアウト
postLoadDelayMs 1500 h1 が表示された後、抽出前の遅延

厳しいScrapelessプランは、interProductDelayMsを小さくし、maxRetriesを低くすることで利益を得ます;厳しいアンチボットターゲットは、両方に対して高い値の利益を享受します。

Q7: どのような失敗カテゴリーが予想できますか?

リトライを使い果たした各製品は構造化された error: { kind, message, attempts } フィールドを持ちます。8つのカテゴリ分けされた種類:

  • blocked — DataDome またはレート制限からの HTTP 403/429(リトライ可能)
  • not-found — HTTP 404、期限切れまたは削除されたリスティング(リトライ不可 — 速やかに失敗)
  • tlsERR_SSL_* / ERR_CERT_* プロキシTLSの不具合(リトライ可能)
  • networkERR_TUNNEL / ERR_CONNECTION_* / ERR_ABORTED(リトライ可能)
  • no-h1 — ページが読み込まれたが <h1> が決して表示されなかった、ソフトDDチャレンジページの可能性(リトライ可能)
  • timeout — ナビゲーションタイムアウトが超過(リトライ可能)
  • open-browser — ScrapelessへのWSSハンドシェイクが失敗(リトライ可能)
  • unknown — その他のすべて(デフォルトでリトライ可能)

下流コードは kind: "not-found" を「このURLをドロップし、再キューイングしない」とし、kind: "blocked" を「DataDomeのIP評判ウィンドウがリセットされる次の時間に再試行する」と扱うことができます。

Scrapelessでは、適用される法律、規制、およびWebサイトのプライバシーポリシーを厳密に遵守しながら、公開されているデータのみにアクセスします。 このブログのコンテンツは、デモンストレーションのみを目的としており、違法または侵害の活動は含まれません。 このブログまたはサードパーティのリンクからの情報の使用に対するすべての責任を保証せず、放棄します。 スクレイピング活動に従事する前に、法律顧問に相談し、ターゲットウェブサイトの利用規約を確認するか、必要な許可を取得してください。

最も人気のある記事

カタログ