I run a visa agency in Bali. The half of my week that has nothing to do with visas goes into scrapers on Apify, and one of them reads Mercado Libre product cards — the search results page of Latin America's biggest marketplace, which is really 18 separate storefronts on 18 domains and currencies: mercadolibre.com.mx, .com.ar, .cl, .com.co, mercadolivre.com.br and a dozen smaller ones.

Because the Actor runs a health check on itself every night, I ended up with something more useful than a one-off measurement: 57 successful runs since 9 October, 1,144 product rows, and 47 of those runs are the same query — audifonos bluetooth on mercadolibre.com.mx, 20 rows a time — spread over 22 distinct days. That is a panel, not a snapshot. So this week I pointed the analysis at my own history instead of at the site.

The field that fell apart first was the one everybody wants.

soldQuantity is a bucket wearing a number's clothes

Units sold is the reason people scrape marketplace search in the first place. Price you can get anywhere; "how many did this seller move" is the signal.

Across 1,144 rows, soldQuantity was filled on 1,084. Those 1,084 rows contained exactly ten distinct values:

1000 293 rows 10000 273 100 148 5000 98 50000 81 250000 71 500 53 100000 35 50 21 25 11 (null) 60 

That is the whole vocabulary. No 1,240. No 37. No 118,000. Mercado Libre's card never prints a sold count — it prints a rung on a ladder: +1000 vendidos, +250mil vendidos. My parser reads either that text or the sold_quantity number in the tracking JSON the page embeds for its own analytics, and both are already rounded down to the same rungs. The rounding is theirs, not mine.

So "sold" here means "at least". A listing in the 1000 bucket sold somewhere between 1,000 and 4,999 units, and the row cannot tell you where.

It is worse than lossy, because the rungs are not even monotone against the other popularity field on the same card. Grouping every row that had both soldQuantity and reviewCount:

bucket rows review count: min / median / max
1000 173 6 / 326 / 8,040
5000 68 432 / 1,709 / 18,262
10000 232 1,159 / 5,066 / 69,436
50000 72 3,252 / 18,676 / 67,793
100000 21 24,872 / 68,323 / 69,436
250000 65 6,909 / 8,669 / 79,514

The 250000 bucket has a lower median review count than the 50000 bucket — 8,669 against 18,676. Partly that is cheap generic earbuds that sell in volume and get reviewed rarely; partly it is that sold counts attach to the catalogue entry while reviews attach to whatever listing is winning the buy box today. Either way, if you were planning to rank sellers by soldQuantity, you are ranking by a ten-value enum with ties everywhere.

Over three weeks, price moves and the bucket does not

Here is the part that only a panel shows. Of the 157 distinct listings that passed through those 22 days of one search, 26 appeared on five or more days. For each of them I diffed price and sold bucket across the whole window:

  • price changed at least once on 22 of 26
  • soldQuantity changed at least once on 2 of 26

One listing — an authorised-reseller AirPods 5 at MXN 2,999 — was in the top 20 on 21 of the 22 days. Its price never moved by a peso. Its bucket went 500 → 1000 once, on day two, and then sat still for three weeks. That single step is the only sales information those 21 rows contain.

Meanwhile the prices are genuinely alive. A Lenovo RS110 open-ear set ran from MXN 196.42 to 453.37 over 14 days — 2.31x. Median swing across the 26 was 1.05x, so most of the movement is a few percent of jitter, but the tail is real and it is where a repricing tool earns its keep. Conclusion for anyone scheduling this kind of scraper: run it daily for price, and treat the sold bucket as a quarterly signal, because that is the resolution it actually has.

Two layouts of one listing, two different prices

While diffing day over day I hit 17 listings (of 340) that appeared sometimes with a variationsText line — Disponible en 4 colores — and sometimes without. Mercado Libre renders a colour family as either a single-variant card or a picker card, apparently at random per request.

For 13 of the 17 the price was the same either way. For one it was not, and it flipped back and forth for three weeks at a fixed position 7 in the results:

2026-10-20 price 182.71 originalPrice null variationsText "Disponible en 4 colores" 2026-10-22 price 101.36 originalPrice 192.16 variationsText null 2026-10-23 price 182.65 originalPrice null variationsText "Disponible en 4 colores" 2026-10-26 price 102.78 originalPrice 194.85 variationsText null 

Same itemId, same slot, 1.8x apart, and the discount only exists in one of the two layouts. The picker card quotes the family's base price with no old price; the single-variant card quotes one discounted colour. Neither is wrong, and no field on the row tells you which reality you got — except variationsText being filled, which is why I now keep that field even though it looks like decoration.

Which brings me to a bug of my own, found in the same pass. That field read "Disponible en 4 colores4" on 79 of 247 rows that had it: the compacted block carries the colour count twice, once in the sentence and once as a screen-reader pill, and .text() glues them. Same family of mistake as the shipping label I had already de-duplicated months earlier ("Envío gratisEnvío gratis"). Fixed by dropping a trailing repeat of a number already in the string; control run today, same query, 20 rows: 3 rows with variations, 0 duplicated. Build 0.1.47.

Positions 1–4 and 13–16 are not results

isSponsored was true on 410 of 1,144 rows — 36%. The placement is not random: of the 160 rows that sat in positions 1, 2 or 3, 155 were ads. The counts cluster at positions 1–4 and again at 13–16, which is exactly where Mercado Libre drops its two ad blocks into a 20-card page.

So an unfiltered top-20 export is roughly a third paid placement, and your "bestsellers for audifonos bluetooth" list starts with four of them. includeSponsored: false drops them. I leave the default on, because for ad-intelligence work those rows are the data.

Two more things the card does not have, measured over the same 1,144 rows: brand is null on 873 (76%) and seller on 421 — Mercado Libre simply does not print either on most search cards. And 320 rows carried a star rating with no review count, because the compacted review widget shows 4.8 alone in some layouts and 4.8 (65161) in others. For a complete record you need the product page, which is a separate, more expensive pass in this Actor for exactly that reason.

Finally, do not read totalResults as a count. It was 9999 on 1,054 of 1,144 rows — the ceiling, not a total. Narrow the query and it becomes honest: cable hdmi 8k 3 metros returned 321 and 322 on two consecutive runs.

Calling it

{ "country": "MX", "searchTerms": ["audifonos bluetooth"], "maxItems": 20, "includeSponsored": false, "sort": "relevance" } 

A real row from today's control run, trimmed:

{ "type": "product", "title": "Auriculares Bluetooth Anker Soundcore P40i 2024, color lila, verde oscuro y claro", "url": "https://articulo.mercadolibre.com.mx/MLM-5259417430-_JM", "itemId": "MLM5259417430", "productId": null, "siteId": "MLM", "country": "MX", "currency": "MXN", "price": 799.01, "originalPrice": 1499, "discountPercent": 46, "installmentsText": "3 meses sin intereses de $266.34", "soldQuantity": 50, "rating": 4.8, "reviewCount": null, "seller": "Soundcore", "isOfficialStore": true, "freeShipping": false, "fullShipping": true, "shippingText": "Llega gratis hoy", "isSponsored": true, "position": 1, "page": 1, "brand": null, "condition": null, "variationsText": null, "domainId": "MLM-HEADPHONES", "categoryId": "MLM5072", "totalResults": 9999, "scrapedAt": "2026-11-06T13:13:33.917Z" } 

Note isOfficialStore: true on a row that is also an ad, and reviewCount: null sitting next to rating: 4.8 — the two traps from above, both in the first row of an ordinary run.

From Python, run and read in one call:

import requests, collections rows = requests.post( "https://api.apify.com/v2/acts/lergassy~mercadolibre-products-scraper/run-sync-get-dataset-items", params={"token": "apify_api_..."}, json={"country": "AR", "searchTerms": ["zapatillas running"], "condition": "new", "sort": "price_asc", "maxItems": 40}, timeout=600, ).json() items = [r for r in rows if r.get("type") == "product"] print(collections.Counter(r["soldQuantity"] for r in items)) # expect a handful of rungs 

The Actor is MercadoLibre Products Scraper. It bills per product row with no start fee, and the nightly 20-row run I have been quoting costs $0.045 and takes 40 to 56 seconds, most of which is the headless browser and the residential proxy that search pages need. Reviews, the deals page and official-store pages come from the same Actor over plain HTTP, no proxy, which is why a review row is priced at $0.001 against $0.0025 for a product card.

When it is the wrong tool. If you need a real sold count, a stock level or a brand on every row, search cards cannot give it to you — open product pages and pay for the heavier pass. If you need questions and answers, nothing public has them; Mercado Libre shows those only to signed-in users and this Actor never signs in. If you want market share for a category, remember totalResults caps at 9,999 and the deepest the site will page is a few thousand cards, so you are sampling, not counting. And if you are writing your own: keep concurrency at three. At five and above, Mercado Libre stops answering with products and starts answering with a challenge page, which looks exactly like an empty category until you log the HTML.