Etsy 스크레이핑: API 엔드포인트 vs HTML의 트레이드오프
Etsy는 공개 Open API를 제공하지만, v3 기준으로 리스팅 검색·카테고리 탐색·샵 통계 등 니치 리서치에 필요한 엔드포인트가 제한적이다. API는 응답 구조가 안정적이지만, 트렌딩 키워드나 판매량 뱃지 같은 데이터는 HTML에만 노출된다. 반면 HTML 스크레이핑은 데이터가 풍부하지만 Cloudflare 보호와 빈번한 마크업 변경이라는 대가가 따른다.
실무에서는 API로 기본 뼈대를 잡고, HTML로 디테일을 보완하는 하이브리드 방식이 가장 효율적이다. 이 글에서는 두 접근의 장단점을 짚고, Etsy proxy 전략과 함께 Python 실전 코드를 끝까지 보여준다.
Etsy 사이트 구조 이해하기
검색 결과 페이지
검색 URL 패턴은 단순하다:
https://www.etsy.com/search?q=custom+dog+bandana&ref=search_bar
https://www.etsy.com/search?q=custom+dog+bandana&page=2검색 결과는 각 리스팅 카드로 구성된다. 주요 CSS 셀렉터:
- 리스팅 카드 컨테이너:
div[data-results-container] > ol > li - 리스팅 링크:
a.listing-link—href에/listing/{id}/포함 - 제목:
h3내 텍스트 - 가격:
span.currency-value - 셀러명:
span.shop-name또는p.text-body-sm
페이지당 최대 48개 리스팅이 로드되며, 무한 스크롤이 아닌 페이지네이션을 사용한다.
리스팅 상세 페이지
URL 패턴: https://www.etsy.com/listing/{id}/{slug}
핵심 데이터 포인트:
- 제목:
h1[data-buy-box-listing-title] - 가격:
div[data-buy-box-price] > .currency-value - 설명:
div[id="description-text"] - 이미지:
img[data-carousel-first-image]및li.carousel-image - 리뷰 수:
span[data-testid="reviews-count"] - 즐겨찾기 수:
a[href*="/favoriters"]내 텍스트
샵 페이지
URL 패턴: https://www.etsy.com/shop/{shopName}
- 샵명:
h1 - 판매량 뱃지:
span.text-midnight-mint— "5,280 Sales" 같은 텍스트 - 리스팅 수: 샵 페이지 하단 리스팅 그리드
div[data-results-container]내li개수 - 리뷰:
div[id="review-list"]내 개별div.review-item
카테고리 트리
Etsy의 카테고리는 https://www.etsy.com/c/{category}/{subcategory} 구조를 갖는다. 최상위 카테고리는 사이트 푸터나 네비게이션 JSON에서 추출할 수 있다:
https://www.etsy.com/api/v3/ajax/member/category-tree이 엔드포인트는 전체 카테고리 계층을 JSON으로 반환하지만, 인증 쿠키가 필요할 수 있다. 대안으로 검색 페이지의 사이드바 필터에서 카테고리 목록을 파싱할 수 있다.
Etsy의 안티봇 시스템과 프록시 전략
Cloudflare + 내부 Rate Limit
Etsy는 Cloudflare를 프론트에 두고 있어, 의심스러운 트래픽에 JS 챌린지(403)를 반환한다. 실측 기준 대략적인 임계값:
- 동일 IP, 세션 없이: ~30–50 req/min 이후 Cloudflare 챌린지 트리거
- 브라우저 세션 유지 시: ~80–120 req/min까지 가능하지만, 패턴 감지 시 차단
- 짧은 시간 다량 403: IP가 임시 블랙리스트에 등록 (보통 10–30분 해제)
데이터센터 IP는 Cloudflare의 신뢰도 점수가 낮아 첫 요청부터 차단되는 경우가 많다. 반면 레지덴셜 IP는 통과율이 90% 이상이다.
프록시 유형 비교
| 프록시 유형 | Cloudflare 통과율 | 속도 | Etsy 적합도 | 비용 |
|---|---|---|---|---|
| 데이터센터 | 10–30% | 빠름 (<100ms) | ❌ 비추천 | 낮음 |
| 레지덴셜 | 90–97% | 중간 (200–500ms) | ✅ 추천 | 중간 |
| 모바일 | 95–99% | 느림 (300–800ms) | ✅ 고차단 시 | 높음 |
결론: Etsy 스크레이핑에는 레지덴셜 프록시가 최적이다. 속도와 통과율의 밸런스가 좋고, 대규모 크롤링이 아니라면 모바일 프록시까지 갈 필요가 없다.
니치 발견을 위한 스크레이핑 패턴
트렌딩 검색어 수집
Etsy 검색창의 자동완성 API를 활용하면 인기 검색어를 추출할 수 있다:
https://www.etsy.com/api/v3/ajax/member/search-suggestions?query=custom+dog샘플 응답 (트렁케이티드):
{
"results": [
{"query": "custom dog bandana", "listing_id": 1234567890},
{"query": "custom dog tag", "listing_id": 9876543210},
{"query": "custom dog portrait", "listing_id": 1122334455}
]
}시드 키워드 목록을 순회하며 자동완성 결과를 수집하면, 수요가 확인된 롱테일 키워드를 빠르게 발견할 수 있다.
셀러 수 per 니치
검색 결과 페이지의 상단 필터 영역에 "X results" 텍스트가 노출된다. 이 숫자를 키워드별로 수집하면:
- results 수가 많고 셀러가 적으면 → 수요 과잉, 공급 부족 = 기회
- results 수도 많고 셀러도 많으면 → 경쟁이 치열한 성숙 시장
셀러 수는 검색 결과의 span.shop-name을 중복 제거하여 카운트한다.
평균 가격대 파악
리스팅 카드의 span.currency-value를 수집해 히스토그램을 그리면, 특정 니치의 가격 분포를 파악할 수 있다. POD 업체라면 $15–$35 구간이 밀집한 니치는 진입 장벽이 낮고, $50+ 구간에 수요가 있으면 프리미엄 포지셔닝 기회다.
Python 실전 예제: 검색 → 리스팅 카드 → 상세 페이지
1단계: 검색 결과 가져오기
import requests
from urllib.parse import quote
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",
}
def fetch_search_results(keyword: str, page: int = 1) -> str:
"""Etsy 검색 결과 HTML 반환"""
url = f"https://www.etsy.com/search?q={quote(keyword)}&page={page}"
resp = requests.get(url, headers=HEADERS, proxies=PROXIES, timeout=15)
resp.raise_for_status()
return resp.text
html = fetch_search_results("custom dog bandana")
print(f"HTML 길이: {len(html):,} chars")2단계: 리스팅 카드 파싱
from bs4 import BeautifulSoup
import re
def parse_listing_cards(html: str) -> list[dict]:
"""검색 결과에서 리스팅 카드 정보 추출"""
soup = BeautifulSoup(html, "html.parser")
listings = []
for li in soup.select("ol[data-results-container] li"):
link_tag = li.select_one("a.listing-link")
if not link_tag:
continue
href = link_tag.get("href", "")
match = re.search(r"/listing/(\d+)", href)
listing_id = match.group(1) if match else None
title = li.select_one("h3")
price_tag = li.select_one("span.currency-value")
shop_tag = li.select_one("span.shop-name") or li.select_one("p.text-body-sm")
listings.append({
"listing_id": listing_id,
"title": title.get_text(strip=True) if title else None,
"price": price_tag.get_text(strip=True) if price_tag else None,
"shop_name": shop_tag.get_text(strip=True) if shop_tag else None,
"url": f"https://www.etsy.com/listing/{listing_id}" if listing_id else href,
})
return listings
cards = parse_listing_cards(html)
print(f"파싱된 리스팅: {len(cards)}개")
for c in cards[:3]:
print(f" {c['listing_id']} | {c['title'][:50]} | ${c['price']}")3단계: 상세 페이지 크롤링 (레지덴셜 프록시 로테이션)
상세 페이지는 Cloudflare 검사가 더 엄격하므로, 요청마다 다른 레지덴셜 IP를 사용해야 한다. ProxyHat의 세션 플래그를 활용하면 각 요청에 새 IP를 할당받을 수 있다:
import time
import random
def get_rotating_proxy(session_id: str = None) -> dict:
"""요청마다 다른 레지덴셜 IP 사용 (세션 ID 미지정 = 새 IP)"""
if session_id:
user = f"user-country-US-session-{session_id}"
else:
user = f"user-country-US-session-{random.randint(1,999999)}"
proxy_url = f"http://{user}:PASSWORD@gate.proxyhat.com:8080"
return {"http": proxy_url, "https": proxy_url}
def fetch_listing_detail(listing_id: str) -> dict:
"""리스팅 상세 페이지 스크레이핑"""
url = f"https://www.etsy.com/listing/{listing_id}"
proxies = get_rotating_proxy() # 매 요청마다 새 IP
resp = requests.get(url, headers=HEADERS, proxies=proxies, timeout=15)
if resp.status_code == 403:
print(f" [403] Cloudflare 챌린지 — {listing_id}")
return {"listing_id": listing_id, "error": "cloudflare_403"}
soup = BeautifulSoup(resp.text, "html.parser")
title = soup.select_one("h1[data-buy-box-listing-title]")
price = soup.select_one("div[data-buy-box-price] .currency-value")
desc = soup.select_one("div[id='description-text']")
review_count = soup.select_one("span[data-testid='reviews-count']")
favoriters = soup.select_one("a[href*='/favoriters']")
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,
"description": desc.get_text(strip=True)[:500] if desc else None,
"review_count": review_count.get_text(strip=True) if review_count else "0",
"favoriters": favoriters.get_text(strip=True) if favoriters else "0",
}
# 배치 크롤링 (1초 간격)
results = []
for card in cards[:20]:
if card["listing_id"]:
detail = fetch_listing_detail(card["listing_id"])
results.append(detail)
time.sleep(random.uniform(1.0, 2.5))
print(f"\n수집된 상세 리스팅: {len(results)}개")4단계: 전체 파이프라인 조합
def etsy_niche_pipeline(keyword: str, max_pages: int = 3) -> list[dict]:
"""키워드 → 검색 → 카드 파싱 → 상세 페이지 전체 파이프라인"""
all_cards = []
# 1) 검색 결과 수집 (여러 페이지)
for page in range(1, max_pages + 1):
html = fetch_search_results(keyword, page)
cards = parse_listing_cards(html)
if not cards:
break
all_cards.extend(cards)
time.sleep(random.uniform(2.0, 4.0))
print(f"총 리스팅 카드: {len(all_cards)}개")
# 2) 상세 페이지 크롤링
details = []
seen = set()
for card in all_cards:
lid = card["listing_id"]
if lid and lid not in seen:
seen.add(lid)
detail = fetch_listing_detail(lid)
detail["search_keyword"] = keyword
detail["shop_name"] = card.get("shop_name")
details.append(detail)
time.sleep(random.uniform(1.0, 2.5))
return details
# 실행
results = etsy_niche_pipeline("custom pet portrait", max_pages=2)
print(f"\n최종 수집: {len(results)}개 리스팅")샵 분석: 리스팅 수, 판매량, 리뷰
Etsy는 샵의 대략적 판매량을 "5,280 Sales" 뱃지로 노출한다. 이 숫자는 정확하지 않지만, 니치 리서치에서는 충분히 유의미하다. 샵 페이지를 스크레이핑하는 함수를 추가하자:
def fetch_shop_analytics(shop_name: str) -> dict:
"""Etsy 샵 페이지에서 판매량·리스팅 수·리뷰 정보 추출"""
url = f"https://www.etsy.com/shop/{shop_name}"
proxies = get_rotating_proxy()
resp = requests.get(url, headers=HEADERS, proxies=proxies, timeout=15)
if resp.status_code == 403:
return {"shop_name": shop_name, "error": "cloudflare_403"}
soup = BeautifulSoup(resp.text, "html.parser")
# 판매량 뱃지 — "5,280 Sales" 형식
sales_tag = soup.select_one("span.text-midnight-mint")
sales_text = sales_tag.get_text(strip=True) if sales_tag else "0"
sales_num = int(re.sub(r"[^0-9]", "", sales_text) or "0")
# 리스팅 수 (샵 페이지 첫 화면에 보이는 리스팅 카드)
listing_items = soup.select("div[data-results-container] li")
listing_count = len(listing_items)
# 리뷰 수
review_count_tag = soup.select_one("button[data-testid=\"reviews-select\"]")
review_text = review_count_tag.get_text(strip=True) if review_count_tag else "0"
review_num = int(re.sub(r"[^0-9]", "", review_text) or "0")
return {
"shop_name": shop_name,
"sales": sales_num,
"visible_listings": listing_count,
"review_count": review_num,
}
# 경쟁 샵 분석 예시
shops = ["ShopA", "ShopB", "ShopC"]
for shop in shops:
data = fetch_shop_analytics(shop)
print(f"{data['shop_name']}: {data['sales']:,} sales, "
f"{data['visible_listings']} listings, {data['review_count']} reviews")
time.sleep(random.uniform(1.5, 3.0))샵 분석 데이터를 니치 리서치에 활용하는 방법:
- 판매량/리스팅 수 비율이 높은 샵은 효율적인 제품 믹스를 가짐 → 어떤 리스팅이 잘 팔리는지 분석
- 리뷰 수가 판매량 대비 높으면 → 고갱 만족도가 높은 니치
- 리스팅 수는 많은데 판매량이 낮으면 → 과포화 니치, 진입 주의
윤리적 고려사항
Etsy 셀러의 상당수는 소규모 독립 크리에이터다. 스크레이핑 데이터는 리서치와 인사이트에만 사용해야 한다:
- ❌ 하지 말 것: 디자인·이미지·소스를 그대로 복사하여 POD에 재생산
- ❌ 하지 말 것: 가격을 낮춰 경쟁 샵을 언더컷하는 자동 가격 조정
- ✅ 해야 할 것: 수요 트렌드 파악, 빈틈 니치 발견, 가격대 인사이트 확보
- ✅ 해야 할 것:
robots.txt준수, 요청 속도 제한 (1–2 req/sec)
Etsy의 robots.txt는 주요 경로에 대해 크롤링을 제한하고 있다. 리서치 목적의 소규모 수집은 실무적으로 허용되는 편이지만, 대규모 자동화 크롤러를 운영할 때는 법적 리스크를 반드시 검토해야 한다. GDPR·CCPA 대상 데이터(개인 식별 정보)는 수집하지 말 것.
원칙: 크리에이터의 창작물을 존중하라. 데이터는 인사이트를 얻기 위한 도구일 뿐, 복제의 재료가 아니다.
핵심 요약
- Etsy는 API보다 HTML에 더 풍부한 니치 리서치 데이터가 있다 — 하지만 Cloudflare 보호가 강하다.
- 데이터센터 프록시는 Etsy에서 거의 통과하지 못한다. 레지덴셜 프록시가 필수.
- 자동완성 API로 롱테일 키워드를 발굴하고, 검색 결과로 경쟁 강도를 파악하라.
- 요청마다 새 레지덴셜 IP를 로테이션하고, 1–2 req/sec 속도를 유지하라.
- 샵의 "X Sales" 뱃지로 경쟁 샵의 규모를 추정할 수 있다.
- 스크레이핑은 리서치 목적으로만. 크리에이터의 디자인을 복제하지 마라.
대규모 Etsy 리서치를 기획 중이라면, ProxyHat 요금제에서 레지덴셜 프록시 트래픽을 확인하고, 지원 국가 목록에서 타겟 국가의 IP 가용성을 체크하라. 미국 시장이 주 타겟이라면 country-US 플래그로 미국 레지덴셜 IP를 확보할 수 있다.
Etsy 외에도 아마존·이베이 등 마켓플레이스 스크레이핑이 필요하다면 마켓플레이스 스크레이핑 프록시 가이드를 참고하라.






