Mercari is one of the largest peer-to-peer marketplaces for secondhand goods, with a US site and a much bigger Japanese one, which makes it a useful source for resale pricing, demand signals, and category research. Every public listing page is served to anonymous visitors, so the data is there to read. The obstacle is getting the page at all: Mercari runs behind Cloudflare bot management, and a plain HTTP request from a server is answered with a "Just a moment" challenge rather than a listing.
This guide builds a Mercari scraper in three parts: how to fetch pages reliably through an unblocker, how to find where Mercari embeds listing data in the HTML, and working extractors in PHP, Node.js, and Rust that read structured data first and fall back to the page's meta tags. It follows the same pattern as our eBay and Etsy guides, so if you've built one of those, most of the code will look familiar.
Public listing data vs. account-gated data
Mercari doesn't require a login to browse, and everything an anonymous visitor can see counts as public:
- Public — item title, description, listing price, condition, category, shipping details, photos, seller display name and rating summary, and sold status.
- Gated — buyer identities, offers and messages, seller analytics, and anything behind a login. These are off-limits for automated collection.
The code below targets only the anonymous, public view. Mercari's terms restrict automated access even to public pages; review them and seek appropriate advice before running at production scale, and keep request volume modest.
Why plain HTTP clients fail
Cloudflare's bot management on Mercari looks at more than the IP address. It fingerprints the TLS handshake and HTTP/2 settings, scores the request headers, and can issue a JavaScript challenge that a bare HTTP client can't complete. The result is a 403 with a "Just a moment" page, from datacenter IPs and often from home connections too. Rotating proxies alone don't fix a client-level block; our guide to residential vs datacenter proxies explains why.
The Bright Data Web Unlocker handles the whole stack: it picks the proxy type, presents a browser-grade fingerprint, solves challenges, and retries internally, so your code receives the rendered listing HTML. Explore Bright Data's e-commerce scraping tools →
Prerequisites
export PROXY_URL="http://brd-customer-<id>-zone-<unblocker_zone>:<password>@brd.superproxy.io:22225"
Listings are identified by an item ID that starts with m in the URL: https://www.mercari.com/us/item/<item_id>/. Search pages take a keyword: https://www.mercari.com/search/?keyword=<query>.
Step 1: Find where the data lives
Marketplace pages change their markup often, so don't start by guessing CSS selectors. Fetch one listing through the unblocker and look at what's embedded in the page:
curl -s -x "$PROXY_URL" -k "https://www.mercari.com/us/item/m12345678901/" -o item.html
grep -o '<script type="application/ld+json">[^<]*' item.html | head -c 800
grep -o '<meta property="og:[a-z:]*" content="[^"]*"' item.html
grep -c '__NEXT_DATA__' item.html
Three sources, in order of preference:
- JSON-LD
Productblock. If present, it carries name, description, price, currency, availability, and sometimes seller and condition, in a stable schema.org shape. Always parse this first. - Embedded app state. A
__NEXT_DATA__or similar JSON blob contains everything the page renders, including fields the JSON-LD leaves out, but its shape is internal and changes without notice. Treat it as a fallback you re-verify after any breakage. - Open Graph meta tags.
og:title,og:description,og:imageare present on nearly every listing page and change least of all. They're the floor: enough to confirm a listing exists and capture its title and headline photo.
The extractors below implement JSON-LD first with an Open Graph fallback, which survives most redesigns. Add an app-state parser only once you've confirmed the field paths on a live page, and keep the discovery commands above handy for when it breaks.
PHP
<?php
// Run: php mercari.php m12345678901
$proxy = getenv('PROXY_URL');
$itemId = $argv[1] ?? 'm12345678901';
$ch = curl_init("https://www.mercari.com/us/item/$itemId/");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_PROXY => $proxy,
CURLOPT_SSL_VERIFYPEER => false,
CURLOPT_TIMEOUT => 90,
CURLOPT_HTTPHEADER => [
'Accept-Language: en-US,en;q=0.9',
'Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8',
],
]);
$html = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($status !== 200) { fwrite(STDERR, "HTTP $status\n"); exit(1); }
$doc = new DOMDocument();
@$doc->loadHTML($html);
$xp = new DOMXPath($doc);
// 1) JSON-LD Product block
$product = null;
foreach ($xp->query('//script[@type="application/ld+json"]') as $node) {
$ld = json_decode($node->textContent, true);
foreach (is_array($ld) && isset($ld[0]) ? $ld : [$ld] as $entry) {
if (($entry['@type'] ?? '') === 'Product') { $product = $entry; break 2; }
}
}
// 2) Open Graph fallback
$og = [];
foreach ($xp->query('//meta[starts-with(@property, "og:")]') as $meta) {
$og[$meta->getAttribute('property')] = $meta->getAttribute('content');
}
$offer = $product['offers'] ?? [];
if (isset($offer[0])) { $offer = $offer[0]; }
echo json_encode([
'id' => $itemId,
'source' => $product ? 'json-ld' : 'open-graph',
'name' => $product['name'] ?? ($og['og:title'] ?? null),
'description' => substr($product['description'] ?? ($og['og:description'] ?? ''), 0, 200),
'price' => $offer['price'] ?? null,
'currency' => $offer['priceCurrency'] ?? null,
'availability' => $offer['availability'] ?? null,
'image' => $product['image'] ?? ($og['og:image'] ?? null),
], JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), PHP_EOL;
Node.js
// mercari.mjs — node mercari.mjs m12345678901
// Install: npm i axios https-proxy-agent cheerio
import axios from 'axios';
import { HttpsProxyAgent } from 'https-proxy-agent';
import * as cheerio from 'cheerio';
const itemId = process.argv[2] ?? 'm12345678901';
const agent = new HttpsProxyAgent(process.env.PROXY_URL, { rejectUnauthorized: false });
const { data: html, status } = await axios.get(`https://www.mercari.com/us/item/${itemId}/`, {
httpsAgent: agent,
timeout: 90_000,
headers: { 'Accept-Language': 'en-US,en;q=0.9' },
validateStatus: () => true,
});
if (status !== 200) { console.error(`HTTP ${status}`); process.exit(1); }
const $ = cheerio.load(html);
// 1) JSON-LD Product block
let product = null;
$('script[type="application/ld+json"]').each((_, el) => {
try {
const ld = JSON.parse($(el).contents().text());
const entries = Array.isArray(ld) ? ld : [ld];
const hit = entries.find((e) => e?.['@type'] === 'Product');
if (hit && !product) product = hit;
} catch {}
});
// 2) Open Graph fallback
const og = {};
$('meta[property^="og:"]').each((_, el) => { og[$(el).attr('property')] = $(el).attr('content'); });
let offer = product?.offers ?? {};
if (Array.isArray(offer)) offer = offer[0] ?? {};
console.log(JSON.stringify({
id: itemId,
source: product ? 'json-ld' : 'open-graph',
name: product?.name ?? og['og:title'] ?? null,
description: (product?.description ?? og['og:description'] ?? '').slice(0, 200),
price: offer.price ?? null,
currency: offer.priceCurrency ?? null,
availability: offer.availability ?? null,
image: product?.image ?? og['og:image'] ?? null,
}, null, 2));
Rust
// Cargo.toml: reqwest = { version = "0.12", features = ["blocking", "gzip"] }
// scraper = "0.20", serde_json = "1"
use scraper::{Html, Selector};
use serde_json::{json, Value};
fn main() -> Result<(), Box<dyn std::error::Error>> {
let item_id = std::env::args().nth(1).unwrap_or_else(|| "m12345678901".into());
let proxy = std::env::var("PROXY_URL")?;
let client = reqwest::blocking::Client::builder()
.proxy(reqwest::Proxy::all(&proxy)?)
.danger_accept_invalid_certs(true)
.timeout(std::time::Duration::from_secs(90))
.build()?;
let resp = client
.get(format!("https://www.mercari.com/us/item/{item_id}/"))
.header("Accept-Language", "en-US,en;q=0.9")
.send()?;
if !resp.status().is_success() {
eprintln!("HTTP {}", resp.status());
std::process::exit(1);
}
let doc = Html::parse_document(&resp.text()?);
// 1) JSON-LD Product block
let ld_sel = Selector::parse(r#"script[type="application/ld+json"]"#).unwrap();
let mut product: Option<Value> = None;
for el in doc.select(&ld_sel) {
if let Ok(v) = serde_json::from_str::<Value>(&el.text().collect::<String>()) {
let entries = match v { Value::Array(a) => a, other => vec![other] };
if let Some(p) = entries.into_iter().find(|e| e["@type"] == "Product") {
product = Some(p);
break;
}
}
}
// 2) Open Graph fallback
let og_sel = Selector::parse(r#"meta[property^="og:"]"#).unwrap();
let og: std::collections::HashMap<String, String> = doc
.select(&og_sel)
.filter_map(|m| Some((m.value().attr("property")?.to_string(), m.value().attr("content")?.to_string())))
.collect();
let p = product.clone().unwrap_or(Value::Null);
let offer = match &p["offers"] { Value::Array(a) => a.get(0).cloned().unwrap_or(Value::Null), o => o.clone() };
let pick = |ld: &Value, key: &str| ld.as_str().map(str::to_string).or_else(|| og.get(key).cloned());
println!("{}", serde_json::to_string_pretty(&json!({
"id": item_id,
"source": if product.is_some() { "json-ld" } else { "open-graph" },
"name": pick(&p["name"], "og:title"),
"description": pick(&p["description"], "og:description").map(|s| s.chars().take(200).collect::<String>()),
"price": offer["price"],
"currency": offer["priceCurrency"],
"availability": offer["availability"],
"image": pick(&p["image"], "og:image"),
}))?);
Ok(())
}
Reading search results
Search pages list many items, and the item IDs are in the listing links. Fetch https://www.mercari.com/search/?keyword=<query> through the same unblocker, collect every link matching /us/item/m<digits>/, de-duplicate, and feed the IDs to the extractor above. Keep a short pause between item fetches: the unblocker handles blocks, but a gentle request rate keeps your costs predictable and your footprint small. For Japanese listings the same approach applies on jp.mercari.com, with Japanese-language fields and yen pricing.
Tracking sold prices
Mercari leaves sold listings publicly viewable and marks them as sold. For resale benchmarks, run the extractor on a schedule, store each pass keyed by item ID with the listing price and availability, and treat the transition to sold as the data point. Over a few weeks that gives you realized prices by category, which is more useful than asking prices alone. The same loop powers the price monitors in our Amazon and Walmart guides.
Scaling beyond a few listings
- Rate and cost. Unlocker products bill per successful request, so a crawl's cost is simply pages fetched. Scope searches tightly and skip listings you've already stored.
- Breakage. When the extractor starts returning
open-graphas the source for every page, the JSON-LD block has moved or gone. Re-run the discovery commands and update the parser rather than trusting the fallback long term. - Alternatives. If you'd rather own the proxy layer, residential proxies plus a real browser can get through, at the cost of maintaining the fingerprinting and challenge handling yourself; see how to avoid getting blocked. Managed scraper APIs from Oxylabs or ZenRows are the other route.
Get started with Bright Data for marketplace scraping →
FAQ
Why does my Mercari scraper get a 403 or a 'Just a moment' page?
Mercari sits behind Cloudflare bot management. It inspects the TLS handshake, headers, and browser signals as well as the IP, so a plain HTTP client from a datacenter or even a home connection is served a challenge page instead of the listing. An unblocking product that handles fingerprinting, proxy selection, and challenges in one layer is the practical fix; see the Web Unlocker review.
What Mercari data is public?
Everything an anonymous visitor sees on a listing or search page: item title, description, listing price, condition, category, shipping information, photos, seller display name and rating summary, and whether the item has sold. Buyer identities, messages, offers, and anything behind a login are not public and should not be collected.
Is scraping Mercari legal?
Mercari's terms of service restrict automated access, and that's the main constraint. Separately, US courts have generally held that collecting publicly visible, non-login-gated data is not a copyright violation on its own. The outcome depends on your use case, data handling, and jurisdiction; get advice before running at production scale, and collect only public data.
How do I track sold prices on Mercari?
Sold listings stay publicly viewable and are marked as sold on the page, so the same scraper that reads active listings can read completed ones. Record the listing price and sold status on each pass, and keep a history keyed by item ID to build price benchmarks for a category or product.