Como Fazer Scraping do Etsy: Guia 2026

O Etsy usa DataDome e recusou todos os 9 transportes que testamos. Veja o que o robots.txt permite, o que corrompe um dataset e quando a Bright Data é a melhor opção.
21 min de leitura
How to Scrape Etsy blog image

Fazer scraping do Etsy significa coletar dados de anúncios, lojas e avaliações das páginas públicas do Etsy. O Etsy reportou mais de 100M de itens e 5,6M de vendedores ativos no relatório anual de 2025. O Etsy usa DataDome. Uma requisição direta retorna um 403 e uma página de bloqueio. As 2 correções que geralmente surgem primeiro são um User-Agent do Chrome e impersonação de TLS. Ambas ainda recebem um 403. Este guia mostra o que muda o 403, o que o robots.txt bloqueia e quais problemas de extração corrompem um dataset do Etsy sem gerar um erro.

TL;DR

  • O Etsy usa DataDome atrás do Fastly. O DataDome coloca uma pontuação de risco no seu próprio cabeçalho de resposta. Você pode avaliar uma requisição antes de escrever um crawler.
  • O Etsy recusou todos os 9 transportes que testamos. Um navegador com interface gráfica carregou a primeira página em 2,2 segundos. O Etsy recusou a segunda requisição.
  • O robots.txt bloqueia busca por palavra-chave e histórico de vendas e não declara sitemap. Páginas de anúncios e lojas permaneceram abertas. Mas os termos do Etsy são mais rígidos.
  • Moeda e preço seguem o IP de saída. Nossa amostra de dataset tinha 19 moedas. Uma média sem filtro dessa coluna estava 227x mais alta.

O que o Etsy retorna a uma requisição automatizada

Leia a recusa primeiro. Não repita a requisição. Uma requisição simples informa qual fornecedor está na borda e como ele avaliou você. Qualquer URL de anúncio funciona. Este pertence a um vendedor, então pode já ter sumido:

curl -sD - -o /dev/null https://www.etsy.com/listing/753913297/smoky-quartz-ring-rose-gold-ring-women

O servidor retorna um 403 com estes cabeçalhos:

HTTP/2 403
server: DataDome
x-datadome: protected
x-datadome-riskscore: 0.9230727100377928
accept-ch: Sec-CH-UA,Sec-CH-UA-Mobile,Sec-CH-UA-Platform,Sec-CH-UA-Arch,Sec-CH-UA-Full-Version-List,Sec-CH-UA-Model,Sec-CH-Device-Memory
set-cookie: datadome=ovuDEZ4S0taK1jDAOvZ9W3Uq2qUujN_iPPE3uXp7~r3msKXSvMFPp4j30em5IvR0...
via: 1.1 varnish
x-served-by: cache-del-vibw2260027-DEL

Esse bloqueio contém 3 fatos. O Etsy usa DataDome, não o Akamai Bot Manager que guias mais antigos ainda reportam, então planeje contra as camadas de detecção do DataDome. O Fastly fica na frente do Etsy, e os cabeçalhos Via e X-Served-By mostram esse salto. X-DataDome-riskscore é a pontuação do DataDome sobre o quão semelhante a um bot a requisição pareceu, em uma escala onde 1,0 é o pior. O Etsy pode trocar de fornecedor, então reexecute o comando antes de planejar contra esses 3 fatos.

Esse cabeçalho fornece um número para medir. Em nossos testes, a pontuação foi determinística. Todas as 6 amostras intercaladas da mesma requisição retornaram 0.9230727100377928. A pontuação rastreia a assinatura da requisição, o IP de origem e o cookie datadome assim que você começa a retorná-lo, não uma contagem acumulada de requisições. Portanto, seus próprios números podem diferir, e em um endereço diferente a classificação entre configurações de cliente também pode diferir.

O corpo, 776 bytes em nossas execuções, é uma página de bloqueio do DataDome que carrega ct.captcha-delivery.com/c.js. Essa página de bloqueio contém um script de desafio, não dados de anúncios, então um parser que a lê retorna campos vazios em vez de um erro. A configuração incorporada continha 't':'fe', a verificação de dispositivo do DataDome, então essa requisição recebeu um desafio que um navegador real pode responder em vez de um banimento permanente. O campo 't' mostra a avaliação atual e assume outros valores conforme essa avaliação muda, então leia seu próprio valor em vez de assumir 'fe'.

Enviamos cada requisição de 1 cliente mínimo de 3 cabeçalhos e variamos apenas o caminho. Cada caminho de conteúdo que tentamos retornou o mesmo 403 e a mesma pontuação 0,482, enquanto /robots.txt e uma URL que não resolve nada foram ao Apache sem proteção:

path                                       HTTP server     riskscore
/                                          403  DataDome   0.482
/listing/753913297/smoky-quartz-ring...    403  DataDome   0.482
/shop/AnemoneJewelry                       403  DataDome   0.482
/legal/terms/                              403  DataDome   0.482
/robots.txt                                200  Apache     -
/nonexistent-path-xyz                      404  Apache     -

Portanto, nos caminhos que o DataDome protege, ele avalia o chamador e não a URL.

Uma requisição com User-Agent do Googlebot ignorou o DataDome e chegou a um limitador de taxa. O Apache respondeu 429 Too Many Requests com uma string de referência nicki_. Pelo menos 1 agente de mecanismo de busca declarado chega a um limitador de taxa próprio, separado do DataDome.

Por que cabeçalhos e fingerprints de TLS não são suficientes

A pontuação de risco permite testar os conselhos habituais sobre cabeçalhos, onde um número menor significa menos semelhante a um bot. Mantivemos o IP e a URL constantes, variamos apenas os cabeçalhos da requisição em um cliente requests e registramos a pontuação que o DataDome retornou:

headers sent                                         score    HTTP
requests, library default headers                    0.977    403
+ Chrome User-Agent only                             0.923    403
full 12-header Chrome set                            0.503    403
User-Agent + accept + sec-fetch-site (3 headers)     0.482    403

Essa tabela contém 2 resultados. Adicionar um User-Agent do Chrome mal moveu a pontuação, porque todo o resto da requisição ainda vinha do requests. E o conjunto de 3 cabeçalhos pontuou melhor do que o conjunto completo de 12 cabeçalhos, então nesse alvo a consistência importou mais do que a quantidade de cabeçalhos.

Estes 3 cabeçalhos pontuaram 0,482, então copie o valor de accept exatamente:

User-Agent:      Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36
                 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
accept:          text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,
                 image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.7
sec-fetch-site:  none

Removemos 1 cabeçalho por vez do conjunto completo e registramos cada mudança, onde um sinal de mais significa que a pontuação piorou:

run                              score     change
full set (baseline)              0.503
minus accept                     0.845     +0.342
minus user-agent                 0.784     +0.281
minus sec-fetch-site             0.669     +0.166
minus every sec-ch-ua* header    0.503      0.000

O DataDome anuncia 7 client hints em seu próprio cabeçalho de resposta Accept-CH. Nosso conjunto completo tinha 3 deles, mas remover cada cabeçalho sec-ch-ua não mudou nada. O cabeçalho accept moveu a pontuação mais do que o User-Agent.

A impersonação de TLS é o próximo passo habitual, então a testamos isoladamente. Cada execução enviou os mesmos 3 cabeçalhos, e apenas o fingerprint de TLS e HTTP/2 mudou. Os valores JA4 abaixo vêm do curl_cffi e não do Etsy, então mudam sempre que essa biblioteca atualiza um perfil:

transport                             JA4                      riskscore  HTTP
python requests (OpenSSL, HTTP/1.1)   t13d1712h1_ab0a1bf427ad  0.482      403
curl_cffi impersonate=chrome110       t13d1516h2_8daaf6152771  0.663      403
curl_cffi impersonate=chrome116       t13d1516h2_8daaf6152771  0.663      403
curl_cffi impersonate=chrome124       t13d1516h2_8daaf6152771  0.503      403
curl_cffi impersonate=chrome131       t13d1516h2_8daaf6152771  0.503      403
curl_cffi impersonate=chrome133a      t13d1516h2_8daaf6152771  0.503      403
curl_cffi impersonate=firefox133      t13d1716h2_5b57614c22b0  0.503      403
curl_cffi impersonate=safari17_0      t13d2014h2_a09f3c656075  0.663      403
curl_cffi impersonate=safari17_2_ios  t13d2014h2_a09f3c656075  0.663      403

Essa tabela contém 3 fingerprints diferentes, um para Chrome, Firefox e Safari. O requests simples em HTTP/1.1 ainda pontuou melhor do que todos os 3. Essa coluna imprime apenas as primeiras 2 partes de um JA4. A terceira parte codifica a lista de extensões e difere entre os perfis Chrome que compartilham um prefixo aqui.

O transporte moveu a pontuação em 0,181 entre a melhor e a pior execução, mas nenhuma execução retornou uma página. Nenhum fingerprint que testamos foi suficiente por si só.

Portanto, fingerprints de TLS não valem o esforço nesse alvo. Lemos os frames HTTP/2 de volta de cada perfil, incluindo a ordem de pseudo-cabeçalhos que difere por engine:

profile      SETTINGS                       | window   | pri | pseudo-header order
chrome131    1:65536;2:0;4:6291456;6:262144 | 15663105 | 0   | m,a,s,p
firefox133   1:65536;2:0;4:131072;5:16384   | 12517377 | 0   | m,p,a,s
safari17_0   2:0;4:4194304;3:100            | 10485760 | 0   | m,s,p,a

Esses 3 handshakes são cópias corretas, e o Etsy recusou todos os 3. Portanto, o DataDome decide com base em algo diferente do handshake.

O que realmente executa a verificação

Em um navegador com interface gráfica, o desafio do DataDome aparece como um controle deslizante, e 2 de seus 4 motivos são o IP de origem e o uso de ferramentas de desenvolvedor:

Etsy's DataDome challenge page. A heading reads "Verification Required" above a widget saying "Slide right to secure your access" with a drag handle, an image-or-audio toggle and a refresh control. Below it, a reasons list: rapid taps or clicks, JavaScript disabled or not working, automated activity on your network with the IP redacted, and use of developer or inspection tools

Esse carregador c.js tem 14 KB, constrói uma URL de iframe e busca uma segunda página. Essa segunda página continha um script inline de 596 KB no dia em que a obtivemos, e os nomes dos módulos mostram o que você teria que reimplementar:

detection-js/dist/vm-obf.js     the detection engine, VM-obfuscated
detection-js/dist/captcha.js    challenge coordination
./picasso                       canvas-based device-class fingerprinting
./mouseMaths                    pointer-movement analysis
./slidercaptcha  ./hash  ./helpers  ./bean

O módulo de detecção é armazenado como bytecode para um interpretador incluído no mesmo arquivo. É por isso que uma busca de texto no bundle não encontra webdriver, cdc_, headless nem _phantom. Um bundle de bytecode oculta essas strings independentemente de as sondagens serem executadas ou não, então o resultado da busca não informa nada.

O build que obtivemos se identificou como 1.34.0, cronometrou sua própria execução e enviou essa duração como sinal. Esse build também registrou um aviso no console pedindo para fechar o DevTools antes de continuar. Espere uma versão diferente e uma lista de módulos diferente quando você verificar, já que esse engine segue o ciclo de lançamento do DataDome, não do Etsy. A arquitetura por trás desses nomes muda muito mais lentamente.

A requisição de verificação tem 16 campos e codifica o ambiente do navegador em userEnv, ddCaptchaEnv e plv3.

Portanto, há 2 conclusões práticas. Reproduzir esse output a partir do requests significa reimplementar uma VM ofuscada contra uma string de versão que incrementa. E o DataDome coleta output de canvas e movimento do ponteiro, que existem apenas após um engine de navegador real ter renderizado a página. O desbloqueio específico para DataDome executa esse engine de navegador como serviço e é construído para retornar a página renderizada em vez do bloqueio.

Essa segunda conclusão é testável, então executamos um Playwright Chromium simples sem patches de stealth e sem proxy. Cada modo foi executado 3 vezes do mesmo endereço em que todos os transportes acima foram recusados:

headless=True (default)        403  403  403     1,530 B    0.5s
headless=True --headless=new   403  403  403     1,530 B    0.4s
headless=False (headed)        200  200  200   532,049 B    2.2s   Product JSON-LD present

Ambas as janelas foram executadas de 1 máquina sem proxy, então a janela com interface gráfica mostra preços na moeda local:

Two Chromium windows side by side. The headless window shows Etsy's block page reading "Access is temporarily restricted" above a list of reasons including automated activity on the network. The headed window shows the full listing page with the product photo, price, variation dropdowns and an Add to cart button

O Chromium headless inclui HeadlessChrome em seu próprio User-Agent, então essas linhas diferem em mais do que a janela. Para verificar uma página manualmente ou obter algumas páginas, um navegador com interface gráfica é a resposta mais simples. A infraestrutura de desbloqueio é construída para as requisições após a primeira, e para uma única busca uma execução com interface gráfica é aproximadamente 10 vezes mais rápida do que roteá-la por ela.

Por que um navegador não é um crawler

Carregamos 12 anúncios um de cada vez em uma única página de navegador, com 2 segundos de intervalo, e obtivemos 1 sucesso e 11 recusas:

#1   200   444,123 B
#2   403     1,527 B
#3-12 403   ~1,530 B each

Descartar o contexto de navegação entre as navegações restaurou o 200, então as recusas vieram do estado da sessão e não do endereço. Mais tarde no mesmo período de testes, essa correção parou de funcionar. O Etsy então recusou cada requisição desse endereço, independentemente de quão novo era o contexto.

A pontuação de risco não se moveu enquanto a taxa de sucesso foi de cada requisição para nenhuma. A execução com 3 cabeçalhos ainda mediu 0,4822 com 4 casas decimais, horas e várias centenas de requisições após a primeira leitura.

O cabeçalho X-DataDome-riskscore avalia 1 requisição por vez, então é útil para testar 1 mudança, mas não para monitorar um crawl. Um pipeline que o observa reportará sucesso enquanto não coleta nada.

Coletar mais do que algumas páginas precisa de 2 coisas: um novo contexto de navegação e um endereço que o DataDome ainda não recusou. Descartar o contexto entre as navegações é barato, então comece por aí, mas essa correção dura apenas até o DataDome recusar o endereço. Proxies residenciais oferecem um pool de endereços para rotacionar.

O Web Unlocker executa ambos, e o script mais adiante fica dentro do limite gratuito. A Browser API gerencia ambos dentro de um navegador hospedado que seu código Playwright controla.

O que o robots.txt e os termos do Etsy não permitem

Antes de construir qualquer coisa, leia o robots.txt do Etsy você mesmo, pois o arquivo bloqueia a busca por palavra-chave e o Etsy o reescreve sem aviso. Quando o lemos, esse arquivo tinha 1.818 linhas e declarava apenas 3 grupos de user-agent: *, AdsBot-Google-Mobile e Spinn3r:

User-agent: *
Disallow: /search?*q=
Disallow: /search/?*q=
Disallow: */shop/*/sold*
Disallow: */listing/*/favoriters*
Disallow: /api/
Allow:    /search/shops

O grupo curinga bloqueia resultados de busca por palavra-chave em todas as variantes de localidade. Esse grupo cobre todos os crawlers que os outros 2 grupos não nomeiam. Histórico de anúncios vendidos e contagens de favoritos também são bloqueados, e estão entre os sinais de demanda mais claros que o Etsy publica. Páginas de anúncios e lojas não têm regra de Disallow, então ambas permanecem abertas.

O arquivo não declara nenhuma diretiva Sitemap:, e /sitemaps.xml responde com 403 e corpo vazio. Essas 2 ausências importam tanto quanto as regras de Disallow acima. Ambas são verificações de 1 linha que vale a pena repetir, pois o Etsy pode adicionar qualquer uma de volta sem aviso. Enquanto permanecem ausentes, você precisa descobrir anúncios nas páginas que o Etsy deixa abertas.

O Etsy não nomeou nenhum crawler de IA em nenhum lugar do arquivo quando o lemos, e não publicou llms.txt ou ai.txt. Essa ausência é a coisa mais provável de ter mudado nesta seção desde que verificamos.

O DataDome recusa esses crawlers na borda independentemente. GPTBot e ClaudeBot receberam um 403, e ambos pontuaram 0,9814 em nosso teste, mais alto do que o 0,923 que o mesmo endereço pontuou com um User-Agent do Chrome. Strings de User-Agent sem sentido com os mesmos cabeçalhos obtiveram a mesma pontuação, então o DataDome avalia a ausência de um navegador conhecido em vez do nome do crawler.

Os Termos de Uso do Etsy, atualizados pela última vez em 26 de agosto de 2025, declaram que você concorda em “não rastrear, fazer scraping ou usar spider em nenhuma página dos Serviços” sem permissão expressa.

Verifique se sua camada de coleta aplica o robots.txt por você, e em que ponto ela decide. O endpoint residencial de desbloqueio decide por requisição. Em uma conta sem Verificação KYC concluída, uma requisição para o caminho de busca bloqueado retorna a regra e o formulário de Verificação KYC:

Residential Failed (bad_endpoint): Requested site is not available for immediate
residential (no KYC) access mode in accordance with robots.txt. To get full
residential access for targeting this site, fill in the KYC form:
https://brightdata.com/cp/kyc

A mesma conta buscou /shop/AnemoneJewelry sem erro, e a resposta foi de 983.937 bytes. O endpoint lê o mesmo robots.txt que você leu, depois serve os caminhos permitidos e encaminha os caminhos bloqueados para uma revisão de conformidade em vez de para um proxy. Decida se seu caso de uso precisa dos caminhos bloqueados antes de começar a construir.

Se você construir a camada de busca por conta própria, toma essa decisão em código e é responsável por manter o tratamento do robots.txt por alvo.

O que a API oficial do Etsy retorna e o que ela omite

A API é o caminho autorizado, então verifique o que ela faz antes de rejeitá-la. A escolha entre uma API oficial e scraping de dados é geral, e no Etsy depende dos campos que a especificação omite. Obtivemos a especificação OpenAPI diretamente e contamos 76 caminhos, dos quais 31 operações GET precisam apenas de uma chave de aplicativo e sem OAuth do vendedor. Esses totais mudam sempre que o Etsy adiciona ou remove um endpoint, então recontagem a partir desse arquivo em vez deste parágrafo.

Afirmações comuns sobre a API v3 estão erradas em 3 pontos. findAllListingsActive não foi removido, e aceita keywords, min_price, max_price, taxonomy_id, shop_location, currency e buyer_country, com um limit máximo de 100 por chamada. getReviewsByListing e getReviewsByShop retornam texto de avaliação apenas com uma chave de aplicativo. getShop retorna transaction_sold_count, review_count, review_average, num_favorers e listing_active_count para qualquer loja.

A especificação carrega 1 campo de demanda e omite o restante. getListing precisa apenas de uma chave de aplicativo e retorna views, uma contagem de visualizações cumulativa atualizada uma vez por dia, no schema ShopListingWithAssociations. Pesquisar o documento inteiro não retorna nenhum campo para volume de busca, impressões, taxa de conversão ou vendas por anúncio. O schema ShopListing tinha 50 propriedades quando contamos, incluindo num_favorers, quantity e price, e nenhuma delas era uma contagem de vendas. Reconfira a contagem na especificação, mas nenhum campo de vendas apareceu ainda.

As transações por anúncio têm um endpoint, getShopReceiptTransactionsByListing, mas ele precisa do escopo OAuth transactions_r, que apenas um proprietário de loja pode conceder para sua própria loja. Para qualquer loja que você não opera, o Etsy publica vendas vitalícias no nível da loja como um total atual sem histórico, e nada por anúncio.

Essa lacuna explica o mercado de ferramentas de terceiros em torno do Etsy. Alguns produtos vendem estimativas de vendas por anúncio ou volume de busca por palavra-chave para lojas que não operam. Nada na especificação que lemos retorna esses números para uma loja que você não possui, então esses dados devem vir de fora desta API.

O Etsy aplica limites de taxa por chave de aplicativo, por segundo e por dia. Os cabeçalhos de resposta x-limit-per-second e x-limit-per-day reportam ambos, e o Etsy retorna um 429 com um retry-after quando você excede qualquer um deles.

A página de limites de taxa rotula seus valores como “Valor de Exemplo” em vez de padrões. O antigo par “10.000 por dia, 10 por segundo” havia sumido dessa página quando a lemos. Leia seus próprios limites no Portal do Desenvolvedor.

Faça scraping de páginas de anúncios e lojas do Etsy com Python

As páginas de anúncios do Etsy incorporam JSON-LD schema.org, então a etapa de extração não precisa de seletores CSS e não quebra quando o Etsy reestiliza a marcação ao redor. A etapa de busca precisa de infraestrutura, e você tem 2 maneiras de fazê-la. Uma API desbloqueada funciona em qualquer lugar e precisa de uma conta. Um navegador local não precisa de conta e serve para algumas páginas.

O script abaixo precisa de 1 biblioteca e 2 variáveis de ambiente:

python3 -m venv .venv && source .venv/bin/activate
pip install requests
export BRIGHTDATA_API_KEY="your-api-token"
export BRIGHTDATA_ZONE="web_unlocker"

O token vem do painel de controle da Bright Data. Cada requisição com esse token gasta o saldo da sua conta, então mantenha-o em uma variável de ambiente em vez de em um script confirmado.

Você cria o segundo valor em Web Access → Add API → Web Unlocker API. O payload da requisição o chama de zone, mas o painel de controle o rotulou como API quando configuramos isso, então pesquisar no painel por “zone” não encontrou nada. BRIGHTDATA_ZONE deve corresponder ao nome que você digitar lá, já que o script tem como padrão web_unlocker. O formulário avisa que o nome é permanente:

The Bright Data control panel on the Web Access section, breadcrumb Web Access then Add API, at step 2 of four: choose API type, configure API, add payment method, test API. The API type reads Web Unlocker API, priced pay only for successful requests. A required Name field contains web_unlocker_test above a note reading "this name cannot be changed later." A panel on the right shows the current plan as pay as you go, at a rate quoted as a CPM

A zona nessa captura se chama web_unlocker_test, então o padrão do script a perderia. Defina BRIGHTDATA_ZONE com o nome que você digitou, ou use web_unlocker e deixe o padrão. O quickstart do Web Unlocker documenta ambos, e começamos com o limite gratuito sem cartão quando configuramos isso. A página de preços do Web Unlocker lista o limite gratuito atual e a taxa de pagamento por uso além dele, cotada por 1K requisições bem-sucedidas. O painel escreve essa taxa como CPM.

Este script envia a requisição pelo endpoint do Web Unlocker e analisa o resultado:

import json
import os
import re
import requests

API_KEY = os.environ.get("BRIGHTDATA_API_KEY")
ZONE = os.environ.get("BRIGHTDATA_ZONE", "web_unlocker")
ENDPOINT = "https://api.brightdata.com/request"

LD_JSON = re.compile(
    r'<script[^>]*type\s*=\s*[\'"]application/ld\+json[\'"][^>]*>(.*?)</script\s*>',
    re.S | re.I,
)


def fetch_html(url, country="us"):
    """Return the rendered HTML for an Etsy URL, or raise on failure."""
    # Checked here rather than at import, so the browser path below runs
    # without an account.
    if not API_KEY:
        raise SystemExit("BRIGHTDATA_API_KEY is not set, see the exports above")
    response = requests.post(
        ENDPOINT,
        headers={"Authorization": f"Bearer {API_KEY}"},
        json={"zone": ZONE, "url": url, "format": "raw", "country": country},
        timeout=90,
    )
    response.raise_for_status()
    body = response.text
    # A quota error arrives as HTTP 200 with a short text body, and a block page
    # arrives as HTTP 200 of valid HTML. Neither contains ld+json, which is the
    # content this actually wants, so test for that rather than for either error.
    if not LD_JSON.search(body):
        raise RuntimeError(f"no ld+json in response, most likely blocked: {body[:200]!r}")
    return body


def product_jsonld(html):
    """Pick the Product block by @type. Every listing we opened had four."""
    for block in LD_JSON.findall(html):
        try:
            parsed = json.loads(block.strip())
        except json.JSONDecodeError:
            continue
        for node in parsed if isinstance(parsed, list) else [parsed]:
            if node.get("@type") == "Product":
                return node
    return None


def parse_listing(node):
    """Flatten a Product node, keeping the offer's range rather than its lowest price."""
    offer = node.get("offers", {})
    # schema.org allows a list of offers and a single priceSpecification object,
    # and Etsy serves both, so normalize before indexing into them.
    if isinstance(offer, list):
        offer = offer[0] if offer else {}
    specs = offer.get("priceSpecification", [])
    if isinstance(specs, dict):
        specs = [specs]
    base = next((s for s in specs if "priceType" not in s), {})
    was = next(
        (s for s in specs if "Strikethrough" in str(s.get("priceType", ""))), {}
    )
    # Not every listing carries a priceSpecification. Without this fallback a
    # single-variant listing records a null price and raises nothing.
    # A range arrives three ways: nested in priceSpecification, as AggregateOffer's
    # own lowPrice and highPrice, or not at all. Try them in that order.
    low = base.get("minPrice") or offer.get("lowPrice") or offer.get("price")
    high = base.get("maxPrice") or offer.get("highPrice") or offer.get("price")
    # availability is the only field that marks a dead listing, and JSON-LD lets it
    # arrive as a bare term, an array, or an @id object. Normalize before comparing.
    avail = offer.get("availability") or ""
    if isinstance(avail, list):
        avail = avail[0] if avail else ""
    if isinstance(avail, dict):
        avail = avail.get("@id", "")
    rating = node.get("aggregateRating", {})
    return {
        "sku": node.get("sku"),
        "title": node.get("name"),
        # Etsy serves a dead listing as a full HTTP 200 page that still
        # carries a price, so availability is the only field that says so.
        "availability": str(avail).rsplit("/", 1)[-1],
        "currency": offer.get("priceCurrency"),
        "price_min": low,
        # high can be a bundle maximum rather than the item's, where a listing
        # has an add-on axis. Count the axes before trusting it as a maximum.
        "price_max": high,
        "list_price": was.get("price"),
        # True only where the variations differ in price. Same-price variants
        # read false, so this is a price-spread test, not a variation test.
        "has_variations": None if low is None else low != high,
        "rating": rating.get("ratingValue"),
        "review_count": rating.get("reviewCount"),
        "shop": node.get("brand", {}).get("name"),
    }


if __name__ == "__main__":
    url = (
        "https://www.etsy.com/listing/753913297/"
        "smoky-quartz-ring-rose-gold-ring-women"
    )
    node = product_jsonld(fetch_html(url, country="us"))
    if node is None:
        raise SystemExit("no Product block on page: blocked, or the layout changed")
    print(json.dumps(parse_listing(node), indent=2))

Executamos o script contra um anúncio ativo e ele retornou um registro plano. O intervalo de preços vem de 1 anúncio com muitas variações com preços separados:

{
  "sku": "753913297",
  "title": "Smoky Quartz Ring · Rose Gold Ring Women · Cocktail Rings · ...",
  "availability": "InStock",
  "currency": "USD",
  "price_min": "89.25",
  "price_max": "5613.75",
  "list_price": "119.00",
  "has_variations": true,
  "rating": "4.5",
  "review_count": 99,
  "shop": "AnemoneJewelry"
}

Em 3 execuções contra o mesmo anúncio, as buscas levaram de 23 a 27 segundos e retornaram entre 540 KB e 750 KB. Esses valores são o custo da etapa de desbloqueio. Planeje para latência nesse intervalo em vez do tempo abaixo de um segundo de um bloqueio.

Se você precisar apenas de algumas páginas e preferir não abrir uma conta, um navegador local chega ao mesmo resultado com aproximadamente a mesma quantidade de código. Esse navegador funciona a partir de um endereço que o DataDome ainda não recusou. Ele precisa de um display, então em um servidor execute-o com interface gráfica sob um display virtual como Xvfb em vez do modo headless. Instale o Playwright e o Chromium uma vez:

python3 -m venv .venv && source .venv/bin/activate   # skip if already active
pip install playwright requests
playwright install chromium

Em seguida, troque a busca, mantendo as mesmas 2 funções de parsing. Coloque isso acima do bloco __main__, com as outras funções:

from playwright.sync_api import sync_playwright


def fetch_html_browser(url):
    """Fetch one page with a visible browser, since headless returns a 403."""
    with sync_playwright() as p:
        browser = p.chromium.launch(headless=False)
        context = browser.new_context(locale="en-US")
        page = context.new_page()
        page.goto(url, wait_until="domcontentloaded", timeout=45000)
        html = page.content()
        browser.close()
        # Same rule as the guard above: test for the content you want. A block
        # page is valid HTML, so only the missing ld+json shows it.
        if not LD_JSON.search(html):
            raise RuntimeError("no ld+json on page, most likely a block page")
        return html

Mude 1 linha dentro do bloco __main__, e nada mais:

node = product_jsonld(fetch_html_browser(url))   # was: fetch_html(url, country="us")

Nossa execução retornou 535.247 bytes e foi analisada corretamente pelas mesmas 2 funções. A página também estava precificada em INR, porque um navegador na sua máquina sai do seu próprio endereço, e o caminho do navegador não aceita argumento de country. Sem esse argumento, você não pode definir o país de saída, então esse caminho serve para algumas páginas em vez de um dataset.

O código acima analisa a página em vez de enviá-la a um modelo. O Etsy publica os campos como dados estruturados, então lê-los é determinístico e efetivamente gratuito. No anúncio que medimos, selecionar o bloco Product e achatá-lo leva uma mediana de 0,3 milissegundos.

Passar a mesma página para um modelo de linguagem significa 179.599 tokens de HTML bruto, a maior parte de uma janela de contexto de 200K tokens para 1 produto. Remover a marcação primeiro reduz isso para 5.352 tokens, uma redução de 97%. Essa proporção importa mais do que a escolha do modelo.

Mas um parser que para de corresponder retorna um campo vazio em vez de um erro, enquanto um modelo produziria pelo menos algo errado e visível. Então use o caminho determinístico em um site que publica schema.org. Use o tempo economizado para verificar se o parser ainda retorna os campos que você espera.

Páginas de lojas também têm 4 desses blocos, sob tipos diferentes, e um deles mostra de onde vêm as URLs de anúncios. /shop/{shop_name} retorna um nó Organization descrevendo a loja e um nó ItemList cujo itemListElement contém URLs completas de anúncios. shop_itemlist seleciona o ItemList por @type, então a mesma função com Organization no lugar retorna os campos da própria loja. Na loja que testamos, numberOfItems indicava 1.842 enquanto uma única página retornou 36 URLs, e ?page=2 retornou mais 36 sem sobreposição.

O enumerador usa um nome de loja como entrada, então você precisa de uma fonte de nomes de lojas, e 3 fontes funcionam sem tocar no caminho de busca bloqueado. Você pode usar lojas que já rastreia, o endpoint findAllListingsActive acima, ou um dataset preparado. Esse endpoint aceita keywords apenas com uma chave de aplicativo, e inclui um shop_id em cada anúncio que retorna.

Buscamos páginas em todo o intervalo e além do fim declarado:

page  2    36 items
page 25    36 items
page 51    36 items
page 52     6 items      51 x 36 + 6 = 1,842
page 53    no ItemList block
page 60    no ItemList block

O total corresponde aos 1.842 que a loja declara. Além do fim, o Etsy continua respondendo com uma página completa de aproximadamente 420 KB e sem ItemList.

O Etsy não dá nenhum erro e nenhum array vazio para terminar, então um loop que espera por qualquer um deles continuará paginando para sempre contra páginas que parecem corretas. Encerre no bloco ausente e deixe a contagem declarada verificar seu trabalho. Os padrões habituais para coleta paginada assumem um desses 2 sinais, então não se aplicam aqui.

Ambas as funções pertencem ao mesmo arquivo que as funções anteriores, já que usam LD_JSON e fetch_html. Comente o ponto de entrada que não estiver executando, já que o enumerador leva 20 minutos:

def shop_itemlist(html):
    """Pick the ItemList block. The first block on a shop page is a video."""
    for block in LD_JSON.findall(html):
        try:
            parsed = json.loads(block.strip())
        except json.JSONDecodeError:
            continue
        for node in parsed if isinstance(parsed, list) else [parsed]:
            if node.get("@type") == "ItemList":
                return node
    return None


def enumerate_shop(shop_name, country="us"):
    """Page a shop until the ItemList stops appearing, and hand back the declared
    count so the caller can check it."""
    base = f"https://www.etsy.com/shop/{shop_name}"
    urls, declared, page = [], None, 1
    while True:
        suffix = "" if page == 1 else f"?page={page}"
        try:
            node = shop_itemlist(fetch_html(base + suffix, country=country))
            if node is None:
                break
            declared = node.get("numberOfItems", declared)
            urls += [item["item"]["url"] for item in node["itemListElement"]]
        except (RuntimeError, requests.RequestException, KeyError, TypeError,
            AttributeError) as err:
            print(f"stopped at page {page}: {err}", flush=True)
            break
        print(f"page {page}: {len(urls)} of {declared}", flush=True)
        # Breaking on the declared count here would make the comparison below
        # vacuous, so page until the ItemList block stops appearing.
        page += 1
    return urls, declared


if __name__ == "__main__":
    urls, declared = enumerate_shop("AnemoneJewelry")
    print(len(urls), "collected,", declared, "declared")

O enumerador faz 53 buscas contra as páginas medidas acima, retorna 1.842 URLs e verifica o total contra a contagem que a loja declara. Compare esses 2 números a cada execução, porque uma leitura curta retorna menos URLs e não gera nenhum erro.

O try precisa cobrir o parsing também, além da busca. Um loop de 53 requisições é longo o suficiente para receber a resposta de limite de taxa, e um único itemListElement malformado causa o mesmo dano que uma requisição com falha. Sem a proteção, qualquer um deles gera erro na página 30 e descarta todas as URLs coletadas até então. Capturar ambos deixa você com uma leitura curta em vez de uma execução perdida, e a comparação de contagem mostra isso. O tratamento de requisições com falha em Python segue um padrão padrão, e a única parte específica do Etsy é colocar o parsing dentro da proteção.

Na latência medida acima, 53 buscas são de 20 a 24 minutos e 53 requisições do limite mensal gratuito, para 1 loja. Execute o enumerador em uma loja pequena primeiro e observe o aumento da contagem de páginas antes de usá-lo em uma loja com 1.842 anúncios. Defina também um limite de uso na zona, para que uma execução que dê errado pare em um limite em vez de no seu saldo.

Executamos a mesma descoberta pelo endpoint de loja do Etsy da Web Scraper API como um job em lote. Executamos o job na loja em vez de em URLs de anúncios, e limitamos a 50 registros para comparação. Ele retornou 50 anúncios em 168,9 segundos, ou aproximadamente 3,4 segundos por registro, sem duplicatas e sem URLs com prefixo de localidade nessa execução.

Coletar toda essa loja leva 53 buscas de enumeração mais 1.842 buscas de anúncios, ou 1.895 requisições e aproximadamente 13 horas em thread único. Verifique essas 1.895 contra o limite gratuito atual antes de começar. Além desse limite, o mesmo trabalho é cobrado à taxa de pagamento por uso por 1K requisições bem-sucedidas.

Fixe o country aqui também. Buscamos a mesma loja sem ele e obtivemos URLs /de/listing/ e títulos em alemão. Essas URLs entrariam em um dataset como chaves diferentes para itens já armazenados sob sua forma /listing/.

Problemas de extração que corrompem silenciosamente um dataset do Etsy

Cada problema aqui retorna HTTP 200 e não gera nenhuma exceção. Dos 5, 4 escrevem uma linha plausível mas errada e o quinto escreve uma linha vazia. Em vez disso, você encontra um número ruim meses depois. As métricas de qualidade de dados capturam esse tipo de falha após o armazenamento dos dados, e não escrever a linha ruim é mais barato.

Uma página de anúncio tem 4 blocos JSON-LD, não 1. O Etsy incluiu Product, VideoObject, BreadcrumbList e FAQPage em 4 tags script separadas em cada anúncio que abrimos, todas com o tipo application/ld+json. O código-fonte da página os mostra em 4 linhas consecutivas:

Four consecutive lines of the listing's page source, numbered 144 to 147, each one a script tag of type application/ld+json. Their @type values in order are Product, VideoObject, BreadcrumbList and FAQPage

Código que chama find() e pega a primeira correspondência funciona nas páginas de anúncios e retorna silenciosamente o bloco de vídeo nas páginas de lojas. Selecione por @type em vez de por posição. Nada mais sobre a mecânica de parsing de JSON em Python muda.

Uma página de loja tem 4 blocos próprios, em uma ordem diferente:

Chrome's find bar on the shop page source reading "application/ld+json" with a counter of 1 of 4, above the four matching script tags. Their @type values in document order are VideoObject, FAQPage, Organization and ItemList

Na loja que testamos, o primeiro bloco é um vídeo e o ItemList que você quer é o último, então pegar a primeira correspondência falha. O script acima seleciona por @type em vez disso.

Em um anúncio com variações, offers.price é o preço mais baixo do intervalo, não o intervalo inteiro. No anúncio que testamos, offers.price indicava 89.25 enquanto a entrada priceSpecification aninhada dentro de offers declarava minPrice 89.25 e maxPrice 5613.75. Um parser que lê offers.price registra a variação mais barata e descarta o intervalo. Armazenar maxPrice também não é a solução, porque neste anúncio maxPrice é o preço de um pacote e não do item sozinho.

Coletamos a mesma URL com variações ativadas e obtivemos 61 registros separados, 1 por variante, com preços de 89,25 a 1.871,25. Uma única linha de anúncio na sua tabela representa 61 SKUs compráveis em um intervalo de preço 21x.

Um segundo menu suspenso na página multiplica esse intervalo:

The Add Matching Jewelry dropdown open on the listing, showing four choices priced as multiples of the ring: No Thanks $89.25 to $1,871.25, either single matching piece $178.50 to $3,742.50, and Full Set Earrings plus Pendant $267.75 to $5,613.75. A Metal Type dropdown sits above it and the headline price reads $89.25 plus, with $119.00 struck through

Esses 2 máximos medem coisas diferentes, e este anúncio tem 2 eixos de variação. Metal Type vai de 14k Gold Filled a 89,25 até as 3 opções de ouro maciço a 1.871,25. Add Matching Jewelry (Optional) então multiplica esse intervalo. No Thanks vai de 89,25 a 1.871,25, qualquer peça de joalheria combinando vai de 178,50 a 3.742,50, e Full Set: Earrings + Pendant vai de 267,75 a 5.613,75.

O maxPrice declarado é o anel de ouro maciço mais ambas as peças combinando, 3 itens a 1 preço. O extrato de variação retornou o anel sozinho e correspondeu à linha No Thanks ao centavo.

Portanto, sempre que um anúncio tem um eixo de complemento, maxPrice é o máximo para um pacote e não para o item. Uma série de preços construída sobre ele rastreia silenciosamente pacotes. Conte os eixos de variação antes de armazenar um intervalo como o próprio produto. Eles não estão no JSON-LD, então leia-os da página renderizada ou obtenha o extrato de variação.

O mesmo array priceSpecification também contém uma segunda entrada marcada como StrikethroughPrice, então o preço de venda e o preço de lista aparecem lado a lado, distinguidos apenas por uma URL schema.org. Essa entrada tem seu próprio minPrice e maxPrice. O list_price que o script registra é o preço mais baixo no intervalo de preço de lista, assim como offers.price é o mais baixo no intervalo atual.

A localidade segue o IP de saída, e muda mais do que o símbolo de moeda. Buscamos 1 anúncio 3 vezes na mesma hora, mudando apenas o país de saída:

country   currency   price    variation range      title
us        USD        89.25    89.25 - 5613.75      Smoky Quartz Ring, Rose Gold Ring Women
de        EUR        95.71    95.71 - 5058.76      Rauchquarzring, Damenring aus Roségold
gb        GBP        82.77    82.77 - 4338.22      Smoky Quartz Ring, Rose Gold Ring Women

O mesmo anúncio é renderizado de forma diferente em cada país de saída:

The same Etsy listing priced from three exit countries: the US exit shows $89.25 with $119.00 struck through, the German exit shows ab 95,88 EUR with ab 127,84 EUR struck through, and the UK exit shows GBP 82.86 with GBP 110.49 struck through and 25% off

Essas capturas vêm de uma busca posterior às 3 linhas, e apenas o valor em dólar permaneceu o mesmo. A página alemã também escreve seu preço mais baixo como ab, ou “a partir de”, e o + marca a mesma coisa nas outras 2 páginas.

As 3 buscas retornaram o mesmo SKU, avaliação e contagem de avaliações, a 3 preços diferentes em 3 moedas. A proporção entre o preço mais baixo e o mais alto difere entre as 3 linhas, 62,9x na linha dos EUA contra 52,9x e 52,4x, então mais do que a taxa de câmbio mudou entre essas buscas. A saída alemã também retornou um título traduzido automaticamente, enquanto as 2 localidades em inglês mantiveram o original.

Um pool rotativo que ignora a geografia produz uma série de preços misturando 3 moedas e um corpus de texto misturando idiomas, e nada no pipeline reporta um problema. Fixe o país de saída por execução de coleta e armazene a moeda junto com cada preço.

O mesmo problema aparece em dados preparados. A amostra de 1.000 registros que baixamos tinha 19 moedas, com 109 linhas em uma moeda diferente de USD, 14 delas precificadas em dong vietnamita. Essa amostra é atualizada, então sua cópia diferirá desses totais. Execute a mesma comparação em sua própria cópia.

Calcular a média da coluna de preços sem ler a coluna de moeda infla a média:

mean(final_price), all rows          31,238.84   n=991
mean(final_price), currency = USD       137.57   n=882
                                        ~227x
median(final_price), all rows            26.00

Nessa coluna, 14 linhas de 991 contribuem com 87% do total, mas 109 linhas são não-USD e todas precisam de tratamento. A consulta é executada, a coluna é um float limpo, e a resposta está errada por 2 ordens de magnitude. A mediana mal se move, porque as linhas não-USD são uma pequena parte da coluna. Uma média e mediana que discordam tanto apenas indicam que uma cauda pesada está presente. Agrupar pela coluna de moeda separa unidades misturadas de assimetria ordinária.

Um artigo do arXiv de março de 2026 sobre anúncios de marketplace, citado novamente mais adiante, restringiu sua amostra a anúncios precificados em USD antes de executar qualquer análise. Isso resolve o problema após a coleta em vez de durante ela. Filtrar dessa forma também muda a população que a média descreve, já que descarta anúncios em outras moedas em vez de convertê-los.

Este problema é mais difícil de ver no caminho do agente. Buscamos este anúncio por meio de um servidor MCP cujo schema aceita apenas uma URL, e obtivemos a loja tcheca precificada em CZK. A chamada REST fixada por país reporta o mesmo anúncio a 89,25 USD.

A causa é a interface da ferramenta, não a busca. Sem parâmetro de país ou localidade para definir, você obtém qualquer saída que o servidor esteja usando. Verifique esse comportamento em qualquer servidor desse tipo antes de conectá-lo a um agente, porque a saída decide a moeda de cada resposta downstream.

Portanto, colete dados do Etsy em seu próprio armazenamento em uma localidade fixada, e faça o agente ler esse armazenamento em vez de buscar ao vivo por pergunta. Um agente que responde silenciosamente em uma moeda diferente a cada vez é pior do que um agente que não consegue responder.

Um anúncio morto é uma página completa com um preço. O Etsy não retorna um 404 para um anúncio expirado ou esgotado. Obtivemos um anúncio de 2007 e recebemos HTTP 200, 465.563 bytes, um bloco Product completo e price 13.00 USD.

A página é renderizada completamente e mostra tanto o banner de esgotado quanto o preço:

An Etsy listing page. A banner across the top reads "This item is sold out." Directly beside it the page still displays a price of $13.00, along with the product photographs, the seller name and the item details, exactly as a live listing would

offers.availability é o único campo que contradiz o preço, e indica schema.org/OutOfStock. Um parser que o ignora registra um item morto com preço cheio, então o script acima lê esse campo.

Esse campo é menos confiável em dados preparados do que na página. Nessa mesma cópia, 74 de 1.000 linhas eram anúncios esgotados, availability estava vazio em todas as 74, e 65 ainda mostravam um preço numérico. O sinal lá é um parâmetro show_sold_out_detail na URL armazenada. Manter apenas as linhas cujo availability indica InStock descartaria 798 linhas ativas, porque o campo também está ausente na maioria delas.

Uma resposta 2xx não significa que você tem dados. Este problema está na camada de coleta e não no payload, e o vimos com o script acima durante os testes. O endpoint de desbloqueio se explica no corpo. Quando a conta atingiu um limite de taxa de requisições, ele respondeu 200 com um corpo de texto simples de 111 bytes indicando Your system is sending too many of this type of request. A linha de status permanece como sucesso, então raise_for_status() passa, o parser não encontra nenhum bloco Product, e um loop escreve linhas vazias sem uma única exceção.

A Web Scraper API trata o mesmo caso por design. Seu endpoint de coleta síncrona retorna registros em 1 minuto, e jobs mais longos continuam de forma assíncrona. O mesmo endpoint responde 202 com um snapshot_id e um retry-after quando um job ultrapassa esse minuto, para que você possa coletar o resultado quando estiver pronto, conforme documentado na referência da API. Ambas as respostas são status de sucesso, então ramifique no código de status antes de indexar em uma lista de registros.

A proteção em fetch_html tem 2 linhas e testa se uma página contém ld+json em vez de qualquer um dos erros. Executar o enumerador contra uma loja ativa encontrou um terceiro caso, um 200 com corpo vazio, e a mesma proteção o capturou sem nenhuma mudança. Portanto, teste a resposta pelo conteúdo que você quer em vez de pelos erros que já viu, já que o texto de erro do fornecedor não é uma interface estável. Escreva o equivalente para qualquer camada de busca que você usar, porque um status de sucesso não garante o payload.

Um exemplo funcional prova que um problema existe, não que é comum. Verificamos os problemas de intervalo de preço e tachado em 100 anúncios de ambos os caminhos de descoberta. Variações estão presentes em 98 de 100, e initial_price difere de final_price nos mesmos 98. Nenhum problema é um caso extremo que você pode adiar. Esses 100 vieram pelos 2 caminhos de descoberta acima em vez de uma amostra aleatória pelas categorias do Etsy. Então leia 98 como um mínimo para anúncios de joalheria em vez de uma taxa para o site.

A mesma verificação mostra que o anúncio usado acima é incomum em um aspecto. Esse anúncio tem 99 avaliações, enquanto a mediana é 1 nesse crawl e 0 na amostra do dataset publicado.

Quando comprar um dataset gerenciado em vez de manter um scraper

Você pode construir o scraper, e o código acima é a maior parte dele. A decisão é quais falhas você quer tratar por conta própria.

Executamos o mesmo anúncio pela Web Scraper API novamente, desta vez coletando por URL em vez de descobrindo a partir de uma loja. Ela retornou 58 campos contra os 14 no bloco Product bruto, e os campos extras tratam a maioria dos problemas acima:

raw JSON-LD (geo=us)          Web Scraper API
-----------------------------------------------------
price 89.25 (range minimum)   final_price 89.25
119.00 (StrikethroughPrice)   initial_price 119
not present                   discount_percentage 25
not present                   listing_has_variations true
not present                   reviews_count_shop 15129
not present                   is_star_seller false
4 embedded reviews            6 top_reviews
14 fields                     58 fields

Você configura esse extrator por URL de entrada, e define all_variations como true para o problema de intervalo de preço:

The Bright Data scrapers library on the etsy.com scraper, headed POST Etsy - collect by URL at a rate quoted per thousand records. An endpoint list offers collect by URL, discover by keywords and discover by shop url. The inputs table holds one row: a listing URL with all_variations set to true. A scraper mode choice offers synchronous, selected, or asynchronous. A code panel shows the generated authenticated request calling api.brightdata.com/datasets/v3/scrape

Os 6 campos que comparamos entre ambos os caminhos concordaram exatamente, e eram moeda, preço cobrado, preço de lista, avaliação, contagem de avaliações do item e origem do envio. Execute essa verificação antes de confiar em qualquer um dos caminhos.

A comparação de tempo se inverte, e o modo decide por quanto. Pelo endpoint síncrono, a extração estruturada levou cerca de 50 segundos para um único registro, contra 27 segundos para a busca bruta. Ele renderiza e normaliza em vez de retornar bytes. Os 50 segundos deixam cerca de 10 segundos dentro do timeout de 1 minuto acima. O endpoint é construído para 1 URL e uma resposta agora.

Executamos o mesmo scraper como um job em lote e obtivemos os 50 registros acima a 3,4 segundos cada. Cada registro custa aproximadamente 1/8 dos 27 segundos que uma busca bruta leva. No modo em lote, o 202 é a resposta esperada, não um erro a tratar.

Para um crawl recorrente, o caminho estruturado remove a manutenção de seletores, a fixação de localidade e o tratamento de intervalo de preço da sua equipe, para os campos que retorna. Para 1 anúncio, é mais lento em qualquer modo que você escolher. A comparação acima mostra o tratamento de preço resolvido, e a execução de 50 registros não mostrou URLs com prefixo de localidade. A manutenção de seletores é a única parte que nenhuma execução única pode testar. O caminho estruturado é cobrado da mesma forma que o Web Unlocker, com o limite gratuito atual e a taxa por 1K registros na página de preços da Web Scraper API.

A mesma amostra contém 2 tipos de registro em vez de 1, então planeje para ambos no lado da compra. Das 1.000 linhas, 128 têm o conjunto completo de campos, e as outras 872 deixam 12 de seus campos vazios, incluindo description, product_category e store_country.

A divisão rastreia a idade do anúncio, com o registro completo em itens listados a partir do final de 2025. Uma extração em massa abrangendo anos, portanto, mistura ambos os tipos em 1 arquivo. Essa proporção deve se inclinar para o registro completo conforme o corpus envelhece. Verifique a cobertura de campos em relação às suas colunas obrigatórias antes de decidir o tamanho do pedido, não depois.

O volume de avaliações precisa da mesma verificação antes de você pedir. Na cópia que obtivemos, 753 de 1.000 anúncios não tinham avaliações, então a mediana é zero. Os 10% principais de anúncios têm 98% de todas as avaliações. A média de 52,5 por anúncio descreve o arquivo como um todo e nenhum anúncio que você abrirá.

A assimetria de avaliações parece o caso de moeda, mas é uma falha diferente com uma solução diferente. O caso de moeda acima é um erro de unidade, e a média está errada. O volume de avaliações é assimetria, e aí a média está certa para um total. Uma amostra aleatória de 1.000 anúncios deve retornar cerca de 52.500 avaliações, então use a média quando planejar um corpus em massa. Em uma amostra de tamanho piloto, essa concentração torna a estimativa não confiável.

Cobertura é uma questão diferente. Com 753 de 1.000 em zero, apenas cerca de 25% das linhas que você compra têm alguma avaliação.

Se os dados de que você precisa são históricos em vez de ao vivo, um dataset preparado ignora o crawl. A página do dataset do Etsy lista a contagem atual de campos, total de registros, preço por registro e pedido mínimo, e esses 4 números são a aritmética do lado da compra.

O valor por registro nessa página é a taxa única, e agendamentos de atualização de semestral a diário vêm como assinaturas que o descontam. A atualidade dos dados também importa. A página descreve registros pré-coletados com dias a meses de idade, enquanto a coleta sob demanda permite definir esse limite antes do checkout. Você seleciona o agendamento de atualização e o nível de volume na própria página do dataset.

Esses valores mudam sem aviso, então leia-os na página antes de orçar, e trate as 2 páginas de preços da mesma forma. A página do dataset mostra uma amostra dos registros abaixo da linha de resumo, desfocada até você solicitar acesso:

The Bright Data Etsy dataset page. A summary row carries the data-field count, the total records, the starting per-record price and the minimum order. Above it sit the Etsy Dataset heading, a description of the attributes the dataset carries, and buttons to contact sales or buy the dataset

Um ponto de referência externo vale mais do que uma afirmação do fornecedor aqui. Esse mesmo artigo, Mecha-nudges for Machines, documenta de onde vieram seus dados no Apêndice B. Os autores declaram que os dados brutos “foram obtidos da empresa Bright Data, que fornece conjuntos de dados estruturados de anúncios de produtos do Etsy”. Seu extrato “foi coletado em 12 de novembro de 2025 e entregue no mesmo dia”. Eles descrevem 2 snapshots, de 5M e 1,06M de anúncios. Esse apêndice é verificável, e um estudo de caso de fornecedor não é.

A regra aqui é estreita. Construa o scraper quando precisar de alguns milhares de anúncios que você pode enumerar por URL, e puder aceitar fixar 1 localidade. Compre a camada de coleta quando a lista de URLs for a parte difícil, ou quando o crawl tiver que continuar funcionando. Compre também quando o dataset alimentar trabalho de Monitoramento de preços, porque os campos de intervalo de preço e tachado chegam já separados. A coluna de moeda ainda precisa de filtragem em qualquer caminho.

Na loja medida acima, construir custa 1.895 requisições e aproximadamente 13 horas em thread único para 1.842 anúncios. O lado da compra é um pedido mínimo de registros preparados. Esses 2 números não são diretamente comparáveis, porque o custo de construção é por loja e o custo de compra é um mínimo que você distribui entre lojas.

Considerações finais

O Etsy bloqueia buscas comuns enquanto publica JSON-LD schema.org nessas mesmas páginas públicas, então a extração é um trabalho curto e o acesso é a maior parte do trabalho. O transporte não decide o acesso, porque um navegador com interface gráfica carregou uma página que 9 clientes HTTP ajustados não conseguiram carregar, e o Etsy recusou esse mesmo navegador na segunda requisição. Portanto, o problema de engenharia é como continuar coletando, não como parecer um navegador. Qualquer caminho que você tome, as falhas de dados custam mais do que as falhas de acesso, porque uma coluna com moedas mistas ou um anúncio morto com preço cheio é analisado corretamente e você o encontra meses depois. Obtenha 1 anúncio por meio de uma busca bruta e um extrator estruturado, compare os campos, e deixe o resultado decidir construir versus comprar antes de escrever o crawler.

Perguntas frequentes

Existe uma API para o Etsy?

Sim. A API Aberta v3 do Etsy listava 76 caminhos quando contamos, dos quais 31 operações GET precisam apenas de uma chave de aplicativo. Ela retorna anúncios, lojas, avaliações e taxonomia. Para lojas que você não opera, omite vendas por anúncio, volume de busca por palavra-chave, taxa de conversão e histórico, que muitos projetos de dados precisam.

A API do Etsy é gratuita?

A API em si não tem preço publicado, mas o acesso depende de aprovação e de limites de taxa definidos por chave de aplicativo. O Etsy decide quem recebe limites mais altos e pode anexar termos ou cobranças extras. O custo prático é o limite de taxa em vez de uma taxa.

Por que meu scraper do Etsy está sendo bloqueado?

O Etsy usa DataDome, que retorna um 403 com uma página de bloqueio curta e um cabeçalho X-DataDome-riskscore avaliando sua requisição, onde 1,0 é o pior. A pontuação segue seu IP de origem e assinatura de requisição. Em nossos testes, edições de cabeçalho reduziram a pontuação e a impersonação de TLS do Chrome a aumentou, e nenhuma das duas retornou uma página.

O Etsy permite scraping de dados?

Não sem permissão expressa do Etsy. Os termos são o documento mais rígido. O robots.txt bloqueia busca por palavra-chave, histórico de vendas e favoritos em todas as variantes de localidade, e deixa páginas de anúncios e lojas abertas. O Etsy reescreve esse arquivo sem aviso, então leia a cópia ao vivo antes de construir.

Você pode fazer scraping do Etsy com Python?

Sim. As páginas de anúncios incorporam JSON-LD schema.org, então a extração não precisa de seletores CSS. Escolha o bloco application/ld+json cujo @type é Product, depois achate o objeto de oferta. A busca é a metade difícil, e coletar em volume precisa de novos contextos de navegação e endereços rotativos.

Como obtenho dados de vendas do Etsy?

As vendas são públicas apenas no nível da loja. O endpoint getShop retorna transaction_sold_count, um valor vitalício para toda a loja. Nada divide isso por anúncio para uma loja que você não opera, então os números por anúncio de uma ferramenta vêm de outro lugar. Trate-os como estimativas e verifique-os contra os totais da loja.

O Etsy tem um sitemap para crawling?

Nenhum em nossa última verificação. O arquivo robots.txt declara zero diretivas Sitemap:, e /sitemaps.xml retorna um 403 com corpo vazio. Sem sitemap, você precisa descobrir URLs a partir de páginas de lojas, do endpoint da API findAllListingsActive, ou de um dataset preparado. Nenhum desses 3 toca no caminho de busca bloqueado.

O Etsy bloqueia crawlers de IA como o GPTBot?

Não pelo nome quando verificamos. O arquivo robots.txt declara apenas 3 grupos de user-agent, nenhum deles um crawler de IA, e o Etsy não publica llms.txt ou ai.txt. O DataDome ainda recusou GPTBot e ClaudeBot, ambos pontuando um pior 0,9814 do que o 0,923 que um User-Agent do Chrome pontuou. Strings sem sentido pontuaram o mesmo.