DEV Community

Boehner
Boehner

Posted on

Building a Competitor Monitoring Tool in 30 Lines of Node.js

I wanted to know when my main competitor changed their pricing page. Not in a shady way — I just wanted to stop finding out weeks after the fact when someone mentioned it in a Slack thread.

The naive solution is bookmarking and checking manually. I did this for about two weeks before admitting it wasn't going to stick. What I actually wanted was to set it once and get notified when something changed.

Here's the script I wrote. It's about 30 lines.

The approach

SnapAPI's /v1/analyze endpoint returns structured data about any URL: the page type, the primary CTA, detected technologies, and more — from a real Chromium browser session. So instead of scraping HTML and parsing it, I can just ask "what's the CTA on this page right now?" and diff that against yesterday's answer.

No parsing. No XPath selectors that break when the site redesigns. Just a clean JSON field.

The script

// competitor-monitor.js
// Polls a URL daily, alerts when the primary CTA or tech stack changes.
// Requirements: Node.js 18+ (built-in fetch), SNAPAPI_KEY env var.

const fs = require('fs');

const API_KEY  = process.env.SNAPAPI_KEY;
const URL      = process.env.MONITOR_URL || 'https://yourcompetitor.com';
const STATE    = './monitor-state.json';

if (!API_KEY) {
  console.error('Set SNAPAPI_KEY env var. Free key at https://snapapi.tech');
  process.exit(1);
}

async function analyze(url) {
  const res = await fetch(
    `https://api.snapapi.tech/v1/analyze?url=${encodeURIComponent(url)}`,
    { headers: { 'x-api-key': API_KEY } }
  );
  if (!res.ok) throw new Error(`SnapAPI ${res.status}: ${await res.text()}`);
  return res.json();
}

async function main() {
  const current = await analyze(URL);

  const snapshot = {
    cta:          current.primary_cta,
    technologies: current.technologies.sort().join(','),
    page_type:    current.page_type,
    checked_at:   new Date().toISOString(),
  };

  // Load previous state
  const prev = fs.existsSync(STATE)
    ? JSON.parse(fs.readFileSync(STATE, 'utf8'))
    : null;

  if (prev) {
    if (prev.cta !== snapshot.cta) {
      console.log(`[CHANGE] CTA: "${prev.cta}" → "${snapshot.cta}"`);
    }
    if (prev.technologies !== snapshot.technologies) {
      console.log(`[CHANGE] Tech stack changed`);
      console.log(`  Before: ${prev.technologies}`);
      console.log(`  After:  ${snapshot.technologies}`);
    }
    if (prev.page_type !== snapshot.page_type) {
      console.log(`[CHANGE] Page type: "${prev.page_type}" → "${snapshot.page_type}"`);
    }
    if (prev.cta === snapshot.cta && prev.technologies === snapshot.technologies) {
      console.log(`[OK] No changes detected. CTA: "${snapshot.cta}"`);
    }
  } else {
    console.log(`[INIT] Baseline set. CTA: "${snapshot.cta}"`);
  }

  fs.writeFileSync(STATE, JSON.stringify(snapshot, null, 2));
}

main().catch(err => { console.error(err.message); process.exit(1); });
Enter fullscreen mode Exit fullscreen mode

Run it:

export SNAPAPI_KEY=your_key_here
export MONITOR_URL=https://competitor.com/pricing
node competitor-monitor.js
Enter fullscreen mode Exit fullscreen mode

Set up a cron to run it daily:

0 9 * * * SNAPAPI_KEY=your_key MONITOR_URL=https://competitor.com/pricing node /path/to/competitor-monitor.js >> /var/log/monitor.log 2>&1
Enter fullscreen mode Exit fullscreen mode

That's it. Every morning at 9am it checks the page, diffs against yesterday's state, and logs any changes.

What it actually catches

This has been running against three competitor URLs for about six weeks. Things it's caught so far:

  • A competitor changed their hero CTA from "Start free trial" to "Get started free" — subtle, but a signal they were A/B testing the copy
  • One competitor's tech stack changed — they dropped an analytics tool I didn't recognize, suggesting a product change
  • A pricing page shifted from "product landing page" to "checkout flow" — they had quietly removed their pricing and replaced it with a direct checkout redirect

None of these would have made it into my RSS feed or Twitter alerts.

Extending it

Slack alerts: Replace the console.log calls with a fetch to a Slack webhook URL:

async function alert(message) {
  await fetch(process.env.SLACK_WEBHOOK, {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ text: message }),
  });
}
Enter fullscreen mode Exit fullscreen mode

Multiple URLs: Wrap the main() logic in a loop over an array of URLs. Each call uses one API credit. With the free tier (100/month), you can monitor 3 URLs daily with room to spare. The $9/month Starter plan covers 33 URLs/day.

Store history: Instead of overwriting monitor-state.json, append to a history.jsonl file (one JSON object per line). Gives you a full audit trail to diff across time.

Visual diff: Add ?screenshot=true to the analyze URL and save each screenshot to disk. Now you have a visual history alongside the structural diff — useful for catching layout or design changes that don't show up in the text fields.

Free API key

100 API calls/month, no credit card, active in 30 seconds.

snapapi.tech

Top comments (0)