🎯 カスタマイズ可能で検出回避型のクラウドブラウザ。自社開発の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ブラウザインフラストラクチャとして機能します。
  • 1つのCONFIGブロックから4つの発見モード — 製品URL、カテゴリURL、キーワード検索(オプションの拡張付き)、ショップURL。入力を入れ替えるだけで、同じパイプラインを利用できます。
  • 8つの構造化フィルター(セール中、送料無料、カスタマイズ可能、配送先、最小/最大価格、状態、並べ替え)を、検索またはカテゴリURLを使用する任意の発見モードと組み合わせて使用できます。
  • 出力スキーマは、variationsbreadcrumbslistedDatereviews[].photos およびユニークなマーチャンダイジング信号(isBestsellerisStarSellerisFreeShippinginStockfavoritesCount、各レビューのサブスコア)を含む、製品ごとに30以上のフィールドをカバーします。
  • コンフィギュラブルなリトライループ(デフォルト maxRetries: 10、成長バックオフ 3秒 → 47秒)は、試行間に新しいセッションと住宅IPに回転し、トランジェントな403を自動的に吸収します。

導入:抗検出クラウドブラウザでのEtsyのスケールスクレイピング

Etsyはeコマースインテリジェンスの金mineです:ショップオーナーにとっての競合者の価格、MLプロジェクトの感情トレーニングコーパス、ドロップシッパーのためのニッチ発見がすべて同じリスティングページから得られます。公式Etsy APIはアクセスが制限されており、長い承認サイクルがあるため、サードパーティのデータ再販業者はコストがかかり、カスタムスクレイパーはDataDomeやEtsyのフロントエンドの頻繁なCSSクラス名の変更に対して継続的なメンテナンスが必要です。

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


これでできること

Etsyデータは多用途な資産で、製品調査から高度なAI分析に至るまで、高影響の商業アプリケーションを生み出します。以下は、同じコードベースから実現可能な5つの現実の商業利用法で、しばしば設定の変更だけで可能です:

  1. ドロップシッピング調査および製品発見キーワード検索モード"macramé plant hanger"に対してスクレイパーを実行し、expandStrategy: "keywords"["boho", "modern", "minimalist"]に設定し、maxProducts: 200を設定し、出力をfavoritesCount × ratingでランク付けします。isStarSeller: trueかつfavoritesCountが中央値を大きく上回るショップにフィルタリングします — それがドロップシッピング候補です。結果のCSVをShopifyやプライベートサプライヤリストに投入します。これは、Etsyをスクレイピングする最も一般的な理由であり、収益に変える最も早い方法です。
  2. 競合ベースの価格監視製品URL(直接URL)モード。競合のリスティングURLのリストをstartUrlsに保持し、スクレイパーを夜間に実行します。各JSONスナップショットをそのscrapedAtタイムスタンプで保存し、実行間でpriceoriginalPricediscountPercentおよびinStockの差分を取ります。価格の下落が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を提供した際の自動解決
  • デバッグ用のセッション記録(選択子の後戻りに対して)
  • PuppeteerやPlaywrightなどのCDPベースのフレームワークをサポートするWebSocketエンドポイント — 学ぶ必要のあるSDKはなし
  • AIエージェント対応: **Scrapeless MCPサーバー**などのツールとシームレスに統合し、AIエージェントにウェブ上での「目と手」を提供します。

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

無料プランでAPIキーを取得するには app.scrapeless.com にアクセスしてください。


前提条件とインストール

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 — スクレイピングブラウザに接続

スクレイパー全体のための接続ヘルパーです。トークン、国、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で解析します。呼び出し元
// は、ブラウザ側のスクロール/待機関数の実行を最初に行う責任があります
// その後、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と一つのpuppeteer.connect。これをスケールする前に知っておくべきことがあります:単一の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呼び出しの前にブラウザセッションごとに一度warmUpSessionを呼び出します。


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

スクレイパーは、同じCONFIGブロック内でリストを見つけるための4つの独立した方法を受け入れます。上流の質問に一致するものを選択し、startUrlsshopUrlcategoryUrl、またはsearchQueryのいずれか一つだけを設定します。2つ以上が設定されている場合、優先順位は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つのモードはすべて、ステップ4〜6で同じリスティングの強化パイプラインに供給され、ステップ8で同じ30フィールドのスキーマを出力します。

構造化フィルター

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

キー 効果
onSale true 現在セールとしてマークされているリスティングのみ
freeShipping true プロキシ国への送料無料のリスティングのみ
customizable true 個人化可能なリスティングのみ
shipsTo ISOコード例: "US" その国への配送が必要
minPrice / maxPrice 数字 価格帯(Etsyのネイティブフィルター)
condition "new" | "vintage" Etsyの状態フィルター
orderBy "最も関連性が高い" | "日付降順" | "価格昇順" | "価格降順" | "レビュー数が最も多い" 結果の順序

ページネーション制御

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


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

Etsyの消費者向けUIは、大半のニッチが枯渇する前にページネーションを制限し、高いリクエストボリュームの下ではIPごとのレート制限が迅速に発動します — 任意の単語は常に1つのランキングスライスしか表面化しません。ニッチを使い切るためには、基本クエリを軸に沿って分割(キーワードまたは価格バケット)し、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/...)でデデュープします。ターゲットが小さい場合、スクレイパーは結果がある最初のクエリの後に短絡し、デデュープ作業を完全にスキップします。

価格のバケット化も同様に機能します — 異なるバケットが異なるランキングスライスを表面化させます。Etsyの「ベストマッチ」は結果セットにおける価格に影響を受けるからです。


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

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

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

  // cheerioを使って安定したDOMをパース — `page.evaluate`の往復なし、
  // 文字列化された関数ボディなし、単に直接セレクタトラバース。
  const $ = await parseWithCheerio(page);
  let cards = $("div.listing-link");
  if (cards.length === 0) cards = $("[data-listing-id]");
  const hits: SearchHit[] = [];
  const seen = new Set<string>();
  cards.each((_, card) => {
    const link = $(card).find('a[href*="/listing/"]').first();
    if (!link.length) return;
    const href = link.attr("href") || "";
    const absolute = href.startsWith("http") ? href : `https://www.etsy.com${href.startsWith("/") ? "" : "/"}${href}`;
    const url = absolute.split("?")[0];
    if (!url || seen.has(url)) return;
    seen.add(url);
    const titleEl = $(card).find("h3").first();
    const title = titleEl.length ? titleEl.text().trim() : (link.attr("title") || null);
    const idMatch = url.match(/\/listing\/(\d+)/);
    const listingId = idMatch ? idMatch[1] : null;
    hits.push({ listingId, url, title, rank: hits.length + 1 });
  });
  return hits;
}

スクロールはpage.evaluateに留まります。これはライブDOMアクション(Etsyのレイジーロードをトリガー)ですが、すべてのパース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);

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

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

  // 評価 — "星"、"評価"、または"中"を含む任意のaria-label。
  let rating: number | null = null;
  $("[aria-label*='star' i], [aria-label*='rating' i]").each((_, n) => {
    if (rating !== null) return false;
    const a = $(n).attr("aria-label") || "";
    const rm = a.match(/(\d+(?:\.\d+)?)\s*(?:out|star|rating)/i);
    if (rm) rating = parseFloat(rm[1]);
  });
ja Copy
// ステータスバッジ — ページ本体を正規表現でチェックします。
  const bodyText = $("body").text();
  const isBestseller = /ベストセラー/i.test(bodyText);
  const isFreeShipping = /送料無料/i.test(bodyText);
  const isStarSeller = /スターセラー/i.test(bodyText);
  const inStock = !/在庫切れ|売り切れ/i.test(bodyText);

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

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

プレフィックス _price_raw は慣例です:下流の enrichProduct はそれを extractNumber に渡し、その後最終的な JSON が出力される前に生文字列フィールドを削除します。このスニペットは省略されています — index.ts の完全な extractOverviewcurrency, discountPercent, reviewsCount, favoritesCount, description, materials, itemDetails, shippingFrom, processingTime, tags, listedDate 及び他のショップフィールドを取得します。全体にわたって同様な cheerio-first パターンが適用されており、ただ選択子が増えます。

少しの通貨に寛容な 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);

  // ノード側:今や水分補給されたページを 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;

psrc = psrc.replace(/il_\d+xN/, "il_fullxfull").replace(/_\d+x\d+./, "_1024x1024。");
if (!photoSeen.has(psrc)) { photoSeen.add(psrc); photos.push(psrc); }
});

out.push({
author, rating, text, date,
itemQuality: subRating("アイテムの品質"),
shipping: subRating("発送"),
customerService: subRating("カスタマーサービス"),
photos,
});
});

return out;
}

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

画像。 Etsyはデフォルトでサムネイルを提供します。URL内のサイズサフィックスを置き換えてフル解像度にアップグレードします:il_75x75il_fullxfull、または_300x300.jpg_1024x1024.jpg。同じ画像で、はるかに高い解像度、追加のリクエストなし。

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;
    // 可能な限りサムネイルのサイズサフィックスをフル解像度にアップグレード。
    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を.populateしません。

バリエーション、パンくずリスト、関連検索。 レビューと画像の後に、各々のtry/catchでラップされた3つの追加エクストラクタが実行されますので、見逃したセレクタは空の配列に降格し、行を壊すことはありません:

  • extractVariations(page) — 出品者が公開するサイズ/色/パーソナライズのオプションを{name, options[]}[]リストとして取得します。[data-selector*='variation']のサブツリー内の<select>要素から populates します。
  • extractBreadcrumbs(page)href内のref=breadcrumb_listingを持つアンカータグからカテゴリ経路(例:["Homepage", "Bags & Purses", "Wallets & Money Clips", "Wallets"])を取得します。Etsyはこれを<nav aria-label="breadcrumb">内にラップすることはなく、単なるリンクであり、refパラメータを使用します。
  • extractRelatedSearches(page) — リスティングページの底部に表示される「関連検索を探る」リンクチップです。エクストラクタはページのフットに再スクロールし、レイジーロードされたタグセクションを待ってからリンクテキストを読み取ります。Etsyは画像のみのチップ(テキストなし)とテキストラベルのチップをA/Bテストするため、このフィールドはおおよそ半数のリスティングで充填されることを期待してください。

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

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

ステップ8 — 製品ごとの弾力的な補強とエラーハンドリング

一つのリスティングをスクレイピングするのは簡単です。連続して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 の値の1つにマッピングし、retryable: boolean フラグを付けます。再試行可能なエラーはバックオフループにフィードされ、再試行不可能なもの(例:古いリストに対するHTTP 404)は即座に中断します。すべての試行が尽きると、製品は error: { kind, message, attempts } が populated され、下流のコードは列が空になっている理由を正確に把握できます。

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 ${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 ${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() はセッションがすでに終了している時点で close するため、.catch(() => {}) で無視されます; 成長するバックオフは十分に遅く成長するため、簡単な製品は早く終了しますが、難しい製品には DataDome がフラグ付き IP に対して強いる数十秒のウィンドウがあります; 製品間のポーズは相関ブロックの可能性を測定可能に減少させます。

8種類のエラー

すべての失敗した製品は 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 2次項
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 URL"
  ],
  "variations": [
    { "name": "カスタマイズ", "options": ["はい、エングレービングを追加", "いいえ、ありがとう"] },
    { "name": "色の選択肢", "options": ["栗色", "黒", "タン"] }
  ],
  "breadcrumbs": ["ホームページ", "バッグと財布", "財布とマネークリップ", "財布"],
  "relatedSearches": ["メンズレザー財布", "スリークメンズ財布", "カスタムスリムレザー二つ折り財布"],
  "listedDate": "2026年4月15日",
  "priceBucket": null,
  "reviews": [
    {
      "author": "リズ",
      "rating": 0,
      "text": "説明通りで迅速に発送されました。ありがとう!",
      "date": "2026年4月12日",
      "itemQuality": null,
      "shipping": null,
      "customerService": null,
      "photos": []
    },
    "... 9 件のレビューがあります"
  ],
  "error": null,
  "scrapedAt": "2026-04-16T17:09:48.919Z"
}

公開されているデータをスクレイピングして価格監視やリサーチを行うことは、Etsyの利用規約を尊重し、個人のユーザーデータをスクレイピングしない限り、一般的に合法です。Scrapelessを使うことで、管理されたペースによってサーバーリソースの尊重を確保しながらスクレイピング活動を行えます。

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

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

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録画」を有効にしてください」と表示される場合は、Scrapelessアカウント設定ページでWeb録画のトグルをONにしてください。すべてのプランで無料ですが、デフォルトではオフになっています。一度有効にすると、今後のすべてのセッションが自動的に録画されます — 録画がオフの間に実行された過去のセッションは遡って回復することはできません。

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

Q3: なぜレビューがページではなく内部エンドポイント経由で読み込まれることがあるのですか?

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

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

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

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

8つのfilters.*キーの任意の組み合わせを設定してください。これらはsearchQueryおよびcategoryUrlモードと組み合わされ、EtsyのURLに直接エンコードされます:

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 (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"
  },
  // ...
};

知っておくべき注意点は2つあります:filters.minPrice / filters.maxPrice/search?q=... URLに設定されている場合は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 }フィールドを持っています。八つのカテゴリ化された種類:

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

最も人気のある記事

カタログ