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