← back to Small Business Builder

scripts/data-hound-social.js

120 lines

#!/usr/bin/env node
// Data Hound #2 — for businesses with a website but no IG/Yelp/FB on file,
// fetch the homepage and scrape outbound social links + about_text fallback.
// Uses existing src/scrape/website_meta.js for the safe SSRF-guarded fetch.
//
//   node scripts/data-hound-social.js
//   LIMIT=100 node scripts/data-hound-social.js
//   DRY=1 node scripts/data-hound-social.js

import 'dotenv/config';
import { query } from '../src/lib/db.js';
import { fetchHtml } from '../src/scrape/website_meta.js';
import * as cheerio from 'cheerio';

const SLEEP_MS = 1500; // be polite — 1 page every 1.5s
const LIMIT = process.env.LIMIT ? parseInt(process.env.LIMIT, 10) : null;
const DRY = process.env.DRY === '1';

const ts = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
const log = m => console.log(`[${ts()}] ${m}`);
const sleep = ms => new Promise(r => setTimeout(r, ms));

const SOCIAL_PATTERNS = {
  instagram: /(?:https?:\/\/)?(?:www\.)?instagram\.com\/([a-zA-Z0-9._]{2,30})\/?(?:[?#].*)?$/i,
  yelp:      /(?:https?:\/\/)?(?:www\.)?yelp\.com\/biz\/([a-zA-Z0-9_-]+)/i,
  facebook:  /(?:https?:\/\/)?(?:www\.)?facebook\.com\/([a-zA-Z0-9.\-_]+)\/?(?:[?#].*)?$/i,
  tiktok:    /(?:https?:\/\/)?(?:www\.)?tiktok\.com\/@([a-zA-Z0-9._]{2,30})/i,
};

const SKIP_HANDLES = new Set([
  'p', 'explore', 'reel', 'reels', 'tv', 'stories', 'login', 'about',
  'pages', 'permalink', 'sharer', 'share', 'help', 'business', 'help-en',
  'privacy', 'terms', 'support',
]);

function extractSocials(html, baseUrl) {
  const $ = cheerio.load(html);
  const found = {};
  $('a[href]').each((_, el) => {
    const href = $(el).attr('href');
    if (!href) return;
    for (const [platform, re] of Object.entries(SOCIAL_PATTERNS)) {
      if (found[platform]) continue;
      const m = href.match(re);
      if (m) {
        const handle = m[1].toLowerCase();
        if (SKIP_HANDLES.has(handle)) continue;
        found[platform] = { url: href.startsWith('http') ? href : 'https://' + href.replace(/^\/\//,''), handle };
      }
    }
  });
  return found;
}

async function run() {
  // Eligible: have a website, missing at least one social platform
  const rows = await query(
    `SELECT b.id, b.slug, b.name, b.website, b.about_text,
            EXISTS (SELECT 1 FROM business_socials s WHERE s.business_id=b.id AND s.platform='instagram') AS has_ig,
            EXISTS (SELECT 1 FROM business_socials s WHERE s.business_id=b.id AND s.platform='yelp')      AS has_yelp,
            EXISTS (SELECT 1 FROM business_socials s WHERE s.business_id=b.id AND s.platform='facebook')  AS has_fb
       FROM businesses b
      WHERE b.website IS NOT NULL AND b.website <> ''
        AND NOT (
          EXISTS (SELECT 1 FROM business_socials s WHERE s.business_id=b.id AND s.platform='instagram')
          AND EXISTS (SELECT 1 FROM business_socials s WHERE s.business_id=b.id AND s.platform='yelp')
        )
      ORDER BY b.id
      ${LIMIT ? `LIMIT ${LIMIT}` : ''}`
  );
  log(`Social-link hound: ${rows.rows.length} candidates`);

  let scanned = 0, found = 0, errs = 0, totalSocials = 0;
  for (const b of rows.rows) {
    scanned++;
    if (DRY) { log(`DRY: ${b.website}`); await sleep(SLEEP_MS); continue; }
    try {
      const { html } = await fetchHtml(b.website);
      const socials = extractSocials(html, b.website);

      let added = 0;
      for (const [platform, info] of Object.entries(socials)) {
        if (b[`has_${platform === 'facebook' ? 'fb' : platform}`]) continue;
        try {
          await query(
            `INSERT INTO business_socials (business_id, platform, url, handle)
             VALUES ($1, $2, $3, $4)
             ON CONFLICT (business_id, platform) DO NOTHING`,
            [b.id, platform, info.url, info.handle]
          );
          added++; totalSocials++;
        } catch {} // ON CONFLICT might still raise on schema mismatches
      }

      // Backfill about_text from <meta description> if empty
      if (!b.about_text) {
        const $ = cheerio.load(html);
        const desc = $('meta[property="og:description"]').attr('content')
                  || $('meta[name="description"]').attr('content');
        if (desc && desc.length >= 40) {
          await query(`UPDATE businesses SET about_text = $1 WHERE id = $2 AND (about_text IS NULL OR about_text = '')`,
            [desc.slice(0, 600), b.id]);
        }
      }

      if (added > 0) found++;
      if (scanned % 25 === 0) log(`  scanned=${scanned} sites_with_finds=${found} socials_added=${totalSocials} errors=${errs}`);
    } catch (e) {
      errs++;
      if (errs % 10 === 0) log(`  ${errs} errors (last: ${e.message?.slice(0, 60)})`);
    }
    await sleep(SLEEP_MS);
  }

  log(`DONE — scanned=${scanned} hits=${found} totalSocials=${totalSocials} errors=${errs}`);
  process.exit(0);
}

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