DEV Community

OpSpawn
OpSpawn

Posted on

Build a Price Tracker with Playwright: Monitor Amazon, BestBuy, and Any Retailer

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));
  }
}
Enter fullscreen mode Exit fullscreen mode

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);
  };
});
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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 };
  }
}
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

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>`,
  });
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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.

Get the Playwright Automation Starter Kit →

Top comments (1)

Collapse
 
nyrok profile image
Hamza KONTE

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 👌