← back to NationalPaperHangers

scripts/scan-ad-signals.js

178 lines

#!/usr/bin/env node
// Ad-pixel fingerprint scanner — pure regex, no LLM.
//
// For each unclaimed installer with a website, fetch homepage and detect
// tracking pixels for: Google Ads, Meta, TikTok, Pinterest, LinkedIn, Twitter,
// Reddit, Bing, Snap. Stores result as JSONB in installers.ad_signals.
//
// Usage:
//   node scripts/scan-ad-signals.js                # dry run
//   node scripts/scan-ad-signals.js --commit       # write to DB
//   node scripts/scan-ad-signals.js --max=200      # cap (default 100)
//   node scripts/scan-ad-signals.js --rescan       # re-scan even already-scanned sites

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

const UA = 'NationalPaperHangersBot/1.0 (+https://nationalpaperhangers.com/about)';
const SOURCE = 'studio_site_adscan';
const REQUEST_DELAY_MS = 3000;
const COMMIT = process.argv.includes('--commit');
const RESCAN = process.argv.includes('--rescan');
const MAX = parseInt((process.argv.find(a => a.startsWith('--max=')) || '--max=100').split('=')[1], 10);

const PIXEL_PATTERNS = {
  google_ads: [
    /googleadservices\.com/i,
    /gtag\(\s*['"]config['"]\s*,\s*['"]AW-/i,
    /google_conversion_id/i,
    /googletagmanager\.com\/gtag\/js\?id=AW-/i
  ],
  google_analytics: [
    /gtag\(\s*['"]config['"]\s*,\s*['"]G-/i,
    /www\.googletagmanager\.com\/gtm\.js/i,
    /www\.google-analytics\.com\/analytics\.js/i
  ],
  meta_pixel: [
    /connect\.facebook\.net\/[^"']*\/fbevents\.js/i,
    /fbq\(\s*['"]init['"]/i,
    /facebook\.com\/tr\?id=/i
  ],
  tiktok_pixel: [
    /analytics\.tiktok\.com/i,
    /ttq\.load\(/i
  ],
  pinterest_tag: [
    /s\.pinimg\.com\/ct\.js/i,
    /pintrk\(\s*['"]load['"]/i
  ],
  linkedin_insight: [
    /snap\.licdn\.com\/li\.lms-analytics/i,
    /_linkedin_partner_id/i
  ],
  twitter_pixel: [
    /static\.ads-twitter\.com\/uwt\.js/i,
    /twq\(\s*['"]init['"]/i
  ],
  reddit_pixel: [
    /www\.redditstatic\.com\/ads\/pixel\.js/i,
    /rdt\(\s*['"]init['"]/i
  ],
  bing_uet: [
    /bat\.bing\.com/i,
    /uetq\.push/i
  ],
  snap_pixel: [
    /sc-static\.net\/scevent/i,
    /snaptr\(\s*['"]init['"]/i
  ]
};

function detectSignals(html) {
  const out = {};
  for (const [k, patterns] of Object.entries(PIXEL_PATTERNS)) {
    out[k] = patterns.some(p => p.test(html));
  }
  // paid_ads_count excludes pure-analytics signals (GA)
  const paidPlatforms = [
    'google_ads', 'meta_pixel', 'tiktok_pixel', 'pinterest_tag',
    'linkedin_insight', 'twitter_pixel', 'reddit_pixel', 'bing_uet', 'snap_pixel'
  ];
  out.paid_ads_count = paidPlatforms.filter(k => out[k]).length;
  return out;
}

async function logRequest(url, status, bytes, durMs, error) {
  await db.query(
    `INSERT INTO scrape_log (source, url, http_status, bytes, duration_ms, error)
     VALUES ($1,$2,$3,$4,$5,$6)`,
    [SOURCE, url, status, bytes, durMs, error]
  );
}

function sleep(ms) { return new Promise(r => setTimeout(r, ms)); }

async function main() {
  console.log(`[ad-scan] start · commit=${COMMIT} · rescan=${RESCAN} · max=${MAX}`);

  const filter = RESCAN ? '' : 'AND ad_signals IS NULL';
  const targets = await db.many(
    `SELECT id, slug, business_name, website
       FROM installers
      WHERE claim_status='unclaimed'
        AND website IS NOT NULL AND website <> ''
        ${filter}
      ORDER BY id
      LIMIT $1`,
    [MAX]
  );

  console.log(`[ad-scan] ${targets.length} candidates`);

  let scanned = 0, paidCount = 0, failed = 0;
  for (const t of targets) {
    const t0 = Date.now();
    let status = 0, html = null, bytes = 0, err = null;
    try {
      const r = await fetch(t.website, {
        headers: { 'user-agent': UA, accept: 'text/html' },
        signal: AbortSignal.timeout(15000),
        redirect: 'follow'
      });
      status = r.status;
      if (r.ok) html = await r.text();
      bytes = html ? html.length : 0;
    } catch (e) {
      err = e.message;
    }
    await logRequest(t.website, status, bytes, Date.now() - t0, err);

    if (!html) {
      console.log(`  ✗ ${t.business_name}: ${status} ${err || ''}`);
      failed++;
      // Still record an empty signals object so we don't keep retrying
      if (COMMIT) {
        await db.query(
          `UPDATE installers SET ad_signals=$2, ad_signals_at=now() WHERE id=$1`,
          [t.id, JSON.stringify({ fetch_failed: true, paid_ads_count: 0 })]
        );
      }
      await sleep(REQUEST_DELAY_MS);
      continue;
    }

    const signals = detectSignals(html);
    scanned++;
    if (signals.paid_ads_count > 0) paidCount++;

    const platforms = Object.entries(signals)
      .filter(([k, v]) => v === true && !['google_analytics'].includes(k))
      .map(([k]) => k.replace(/_/g, ' '));

    if (platforms.length > 0) {
      console.log(`  💰 ${t.business_name}: [${platforms.join(', ')}]`);
    } else if (signals.google_analytics) {
      console.log(`  · ${t.business_name}: GA only (no paid ads)`);
    } else {
      console.log(`  · ${t.business_name}: no tracking detected`);
    }

    if (COMMIT) {
      await db.query(
        `UPDATE installers SET ad_signals=$2, ad_signals_at=now() WHERE id=$1`,
        [t.id, JSON.stringify(signals)]
      );
    }

    await sleep(REQUEST_DELAY_MS);
  }

  console.log(`[ad-scan] done · scanned=${scanned} runningPaidAds=${paidCount} failed=${failed}`);
  if (failed / Math.max(1, targets.length) > 0.10) {
    console.warn(`[ad-scan] FAILURE-RATE-WARNING: ${(failed/targets.length*100).toFixed(0)}%`);
  }
  await db.pool.end();
}

main().catch(e => { console.error(e); process.exit(1); });