← back to Ads Dashboard

scripts/scan-ad-signals.mjs

148 lines

#!/usr/bin/env node
/**
 * scan-ad-signals.mjs — $0 local ad-platform pixel scanner for ads-dashboard.
 *
 * Fetches each domain's homepage HTML and fingerprints which paid-ad platforms
 * it is running tracking pixels for. NO fabrication: a platform only appears if
 * its pixel is actually present in the served HTML.
 *
 *   DW-owned domains  -> data/campaigns.dw.json  (our own active ad channels; spend=null, needs ad-account API)
 *   competitor domains -> data/signals.dw.json   (competitor ad signals)
 *
 * Zero dependencies (global fetch). Run: node scripts/scan-ad-signals.mjs
 */
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const DATA_DIR = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'data');

// Real DW properties (→ campaigns: our own detected ad channels)
const OWN = ['designerwallcoverings.com', 'apartmentwallpaper.com', 'philipperomano.com'];
// Real wallcovering competitors (→ signals: competitor ad presence)
const COMPETITORS = [
  'fschumacher.com', 'thibautdesign.com', 'yorkwallcoverings.com',
  'phillipjeffries.com', 'cole-and-son.com',
];

// Pixel fingerprints: platform -> [regexes that prove the ad pixel is present]
const FINGERPRINTS = {
  'Google Ads':      [/googleadservices\.com\/pagead\/conversion/i, /gtag\(['"]config['"],\s*['"]AW-/i, /google_conversion_id/i, /googleads\.g\.doubleclick\.net/i],
  'Meta (Facebook)': [/connect\.facebook\.net\/[a-z_]+\/fbevents\.js/i, /\bfbq\(/i],
  'TikTok':          [/analytics\.tiktok\.com/i, /ttq\.load\(/i],
  'Pinterest':       [/s\.pinimg\.com\/ct\//i, /\bpintrk\(/i],
  'LinkedIn':        [/snap\.licdn\.com/i, /_linkedin_partner_id/i],
  'X (Twitter)':     [/static\.ads-twitter\.com/i, /\btwq\(/i],
  'Microsoft (Bing)':[/bat\.bing\.com/i, /\buetq\b/i],
  'Snapchat':        [/sc-static\.net\/scevent/i, /\bsnaptr\(/i],
  'Reddit':          [/redditstatic\.com\/ads/i, /\brdt\(/i],
};

const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/124.0 Safari/537.36';

async function fetchHTML(domain) {
  const url = 'https://' + domain + '/';
  try {
    const ctrl = new AbortController();
    const t = setTimeout(() => ctrl.abort(), 15000);
    const r = await fetch(url, { headers: { 'User-Agent': UA, 'Accept': 'text/html' }, redirect: 'follow', signal: ctrl.signal });
    clearTimeout(t);
    if (!r.ok) return { ok: false, reason: 'http ' + r.status };
    return { ok: true, html: await r.text() };
  } catch (e) { return { ok: false, reason: e.name === 'AbortError' ? 'timeout' : e.message }; }
}

function detect(html) {
  const hit = [];
  for (const [platform, regexes] of Object.entries(FINGERPRINTS)) {
    if (regexes.some(re => re.test(html))) hit.push(platform);
  }
  return hit;
}

// Fetch a raw JS/text URL (GTM container) and return its body.
async function fetchText(url) {
  try {
    const ctrl = new AbortController();
    const t = setTimeout(() => ctrl.abort(), 15000);
    const r = await fetch(url, { headers: { 'User-Agent': UA }, redirect: 'follow', signal: ctrl.signal });
    clearTimeout(t);
    return r.ok ? await r.text() : '';
  } catch { return ''; }
}

// Pull GTM container ids (GTM-XXXX) referenced in the page.
function gtmIds(html) {
  const ids = new Set();
  const re = /GTM-[A-Z0-9]+/g; let m;
  while ((m = re.exec(html))) ids.add(m[0]);
  return [...ids];
}

async function scan(domain) {
  const res = await fetchHTML(domain);
  if (!res.ok) return { domain, ok: false, reason: res.reason, platforms: [] };
  const found = new Set(detect(res.html));
  // Second hop: many ad pixels are injected by Google Tag Manager, not inline.
  // Fetch each GTM container and fingerprint the tags configured inside it.
  const ids = gtmIds(res.html);
  for (const id of ids) {
    const container = await fetchText('https://www.googletagmanager.com/gtm.js?id=' + id);
    if (container) detect(container).forEach(p => found.add(p));
    // Google Ads conversion linker inside a container:
    if (/AW-\d{6,}/.test(container)) found.add('Google Ads');
  }
  if (ids.length) found.add('Google Tag Manager');
  return { domain, ok: true, platforms: [...found], gtm: ids };
}

(async () => {
  const nowISO = new Date().toISOString();
  console.log('[scan] fetching', OWN.length + COMPETITORS.length, 'domains…');

  const ownResults = await Promise.all(OWN.map(scan));
  const compResults = await Promise.all(COMPETITORS.map(scan));

  // DW-owned detected platforms -> campaigns (our active ad channels; spend unknown w/o ad-account API)
  const campaigns = [];
  for (const r of ownResults) {
    for (const p of r.platforms) {
      campaigns.push({
        name: p + ' — ' + r.domain.replace(/\.com$/, ''),
        domain: r.domain,
        platform: p,
        status: 'active',       // pixel is live on the site
        spend: null,            // honest: real $ needs Google Ads / Meta API (renders "—")
        created_at: nowISO,
        detected_by: 'pixel-scan',
      });
    }
    console.log('  own', r.domain, r.ok ? r.platforms.join(', ') || '(no ad pixels)' : 'FAIL: ' + r.reason);
  }

  // Competitor detected platforms -> signals
  const signals = [];
  for (const r of compResults) {
    for (const p of r.platforms) {
      signals.push({
        advertiser: r.domain,
        platform: p,
        url: 'https://' + r.domain + '/',
        seen_at: nowISO,
        detected_by: 'pixel-scan',
      });
    }
    console.log('  comp', r.domain, r.ok ? r.platforms.join(', ') || '(no ad pixels)' : 'FAIL: ' + r.reason);
  }

  const campaignsOut = { source: 'pixel-scan (DW-owned domains)', present: true, generated_at: nowISO,
    note: 'Active ad-platform presence detected via tracking pixels. Spend is null until a Google Ads / Meta Ads account is connected — not fabricated.',
    campaigns };
  const signalsOut = { source: 'advertising-signals (pixel-scan, competitor domains)', present: true, generated_at: nowISO,
    scanned: COMPETITORS, signals };

  fs.writeFileSync(path.join(DATA_DIR, 'campaigns.dw.json'), JSON.stringify(campaignsOut, null, 2));
  fs.writeFileSync(path.join(DATA_DIR, 'signals.dw.json'), JSON.stringify(signalsOut, null, 2));
  console.log(`[scan] wrote ${campaigns.length} campaign rows, ${signals.length} signal rows. $0 (local).`);
})();