DEV Community

Cover image for Build a Facebook Ad Spy Tool to Reverse-Engineer Winning Campaigns
Olamide Olaniyan
Olamide Olaniyan

Posted on

Build a Facebook Ad Spy Tool to Reverse-Engineer Winning Campaigns

Your competitor just 10x'd their revenue. You saw it on Twitter. Their founder's bragging about it.

What changed? Their ads.

Every Facebook ad ever run is public. Meta's Ad Library exists. But scraping it manually is painful — you're clicking through pages, can't filter properly, and there's no way to track changes over time.

So let's build a tool that does it for you. A Facebook Ad Spy that:

  1. Finds every ad a company is running
  2. Tracks how long they've been running (longer = profitable)
  3. Analyzes ad copy patterns and formats
  4. Alerts you when competitors launch new campaigns

Why Ad Longevity Matters

Here's the thing most people miss: if an ad has been running for 30+ days, it's profitable.

No sane media buyer keeps a losing ad running for a month. So the simplest competitive intelligence hack is: find ads that have been running the longest.

That's it. No fancy AI. No complex analysis. Just time.

Facebook's Ad Library lets you see this data. We're going to automate extracting it.

The Stack

  • Node.js: Runtime
  • SociaVault API: Facebook Ad Library endpoints
  • OpenAI: Ad copy analysis
  • SQLite (via better-sqlite3): Local storage for tracking

Step 1: Setup

mkdir fb-ad-spy
cd fb-ad-spy
npm init -y
npm install axios better-sqlite3 openai dotenv
Enter fullscreen mode Exit fullscreen mode

Create .env:

SOCIAVAULT_API_KEY=your_key_here
OPENAI_API_KEY=your_openai_key
Enter fullscreen mode Exit fullscreen mode

Step 2: Initialize the Database

We need to track ads over time to detect new launches and long-running winners.

Create db.js:

const Database = require('better-sqlite3');
const path = require('path');

const db = new Database(path.join(__dirname, 'ad-spy.db'));

db.exec(`
  CREATE TABLE IF NOT EXISTS companies (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    name TEXT NOT NULL,
    page_id TEXT UNIQUE,
    added_at TEXT DEFAULT (datetime('now'))
  );

  CREATE TABLE IF NOT EXISTS ads (
    id TEXT PRIMARY KEY,
    company_id INTEGER,
    headline TEXT,
    body TEXT,
    link_url TEXT,
    media_type TEXT,
    status TEXT,
    start_date TEXT,
    first_seen TEXT DEFAULT (datetime('now')),
    last_seen TEXT DEFAULT (datetime('now')),
    days_running INTEGER DEFAULT 0,
    FOREIGN KEY (company_id) REFERENCES companies(id)
  );

  CREATE TABLE IF NOT EXISTS snapshots (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    ad_id TEXT,
    snapshot_date TEXT DEFAULT (datetime('now')),
    status TEXT,
    FOREIGN KEY (ad_id) REFERENCES ads(id)
  );
`);

module.exports = db;
Enter fullscreen mode Exit fullscreen mode

Step 3: Find Companies in the Ad Library

First, we need to search for a company and get their page ID:

Create spy.js:

require('dotenv').config();
const axios = require('axios');
const db = require('./db');

const API_BASE = 'https://api.sociavault.com';
const headers = { 'Authorization': `Bearer ${process.env.SOCIAVAULT_API_KEY}` };

async function searchCompany(query) {
  console.log(`šŸ” Searching for "${query}"...`);

  const { data } = await axios.get(`${API_BASE}/v1/scrape/facebook-ad-library/search-companies`, {
    params: { query },
    headers
  });

  const companies = data.data || [];

  if (companies.length === 0) {
    console.log('No companies found.');
    return null;
  }

  console.log(`\nFound ${companies.length} companies:\n`);
  companies.forEach((c, i) => {
    console.log(`  ${i + 1}. ${c.name || c.pageName} (${c.pageId || c.id})`);
    if (c.categories) console.log(`     Categories: ${c.categories.join(', ')}`);
  });

  return companies;
}
Enter fullscreen mode Exit fullscreen mode

Step 4: Pull All Ads from a Company

This is the meat of it. We pull every ad, handle pagination, and store them:

async function getCompanyAds(companyName, pageId, options = {}) {
  console.log(`\nšŸ“„ Fetching ads for ${companyName}...`);

  // Save company to db
  const insertCompany = db.prepare(
    'INSERT OR IGNORE INTO companies (name, page_id) VALUES (?, ?)'
  );
  insertCompany.run(companyName, pageId);

  const company = db.prepare('SELECT * FROM companies WHERE page_id = ?').get(pageId);

  let allAds = [];
  let cursor = null;
  let page = 0;

  while (true) {
    page++;
    console.log(`  Page ${page}...`);

    const params = {
      pageId,
      country: options.country || 'US',
      status: options.status || 'active',
      ...(options.media_type && { media_type: options.media_type }),
      ...(cursor && { cursor })
    };

    const { data } = await axios.get(
      `${API_BASE}/v1/scrape/facebook-ad-library/company-ads`,
      { params, headers }
    );

    const ads = data.data?.ads || data.data || [];
    allAds = allAds.concat(ads);

    // Check for pagination
    cursor = data.data?.cursor || data.data?.paging?.next;
    if (!cursor || ads.length === 0) break;

    // Don't hammer the API
    await new Promise(r => setTimeout(r, 1000));
  }

  console.log(`  Found ${allAds.length} ads total`);

  // Store ads
  const insertAd = db.prepare(`
    INSERT INTO ads (id, company_id, headline, body, link_url, media_type, status, start_date)
    VALUES (?, ?, ?, ?, ?, ?, ?, ?)
    ON CONFLICT(id) DO UPDATE SET
      last_seen = datetime('now'),
      status = excluded.status,
      days_running = CAST(
        (julianday('now') - julianday(COALESCE(ads.start_date, ads.first_seen))) AS INTEGER
      )
  `);

  const insertSnapshot = db.prepare(
    'INSERT INTO snapshots (ad_id, status) VALUES (?, ?)'
  );

  const transaction = db.transaction((ads) => {
    for (const ad of ads) {
      const adId = ad.id || ad.adArchiveID;
      insertAd.run(
        adId,
        company.id,
        ad.headline || ad.title || '',
        ad.body || ad.bodyText || '',
        ad.linkUrl || ad.link || '',
        ad.mediaType || detectMediaType(ad),
        ad.isActive ? 'active' : 'inactive',
        ad.startDate || ad.startedRunningOn || null
      );
      insertSnapshot.run(adId, ad.isActive ? 'active' : 'inactive');
    }
  });

  transaction(allAds);

  return allAds;
}

function detectMediaType(ad) {
  if (ad.videos?.length > 0 || ad.video) return 'video';
  if (ad.images?.length > 1 || ad.carousel) return 'carousel';
  return 'image';
}
Enter fullscreen mode Exit fullscreen mode

Step 5: Identify Long-Running Winners

This is the money feature. Ads that run the longest are the profitable ones:

function getLongRunningAds(pageId, minDays = 14) {
  const company = db.prepare('SELECT * FROM companies WHERE page_id = ?').get(pageId);
  if (!company) return [];

  const ads = db.prepare(`
    SELECT * FROM ads 
    WHERE company_id = ? AND days_running >= ?
    ORDER BY days_running DESC
  `).all(company.id, minDays);

  return ads;
}

function printWinningAds(pageId) {
  console.log('\nšŸ† WINNING ADS (Running 14+ Days)');
  console.log('═'.repeat(60));

  const winners = getLongRunningAds(pageId, 14);

  if (winners.length === 0) {
    console.log('No long-running ads found yet. Run the spy again in a few days.');
    return;
  }

  winners.forEach((ad, i) => {
    console.log(`\n${i + 1}. [${ad.days_running} days] ${ad.media_type.toUpperCase()}`);
    console.log(`   Headline: ${ad.headline || '(none)'}`);
    console.log(`   Body: ${(ad.body || '').substring(0, 120)}...`);
    if (ad.link_url) console.log(`   URL: ${ad.link_url}`);
    console.log(`   Status: ${ad.status}`);
  });

  console.log(`\nTotal winners: ${winners.length}`);
}
Enter fullscreen mode Exit fullscreen mode

Step 6: Analyze Ad Copy Patterns with AI

What are the winning ads actually saying? Let's find the patterns:

const OpenAI = require('openai');
const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY });

async function analyzeAdPatterns(pageId) {
  const winners = getLongRunningAds(pageId, 7);

  if (winners.length < 3) {
    console.log('Need at least 3 ads to find patterns.');
    return;
  }

  console.log(`\n🧠 Analyzing patterns across ${winners.length} ads...\n`);

  const adData = winners.map(ad => ({
    headline: ad.headline,
    body: ad.body,
    type: ad.media_type,
    days: ad.days_running
  }));

  const completion = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [{
      role: 'user',
      content: `Analyze these Facebook ads from the same company and find patterns.

These are their longest-running (most profitable) ads:

${JSON.stringify(adData, null, 2)}

Return JSON:
{
  "hooks": ["list of hook formulas they use repeatedly"],
  "ctas": ["list of call-to-action patterns"],  
  "copyFormulas": ["list of copy frameworks (PAS, AIDA, etc)"],
  "emotionalTriggers": ["urgency, FOMO, social proof, etc"],
  "mediaStrategy": "what types of media dominate their winners",
  "angleBreakdown": {
    "pain_point_ads": "percentage and description",
    "benefit_ads": "percentage and description",
    "social_proof_ads": "percentage and description"
  },
  "recommendations": ["3 specific ads you could create based on their patterns"]
}`
    }],
    response_format: { type: 'json_object' }
  });

  const analysis = JSON.parse(completion.choices[0].message.content);

  console.log('šŸ“Š AD COPY ANALYSIS');
  console.log('═'.repeat(60));

  console.log('\nšŸŽ£ Top Hooks:');
  analysis.hooks.forEach(h => console.log(`  • ${h}`));

  console.log('\nšŸ“¢ CTAs:');
  analysis.ctas.forEach(c => console.log(`  • ${c}`));

  console.log('\nšŸ“ Copy Formulas:');
  analysis.copyFormulas.forEach(f => console.log(`  • ${f}`));

  console.log('\n😤 Emotional Triggers:');
  analysis.emotionalTriggers.forEach(t => console.log(`  • ${t}`));

  console.log(`\nšŸ“ø Media Strategy: ${analysis.mediaStrategy}`);

  console.log('\nšŸ’” Ads You Could Create:');
  analysis.recommendations.forEach((r, i) => console.log(`  ${i + 1}. ${r}`));

  return analysis;
}
Enter fullscreen mode Exit fullscreen mode

Step 7: Get Deep Details on Any Ad

Want to look at a specific ad more closely? Get the full creative:

async function getAdDetails(adId) {
  console.log(`\nšŸ”Ž Fetching ad details...`);

  const { data } = await axios.get(
    `${API_BASE}/v1/scrape/facebook-ad-library/ad-details`,
    { params: { id: adId, get_transcript: true }, headers }
  );

  const ad = data.data;

  console.log('\nšŸ“‹ AD DETAILS');
  console.log('─'.repeat(50));
  console.log(`  ID: ${ad.id || adId}`);
  console.log(`  Page: ${ad.pageName || ad.page_name}`);
  console.log(`  Status: ${ad.isActive ? 'Active' : 'Inactive'}`);
  console.log(`  Started: ${ad.startDate || ad.startedRunningOn}`);
  console.log(`  Platforms: ${(ad.platforms || []).join(', ')}`);

  if (ad.headline) console.log(`\n  Headline: ${ad.headline}`);
  if (ad.body) console.log(`  Body: ${ad.body}`);
  if (ad.linkUrl) console.log(`  Link: ${ad.linkUrl}`);
  if (ad.cta) console.log(`  CTA: ${ad.cta}`);

  if (ad.transcript) {
    console.log(`\n  šŸ“ Video Transcript:`);
    console.log(`  ${ad.transcript.substring(0, 300)}...`);
  }

  return ad;
}
Enter fullscreen mode Exit fullscreen mode

Step 8: Search Ads by Keyword

Don't know who your competitors are? Search the entire ad library:

async function searchAds(query, options = {}) {
  console.log(`\nšŸ” Searching ads for "${query}"...`);

  let allAds = [];
  let cursor = null;
  let pages = 0;
  const maxPages = options.maxPages || 3;

  while (pages < maxPages) {
    pages++;

    const params = {
      query,
      country: options.country || 'US',
      status: options.status || 'active',
      sort_by: options.sort_by || 'relevance',
      ...(options.media_type && { media_type: options.media_type }),
      ...(cursor && { cursor })
    };

    const { data } = await axios.get(
      `${API_BASE}/v1/scrape/facebook-ad-library/search`,
      { params, headers }
    );

    const ads = data.data?.ads || data.data || [];
    allAds = allAds.concat(ads);

    cursor = data.data?.cursor || data.data?.paging?.next;
    if (!cursor || ads.length === 0) break;

    await new Promise(r => setTimeout(r, 1000));
  }

  console.log(`Found ${allAds.length} ads\n`);

  // Group by company
  const byCompany = {};
  allAds.forEach(ad => {
    const name = ad.pageName || ad.page_name || 'Unknown';
    if (!byCompany[name]) byCompany[name] = [];
    byCompany[name].push(ad);
  });

  console.log('šŸ“Š Ads by Company:');
  Object.entries(byCompany)
    .sort((a, b) => b[1].length - a[1].length)
    .slice(0, 10)
    .forEach(([name, ads]) => {
      console.log(`  ${name}: ${ads.length} ads`);
    });

  return { ads: allAds, byCompany };
}
Enter fullscreen mode Exit fullscreen mode

Step 9: New Ad Alert System

Set this up as a cron job and get notified when competitors launch new ads:

async function checkForNewAds(pageId) {
  const company = db.prepare('SELECT * FROM companies WHERE page_id = ?').get(pageId);
  if (!company) return [];

  const existingIds = new Set(
    db.prepare('SELECT id FROM ads WHERE company_id = ?')
      .all(company.id)
      .map(a => a.id)
  );

  // Fetch current ads
  const currentAds = await getCompanyAds(company.name, pageId);

  // Find new ones
  const newAds = currentAds.filter(ad => {
    const adId = ad.id || ad.adArchiveID;
    return !existingIds.has(adId);
  });

  if (newAds.length > 0) {
    console.log(`\n🚨 ${newAds.length} NEW ADS DETECTED!`);
    console.log('═'.repeat(50));

    newAds.forEach((ad, i) => {
      console.log(`\n${i + 1}. NEW: ${ad.headline || ad.title || '(no headline)'}`);
      console.log(`   ${(ad.body || ad.bodyText || '').substring(0, 100)}`);
      console.log(`   Type: ${ad.mediaType || detectMediaType(ad)}`);
    });
  } else {
    console.log('No new ads since last check.');
  }

  return newAds;
}
Enter fullscreen mode Exit fullscreen mode

Step 10: Tie It All Together

async function main() {
  const command = process.argv[2];
  const target = process.argv[3];

  switch (command) {
    case 'search':
      await searchCompany(target);
      break;

    case 'spy':
      // spy on a company by name or page ID
      const companies = await searchCompany(target);
      if (companies && companies.length > 0) {
        const company = companies[0];
        const id = company.pageId || company.id;
        await getCompanyAds(company.name || company.pageName, id);
        printWinningAds(id);
        await analyzeAdPatterns(id);
      }
      break;

    case 'search-ads':
      await searchAds(target);
      break;

    case 'winners':
      printWinningAds(target);
      break;

    case 'check':
      await checkForNewAds(target);
      break;

    case 'detail':
      await getAdDetails(target);
      break;

    default:
      console.log('Usage:');
      console.log('  node spy.js search "Nike"           - Find a company');
      console.log('  node spy.js spy "Nike"              - Full spy analysis');
      console.log('  node spy.js search-ads "protein"    - Search ads by keyword');
      console.log('  node spy.js winners <pageId>        - Show winning ads');
      console.log('  node spy.js check <pageId>          - Check for new ads');
      console.log('  node spy.js detail <adId>           - Get ad details');
  }
}

main().catch(console.error);
Enter fullscreen mode Exit fullscreen mode

Running It

# Find a competitor
node spy.js search "Gymshark"

# Full spy session
node spy.js spy "Gymshark"

# Search entire ad library for your niche
node spy.js search-ads "meal prep delivery"

# Check for new ads (run daily via cron)
node spy.js check 123456789
Enter fullscreen mode Exit fullscreen mode

Sample Output

šŸ” Searching for "Gymshark"...

Found 3 companies:
  1. Gymshark (1234567890)
     Categories: Clothing Brand, Sports & Recreation

šŸ“„ Fetching ads for Gymshark...
  Page 1...
  Page 2...
  Page 3...
  Found 147 ads total

šŸ† WINNING ADS (Running 14+ Days)
════════════════════════════════════════════════════════
1. [87 days] VIDEO
   Headline: This is the workout split that changed everything
   Body: 3 days. Full body. No excuses. Our athletes swear by this...
   Status: active

2. [54 days] CAROUSEL
   Headline: The only leggings that passed the squat test
   Body: See-through leggings? Never again. We tested 247 fabrics...
   Status: active

3. [41 days] IMAGE
   Headline: Free shipping on orders over $50
   Body: New arrivals just dropped. 200+ new styles...
   Status: active

šŸ“Š AD COPY ANALYSIS
════════════════════════════════════════════════════════

šŸŽ£ Top Hooks:
  • Transformation/before-after framing
  • Problem-solution with specific numbers
  • Social proof from athletes

šŸ“¢ CTAs:
  • "Shop Now" (72% of ads)
  • "See Collection" for new drops
  • Free shipping threshold urgency

šŸ’” Ads You Could Create:
  1. "I tested 50 gym shorts in 30 days. Here's the one I kept."
  2. Video showing fabric stretch test with competitors
  3. Carousel of "What I ordered vs what I got" positive reveals
Enter fullscreen mode Exit fullscreen mode

What This Replaces

Manual competitor ad research tools charge a lot:

Tool Price What You Get
AdSpy $149/mo Facebook + IG ads
BigSpy $99/mo Multi-platform
PowerAdSpy $49/mo Limited searches
This tool ~$0.02/spy session Everything above + AI analysis

The difference: you own the data and can customize the analysis.

Get Your API Key

The SociaVault Facebook Ad Library endpoints make this embarrassingly simple. No scraping headless browsers. No proxy rotations. Just clean API calls.

  1. Get your key at sociavault.com
  2. Each ad library request costs 1 credit
  3. Start spying on your competitors today

Your competitors' winning ads are public. Not looking at them is leaving money on the table.

javascript #marketing #facebook #webdev

Top comments (0)