Call the Mercado Libre Search Results Scraper from Node.js with this repository. It is one ES module on top of apify-client: you post a keyword, Apify runs a browser against mercadolibre.com.mx from a Mexican residential IP, and you receive plain objects for each organic listing with item ID, title, seller, rating, peso prices, markdown percentage, installment wording and delivery promise. None of that browser work lives in your service, which matters because the site checks visitors before it shows results and that check needs far more memory than a typical API container or serverless function has to spare. By default the rows are saved to results.json and results.csv; import the core call and they stay in memory for your own handler.
Actor: Mercado Libre Search Results Scraper · Publisher: Devil Scrapes · Also in Python: mercadolibre-scraper-python
- Numbers, not strings, for money:
salePriceandlistPricearrive as numbers in pesos, withcurrencyset toMXN, so arithmetic works without a cleanup pass. - Discounts made explicit: when a card shows a markdown, the old price lands in
listPriceand the percentage indiscountPercent; full-price items carrynullin both. - Organic matches only: paid placements, which point at click-tracking redirect links, are dropped before a row is built, so ad slots never pollute a price sample.
- Spanish text kept as written:
installmentsTextandshippingTexthold the exact strings from the card (for example a meses-sin-intereses offer), which keeps your own parsing honest. - Bounded depth: 48 results per page, at most 9 pages and 432 rows per run, controlled by
maxItemsandmaxPages; whichever limit is hit first stops the run. - Loud failure instead of a silent empty file: if Mercado Libre's page check cannot be passed after a few fresh attempts, the run ends as failed with a status message saying so.
- A search with no hits is not an error: the run succeeds with zero rows and, because the start event is charged with the first delivered rows, costs nothing.
A scheduled worker runs your tracked keywords, stores salePrice per itemId and notifies the user when a listing drops below a target. The recipe below shows the comparison step with nothing but Node built-ins.
If you sell on Mercado Libre yourself, feed competitor rows for your keywords into the service that sets your prices. Numeric prices mean your rules engine compares numbers directly instead of scraping digits out of strings.
Expose an endpoint that accepts a phrase, starts a run without waiting and returns the run ID. Let an Apify webhook call your function when the run succeeds, then read the dataset there and push the results to the client or a cache.
Match your SKUs to Mercado Libre listings by title and keep itemUrl next to them. Your product team gets a direct link to the live marketplace offer, with the current peso price and delivery wording attached.
You need an Apify account and an API token from Console → Settings → Integrations. Apify's free plan includes monthly usage credit; this Actor's event prices are listed under Pricing below.
git clone https://github.com/devilscrapes/mercadolibre-scraper-javascript.git
cd mercadolibre-scraper-javascript
npm install
export APIFY_TOKEN=your_token_here
node index.js # uses input.json
node index.js my-input.json --out data --max-charge 2The script starts the Mercado Libre Search Results Scraper, waits for it to finish, prints a link to the run in Apify Console and saves every result to results.json and results.csv. It exits with code 1 and the run's status message if the run fails.
The integration fits in a handful of lines. call() resolves once the run has finished, and listItems() pulls the stored rows from that run's default dataset.
import { ApifyClient } from 'apify-client';
const client = new ApifyClient({ token: 'YOUR_APIFY_TOKEN' });
const run = await client.actor('DevilScrapes/mercadolibre-scraper').call({"query": "laptop", "tld": "mx", "countryCode": "MX", "maxItems": 10, "maxPages": 1, "proxyConfiguration": {"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]}});
const { items } = await client.dataset(run.defaultDatasetId).listItems();
console.log(items);index.js forwards input.json unchanged. The sample asks for one page of laptop results and stops at 10 rows, a quick check that your token and the output mapping work before you spend more. In a service you would build this object per request; keep tld at mx, countryCode at MX and the residential proxy group, because the Actor validates them and the site needs them.
{
"query": "laptop",
"tld": "mx",
"countryCode": "MX",
"maxItems": 10,
"maxPages": 1,
"proxyConfiguration": {
"useApifyProxy": true,
"apifyProxyGroups": [
"RESIDENTIAL"
]
}
}| Field | Type | Required | Default | Allowed values |
|---|---|---|---|---|
query |
string | yes | "laptop" |
— |
tld |
string | no | "mx" |
mx |
countryCode |
string | no | "MX" |
— |
maxItems |
integer | no | 100 |
— |
maxPages |
integer | no | 3 |
— |
proxyConfiguration |
object | no | {"useApifyProxy": true, "apifyProxyGroups": ["RESIDENTIAL"]} |
— |
The dataset is an array of flat objects, one per organic listing. Optional values such as rating, listPrice or shippingText are null when the card does not show them, never missing keys, so a TypeScript interface with nullable fields describes the shape exactly.
| Field | Type | Always present |
|---|---|---|
itemId |
string | yes |
title |
string | yes |
seller |
string | yes |
rating |
number or null | no |
listPrice |
number or null | no |
salePrice |
number | yes |
discountPercent |
integer or null | no |
installmentsText |
string or null | no |
shippingText |
string or null | no |
itemUrl |
string | yes |
currency |
string | yes |
query |
string | yes |
page |
integer | yes |
scrapedAt |
string | yes |
| Event | Price (USD) |
|---|---|
| Actor start | $0.2 |
| Result emitted | $0.005 |
Prices as configured on 2026-10-16. The Actor page always shows the current price. Runs are billed to the Apify account that owns the token. Use --max-charge to cap what a single run can cost.
This script keeps the last seen price per listing in last-prices.json, compares it with the fresh rows and prints the drops, largest first. Swap console.table for a fetch to Slack or your own webhook to turn it into an alert. Node.js built-ins only.
import { readFile, writeFile } from 'node:fs/promises';
const rows = JSON.parse(await readFile('results.json', 'utf8'));
const previous = new Map(
JSON.parse(await readFile('last-prices.json', 'utf8').catch(() => '[]')).map((r) => [r.itemId, r.salePrice]),
);
const changes = [];
for (const row of rows) {
const before = previous.get(row.itemId);
if (before !== undefined && before !== row.salePrice) {
const pct = Math.round(((row.salePrice - before) / before) * 1000) / 10;
changes.push({ itemId: row.itemId, seller: row.seller, before, now: row.salePrice, pct, url: row.itemUrl });
}
}
await writeFile('last-prices.json', JSON.stringify(rows.map(({ itemId, salePrice }) => ({ itemId, salePrice }))));
const drops = changes.filter((c) => c.now < c.before).sort((a, b) => a.pct - b.pct);
console.log(`${rows.length} listings checked, ${changes.length} price changes, ${drops.length} drops`);
console.table(drops.slice(0, 10));Your function only talks to the Apify API, so yes. The browser runs on Apify with 8 GB of memory; for longer searches start the run asynchronously and react to a webhook instead of holding the function open.
Yes. salePrice is always a number and listPrice is a number or null. Installment and shipping details stay as the Spanish text the card shows, so localise or parse them in your app.
It finishes with status SUCCEEDED and an empty dataset, and its status message names the query. Treat it as a normal outcome rather than an exception path.
Key on itemId, which looks like MLM followed by digits. itemUrl has the click-tracking fragment removed, so it is also stable enough to store as a link.
Yes. Pass --max-charge to index.js, or set maxTotalChargeUsd on the call options in your own code. Apify stops charging at that amount even if more rows were requested.
Set APIFY_TOKEN first: export the token in the same shell, or put it in a local.envfile your shell loads. Never commit it.- Run finished with
FAILEDorABORTED: open the printed Console link; the run log says why. Input validation errors name the field. - Fewer rows than expected: a
--max-chargecap also limits how many results can be charged. Raise the cap or lower the limits in your input. - Something looks wrong in the data: open an issue on the Actor page so the maintainers see it.
- Mercado Libre Search Results Scraper on Apify Store
- Python version of this example
- All Devil Scrapes Actors
- Apify web scraping cookbook
Related Actors:
- AliExpress Products Scraper — cross-border retail prices for the same product categories.
- eBay Product & Listings Scraper — a second marketplace for price comparison outside Latin America.
- OLX Brazil Listings Scraper — classified listings from Brazil for a wider LatAm view.
MIT licensed example code. The Actor itself runs on Apify and is maintained by Devil Scrapes. Not affiliated with Mercado Libre or MercadoLibre, Inc..