Building a price tracker with Playwright is straightforward once you have the right primitives. Here's everything you need.
The Core Loop
interface PriceAlert {
url: string;
selector: string;
targetPrice: number;
checkIntervalMs: number;
}
async function monitorPrice(alert: PriceAlert): Promise<void> {
const browser = await chromium.launch();
while (true) {
const page = await browser.newPage();
try {
await page.goto(alert.url, { waitUntil: 'networkidle' });
const priceText = await page.$eval(alert.selector, el => el.textContent);
const price = parseFloat(priceText?.replace(/[^0-9.]/g, '') || '0');
if (price <= alert.targetPrice) {
await sendAlert(alert.url, price, alert.targetPrice);
break;
}
} finally {
await page.close();
}
await new Promise(r => setTimeout(r, alert.checkIntervalMs));
}
}
Anti-Detection (Critical)
Without this, most retailers will 403 you within minutes:
const context = await browser.newContext({
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
viewport: { width: 1920, height: 1080 },
locale: 'en-US',
timezoneId: 'America/New_York',
});
await context.addInitScript(() => {
Object.defineProperty(navigator, 'webdriver', { get: () => undefined });
Object.defineProperty(navigator, 'plugins', { get: () => [1, 2, 3, 4, 5] });
const getParameter = WebGLRenderingContext.prototype.getParameter;
WebGLRenderingContext.prototype.getParameter = function(param) {
if (param === 37445) return 'Intel Open Source Technology Center';
if (param === 37446) return 'Mesa DRI Intel(R) HD Graphics';
return getParameter.call(this, param);
};
});
Dynamic Price Selectors
Different sites use different structures:
const PRICE_SELECTORS: Record<string, string[]> = {
amazon: [
'span.a-price-whole',
'#priceblock_ourprice',
'#priceblock_dealprice',
'.a-offscreen:first-of-type',
],
bestbuy: ['.priceView-customer-price span[aria-hidden="true"]'],
walmart: ['.price-characteristic', '[itemprop="price"]'],
};
async function extractPrice(page: Page, retailer: string): Promise<number | null> {
const selectors = PRICE_SELECTORS[retailer] || ['.price', '.product-price'];
for (const selector of selectors) {
try {
const text = await page.$eval(selector, el => el.textContent);
if (text) {
const price = parseFloat(text.replace(/[^0-9.]/g, ''));
if (price > 0) return price;
}
} catch { /* try next */ }
}
return null;
}
Hash-Based Change Detection
For availability changes, spec updates, anything non-numeric:
import { createHash } from 'crypto';
import { promises as fs } from 'fs';
async function hasChanged(url: string, selector: string): Promise<{ changed: boolean; diff?: string }> {
const page = await browser.newPage();
await page.goto(url);
const content = await page.$eval(selector, el => el.textContent?.trim() || '');
const hash = createHash('sha256').update(content).digest('hex');
const cacheKey = Buffer.from(url).toString('base64').slice(0, 20);
const cacheFile = `.cache/${cacheKey}.json`;
try {
const cached = JSON.parse(await fs.readFile(cacheFile, 'utf8'));
if (cached.hash === hash) return { changed: false };
await fs.writeFile(cacheFile, JSON.stringify({ hash, content, updatedAt: new Date().toISOString() }));
return { changed: true, diff: `Was: "${cached.content}"\nNow: "${content}"` };
} catch {
await fs.mkdir('.cache', { recursive: true });
await fs.writeFile(cacheFile, JSON.stringify({ hash, content }));
return { changed: false };
}
}
Rate Limiting
For multi-product trackers, throttle to avoid IP bans:
class RequestQueue {
private queue: Array<() => Promise<void>> = [];
private running = false;
async add(fn: () => Promise<void>): Promise<void> {
const delayMs = 2000 + Math.random() * 3000; // 2-5 seconds
return new Promise((resolve, reject) => {
this.queue.push(async () => {
await new Promise(r => setTimeout(r, delayMs));
try { await fn(); resolve(); } catch (e) { reject(e); }
});
if (!this.running) this.process();
});
}
private async process(): Promise<void> {
this.running = true;
while (this.queue.length > 0) await this.queue.shift()!();
this.running = false;
}
}
Random 2-5 second delays look human. Consistent intervals (5000ms exactly) get flagged.
Email Alerts
import nodemailer from 'nodemailer';
async function sendAlert(url: string, currentPrice: number, targetPrice: number): Promise<void> {
const transporter = nodemailer.createTransporter({
service: 'gmail',
auth: { user: process.env.ALERT_EMAIL, pass: process.env.ALERT_PASSWORD },
});
await transporter.sendMail({
from: process.env.ALERT_EMAIL,
to: process.env.ALERT_EMAIL,
subject: `Price Drop: $${currentPrice} (your target: $${targetPrice})`,
html: `<h2>Price Drop Detected</h2>
<p>Current: <strong>$${currentPrice}</strong> | Target: $${targetPrice}</p>
<p><a href="${url}">View Product</a></p>`,
});
}
Putting It Together
const queue = new RequestQueue();
const products = [
{ url: 'https://amazon.com/dp/B0abc...', targetPrice: 299, retailer: 'amazon' },
{ url: 'https://bestbuy.com/...', targetPrice: 249, retailer: 'bestbuy' },
];
// Check every 15 minutes
setInterval(() => {
for (const product of products) {
queue.add(async () => {
const price = await extractPrice(page, product.retailer);
if (price && price <= product.targetPrice) {
await sendAlert(product.url, price, product.targetPrice);
}
});
}
}, 15 * 60 * 1000);
The patterns above — anti-detection, hash diffing, request queuing, and alert system — are all included in the Playwright Browser Automation Starter Kit.
It's 20+ TypeScript scripts you can drop into any project: price trackers, scrapers, PDF generators, form automation, login flows, page monitors. MIT licensed. One-time $19 — you own it.
Top comments (1)
Nice tutorial! Playwright + price tracking is a classic combo. One thing I've found when building automation pipelines that involve AI (e.g. using an LLM to summarize price trends or generate alerts) is that prompt quality becomes a bottleneck quickly.
I built flompt (flompt.dev) — a free visual prompt builder that structures prompts into semantic blocks (role, constraints, output format, etc.) and compiles them into Claude-optimized XML. Especially useful when you're embedding AI into agentic workflows like price trackers. There's even an MCP server for Claude Code:
claude mcp add flompt https://flompt.dev/mcp/Great walkthrough — the selector fallback strategy for different retailers is 👌