How to build a price drop alert in Node.js
You are watching a few products and want to hear about it the day one gets cheaper. This guide builds a price drop alert in Node.js in about 70 lines: it reads each product's price with one API call, turns the price text into a number, compares it with what it saw last time, and prints or posts an alert when a price falls. It needs Node.js 18 or newer and no packages.
How it works
- For each product URL,
POST /v1/extractreturns the product name and price text using two CSS selectors. - The script parses
"£51.77"into51.77. - It loads
prices.jsonfrom the previous run, compares, and collects every product whose price went down. - It saves the new prices, then sends one alert listing all the drops.
The examples use books.toscrape.com, a practice shop that is safe to scrape. On a book page, the title is the h1 and the price is .price_color inside .product_main. For your own shops, open a product page, right-click the price, choose Inspect, and swap in the selectors you find.
The script
Save this as price-alert.mjs. The .mjs extension lets it use top-level await.
// price-alert.mjs: check product prices, alert on drops. Node.js 18+.
import { readFile, writeFile } from "node:fs/promises";
const API = "https://scrape.land";
const KEY = process.env.SCRAPELAND_KEY;
const STATE = "prices.json";
const PRODUCTS = [
"https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html",
"https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html",
"https://books.toscrape.com/catalogue/soumission_998/index.html",
];
const FIELDS = { name: "h1", price: ".product_main .price_color" };
async function extract(url) {
const res = await fetch(`${API}/v1/extract`, {
method: "POST",
headers: { "X-Api-Key": KEY, "Content-Type": "application/json" },
body: JSON.stringify({ url, fields: FIELDS }),
});
const body = await res.json();
if (!res.ok) throw new Error(`${res.status}: ${body.error}`);
return body.data;
}
// "£1,049.00" -> 1049; null if there is no number in it.
function toNumber(text) {
const n = parseFloat(String(text ?? "").replace(/[^0-9.]/g, ""));
return Number.isFinite(n) ? n : null;
}
async function notify(drops) {
const lines = drops.map(d => `${d.name}: ${d.old} -> ${d.now} (${d.url})`);
console.log("PRICE DROP\n" + lines.join("\n"));
if (process.env.WEBHOOK_URL) {
await fetch(process.env.WEBHOOK_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ text: "Price drop\n" + lines.join("\n") }),
});
}
}
let seen = {};
try { seen = JSON.parse(await readFile(STATE, "utf8")); } catch { /* first run */ }
const drops = [];
for (const url of PRODUCTS) {
try {
const { name, price } = await extract(url);
const now = toNumber(price);
if (now === null) { console.log(`no price found: ${url}`); continue; }
const old = seen[url]?.price;
console.log(`${name}: ${price}${old != null ? ` (was ${old})` : " (first check)"}`);
if (old != null && now < old) drops.push({ name, url, old, now });
seen[url] = { name, price: now, checked: new Date().toISOString() };
} catch (err) {
console.log(`failed ${url}: ${err.message}`);
}
}
await writeFile(STATE, JSON.stringify(seen, null, 2));
if (drops.length) await notify(drops);A few choices in there are deliberate:
- One product failing does not stop the run. The
tryinside the loop logs the failure and moves on, and a product that failed keeps its old price in the file, so it is compared properly next time. - A missing price is skipped, not saved as zero. If the selector finds nothing (the page changed, or the product was removed),
toNumberreturnsnulland the old price stays. Saving zero would trigger a false drop alert now and a false rise later. - The name comes from the page, so the alert reads "Tipping the Velvet" instead of a URL.
First run
export SCRAPELAND_KEY=YOUR_KEY
node price-alert.mjsThis is the real output of that first run. There is nothing to compare with yet, so every product is a first check:
A Light in the Attic: £51.77 (first check)
Tipping the Velvet: £53.74 (first check)
Soumission: £50.10 (first check)And the prices.json it wrote (the first entry shown; the other two look the same):
{
"https://books.toscrape.com/catalogue/a-light-in-the-attic_1000/index.html": {
"name": "A Light in the Attic",
"price": 51.77,
"checked": "2026-09-30T22:08:21.220Z"
}
}Seeing a drop
The practice shop never changes its prices, so to test the alert we edited prices.json by hand and set the stored price of "Tipping the Velvet" to 59.99, as if it had cost more yesterday. The next run, for real:
A Light in the Attic: £51.77 (was 51.77)
Tipping the Velvet: £53.74 (was 59.99)
Soumission: £50.10 (was 50.1)
PRICE DROP
Tipping the Velvet: 59.99 -> 53.74 (https://books.toscrape.com/catalogue/tipping-the-velvet_999/index.html)Editing the state file is also how you should test your own setup before trusting it with real alerts.
Sending the alert somewhere useful
With WEBHOOK_URL set, the script also posts {"text": "..."} to it. That is the format a Slack incoming webhook expects, so creating one in Slack and pasting its URL is enough. Discord webhooks want the message under content instead of text; change that one key. For email, replace the body of notify with a call to your mail provider's API.
Running it every day
The script keeps its memory in prices.json next to it, so any scheduler works. On Linux or macOS, crontab -e and add a line to run it at 8:00 every morning:
0 8 * * * cd /home/you/price-alert && SCRAPELAND_KEY=YOUR_KEY /usr/bin/node price-alert.mjs >> alert.log 2>&1Use the full path to node (which node prints it), because cron does not load your shell's PATH. On Windows, Task Scheduler does the same job: run node with the script as its argument and the script's folder as the start directory.
Once a day is enough for most shops. Checking every few minutes rarely catches anything a daily check misses, and it multiplies your request count.
When prices look wrong
- Prices differ from what you see in your browser. Many shops price by visitor location. Add
country: "us"(or your market) to the request body so every check sees the same shop. - Every price is missing. The price may be drawn by JavaScript after the page loads. Add
render: trueto the body, andblock_resources: trueto keep the cost at 1 unit. - Commas as decimals.
toNumbertreats1.049,00incorrectly. For European shops, swap the dots and commas before parsing.
What it costs
Each product check is one CSS extraction: 1 request unit, on every plan. 30 products checked once a day is about 900 units a month, which fits the Free plan's 1,000; blocked or failed requests are not billed. See pricing.
Next steps
To watch whole category pages instead of single products, read monitoring competitor prices on a schedule. To keep a history in a spreadsheet, see extracting prices into a spreadsheet. The docs cover every option, and you can get a free key to run the script today.