I run a visa agency in Bali, and the scrapers I publish on Apify are the other half of my week. The habit that has saved me most often is boring: every Actor I ship gets a daily health run with a frozen input, and I never delete the datasets. Eleven days later you have a small longitudinal study for free.
For my Mercado Livre Brazil scraper the fixture is this, unchanged since 17 October:
{ "country": "BR", "searchTerms": ["fone de ouvido bluetooth"], "maxItems": 20 } Eleven days, 18 runs, 360 product rows. This post is about one column in them — installmentsText — because it turned out to hold a price that none of the price fields hold, on more than a quarter of the rows.
A Mercado Livre card does not show one price. It shows up to three
Here is a real row, 23 October, position 12:
{ "title": "AirPods 5 - Distribuidor Autorizado", "productId": "MLB1077831584", "itemId": "MLB7616021802", "price": 1349, "originalPrice": 1499, "discountPercent": 10, "installmentsText": "ou R$1.499 em 10x R$149,90 sem juros", "soldQuantity": 500, "rating": 4.7, "reviewCount": 71, "seller": "Apple", "isOfficialStore": true, "labels": ["LOJA OFICIAL APPLE"], "position": 12, "totalResults": 9999 } Read it as a Brazilian shopper and it is obvious: R$1,349 if you pay by Pix or in cash, or R$1,499 if you want ten interest-free instalments. Read it as a dataframe and it is a trap. price is only one of the two things you can actually pay, and the other one is inside a Portuguese sentence.
That ou R$… em Nx R$… shape appeared on 97 of the 360 rows — 27%. In 94 of those 97 the number inside the sentence was a third value, strictly between price and originalPrice:
productId price originalPrice price in sentence MLB29041007 602.70 999.00 632.70 MLB65521325 901.07 1599.00 1001.00 MLB53767455 1519.00 1999.00 1599.00 MLB66000537 269.00 899.00 279.00 MLB42104100 154.28 270.00 177.33 Take the first row. discountPercent: 39 is true against R$999 — but only for the Pix price. By card it is R$632.70, a 36.7% discount, and no field in the row says so. The AirPods above are one of just 3 rows out of 97 where the sentence price happened to equal originalPrice, so you cannot even lean on "the strike-through is the card price". Sometimes it is a list price, sometimes it is the card price.
Every one of the 97 gaps was a round number
This is the part I did not expect. I took the difference between the sentence price and price on all 97 rows and tried to express each one as either a round percentage or a round amount in reais. All 97 decomposed. Nothing left over.
round percentage off round amount off 5% × 46 rows R$20 × 12 rows 3% × 11 R$10 × 6 10% × 10 R$30 × 6 13% × 3 R$5 × 2 6% × 1 Median gap 5.26%, mean 5.59%, maximum 14.94%; 13 rows above 10%. So the second price is not noise and not a scraping artefact — it is a Pix/cash incentive set by the seller in whole percents or whole reais, and price already has it applied. If you are benchmarking "what does this product cost on Mercado Livre", you are reading the discounted number on 27% of your rows and the plain number on the rest, mixed in one column.
The same field holds four different sentences
Across the 360 rows, installmentsText took exactly four shapes:
| Shape | Rows | What it means |
|---|---|---|
10x R$89,90 sem juros | 158 | instalments on the displayed price |
null | 96 | no instalment line on the card at all |
ou R$999 em 10x R$99,90 sem juros | 94 | a second, higher price — the one above |
12x R$15,80 (no sem juros) | 9 | instalments with interest |
ou R$149 em outros meios | 3 | second price, no instalments offered |
Three things worth knowing about those groups.
The null rows are not missing data. All 96 of them are cheap: 90 under R$100, every one under R$145. The cheapest row that did carry an instalment line was R$101. Mercado Livre simply does not offer parcelas on a R$35 pair of earbuds. Treat null here as "not offered", not "scraper missed it".
The 9 rows without sem juros are the expensive ones. 12x R$15,80 on a R$159.90 product totals R$189.60 — 18.6% more than the sticker. The other two came in at 17.4% and 18.8%. If your pipeline multiplies instalments to reconstruct a total, those rows will quietly inflate it.
Interest-free arithmetic is exact, but round your comparison. On 146 of the 158 sem juros rows, instalments × value equals price to the cent. The 12 that did not were all the same product, off by R$1 or less — Mercado Livre rounds the per-instalment figure to two decimals and eats the remainder. Compare with a tolerance of about R$1, not ==.
A parser that respects all four shapes is short:
import re OU = re.compile(r"^ou R\$ ?([\d.]*\d(?:,\d+)?) em (\d+)x") OU_X = re.compile(r"^ou R\$ ?([\d.]*\d(?:,\d+)?) em outros meios$") NX = re.compile(r"^(\d+)x R\$ ?([\d.]*\d(?:,\d+)?)( sem juros)?$") brl = lambda s: float(s.replace(".", "").replace(",", ".")) def prices(row): """-> (pay_now, card_price, instalments, interest_free)""" t = row.get("installmentsText") p = row["price"] if not t: return p, p, None, None if m := OU.match(t): return p, brl(m[1]), int(m[2]), "sem juros" in t if m := OU_X.match(t): return p, brl(m[1]), None, None if m := NX.match(t): n, per = int(m[1]), brl(m[2]) return p, (p if m[3] else round(n * per, 2)), n, bool(m[3]) raise ValueError(t) soldQuantity is a bucket, and the review count proves it
While I was in there: across 360 rows soldQuantity took nine distinct values ever — 25, 50, 100, 500, 1000, 5000, 10000, 50000, 100000 — plus null on 31 rows. It is Mercado Livre's own "+10 mil vendidos" badge, parsed. A floor, not a count.
You do not have to take my word for it, because the dataset contradicts itself if you read it literally. On 46 rows the review count exceeds the units sold:
Samsung Galaxy Buds Core soldQuantity 10000 reviewCount 32195 Fone Bluetooth Wave Buds 2 soldQuantity 10000 reviewCount 25417 Soundcore Liberty 5 soldQuantity 500 reviewCount 606 32,195 people reviewed 10,000 sales. Use soldQuantity to sort, never to sum.
Two smaller ones from the same audit. totalResults was 9999 on all 360 rows — that is Mercado Livre's cap, not a count, so there is no way to learn how big a result set really is. And reviewCount is null on 163 rows, of which 121 are sponsored placements: the ad layout prints stars without the number. rating without reviewCount is normal here, not broken.
Calling it
At this size the 18 runs took 28 to 117 seconds — 16 of the 18 under a minute — so the synchronous endpoint is fine:
curl -X POST "https://api.apify.com/v2/acts/lergassy~mercado-livre-scraper/run-sync-get-dataset-items?token=$APIFY_TOKEN" \ -H 'Content-Type: application/json' \ -d '{"country":"BR","searchTerms":["fone de ouvido bluetooth"],"maxItems":20}' The Actor is Mercado Livre Scraper. Every row carries a type, so one dataset can mix product, review, deal and store rows and you split them in one filter — including a free notice row with the run's own cost (my 20-row fixture charges $0.045). Pages the site refuses come back as free error rows rather than gaps.
When it is the wrong tool
Three fields were null on 360 of 360 rows: brand, condition, location. Search cards do not print them, and no amount of retrying will change that — if you need them, feed product URLs instead of keywords and pay for the full product page. Same for specifications, stock and descriptions.
Two more honest limits. The 125 distinct products behind these 360 rows include 59 that appeared exactly once in eleven days, and not one product appeared in all 18 runs — a keyword snapshot is a sample of a moving list, not a census, so do not diff position day over day and call it ranking. And productId equals itemId on only 153 of 360 rows: catalogue pages and individual listings are two different ID spaces, so deduplicate on productId and expect several itemId values under one of them.
If you want the Spanish-language marketplaces instead — Mexico, Argentina, Chile and 15 more — the same engine ships as Mercado Libre Scraper — same fields, same row types, Portuguese swapped for Spanish in the text fields.
The general lesson is older than this site: when a marketplace shows a shopper two numbers, a schema with one price column picks the wrong one somewhere. Keep the sentence.