← back to Stars of Design
feat(starsofdesign): signature-block LLM extraction → Jo Hempelmann shipped
f5688ed6b9dd65c52ca6854df1c9f16ca88b0565 · 2026-05-12 09:19:41 -0700 · Steve Abrams
scrapers/extract-from-signatures.js — for each candidate with empty
display_name (148 such), pull most-recent George info@ message body,
slice to signature region (after -- delimiter or last 14 non-empty
lines), gemma3:12b in JSON-mode extracts:
full_name, firm_name, firm_city, firm_role,
linkedin_personal, linkedin_company, website, phone.
Then materializes:
- UPDATE sod_email_candidates.display_name + last_signature
- INSERT sod_designers + sod_firms with role/city
- INSERT sod_designer_firm join with extracted title
- INSERT linkedin/website URLs into sod_links (kind='sig-extract')
Two important guardrails:
- Length sanity check on name (3-80 chars) and firm (3-120 chars).
- Hard refusal of 'Designer Wallcoverings*' as firm_name — that's
DW's OWN outgoing signature embedded in the contact's reply, NOT
their firm. LLM grabbed it from rpaschall@pinecrestinsurance.com,
kcfox007@gmail.com, angela@handwerk.company first run; guard
added, hallucinated 'Designer Wallcoverings and Fabrics' firm row
deleted.
First-pass result on 25 highest-signal empty-name candidates:
jo@katieswallpaper.ca → Jo Hempelmann, Katie's Wallpaper Installation
Inc., Los Angeles, Wallpaper Installation Project Manager
export@bmwallpaper.com → Jenny Liu (name only — no firm in sig)
jcamacho427@gmail.com → Jerry Camacho (later flagged is_noise —
Steve's accountant, not a designer)
rpaschall / kcfox007 / angela@handwerk → hallucinated DW (guarded
after run)
17 of 25 had no message in info@ (personal-Gmail-only contacts)
Manual cleanup tied to this tick (27 candidates flipped is_noise):
Family: nataliaabrams (Steve's wife), bobfox729, billfox92117,
jiabramsla, dylan.r.fox, kcfox007 (all relatives).
Service providers / vendors: hrftax53 (accountant), jcamacho427
(accountant), tom@osbornepartners (financial advisor), michael@
montereyrentals (landlord), mike@5to1web (IT vendor), rpaschall@
pinecrestinsurance (insurance), angela@handwerk, email@rentmanager,
circulation1@labj*, jimdultz@comcast (vendor),
carole.fabrics@carolefabrics, helen.brier.b6@mail.toray (Toray),
kundservice@photowall (Photowall), noreply@zopim (transactional),
natalia/sabrina@studentdebtcrisis (wife's nonprofit), chounanian
(ex-DW VP), browntwn / mitz / greg_perry (personal).
Ledger after this tick:
real_designer_candidates 200 (was 225 — 25 dropped to noise)
noise_total 58 (was 31)
vendors_total 84
sod_designers (real) 4 (was 1 — Jo Hempelmann added)
sod_designers (reps) 40
sod_firms_total 82 (+1 Katie's Wallpaper, hallucinated DW removed)
Files touched
A scrapers/extract-from-signatures.js
Diff
commit f5688ed6b9dd65c52ca6854df1c9f16ca88b0565
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue May 12 09:19:41 2026 -0700
feat(starsofdesign): signature-block LLM extraction → Jo Hempelmann shipped
scrapers/extract-from-signatures.js — for each candidate with empty
display_name (148 such), pull most-recent George info@ message body,
slice to signature region (after -- delimiter or last 14 non-empty
lines), gemma3:12b in JSON-mode extracts:
full_name, firm_name, firm_city, firm_role,
linkedin_personal, linkedin_company, website, phone.
Then materializes:
- UPDATE sod_email_candidates.display_name + last_signature
- INSERT sod_designers + sod_firms with role/city
- INSERT sod_designer_firm join with extracted title
- INSERT linkedin/website URLs into sod_links (kind='sig-extract')
Two important guardrails:
- Length sanity check on name (3-80 chars) and firm (3-120 chars).
- Hard refusal of 'Designer Wallcoverings*' as firm_name — that's
DW's OWN outgoing signature embedded in the contact's reply, NOT
their firm. LLM grabbed it from rpaschall@pinecrestinsurance.com,
kcfox007@gmail.com, angela@handwerk.company first run; guard
added, hallucinated 'Designer Wallcoverings and Fabrics' firm row
deleted.
First-pass result on 25 highest-signal empty-name candidates:
jo@katieswallpaper.ca → Jo Hempelmann, Katie's Wallpaper Installation
Inc., Los Angeles, Wallpaper Installation Project Manager
export@bmwallpaper.com → Jenny Liu (name only — no firm in sig)
jcamacho427@gmail.com → Jerry Camacho (later flagged is_noise —
Steve's accountant, not a designer)
rpaschall / kcfox007 / angela@handwerk → hallucinated DW (guarded
after run)
17 of 25 had no message in info@ (personal-Gmail-only contacts)
Manual cleanup tied to this tick (27 candidates flipped is_noise):
Family: nataliaabrams (Steve's wife), bobfox729, billfox92117,
jiabramsla, dylan.r.fox, kcfox007 (all relatives).
Service providers / vendors: hrftax53 (accountant), jcamacho427
(accountant), tom@osbornepartners (financial advisor), michael@
montereyrentals (landlord), mike@5to1web (IT vendor), rpaschall@
pinecrestinsurance (insurance), angela@handwerk, email@rentmanager,
circulation1@labj*, jimdultz@comcast (vendor),
carole.fabrics@carolefabrics, helen.brier.b6@mail.toray (Toray),
kundservice@photowall (Photowall), noreply@zopim (transactional),
natalia/sabrina@studentdebtcrisis (wife's nonprofit), chounanian
(ex-DW VP), browntwn / mitz / greg_perry (personal).
Ledger after this tick:
real_designer_candidates 200 (was 225 — 25 dropped to noise)
noise_total 58 (was 31)
vendors_total 84
sod_designers (real) 4 (was 1 — Jo Hempelmann added)
sod_designers (reps) 40
sod_firms_total 82 (+1 Katie's Wallpaper, hallucinated DW removed)
---
scrapers/extract-from-signatures.js | 264 ++++++++++++++++++++++++++++++++++++
1 file changed, 264 insertions(+)
diff --git a/scrapers/extract-from-signatures.js b/scrapers/extract-from-signatures.js
new file mode 100644
index 0000000..fe00ee9
--- /dev/null
+++ b/scrapers/extract-from-signatures.js
@@ -0,0 +1,264 @@
+#!/usr/bin/env node
+'use strict';
+// Tick 3 — Fill empty display_name + firm/role/city/LinkedIn for sod_email_candidates
+// by parsing each contact's most-recent George info@ message body. Why this
+// tick — promote-candidates.js can't materialize sod_designers/sod_firms rows
+// without a name, and Gmail MCP returned bare-email From: headers for many
+// threads. Their actual identity lives in the message body's signature block.
+//
+// Flow per candidate:
+// 1. george /api/search?account=info&q=from:<email> OR to:<email> → message ids
+// 2. george /api/messages/<id>?account=info → full body
+// 3. Slice body to plausible signature region (last ~1.5 KB)
+// 4. gemma3:12b in JSON mode → { full_name, firm_name, firm_city, firm_role,
+// linkedin_personal, linkedin_company, website, phone }
+// 5. UPDATE sod_email_candidates(display_name, last_signature, notes)
+// 6. INSERT linkedin/site URLs into sod_links via designer_id / firm_id
+// 7. Promote via promote-candidates.js when the LLM gives us at least a
+// person OR firm name.
+//
+// Usage:
+// node scrapers/extract-from-signatures.js --limit=25
+// node scrapers/extract-from-signatures.js --email=jane@studio.com # one row
+// node scrapers/extract-from-signatures.js --dry # no writes
+
+require('dotenv').config();
+const { Pool } = require('pg');
+const path = require('path');
+
+const args = process.argv.slice(2).reduce((m, a) => {
+ const [k, v] = a.replace(/^--/, '').split('=');
+ m[k] = v ?? true; return m;
+}, {});
+const LIMIT = parseInt(args.limit || '25', 10);
+const ONLY_EMAIL = args.email || null;
+const DRY = !!args.dry;
+const MODEL = args.model || 'gemma3:12b';
+const GEORGE = process.env.GEORGE_URL || 'http://127.0.0.1:9850';
+const GEORGE_AUTH = 'Basic ' + Buffer.from('admin:DWSecure2024!').toString('base64');
+const OLLAMA = process.env.OLLAMA_HOST || 'http://127.0.0.1:11434';
+
+const pool = new Pool({
+ connectionString: process.env.DATABASE_URL || 'postgresql:///dw_unified?host=/tmp&user=stevestudio2',
+ max: 4,
+});
+
+const SYSTEM = `You extract the actual person and firm identity from an email signature
+block. The body is the most recent email from / to a designer or firm DW corresponds
+with. Look for the signature region (after a -- delimiter, or the final
+4-12 lines containing name + title + firm + phone + url patterns).
+
+Output STRICT JSON, no preamble:
+{
+ "full_name": "<person's first+last> or null",
+ "firm_name": "<firm/studio/agency> or null",
+ "firm_city": "<city the firm is in> or null",
+ "firm_role": "<the person's title> or null (Principal, Senior Designer, Founder, Partner, etc.)",
+ "linkedin_personal": "<linkedin.com/in/... or null>",
+ "linkedin_company": "<linkedin.com/company/... or null>",
+ "website": "<firm primary URL or null>",
+ "phone": "<phone (E.164 preferred) or null>"
+}
+
+Rules:
+- Don't invent. If a field isn't clearly in the body, return null.
+- Skip the email-quoting (lines starting with >).
+- Skip footer disclaimers and unsubscribe links.
+- If the body is just a Shopify order notification with no signature, return
+ all nulls.
+- Strip emoji and decorative characters from names.
+`;
+
+async function ask(systemPrompt, userPrompt) {
+ const r = await fetch(`${OLLAMA}/api/chat`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ model: MODEL,
+ stream: false,
+ format: 'json',
+ messages: [
+ { role: 'system', content: systemPrompt },
+ { role: 'user', content: userPrompt.slice(0, 6000) },
+ ],
+ options: { temperature: 0.1, num_predict: 280 },
+ }),
+ });
+ if (!r.ok) throw new Error(`ollama ${r.status}`);
+ const j = await r.json();
+ const raw = (j.message && j.message.content) || '';
+ const stripped = raw.replace(/<think>[\s\S]*?<\/think>/gi, '').trim();
+ const m = stripped.match(/\{[\s\S]*\}/);
+ if (!m) throw new Error('no JSON in response');
+ return JSON.parse(m[0]);
+}
+
+async function georgeSearch(q, max = 1) {
+ const url = `${GEORGE}/api/search?account=info&q=${encodeURIComponent(q)}&maxResults=${max}`;
+ const r = await fetch(url, { headers: { Authorization: GEORGE_AUTH } });
+ if (!r.ok) return null;
+ const j = await r.json();
+ return (j.messages || [])[0] || null;
+}
+async function georgeGet(id) {
+ const url = `${GEORGE}/api/messages/${id}?account=info`;
+ const r = await fetch(url, { headers: { Authorization: GEORGE_AUTH } });
+ if (!r.ok) return null;
+ return r.json();
+}
+
+function trimToSignature(body) {
+ if (!body) return '';
+ // Drop quoted-reply lines (start with > or "On <date> ... wrote:")
+ const lines = body.split(/\r?\n/);
+ const cutIdx = lines.findIndex(l => /^On\s+.+\s+wrote:\s*$/.test(l));
+ const sliced = cutIdx > 0 ? lines.slice(0, cutIdx) : lines;
+ const noQuotes = sliced.filter(l => !/^[>]/.test(l));
+ // Find -- delimiter
+ let sigStart = -1;
+ for (let i = noQuotes.length - 1; i >= 0 && i >= noQuotes.length - 40; i--) {
+ if (/^--\s*$/.test(noQuotes[i])) { sigStart = i; break; }
+ }
+ if (sigStart >= 0) return noQuotes.slice(sigStart, sigStart + 15).join('\n');
+ // No -- delimiter: take last 14 non-empty lines
+ const nonEmpty = noQuotes.map(l => l.trim()).filter(Boolean);
+ return nonEmpty.slice(-14).join('\n');
+}
+
+function slugify(s) {
+ return String(s || '').toLowerCase().replace(/[^a-z0-9]+/g,'-').replace(/^-+|-+$/g,'').slice(0,100);
+}
+
+async function processCandidate(c) {
+ // Search George for the most recent message exchanged with this email.
+ // Try `from:` first (incoming), then `to:` (outgoing).
+ let m = await georgeSearch(`from:${c.email}`, 1);
+ if (!m) m = await georgeSearch(`to:${c.email}`, 1);
+ if (!m) { console.log(` ${c.email.padEnd(36)} no-message`); return { skipped: true }; }
+ const full = await georgeGet(m.id);
+ if (!full) { console.log(` ${c.email.padEnd(36)} get-failed`); return { skipped: true }; }
+ // Body candidates: plaintext_body OR snippet OR payload.body
+ const body = full.plaintext_body || full.body || full.snippet || '';
+ if (!body || body.length < 60) { console.log(` ${c.email.padEnd(36)} body-too-short`); return { skipped: true }; }
+ const sig = trimToSignature(body);
+ if (!sig || sig.length < 30) { console.log(` ${c.email.padEnd(36)} no-sig-region`); return { skipped: true }; }
+
+ let parsed;
+ try {
+ parsed = await ask(SYSTEM, `Email: ${c.email}\nDomain: ${c.domain}\n\nSignature region:\n${sig}`);
+ } catch (e) {
+ console.log(` ${c.email.padEnd(36)} llm-fail: ${e.message}`);
+ return { skipped: true };
+ }
+
+ // Refuse obviously-bad LLM output
+ const name = parsed.full_name && parsed.full_name.length > 2 && parsed.full_name.length < 80 ? parsed.full_name.trim() : null;
+ let firm = parsed.firm_name && parsed.firm_name.length > 2 && parsed.firm_name.length < 120 ? parsed.firm_name.trim() : null;
+ // GUARD: the LLM occasionally grabs DW's outgoing signature ("Designer
+ // Wallcoverings and Fabrics") and reports it as the contact's firm. The
+ // contact is NOT working for DW — that's our own signature inside an
+ // outgoing message the contact replied to. Refuse.
+ if (firm && /^Designer\s+Wallcoverings/i.test(firm)) firm = null;
+
+ console.log(` ${c.email.padEnd(36)} → name=${name||'-'} firm=${firm||'-'} city=${parsed.firm_city||'-'}`);
+
+ if (DRY) return { dry: true, parsed };
+
+ // 1. Update candidate row
+ await pool.query(`
+ UPDATE sod_email_candidates
+ SET display_name = COALESCE($1, display_name),
+ last_signature = $2,
+ notes = CONCAT_WS(' || ', NULLIF(notes,''), 'sig-extract:' || COALESCE($1,'null') || '|' || COALESCE($3,'null') || '|' || COALESCE($4,'null')),
+ updated_at = NOW()
+ WHERE id = $5`,
+ [name, sig, firm, parsed.firm_role, c.id]);
+
+ // 2. If we got a person name OR firm name, materialize in sod_designers/sod_firms
+ let designerId = null, firmId = null;
+ if (firm) {
+ const slug = slugify(firm);
+ const f = await pool.query(`
+ INSERT INTO sod_firms (slug, name, city, primary_email)
+ VALUES ($1, $2, $3, $4)
+ ON CONFLICT (slug) DO UPDATE SET
+ city = COALESCE(sod_firms.city, EXCLUDED.city),
+ primary_email = COALESCE(sod_firms.primary_email, EXCLUDED.primary_email),
+ updated_at = NOW()
+ RETURNING id`,
+ [slug, firm, parsed.firm_city, c.email]);
+ firmId = f.rows[0].id;
+ }
+ if (name) {
+ const slug = slugify(name);
+ const d = await pool.query(`
+ INSERT INTO sod_designers (slug, full_name, primary_email, city, role, headshot_source)
+ VALUES ($1, $2, $3, $4, $5, '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),
+ role = COALESCE(sod_designers.role, EXCLUDED.role),
+ updated_at = NOW()
+ RETURNING id`,
+ [slug, name, c.email, parsed.firm_city, parsed.firm_role]);
+ designerId = d.rows[0].id;
+ if (firmId) {
+ await pool.query(`
+ INSERT INTO sod_designer_firm (designer_id, firm_id, title, is_current, source)
+ VALUES ($1, $2, $3, true, 'sig-extract')
+ ON CONFLICT DO NOTHING`,
+ [designerId, firmId, parsed.firm_role || 'Designer']);
+ }
+ }
+ // 3. Stash any LinkedIn / website URLs
+ for (const [url, kind] of [
+ [parsed.linkedin_personal, 'linkedin'],
+ [parsed.linkedin_company, 'linkedin'],
+ [parsed.website, 'firm-site'],
+ ]) {
+ if (!url || !/^https?:\/\//.test(url)) continue;
+ if (!designerId && !firmId) continue;
+ await pool.query(`
+ INSERT INTO sod_links (designer_id, firm_id, url, kind, added_by)
+ VALUES ($1, $2, $3, $4, 'sig-extract')
+ ON CONFLICT DO NOTHING`,
+ [designerId, firmId, url, kind]);
+ }
+ if (parsed.phone && designerId) {
+ // store phone in sod_designers via bio fallback isn't right; skip for now.
+ }
+ return { ok: true, name, firm };
+}
+
+async function main() {
+ let sql, params;
+ if (ONLY_EMAIL) {
+ sql = `SELECT id, email, domain, signal_score
+ FROM sod_email_candidates
+ WHERE email = $1
+ LIMIT 1`;
+ params = [ONLY_EMAIL];
+ } else {
+ sql = `SELECT id, email, domain, signal_score
+ FROM sod_email_candidates
+ WHERE status = 'is_designer'
+ AND (display_name IS NULL OR display_name = '')
+ ORDER BY signal_score DESC NULLS LAST
+ LIMIT $1`;
+ params = [LIMIT];
+ }
+ const r = await pool.query(sql, params);
+ console.log(`[sig-extract] ${r.rows.length} candidate(s) model=${MODEL} dry=${DRY}`);
+ const tally = { ok:0, withName:0, withFirm:0, skipped:0 };
+ for (const c of r.rows) {
+ const res = await processCandidate(c);
+ if (res.ok || res.dry) {
+ tally.ok++;
+ if (res.name) tally.withName++;
+ if (res.firm) tally.withFirm++;
+ } else tally.skipped++;
+ }
+ console.log(`[sig-extract] done. ${JSON.stringify(tally)}`);
+ await pool.end();
+}
+main().catch(e => { console.error('fatal:', e); process.exit(1); });
← 4ced542 expand to 150 designers — added 62 active US firms with firm
·
back to Stars of Design
·
feat(starsofdesign): DEEP George info@ recon (7-window × 8-t a7bdb25 →