← back to NationalPaperHangers

scripts/enrich-instagram.js

151 lines

#!/usr/bin/env node
// IG enrichment for unclaimed installers.
//
// Per DATA_POLICY.md §3 — IG handle ONLY if it appears as a clickable link on
// the studio's own public website. We do NOT touch instagram.com directly.
//
// For each unclaimed installer with a website and no IG handle yet:
//   1. Skip if website's domain is in directory_optout
//   2. fetch() the homepage with our identifying UA
//   3. Look for instagram.com/<handle> patterns in the HTML
//   4. Filter out non-profile paths (p, reel, tv, explore, etc.)
//   5. UPDATE installer with first valid handle
//   6. 3-second delay between requests
//
// Usage:
//   node scripts/enrich-instagram.js                # dry-run
//   node scripts/enrich-instagram.js --commit       # write IG handles
//   node scripts/enrich-instagram.js --max=100      # cap (default 50)

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

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

// Reserved Instagram paths that aren't profile handles
const NON_HANDLE_PATHS = new Set(['p', 'reel', 'reels', 'tv', 'explore', 'about', 'developer', 'directory', 'accounts', 'web']);
// Site-builder / platform / generic handles that show up in footers but aren't the studio
const PLATFORM_HANDLES = new Set([
  'squarespace', 'wix', 'wixcom', 'godaddy', 'wordpress', 'weebly', 'shopify',
  'bigcommerce', 'webflow', 'duda', 'hostgator', 'wpengine',
  'meta', 'instagram', 'facebook', 'whatsapp',
  'google', 'youtube', 'twitter', 'tiktok', 'linkedin', 'pinterest',
  'mailchimp', 'constantcontact'
]);

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

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]
  );
}

async function loadOptOutSet() {
  const rows = await db.many(`SELECT domain FROM directory_optout`);
  return new Set(rows.map(r => (r.domain || '').toLowerCase()));
}

function extractIG(html) {
  if (!html) return null;
  // Match instagram.com/<handle> where handle is letters/digits/dot/underscore
  // Stop at next quote, slash, ?, #, or whitespace
  const re = /(?:https?:)?\/\/(?:www\.)?instagram\.com\/([A-Za-z0-9._]{1,30})(?=["'\/?#\s>])/g;
  let m;
  const handles = new Set();
  while ((m = re.exec(html)) !== null) {
    const h = m[1];
    if (h.length < 2) continue;
    const hl = h.toLowerCase();
    if (NON_HANDLE_PATHS.has(hl)) continue;
    if (PLATFORM_HANDLES.has(hl)) continue;
    handles.add(h);
  }
  // Prefer the first non-generic handle
  for (const h of handles) return h;
  return null;
}

async function main() {
  console.log(`[ig-enrich] start · commit=${COMMIT} · max=${MAX}`);

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

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

  // Preload all opt-out domains once — avoids an N+1 query per installer.
  const optOutSet = await loadOptOutSet();

  let found = 0, missed = 0, skipped = 0, failed = 0;
  for (const t of targets) {
    let host;
    try { host = new URL(t.website).hostname.replace(/^www\./, ''); } catch {
      console.log(`  · ${t.business_name}: bad URL — skip`);
      skipped++;
      continue;
    }
    if (optOutSet.has(host.toLowerCase())) { console.log(`  · ${t.business_name}: opted out`); skipped++; continue; }

    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} (${host}): ${status} ${err || ''}`);
      failed++;
      await sleep(REQUEST_DELAY_MS);
      continue;
    }

    const handle = extractIG(html);
    if (!handle) {
      console.log(`  · ${t.business_name} (${host}): no IG`);
      missed++;
    } else {
      console.log(`  ✓ ${t.business_name} (${host}) → @${handle}`);
      found++;
      if (COMMIT) {
        await db.query('UPDATE installers SET instagram_handle=$2 WHERE id=$1', [t.id, handle]);
      }
    }

    await sleep(REQUEST_DELAY_MS);
  }

  console.log(`[ig-enrich] done · found=${found} missed=${missed} skipped=${skipped} failed=${failed}`);
  if (failed / Math.max(1, targets.length) > 0.10) {
    console.warn(`[ig-enrich] FAILURE-RATE-WARNING: failure rate exceeded 10%`);
  }
  await db.pool.end();
}

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