← back to Professional Directory

scripts/check_prospect_domains.js

158 lines

#!/usr/bin/env node
/**
 * Domain availability check for small-biz prospects.
 *
 * For each org with no website, slugify the name → candidate.com, check
 * availability via Verisign RDAP (free, no key, public). Persist back to
 * organizations.suggested_domain / domain_available / domain_checked_at.
 *
 * RDAP semantics:
 *   404 → available (or never registered; unallocated)
 *   200 → registered
 *   429 → rate-limited (retry with backoff)
 *
 * Run:
 *   node scripts/check_prospect_domains.js                  # all prospects, all categories
 *   LIMIT=200 node scripts/check_prospect_domains.js        # small batch
 *   CATEGORY=cosmetology node scripts/check_prospect_domains.js
 */
const { fetch } = require('undici');
const { pool, query } = require('../agents/shared/db');

const CATEGORY_TYPES = {
  cosmetology:     ['salon','barbershop','nail_salon','beauty_supply','spa'],
  chiropractic:    ['chiropractor_office'],
  chinese_medicine:['acupuncture_clinic','tcm_clinic','tcm_herbalist'],
  dental:          ['dental_office'],
  optometry:       ['optometrist_office','optical_shop'],
  fitness:         ['yoga_studio','pilates_studio','gym'],
  tattoo:          ['tattoo_studio','piercing_studio'],
  pets:            ['vet_clinic','pet_grooming','pet_shop'],
};

const RDAP_COM = 'https://rdap.verisign.com/com/v1/domain';
const RPS = parseFloat(process.env.RDAP_RPS || '5');   // 5 req/s — Verisign tolerates it
const USER_AGENT = process.env.USER_AGENT
  || 'ProfessionalDirectoryBot/0.1 (small-biz prospect tool; contact: steve@designerwallcoverings.com)';

const STOP_WORDS = new Set([
  'the','and','of','for','llc','inc','co','company','corp','corporation',
  'studio','group','center','centre','clinic','salon','spa','salon','shop','services','office',
]);

function slugify(name) {
  if (!name) return null;
  // 1) take alphanumerics only, treat hyphen/space as boundary
  let s = name.toLowerCase()
    .replace(/&/g, ' and ')
    .replace(/[^a-z0-9\s-]/g, ' ')
    .trim();
  // 2) drop common stop words but keep meaningful tokens
  const tokens = s.split(/[\s-]+/).filter(t => t && !STOP_WORDS.has(t));
  if (tokens.length === 0) return null;
  // 3) join, drop the leading 'a'/'i' digits etc
  let joined = tokens.join('');
  // 4) cap at 63 chars (RFC 1035 label limit)
  if (joined.length < 3 || joined.length > 63) return null;
  // 5) start/end alphanumeric
  if (!/^[a-z0-9]/.test(joined) || !/[a-z0-9]$/.test(joined)) return null;
  return joined;
}

let lastFetch = 0;
async function gate() {
  const minInterval = 1000 / RPS;
  const wait = Math.max(0, lastFetch + minInterval - Date.now());
  if (wait > 0) await new Promise(r => setTimeout(r, wait));
  lastFetch = Date.now();
}

async function checkRdap(slug) {
  await gate();
  const url = `${RDAP_COM}/${slug}.com`;
  try {
    const res = await fetch(url, {
      headers: { 'User-Agent': USER_AGENT, Accept: 'application/json' },
      signal: AbortSignal.timeout(10000),
    });
    if (res.status === 404) return { available: true };
    if (res.status === 200) return { available: false };
    if (res.status === 429) {
      await new Promise(r => setTimeout(r, 5000));
      return null; // signal retry-later
    }
    return null;
  } catch (e) {
    return null;
  }
}

async function loadProspects(limit, category) {
  const where = ["(website IS NULL OR website = '')",
                 "name IS NOT NULL",
                 "domain_checked_at IS NULL",
                 "opted_out = FALSE"];
  const params = []; let i = 1;
  if (category && CATEGORY_TYPES[category]) {
    where.push(`type = ANY($${i}::text[])`); params.push(CATEGORY_TYPES[category]); i++;
  } else {
    // Default: only the small-biz types
    const allTypes = Object.values(CATEGORY_TYPES).flat();
    where.push(`type = ANY($${i}::text[])`); params.push(allTypes); i++;
  }
  const r = await query(
    `SELECT id, name FROM organizations WHERE ${where.join(' AND ')} ORDER BY id LIMIT ${Number(limit) || 1000000}`,
    params
  );
  return r.rows;
}

async function persist(orgId, slug, available) {
  await query(`
    UPDATE organizations
       SET suggested_domain  = $2,
           domain_available  = $3,
           domain_checked_at = NOW()
     WHERE id = $1
  `, [orgId, slug ? `${slug}.com` : null, available]);
}

async function main() {
  const limit = process.env.LIMIT ? Number(process.env.LIMIT) : null;
  const category = process.env.CATEGORY || null;
  console.log(`[domains] mode=${category || 'all'} limit=${limit || 'all'} rps=${RPS}`);

  const prospects = await loadProspects(limit, category);
  console.log(`[domains] candidates=${prospects.length}`);

  let checked = 0, available = 0, taken = 0, skipped = 0, errors = 0;
  for (const p of prospects) {
    const slug = slugify(p.name);
    if (!slug) { await persist(p.id, null, null); skipped++; continue; }

    let result = await checkRdap(slug);
    if (result === null) {
      // One retry on transient.
      await new Promise(r => setTimeout(r, 1000));
      result = await checkRdap(slug);
    }
    if (result === null) { errors++; continue; }
    await persist(p.id, slug, result.available);
    checked++;
    if (result.available) available++; else taken++;

    if (checked % 50 === 0) {
      console.log(`[domains] checked=${checked} available=${available} taken=${taken} skipped=${skipped} errors=${errors}`);
    }
  }

  console.log(`[domains] done. checked=${checked} available=${available} taken=${taken} skipped=${skipped} errors=${errors}`);
  await pool.end();
}

main().catch(async (err) => {
  console.error('[domains] fatal:', err);
  try { await pool.end(); } catch (_) {}
  process.exit(1);
});