← back to Stars of Design
scrapers/shopify-recon.js
192 lines
#!/usr/bin/env node
'use strict';
// Pull designer-adjacent contacts from Steve's Shopify-side databases into
// the StarsOfDesign directory. Three sources, three signal tiers:
//
// 1. dw_unified.trade_signups
// — verified DW trade-program signups (architect/interior_designer/stager).
// — Highest signal: every row is a self-identified design pro.
// — Lands in sod_email_candidates with status='is_designer' (auto-promoted)
// and signal_score=0.95; promote-candidates.js will then create the
// sod_designers + sod_firms rows.
// 2. dw_unified.digital_sample_orders
// — paid DW customers who ordered digital samples. Could be designers,
// could be DIY homeowners. Mid signal (0.70).
// — Land in sod_email_candidates with status='new', awaiting triage.
// 3. designer_representatives.designer_reps (separate DB)
// — manufacturer rep agencies that already have name+agency+territories.
// — Don't go into sod_email_candidates (they're not candidate-stage —
// they're vetted). Direct-insert into sod_designers + sod_firms with
// a 'rep' role tag.
//
// Idempotent. Re-running merges via ON CONFLICT.
require('dotenv').config();
const { Pool } = require('pg');
const dw = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
max: 4,
});
const reps = new Pool({
connectionString: 'postgresql:///designer_representatives?host=/tmp&user=stevestudio2',
max: 2,
});
function slugify(s) {
return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 100);
}
async function runStart(kind, source) {
const r = await dw.query(
`INSERT INTO sod_ingest_runs (run_kind, source, status) VALUES ($1, $2, 'running') RETURNING id`,
[kind, source]
);
return r.rows[0].id;
}
async function runDone(id, ok, inN, note) {
await dw.query(
`UPDATE sod_ingest_runs SET status=$1, finished_at=NOW(), records_in=$2, records_out=$3, notes=$4 WHERE id=$5`,
[ok ? 'ok' : 'err', inN, ok ? inN : 0, note || null, id]
);
}
// ── Source 1: trade_signups → sod_email_candidates (auto-promoted to designer)
async function importTradeSignups() {
const runId = await runStart('shopify-trade-signups', 'dw_unified.trade_signups');
const r = await dw.query(`SELECT name, email, company, profession, license, status, created_at FROM trade_signups`);
let ok = 0;
for (const t of r.rows) {
const email = String(t.email || '').toLowerCase().trim();
if (!email.includes('@')) continue;
const domain = email.split('@')[1];
const note = `dw_trade_signup · ${t.profession || 'unknown'} · ${t.status || 'approved'}${t.license ? ' · lic ' + t.license : ''}${t.company ? ' · ' + t.company : ''}`;
await dw.query(`
INSERT INTO sod_email_candidates
(email, display_name, domain, first_seen, last_seen, thread_count, steve_replied,
signal_score, status, notes)
VALUES ($1,$2,$3,$4,$4,0,0,0.95,'is_designer',$5)
ON CONFLICT (email) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, sod_email_candidates.display_name),
signal_score = GREATEST(sod_email_candidates.signal_score, EXCLUDED.signal_score),
status = CASE WHEN sod_email_candidates.status = 'new' THEN 'is_designer' ELSE sod_email_candidates.status END,
notes = CONCAT_WS(' || ', NULLIF(sod_email_candidates.notes,''), EXCLUDED.notes),
updated_at = NOW()
`, [email, t.name, domain, t.created_at, note]);
ok++;
}
await runDone(runId, true, ok, `trade signups: ${ok}/${r.rows.length}`);
console.log(`[trade_signups] ${ok}/${r.rows.length} rows`);
}
// ── Source 2: digital_sample_orders → sod_email_candidates (mid signal)
async function importDigitalSampleOrders() {
const runId = await runStart('shopify-digital-samples', 'dw_unified.digital_sample_orders');
const r = await dw.query(`
SELECT customer_email, MAX(customer_name) AS customer_name,
MIN(detected_at) AS first_seen, MAX(detected_at) AS last_seen,
count(*) AS order_count,
array_agg(DISTINCT unnest_skus) FILTER (WHERE unnest_skus IS NOT NULL) AS skus
FROM digital_sample_orders, LATERAL unnest(COALESCE(dig_skus,'{}')) AS unnest_skus
WHERE customer_email IS NOT NULL AND customer_email <> ''
GROUP BY customer_email`);
let ok = 0;
for (const c of r.rows) {
const email = String(c.customer_email || '').toLowerCase().trim();
if (!email.includes('@')) continue;
const domain = email.split('@')[1];
const note = `dw_sample_orders · ${c.order_count} order(s)${c.skus ? ' · ' + c.skus.slice(0,5).join(',') : ''}`;
await dw.query(`
INSERT INTO sod_email_candidates
(email, display_name, domain, first_seen, last_seen, thread_count, steve_replied,
signal_score, status, notes)
VALUES ($1,$2,$3,$4,$5,$6,0,0.70,'new',$7)
ON CONFLICT (email) DO UPDATE SET
display_name = COALESCE(EXCLUDED.display_name, sod_email_candidates.display_name),
thread_count = sod_email_candidates.thread_count + EXCLUDED.thread_count,
first_seen = LEAST(sod_email_candidates.first_seen, EXCLUDED.first_seen),
last_seen = GREATEST(sod_email_candidates.last_seen, EXCLUDED.last_seen),
signal_score = GREATEST(sod_email_candidates.signal_score, EXCLUDED.signal_score),
notes = CONCAT_WS(' || ', NULLIF(sod_email_candidates.notes,''), EXCLUDED.notes),
updated_at = NOW()
`, [email, c.customer_name, domain, c.first_seen, c.last_seen, c.order_count, note]);
ok++;
}
await runDone(runId, true, ok, `digital sample orders: ${ok}/${r.rows.length}`);
console.log(`[digital_sample_orders] ${ok}/${r.rows.length} unique customers`);
}
// ── Source 3: designer_reps (separate DB) → sod_designers + sod_firms (direct curated rows)
async function importDesignerReps() {
const runId = await runStart('shopify-designer-reps', 'designer_representatives.designer_reps');
const r = await reps.query(`
SELECT slug, name, agency, city, region, email, phone, website_url, bio,
territories, lines, vertical, is_claimed
FROM designer_reps
WHERE is_published = true`);
let ok = 0, firmsCreated = 0, designersCreated = 0;
for (const rep of r.rows) {
// Firm/agency first
let firmId = null;
if (rep.agency) {
const firmSlug = slugify(rep.agency);
const f = await dw.query(`
INSERT INTO sod_firms (slug, name, city, state_or_region, primary_email, website, bio, styles)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
ON CONFLICT (slug) DO UPDATE SET
primary_email = COALESCE(sod_firms.primary_email, EXCLUDED.primary_email),
website = COALESCE(sod_firms.website, EXCLUDED.website),
bio = COALESCE(sod_firms.bio, EXCLUDED.bio),
updated_at = NOW()
RETURNING id, (xmax = 0) AS inserted`,
[firmSlug, rep.agency, rep.city, rep.region, rep.email, rep.website_url, rep.bio, rep.lines || []]);
firmId = f.rows[0].id;
if (f.rows[0].inserted) firmsCreated++;
}
// Designer (treat rep as a designer-adjacent contact with role='rep')
if (rep.name) {
const designerSlug = slugify(rep.name) + '-rep';
const d = await dw.query(`
INSERT INTO sod_designers (slug, full_name, primary_email, city, state_or_region, role, bio, styles, headshot_source)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'made-with-ai')
ON CONFLICT (slug) DO UPDATE SET
primary_email = COALESCE(sod_designers.primary_email, EXCLUDED.primary_email),
city = COALESCE(sod_designers.city, EXCLUDED.city),
state_or_region = COALESCE(sod_designers.state_or_region, EXCLUDED.state_or_region),
role = COALESCE(sod_designers.role, EXCLUDED.role),
bio = COALESCE(sod_designers.bio, EXCLUDED.bio),
updated_at = NOW()
RETURNING id, (xmax = 0) AS inserted`,
[designerSlug, rep.name, rep.email, rep.city, rep.region, 'Manufacturer Rep', rep.bio, rep.lines || []]);
const designerId = d.rows[0].id;
if (d.rows[0].inserted) designersCreated++;
if (firmId) {
await dw.query(`
INSERT INTO sod_designer_firm (designer_id, firm_id, title, is_current, source)
VALUES ($1, $2, 'Manufacturer Rep', true, 'designer-reps-import')
ON CONFLICT DO NOTHING`, [designerId, firmId]);
}
// Add website + email as sod_links if present
if (rep.website_url) {
await dw.query(`
INSERT INTO sod_links (designer_id, firm_id, url, kind, added_by)
VALUES ($1, $2, $3, 'firm-site', 'designer-reps-import')
ON CONFLICT DO NOTHING`, [designerId, firmId, rep.website_url]);
}
}
ok++;
}
await runDone(runId, true, ok, `reps: ${ok}/${r.rows.length} · firms+${firmsCreated} · designers+${designersCreated}`);
console.log(`[designer_reps] ${ok}/${r.rows.length} reps · firms+${firmsCreated} · designers+${designersCreated}`);
}
async function main() {
await importTradeSignups();
await importDigitalSampleOrders();
await importDesignerReps();
await dw.end();
await reps.end();
}
main().catch(e => { console.error('fatal:', e); process.exit(1); });