← back to Stars of Design
scrapers/extract-links-from-signatures.js
106 lines
#!/usr/bin/env node
'use strict';
// Free cheap pass — regex-scan every sod_email_candidates.last_signature for
// URL patterns and store them in sod_links. The LLM in extract-from-signatures
// often misses URLs (it grabs the obvious linkedin one and skips the firm
// site, or vice versa), so this catches everything that's actually in the
// text. Cheap, deterministic, idempotent.
//
// Patterns captured:
// linkedin.com/in/<slug> → kind='linkedin' (personal)
// linkedin.com/company/<slug> → kind='linkedin' (firm)
// instagram.com/<slug> → kind='instagram'
// <something>.com/.net/.studio → kind='firm-site' (if matches the
// candidate's domain)
// houzz.com/... → kind='houzz'
// pinterest.com/... → kind='pinterest'
//
// Linked to sod_designers if a row exists matching primary_email; OR to
// sod_firms via the most-common firm join for that designer. Falls back to
// "orphan" rows (designer_id=NULL, firm_id=NULL) — those get adopted later
// when the designer's profile gets materialized.
require('dotenv').config();
const { Pool } = require('pg');
const pool = new Pool({
connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
max: 4,
});
const URL_RE = /https?:\/\/[^\s)\]"'>]+/gi;
function classifyUrl(raw, candidateDomain) {
// Strip trailing punctuation that sneaks in from sig text
let u = raw.replace(/[.,;:!?\)\]>'"]+$/, '');
try { u = new URL(u).toString(); } catch { return null; }
const lower = u.toLowerCase();
if (lower.includes('linkedin.com/in/')) return { url: u, kind: 'linkedin' };
if (lower.includes('linkedin.com/company/')) return { url: u, kind: 'linkedin' };
if (lower.includes('instagram.com/')) return { url: u, kind: 'instagram' };
if (lower.includes('houzz.com/')) return { url: u, kind: 'houzz' };
if (lower.includes('pinterest.com/')) return { url: u, kind: 'pinterest' };
if (lower.includes('facebook.com/')) return { url: u, kind: 'facebook' };
if (lower.includes('twitter.com/') || lower.includes('x.com/')) return { url: u, kind: 'twitter' };
// Firm-site heuristic: if the URL's host matches (or is a subdomain of) the
// candidate's email domain, count it as the firm site.
if (candidateDomain) {
try {
const host = new URL(u).hostname.replace(/^www\./, '');
if (host === candidateDomain || host.endsWith('.' + candidateDomain)) {
return { url: u, kind: 'firm-site' };
}
} catch {}
}
// Skip generic links (mailing services, calendar, image hosts, etc.) — too
// noisy to surface on a designer profile without curation.
return null;
}
async function main() {
const r = await pool.query(`
SELECT c.id, c.email, c.domain, c.last_signature, c.notes,
d.id AS designer_id
FROM sod_email_candidates c
LEFT JOIN sod_designers d ON d.primary_email = c.email
WHERE c.status = 'is_designer'
AND c.last_signature IS NOT NULL
AND length(c.last_signature) >= 50`);
console.log(`[extract-links] scanning ${r.rows.length} signatures for URLs`);
let inserted = 0, dupes = 0, classified = {};
for (const row of r.rows) {
const urls = row.last_signature.match(URL_RE);
if (!urls) continue;
for (const raw of new Set(urls)) {
const c = classifyUrl(raw, row.domain);
if (!c) continue;
// Find firm_id via the designer's current firm-join, if any
let firmId = null;
if (row.designer_id) {
const fq = await pool.query(
`SELECT firm_id FROM sod_designer_firm WHERE designer_id=$1 AND is_current=true LIMIT 1`,
[row.designer_id]
);
firmId = fq.rows[0]?.firm_id || null;
}
const ins = await pool.query(`
INSERT INTO sod_links (designer_id, firm_id, url, kind, added_by)
VALUES ($1, $2, $3, $4, 'sig-regex-pass')
ON CONFLICT DO NOTHING
RETURNING id`,
[row.designer_id, firmId, c.url, c.kind]
);
if (ins.rowCount > 0) {
inserted++;
classified[c.kind] = (classified[c.kind] || 0) + 1;
} else {
dupes++;
}
}
}
console.log(`[extract-links] inserted=${inserted} dupes=${dupes} by-kind=${JSON.stringify(classified)}`);
await pool.end();
}
main().catch(e => { console.error('fatal:', e); process.exit(1); });