← back to Stars of Design
scrapers/promote-candidates.js
135 lines
#!/usr/bin/env node
'use strict';
// Promote high-signal `sod_email_candidates` rows into real `sod_designers`
// (and `sod_firms` when the firm domain is obvious). Idempotent — running
// it twice doesn't double-create. Default signal_score floor: 0.6.
//
// Usage:
// node scrapers/promote-candidates.js # promote all >= 0.6
// node scrapers/promote-candidates.js --min=0.8 # higher bar
// node scrapers/promote-candidates.js --email=info@nickolsenstyle.com
require('dotenv').config();
const { Pool } = require('pg');
const args = process.argv.slice(2).reduce((m, a) => {
const [k, v] = a.replace(/^--/, '').split('=');
m[k] = v ?? true; return m;
}, {});
const MIN_SCORE = parseFloat(args.min || '0.6');
const ONLY_EMAIL = args.email || null;
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
max: 4,
});
function slugify(s) {
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100);
}
function guessNameAndFirm(displayName, domain) {
// "Nick Olsen Style" → name=null, firm="Nick Olsen Style"
// "John Smith" → name="John Smith", firm=null
// "John Smith — Smith Design" → name="John Smith", firm="Smith Design"
if (!displayName) return { person: null, firm: null };
const split = displayName.split(/[-—|·]/);
if (split.length >= 2) {
return { person: split[0].trim(), firm: split.slice(1).join(' ').trim() };
}
// Heuristic: if domain matches the display name (vs gmail/hotmail/etc), it's a firm domain
const personalESPs = ['gmail.com','hotmail.com','yahoo.com','outlook.com','icloud.com','aol.com','me.com'];
const domHasName = domain && !personalESPs.includes(domain) && displayName.toLowerCase().includes(domain.split('.')[0].slice(0, 4));
if (domHasName || /\bdesign|\bstudio|\binteriors|\bgroup|\bassociates|\barchitect/i.test(displayName)) {
return { person: null, firm: displayName };
}
return { person: displayName, firm: null };
}
async function main() {
let params, where;
if (ONLY_EMAIL) { params = [ONLY_EMAIL]; where = `email = $1`; }
else {
// INTENTIONALLY restrictive: only promote rows whose status is the
// original 'new' lifecycle state. The sig-extract pipeline repurposes
// `status` for an LLM triage label (is_designer / is_vendor /
// is_noise), and the LLM is wrong often enough — Shopify/Twitter/TikTok
// transactional senders, manufacturer reps, and our own outbound have
// all been mis-labeled is_designer in past runs — that we don't trust
// it as a promotion signal. To bulk-promote is_designer rows safely,
// build a separate audit script (curated allow-list, not a block-list)
// and run it with explicit --email targets.
params = [MIN_SCORE];
where = `status='new' AND signal_score >= $1`;
}
const r = await pool.query(
`SELECT * FROM sod_email_candidates WHERE ${where} ORDER BY signal_score DESC NULLS LAST`,
params
);
console.log(`[promote] ${r.rows.length} candidate(s) to promote (min=${MIN_SCORE})`);
for (const c of r.rows) {
const { person, firm } = guessNameAndFirm(c.display_name, c.domain);
let personId = null;
let firmId = null;
if (firm) {
const firmSlug = slugify(firm);
const f = await pool.query(`
INSERT INTO sod_firms (slug, name, website, primary_email)
VALUES ($1, $2, $3, $4)
ON CONFLICT (slug) DO UPDATE SET
primary_email = COALESCE(sod_firms.primary_email, EXCLUDED.primary_email)
RETURNING id`, [firmSlug, firm, c.domain ? `https://${c.domain}` : null, c.email]);
firmId = f.rows[0].id;
}
if (person) {
const slug = slugify(person);
const p = await pool.query(`
INSERT INTO sod_designers (slug, full_name, primary_email)
VALUES ($1, $2, $3)
ON CONFLICT (slug) DO UPDATE SET
primary_email = COALESCE(sod_designers.primary_email, EXCLUDED.primary_email)
RETURNING id`, [slug, person, c.email]);
personId = p.rows[0].id;
if (firmId) {
await pool.query(`
INSERT INTO sod_designer_firm (designer_id, firm_id, source)
VALUES ($1, $2, 'gmail-candidate-promotion')
ON CONFLICT DO NOTHING`, [personId, firmId]);
}
} else if (firm && !person) {
// Firm-only candidate: also create a placeholder designer = firm name
// so /designer/:slug works even before we know individual names.
const slug = slugify(firm);
const p = await pool.query(`
INSERT INTO sod_designers (slug, full_name, primary_email)
VALUES ($1, $2, $3)
ON CONFLICT (slug) DO UPDATE SET
primary_email = COALESCE(sod_designers.primary_email, EXCLUDED.primary_email)
RETURNING id`, [slug + '-firm', firm, c.email]);
personId = p.rows[0].id;
if (firmId) {
await pool.query(`
INSERT INTO sod_designer_firm (designer_id, firm_id, title, source)
VALUES ($1, $2, 'Firm placeholder', 'gmail-candidate-promotion')
ON CONFLICT DO NOTHING`, [personId, firmId]);
}
}
await pool.query(`
UPDATE sod_email_candidates
SET status = $1::text, promoted_designer_id = $2::bigint, promoted_firm_id = $3::bigint, updated_at = NOW()
WHERE id = $4::bigint`,
[firm && !person ? 'is_firm' : 'is_designer', personId, firmId, c.id]);
console.log(` ${c.email} → designer=${personId} firm=${firmId} (${person || ''}${person && firm ? ' @ ' : ''}${firm || ''})`);
}
console.log(`[promote] done.`);
await pool.end();
}
main().catch(e => { console.error('fatal:', e); process.exit(1); });