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 the newest one reads Mercado Libre — the marketplace that runs 18 separate storefronts across Latin America, each on its own domain and currency: mercadolibre.com.mx, mercadolivre.com.br, mercadolibre.com.ar, .cl, .com.co, and a dozen smaller ones.

I expected the interesting problem to be the bot gate. It is there, and I will get to it. But the thing that actually cost me rewrites is far more boring and far more likely to break your code: a Mercado Libre search page does not hand out one kind of link. On 11 October 2026 I ran a 10-result search for zapatillas on the Argentine site and got back four distinct URL shapes in ten rows.

The four shapes

Positions 1 to 4 — every single one flagged isSponsored: true — looked like this (truncated; the real thing is about a kilobyte):

https://click1.mercadolibre.com.ar/mclics/clicks/external/MLA/count ?a=XrvZILjHGldBLoVC1wdirEioJadSenEQxPY7kCoIIJBNUf0R0v3FE0pq… &pdp_filters=item_id%3AMLA3183930614 

That is not a product page. It is the ad click counter, and the product it points at is nowhere in the path — the only place the identifier survives is the pdp_filters query parameter, URL-encoded.

Positions 5, 7, 8 and 10 were a shape I had not seen in any Mercado Libre scraping write-up:

https://www.mercadolibre.com.ar/zapatilla-topper-sin-genero-lisa-estandar-rocket-tofunegro/up/MLAU3637728744 

Note the identifier: MLAU3637728744. Site prefix MLA, then a literal U, then digits. It is not the item ID, and it is not the catalog product ID (/p/MLA…) that older pages use.

Positions 6 and 9 were the classic listing URL everybody's regex knows:

https://articulo.mercadolibre.com.ar/MLA-2815639876-zapatillas-skate-oversize-clasicas-livianas-_JM?searchVariation=189936656896 

Four shapes, three different identifier formats, one search page. The practical consequences are immediate:

  • You cannot pull the ID with one pattern. You need the /p/MLA… path form, the MLA-1234567890 hyphenated form, the wid= and pdp_filters=item_id: query forms, and the /up/MLAU… form — and they do not all mean the same object.
  • You cannot dedupe by URL. Two ad impressions of the same product carry different a= blobs, so string comparison says they are different products. Dedupe on the extracted ID instead.
  • You cannot assume the top of the page is organic. In my run, 4 of the first 10 rows were paid placements. If you are tracking a competitor's rank, counting ads as rank is the most common way to get a chart that means nothing. I ship isSponsored as a field for exactly that reason, and includeSponsored: false drops them at the source.

The extraction in my parser is four fallbacks deep, which looks ugly and is the honest shape of the problem:

const ids = idFromUrl(href) || {}; // /p/MLA… or MLA-123… const wid = (href.match(/[?&#]wid=(M[A-Z]{2}\d+)/) || [])[1] || null; // variation picker const fromAd = (href.match(/item_id(?:%3A|:)(M[A-Z]{2}\d+)/) || [])[1] || null; // ad redirect const itemId = ids.kind === 'item' ? ids.id : (wid || fromAd || null); 

The honest bug this article found

While gathering the sample for this post I pasted one of those /up/MLAU… URLs straight into reviews mode. My router does not recognise the shape, so it fell through to the "this must be a listing page" branch, opened it in a browser, found no product cards, and wrote this:

{ "type": "error", "url": "https://www.mercadolibre.com.ar/zapatilla-topper…/up/MLAU3637728744", "error": "no product cards on listing page", "kind": "listing" } 

Two things I will defend about that output, and one I will not.

I will defend the error row: it is in the dataset, it names the URL, and it is billed at zero. A scraper that silently drops the inputs it could not read is worse than one that admits them, because the dataset still looks finished. And I will defend the routing retry visible in the log — plain HTTP got Mercado Libre's login wall (41 KB of "ingresá a tu cuenta"), the browser took over automatically, no human intervention needed.

What I will not defend is the router. The fix is to teach idFromUrl the /up/MLAU… shape; until it ships, feed reviews mode the productId / itemId that the search rows already give you, not the page URL. For the row above that is MLA2796328360. Search first, then reviews — that chain works today and is what the Actor's own example input does.

Where units-sold actually lives

Mercado Libre stopped printing "+5 mil vendidos" on most organic cards sometime before I measured this in late September 2026. The number did not go away; it moved into the page's own tracking payload, which is still in the HTML of every search page:

function soldByItem(html) { const map = new Map(); for (const m of String(html).matchAll(/\{"item_id":"(M[A-Z]{2}\d+)"[^{}]*?"sold_quantity":(\d+)/g)) { if (!map.has(m[1])) map.set(m[1], Number(m[2])); } return map; } 

Read it from there and you get a figure for every result on the page. Read it only from the visible card text and most rows come back null.

One caveat that matters if you plan to do arithmetic: the numbers are Mercado Libre's own rounding, not real sales. My ten rows returned 500, 50, 1000, 100, 5000, 1000, 100, 1000, 500 and 1000. Those are buckets. "Product A sold 1,000 and product B sold 500" means "A is in the 1,000 bucket" — fine for ranking, useless for revenue estimates.

While we are on untrustworthy integers: totalResults came back as 9,999 for zapatillas in Argentina, and 9,999 again for notebook in Chile on the same day. That is a ceiling, not a count. Treat any 9,999 as "more than we are being told", the same way you treat a capped page count.

And prices are never converted. The Chilean row is 1009990 with currency: "CLP"; the Argentine row is 49667 with "ARS". Mixing marketplaces in one run is supported, and if you average that column without grouping by currency you will produce a very confident, very wrong number.

Reviews: the rating total is not the review total

Reviews come from a JSON endpoint rather than the HTML, which is the cheap half of this Actor — no browser, no proxy traffic. A reviews-mode run on 11 October 2026 (maxReviewsPerProduct: 5) produced one summary row and five review rows:

{ "type": "product_rating", "productId": "MLA2815639876", "ratingAverage": 4.5, "ratingCount": 366, "reviewsWithText": 191, "ratingBreakdown": { "5": { "count": 278, "percentage": 75.96 }, "4": { "count": 44, "percentage": 12.02 }, "3": { "count": 25, "percentage": 6.83 }, "2": { "count": 5, "percentage": 1.37 }, "1": { "count": 14, "percentage": 3.83 } }, "picturesCount": 57, "videosCount": 18, "aiSummaryStatus": "published" } 

ratingCount: 366 but reviewsWithText: 191. Star ratings without a comment are counted in the average and are not text you can read, so a job sized at "366 reviews" will finish at roughly half that and look like it failed. Size review jobs by reviewsWithText.

The breakdown row is the part I would use even if I never read a single comment: it gives you the true star distribution — 76% five-star here — without paginating anything. Scraping page one of the reviews and counting stars gives you the relevance-sorted distribution, which is a different and much happier number.

Three more details from the same run. The endpoint returns 15 reviews per page, hard — ask for 100 and you are making seven requests. Every title field was null; the text instead carries Mercado Libre's guided-form prefixes, which is free structure if you want per-aspect sentiment:

"Diseño: hermoso el estilo y el color negro… Comodidad: bastante cómodas por dentro, y son ligeras." 

And each review names the variant bought — "Color: Negro · Talle: 44 AR" — plus both a relative date ("Hace 6 meses") and a real timestamp (2026-03-17T15:57:45Z). Use the second one.

Calling it

from apify_client import ApifyClient client = ApifyClient("<YOUR_APIFY_TOKEN>") run = client.actor("lergassy/mercadolibre-reviews-scraper").call(run_input={ "mode": "search", "country": "AR", "searchTerms": ["zapatillas"], "maxItems": 100, "includeSponsored": False, }) for row in client.dataset(run["defaultDatasetId"]).iterate_items(): print(row["itemId"], row["price"], row["currency"], row.get("soldQuantity")) 

The Actor lives at apify.com/lergassy/mercadolibre-reviews-scraper — search, product detail, reviews, daily deals and official-store modes in one input, dataset rows priced per result with no per-run fee.

When this is the wrong tool

If you are a Mercado Libre seller working with your own listings, orders or stock, use the official API — you have the OAuth credentials and the permissions, and you get write access that no scraper can give you. If you need one price checked once, the product page in a browser is faster than any pipeline. And if you need a guaranteed-complete category, no scraper can promise it: the 9,999 ceiling is real, and the only honest way past it is to slice the query by brand, price band or subcategory until each slice comes back under the cap.

What scraping is genuinely good for here is the public, cross-seller, cross-country view that the seller API will never hand you: what the competition charges in six currencies, which of their listings are paid placements, and what 191 buyers said about a product you do not sell.