← back to Professional Directory

scripts/check_alt_domains.js

205 lines

#!/usr/bin/env node
/**
 * Alternate-domain pass — for prospects whose primary slug.com was taken,
 * try a small set of variants and TLDs and persist the FIRST available match.
 *
 * Variants per name (in priority order):
 *   1. <slug>.com                      (already tried in primary pass — skip)
 *   2. <slug-with-stopwords>.com       (e.g. "thejointchiro" — keep "the")
 *   3. <slug>-<city>.com               (e.g. "miramassage-malibu.com")
 *   4. <slug>la.com                    (e.g. "miramassagela.com")
 *   5. <slug>.salon / .clinic / .care / .studio (TLD by category)
 *   6. <slug>.co
 *   7. <slug>.net
 *
 * Stops at the first available result. Free RDAP for .com/.net/.co/etc;
 * RDAP for niche TLDs falls back to a polite TCP WHOIS lookup, but we keep
 * the lookup stack small to respect public infra.
 *
 * Run:
 *   node scripts/check_alt_domains.js                # all
 *   LIMIT=200 node scripts/check_alt_domains.js
 */
const { fetch } = require('undici');
const { pool, query } = require('../agents/shared/db');

const RPS = parseFloat(process.env.RDAP_RPS || '5');
const UA = process.env.USER_AGENT
  || 'ProfessionalDirectoryBot/0.1 (small-biz prospect tool; contact: steve@designerwallcoverings.com)';

// IANA RDAP bootstrap → these TLDs all work via the same Verisign-style endpoints.
const RDAP_BY_TLD = {
  com: 'https://rdap.verisign.com/com/v1/domain',
  net: 'https://rdap.verisign.com/net/v1/domain',
  co:  'https://rdap.nic.co/domain',
};

const TLDS_BY_CATEGORY = {
  cosmetology: ['salon','beauty','co','net'],
  chiropractic: ['clinic','health','co','net'],
  chinese_medicine: ['clinic','care','co','net'],
  dental: ['dental','clinic','care','co','net'],
  optometry: ['vision','clinic','care','co','net'],
  fitness: ['fitness','studio','yoga','co','net'],
  tattoo: ['ink','studio','co','net'],
  pets: ['pet','vet','care','co','net'],
};

const SMALL_TYPES = [
  'salon','barbershop','nail_salon','beauty_supply','spa',
  'chiropractor_office',
  'acupuncture_clinic','tcm_clinic','tcm_herbalist',
  'dental_office','optometrist_office','optical_shop',
  'yoga_studio','pilates_studio','gym',
  'tattoo_studio','piercing_studio',
  'vet_clinic','pet_grooming','pet_shop',
];

function categoryOf(type) {
  if (['salon','barbershop','nail_salon','beauty_supply','spa'].includes(type)) return 'cosmetology';
  if (type === 'chiropractor_office') return 'chiropractic';
  if (['acupuncture_clinic','tcm_clinic','tcm_herbalist'].includes(type)) return 'chinese_medicine';
  if (type === 'dental_office') return 'dental';
  if (['optometrist_office','optical_shop'].includes(type)) return 'optometry';
  if (['yoga_studio','pilates_studio','gym'].includes(type)) return 'fitness';
  if (['tattoo_studio','piercing_studio'].includes(type)) return 'tattoo';
  if (['vet_clinic','pet_grooming','pet_shop'].includes(type)) return 'pets';
  return null;
}

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

function slugifyKeepStop(name) {
  if (!name) return null;
  let s = name.toLowerCase().replace(/&/g,' and ').replace(/[^a-z0-9\s-]/g,' ').trim();
  const tokens = s.split(/[\s-]+/).filter(Boolean);
  const joined = tokens.join('');
  if (joined.length < 3 || joined.length > 63) return null;
  if (!/^[a-z0-9]/.test(joined) || !/[a-z0-9]$/.test(joined)) return null;
  return joined;
}

function slugifyDrop(name) {
  if (!name) return null;
  let s = name.toLowerCase().replace(/&/g,' and ').replace(/[^a-z0-9\s-]/g,' ').trim();
  const tokens = s.split(/[\s-]+/).filter(t => t && !STOP_WORDS.has(t));
  if (tokens.length === 0) return null;
  const joined = tokens.join('');
  if (joined.length < 3 || joined.length > 63) return null;
  if (!/^[a-z0-9]/.test(joined) || !/[a-z0-9]$/.test(joined)) return null;
  return joined;
}

function citySlug(city) {
  if (!city) return null;
  const c = city.toLowerCase().replace(/[^a-z0-9]/g, '');
  return c || null;
}

function buildCandidates(name, city, type) {
  const cat = categoryOf(type);
  const niche = TLDS_BY_CATEGORY[cat] || ['co','net'];
  const slugA = slugifyDrop(name);     // already-tried slug
  const slugB = slugifyKeepStop(name); // includes stop words
  const cs    = citySlug(city);

  const out = [];
  // 1. Variants on .com first (cheapest TLD)
  if (slugB && slugB !== slugA) out.push({ slug: slugB, tld: 'com' });
  if (slugA && cs) out.push({ slug: `${slugA}-${cs}`, tld: 'com' });
  if (slugA)      out.push({ slug: `${slugA}la`, tld: 'com' });
  // 2. Niche TLDs of category for the canonical slug
  for (const t of niche) {
    if (slugA) out.push({ slug: slugA, tld: t });
  }
  // Dedupe.
  const seen = new Set();
  return out.filter(c => {
    const k = `${c.slug}.${c.tld}`;
    if (seen.has(k)) return false;
    seen.add(k);
    return c.slug && c.tld;
  });
}

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, tld) {
  const base = RDAP_BY_TLD[tld];
  if (!base) return null;     // no RDAP endpoint for this TLD; skip silently
  await gate();
  try {
    const res = await fetch(`${base}/${slug}.${tld}`, {
      headers: { 'User-Agent': UA, Accept: 'application/json' },
      signal: AbortSignal.timeout(10000),
    });
    if (res.status === 404) return true;
    if (res.status === 200) return false;
    if (res.status === 429) { await new Promise(r => setTimeout(r, 5000)); return null; }
    return null;
  } catch (_) { return null; }
}

async function loadCandidates(limit) {
  const r = await query(`
    SELECT id, name, city, type
      FROM organizations
     WHERE domain_available = FALSE
       AND type = ANY($1::text[])
       AND NOT is_chain
       AND opted_out = FALSE
     ORDER BY id
     ${limit ? `LIMIT ${Number(limit)}` : ''}
  `, [SMALL_TYPES]);
  return r.rows;
}

async function main() {
  const limit = process.env.LIMIT ? Number(process.env.LIMIT) : null;
  const cands = await loadCandidates(limit);
  console.log(`[alt] candidates=${cands.length} rps=${RPS}`);

  let upgraded = 0, examined = 0, stillTaken = 0;
  for (const c of cands) {
    const variants = buildCandidates(c.name, c.city, c.type);
    let foundDomain = null;
    for (const v of variants) {
      const result = await checkRdap(v.slug, v.tld);
      if (result === true) { foundDomain = `${v.slug}.${v.tld}`; break; }
    }
    examined++;
    if (foundDomain) {
      await query(
        `UPDATE organizations
            SET suggested_domain = $2,
                domain_available = TRUE,
                domain_checked_at = NOW()
          WHERE id = $1`,
        [c.id, foundDomain]
      );
      upgraded++;
    } else {
      stillTaken++;
    }
    if (examined % 50 === 0) console.log(`[alt] examined=${examined} upgraded=${upgraded} stillTaken=${stillTaken}`);
  }

  console.log(`[alt] done. examined=${examined} upgraded=${upgraded} stillTaken=${stillTaken}`);
  await pool.end();
}

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