Etsyスクレイピングの前提:APIかHTMLか
Etsyは2023年に
本記事では、Etsyのページ構造を分解し、Cloudflareを回避しつつ検索→リスティング→ショップと段階的にデータを取得する実装パターンを解説します。すべての例でレスデンシャルプロキシを使い、小規模ビジネスの倫理を守るスクレイピング手法に焦点を当てます。
Etsyのサイト構造を理解する
スクレイピングを設計する前に、EtsyのURL構造とページ構成を把握しましょう。
検索ページ
基本URLパターン:
https://www.etsy.com/search?q=coffee+mug&ref=search_bar
https://www.etsy.com/search?q=coffee+mug&locationQuery=0&explicit=1&ship_to=US
検索結果は最大250ページ(約9,600件)まで表示されます。それ以降はEtsy側で打ち切られます。各検索結果ページには最大48件のリスティングカードが含まれます。ページネーションは&page=2のクエリパラメータで制御します。
リスティングカードの主要セレクタ:
- リスティングリンク:
div.v2-listing-card a.listing-link(hrefに/listing/を含む) - タイトル:
h3(カード内) - 価格:
span.currency-value - ショップ名:
span.text-body-sm - レビュー数:
span.text-gray-lighter内の星評価テキスト
リスティング詳細ページ
URLパターン:
https://www.etsy.com/listing/123456789/custom-coffee-mug
詳細ページから取得できるデータ:
- タイトル・説明文:
h1およびdiv[data-appears-component-id='description'] - 価格:
p[data-selector='price-only'] .currency-value - バリエーション:
div.variation-select内のセレクト要素 - 画像URL:
img[data-appreciate-image] - セールス数バッジ:
span.text-gray-lighter内の「X sales」テキスト
ショップページ
URLパターン:
https://www.etsy.com/shop/ShopName
https://www.etsy.com/shop/ShopName/sold
ショップページの構造要素:
- リスティング総数:
span.countまたはdiv.shop-info span - 販売数バッジ: 「X sales」バッジは
span.text-gray-lighterに含まれる - レビュー:
div.review、評価はinput[name='rating']のvalue属性 - ショップ開設日:
span.text-gray-lighter内の「On Etsy since YYYY」
カテゴリツリー
Etsyのカテゴリは/c/パスで表現されます:
https://www.etsy.com/c/jewelry
https://www.etsy.com/c/jewelry/rings
https://www.etsy.com/c/clothing/womens-clothing/dresses
カテゴリページは検索ページと同じUIコンポーネントを使いますが、フィルタリングがカテゴリに固定されています。ニッチ調査では、カテゴリツリーを起点にしてサブカテゴリごとのリスティング密度を測るのが有効です。
Etsyのアンチボット対策とプロキシ戦略
EtsyはCloudflareと独自のレート制限の二層構造でスクレイピングを防いでいます。
Cloudflareの挙動
- JSチャレンジ: 初回アクセス時にJavaScriptチャレンジを発行。解決できないIPは403を返す。
- レート制限: 同一IPから約60〜80リクエスト/分を超えると429または403を返す(閾値は変動)。
- IPレピュテーション: データセンタIPはCloudflareのIPレピュテーションDBで低スコアとなり、JSチャレンジの頻度が上がる。
プロキシタイプの比較
| プロキシタイプ | Cloudflare通過率 | レート制限耐性 | コスト | Etsy適性 |
|---|---|---|---|---|
| データセンタ | 低(JSチャレンジ頻発) | 低(IPブロックされやすい) | $ | ✗ |
| レスデンシャル | 高(家庭用IPと同一) | 高(IPローテーション可能) | $$$ | ★★★ |
| モバイル | 最高(キャリアIP) | 最高(IPプール最大) | $$$$ | ★★☆(コスト過多) |
結論:レスデンシャルプロキシがコストと通過率のベストバランスです。データセンタIPはCloudflareのJSチャレンジで弾かれる確率が高く、Etsyスクレイピングには不向きです。モバイルプロキシは通過率は最高ですが、Etsyのテキストデータ取得にはコストが見合いません。
ProxyHatレスデンシャルプロキシの設定
ProxyHatのレスデンシャルプロキシを使う場合、国指定で米国IPを取得できます:
# HTTP プロキシ(米国IP指定)
http://user-country-US:password@gate.proxyhat.com:8080
# SOCKS5 プロキシ(米国IP指定)
socks5://user-country-US:password@gate.proxyhat.com:1080
# スティッキーセッション(同一IPを15分間維持)
http://user-country-US-session-abc123:password@gate.proxyhat.com:8080
country-USフラグで米国IPを指定するのは、Etsyが地域によって検索結果や価格表示を変えるためです。POD市場の主力である米国市場のデータを取得するには、米国IPが必須です。
ニッチ発見のためのスクレイピングパターン
PODビジネスでEtsyスクレイピングを使う最大の目的はニッチ発見です。以下の3つの指標を組み合わせて有望なニッチを特定します。
1. トレンド検索キーワードの抽出
EtsyのオートコンプリートAPIは公開されており、ベースURLだけ知っていればキーワード候補を取得できます:
https://www.etsy.com/api/v3/ajax/member/search-suggestions?query=coffee+mug
このエンドポイントはCloudflareの保護下にありますが、レスデンシャルプロキシ経由であれば通常アクセス可能です。返却されるJSONのresults配列にサジェストキーワードが含まれます。
実装のコツ:アルファベットの各文字をサフィックスとして付与し(coffee mug a, coffee mug b...)、ロングテールキーワードを網羅的に収集します。
2. ニッチごとのセラー数の測定
特定キーワードの検索結果ページのセラー数をカウントすることで、競合密度を測れます。手順:
- 検索結果の全ページ(最大250ページ)をスクレイプ
- 各リスティングカードからショップ名を抽出
- ユニークショップ名の数をカウント
セラー数が少なく、リスティング数が多い=需要に対して供給が不足している可能性が高いニッチです。
3. 平均価格ポイントの算出
検索結果の価格データを収集し、統計量を算出します:
- 中央値: そのニッチの典型的な価格
- 第1四分位〜第3四分位: 価格帯の幅
- 外れ値: 高価格帯のリスティングは付加価値(カスタマイズ、ギフト包装など)のヒント
PODの場合、$15〜$35の価格帯が利益率と競争力のバランスが良い目安です。この範囲より高いニッチは、プレミアムPODの機会があるかもしれません。
Python実装:検索→リスティング→詳細ページの段階的スクレイピング
以下は、ProxyHatのレスデンシャルプロキシを使ってEtsyの検索結果→リスティングカード→詳細ページの3段階でデータを取得するPythonスクリプトです。
ステップ1:検索結果の取得とリスティングカードのパース
import requests
from bs4 import BeautifulSoup
import re
import time
import random
PROXY = "http://user-country-US:password@gate.proxyhat.com:8080"
proxies = {"http": PROXY, "https": PROXY}
HEADERS = {
"User-Agent": (
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
"AppleWebKit/537.36 (KHTML, like Gecko) "
"Chrome/125.0.0.0 Safari/537.36"
),
"Accept-Language": "en-US,en;q=0.9",
"Accept": "text/html,application/xhtml+xml",
}
def fetch_search_page(keyword: str, page: int = 1) -> BeautifulSoup:
"""Etsy検索結果ページを取得してBeautifulSoupでパース"""
url = f"https://www.etsy.com/search?q={keyword}&page={page}"
resp = requests.get(url, headers=HEADERS, proxies=proxies, timeout=30)
resp.raise_for_status()
return BeautifulSoup(resp.text, "html.parser")
def parse_listing_cards(soup: BeautifulSoup) -> list[dict]:
"""検索結果ページからリスティングカード情報を抽出"""
cards = []
for link in soup.select("a.listing-link[href*='/listing/']"):
title_el = link.select_one("h3")
price_el = link.select_one("span.currency-value")
shop_el = link.select_one("span.text-body-sm")
listing_id = re.search(r"/listing/(\d+)", link.get("href", ""))
cards.append({
"listing_id": listing_id.group(1) if listing_id else None,
"title": title_el.get_text(strip=True) if title_el else None,
"price": price_el.get_text(strip=True) if price_el else None,
"shop_name": shop_el.get_text(strip=True) if shop_el else None,
"url": f"https://www.etsy.com{link.get('href', '').split('?')[0]}",
})
return cards
# 実行例
soup = fetch_search_page("coffee mug", page=1)
listings = parse_listing_cards(soup)
print(f"取得件数: {len(listings)}")
for l in listings[:3]:
print(f" {l['listing_id']}: {l['title']} - ${l['price']}")
ステップ2:リスティング詳細ページの取得(IPローテーション付き)
検索結果からリスティングIDを収集したら、各詳細ページにアクセスします。ここで重要なのがIPローテーションです。同一IPで連続アクセスするとCloudflareに検知されるリスクが高まります。
def fetch_listing_detail(listing_id: str, session_id: str = None) -> dict:
"""リスティング詳細ページを取得してパース"""
# セッションIDを変えることでIPをローテーション
user_part = f"user-country-US"
if session_id:
user_part = f"user-country-US-session-{session_id}"
proxy = f"http://{user_part}:password@gate.proxyhat.com:8080"
prx = {"http": proxy, "https": proxy}
url = f"https://www.etsy.com/listing/{listing_id}"
resp = requests.get(url, headers=HEADERS, proxies=prx, timeout=30)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
# 詳細データの抽出
title = soup.select_one("h1")
price = soup.select_one(".currency-value")
desc = soup.select_one("[data-appears-component-id='description']")
sales_text = None
for span in soup.select("span.text-gray-lighter"):
match = re.search(r"(\d[\d,]*)\s+sales", span.get_text())
if match:
sales_text = match.group(1).replace(",", "")
break
return {
"listing_id": listing_id,
"title": title.get_text(strip=True) if title else None,
"price": price.get_text(strip=True) if price else None,
"sales": int(sales_text) if sales_text else None,
"description": desc.get_text(strip=True)[:500] if desc else None,
}
# ローテーション実行例
for i, listing in enumerate(listings[:10]):
if not listing["listing_id"]:
continue
detail = fetch_listing_detail(
listing["listing_id"],
session_id=f"niche{i:04d}" # 各リクエストで異なるセッションID
)
print(f" {detail['listing_id']}: sales={detail['sales']}")
time.sleep(random.uniform(2, 5)) # 人間らしい間隔
ステップ3:ニッチ分析の集計
import statistics
def analyze_niche(listings: list[dict], details: list[dict]) -> dict:
"""ニッチの統計サマリーを算出"""
prices = []
for d in details:
if d["price"]:
try:
prices.append(float(d["price"].replace(",", "").replace("$", "")))
except ValueError:
pass
sales = [d["sales"] for d in details if d["sales"] is not None]
shops = set(l["shop_name"] for l in listings if l["shop_name"])
return {
"total_listings": len(listings),
"unique_shops": len(shops),
"price_median": statistics.median(prices) if prices else None,
"price_p25": statistics.quantiles(prices, n=4)[0] if len(prices) >= 4 else None,
"price_p75": statistics.quantiles(prices, n=4)[2] if len(prices) >= 4 else None,
"avg_sales": statistics.mean(sales) if sales else None,
"top_sellers": sorted(details, key=lambda x: x["sales"] or 0, reverse=True)[:5],
}
# 実行
summary = analyze_niche(listings, [fetch_listing_detail(
l["listing_id"], session_id=f"a{i:04d}"
) for i, l in enumerate(listings[:20]) if l["listing_id"]])
print(f"ユニークショップ数: {summary['unique_shops']}")
print(f"価格中央値: ${summary['price_median']:.2f}")
print(f"平均販売数: {summary['avg_sales']:.0f}")
ショップ分析:販売数とレビューの抽出
Etsyはショップの累積販売数を「X sales」バッジとして公開しています。このデータはショップの規模と信頼性を測る貴重な指標です。
ショップページからのデータ抽出
def fetch_shop_analytics(shop_name: str) -> dict:
"""ショップページから分析データを抽出"""
proxy = f"http://user-country-US-session-shop-{shop_name}:password@gate.proxyhat.com:8080"
prx = {"http": proxy, "https": proxy}
url = f"https://www.etsy.com/shop/{shop_name}"
resp = requests.get(url, headers=HEADERS, proxies=prx, timeout=30)
resp.raise_for_status()
soup = BeautifulSoup(resp.text, "html.parser")
# 販売数
sales_count = None
for el in soup.select("span"):
m = re.search(r"([\d,]+)\s+sales?", el.get_text())
if m:
sales_count = int(m.group(1).replace(",", ""))
break
# リスティング数
listing_count = None
count_el = soup.select_one("span.count")
if count_el:
m = re.search(r"([\d,]+)", count_el.get_text())
if m:
listing_count = int(m.group(1).replace(",", ""))
# レビュー数と平均評価
review_count = None
avg_rating = None
review_el = soup.select_one("[data-selector='review-count']")
if review_el:
m = re.search(r"([\d,]+)", review_el.get_text())
if m:
review_count = int(m.group(1).replace(",", ""))
rating_el = soup.select_one("input[name='rating']")
if rating_el:
avg_rating = float(rating_el.get("value", 0))
# 開設年
since_year = None
for el in soup.select("span"):
m = re.search(r"since (\d{4})", el.get_text())
if m:
since_year = int(m.group(1))
break
return {
"shop_name": shop_name,
"sales_count": sales_count,
"listing_count": listing_count,
"review_count": review_count,
"avg_rating": avg_rating,
"since_year": since_year,
"sales_per_listing": round(sales_count / listing_count, 1) if sales_count and listing_count else None,
}
# 実行例
analytics = fetch_shop_analytics("ExampleShop")
print(f"販売数: {analytics['sales_count']}")
print(f"リスティングあたりの販売数: {analytics['sales_per_listing']}")
販売数バッジの読み方
Etsyの「X sales」バッジは累積販売数を示しますが、正確な数字ではありません。Etsyは以下のバッジ段階を使います:
- 1 Sale — 1〜4件
- 5 Sales — 5〜24件
- 25 Sales — 25〜49件
- 50 Sales — 50〜99件
- 100 Sales — 100〜499件
- 500 Sales — 500〜999件
- 1,000 Sales — 1,000〜4,999件
- 5,000 Sales — 5,000件以上
ショップページではより正確な数字が表示されることがありますが、検索結果ページでは段階的なバッジのみです。分析ではこの誤差を考慮して、最小値として扱うのが安全です。
レビュー分析の応用
レビューデータはニッチ調査において強力なシグナルです:
- レビュー数/販売数比: 比率が高い=顧客満足度が高いニッチ
- レビュー内容の感情分析: 「loved」「perfect」などのポジティブ語と「disappointed」「cheap」などのネガティブ語の比率で、製品改良の方向性を特定
- レビューの期間分布: 直近のレビューが集中していれば成長中のニッチ
Etsyスクレイピングのベストプラクティス
レート制限への対処
- リクエスト間隔: 2〜5秒のランダムな間隔を空ける。一定間隔(例: 常に3秒)はボット検知のシグナルになる。
- 1時間あたりの上限: 同一IPで100リクエスト/時以内に抑える。ProxyHatのIPローテーションを使えば複数IPで分散可能。
- ピーク時間を避ける: 米国時間の深夜〜早朝(日本時間の夕方〜深夜)はトラフィックが少なく、レート制限が緩い傾向がある。
Cloudflare対策
- レスデンシャルプロキシの使用: 前述の通り、データセンタIPはJSチャレンジで弾かれる。
- ブラウザフィンガープリントの偽装:
User-Agent、Accept-Language、Acceptヘッダを実際のブラウザと一致させる。 - セッションの一貫性: 同一セッション内ではCookieを維持する。
requests.Session()を使うとCookieが自動管理される。
データ品質の確保
- HTML構造の変更監視: Etsyは定期的にCSSクラス名を変更する。セレクタの有効性を定期的にテストする仕組みを構築する。
- データ検証: 価格が0や極端に高い場合はパースエラーの可能性がある。異常値の自動検出を組み込む。
- キャッシュの活用: 同一キーワードの検索結果は24時間キャッシュし、無駄なリクエストを減らす。
倫理的スクレイピング:小規模ビジネスを尊重する
Etsyのセラーはその多くが個人や小規模ビジネスです。スクレイピングには明確な倫理的ガイドラインが必要です。
原則:調査のためにスクレイプし、盗作のためにスクレイプしない。
守るべきこと
- 市場調査に使う: 価格帯、需要、競合密度の分析は正当な用途。
- デザインの盗用を避ける: 画像のダウンロードやデザインのコピーは著作権侵害。
- サーバーに配慮する: レート制限を守り、Etsyのインフラに負荷をかけない。
- robots.txtを尊重する:
https://www.etsy.com/robots.txtで禁止されているパスにはアクセスしない。 - GDPR/CCPAを遵守する: 個人を特定できる情報(セラーの実名、住所など)の収集と保管には法的リスクがある。
避けるべきこと
- デザインの自動コピー: 人気リスティングのデザインを自動でPODプラットフォームに転載する行為。
- 価格の過度な下支え: 競合の価格データだけを使って常に最低価格を設定する行為は、市場を疲弊させる。
- セラーの個人情報収集: レビュアーの名前や住所を収集・保管する行為。
PODビジネスにおいて、Etsyスクレイピングはインスピレーションの源として使うべきであり、コピーの道具としてではありません。ニッチの需要を確認し、独自の付加価値で差別化する——それが健全なスクレイピングのあり方です。
Key Takeaways
- Etsyに公開APIはない:HTMLスクレイピングが唯一の現実的選択肢。Cloudflareとレート制限の二重の壁がある。
- レスデンシャルプロキシが必須:データセンタIPはCloudflareのJSチャレンジで弾かれる。ProxyHatのレスデンシャルプロキシで国指定IPを利用する。
- 3段階のスクレイピング:検索ページ→リスティングカード→詳細ページの順で深掘りする。各段階でIPローテーションを行う。
- ニッチの3指標:トレンドキーワード、セラー数、価格中央値で需要と競合のバランスを評価する。
- 販売数バッジは段階的:Etsyの「X sales」は正確な数字ではなく段階表示。分析では最小値として扱う。
- 倫理を守る:調査のためにスクレイプし、デザインの盗用はしない。小規模ビジネスを尊重する。
Etsyスクレイピングを本格的に始めるなら、ProxyHatの料金プランでレスデンシャルプロキシの利用枠を確認しましょう。また、ウェブスクレイピングのユースケースページでも他プラットフォームのスクレイピング手法を紹介しています。






